diff --git a/.gitignore b/.gitignore
index d4c3a57e..c91dc68b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@
.cxx
local.properties
/.idea/
+.claude/
diff --git a/README.md b/README.md
index c4a9f02d..15721cf8 100644
--- a/README.md
+++ b/README.md
@@ -1,45 +1,64 @@
-**PositionMe** is an indoor positioning data collection application initially developed for the University of Edinburgh's Embedded Wireless course. The application now includes enhanced features, including **trajectory playback**, improved UI design, and comprehensive location tracking.
-
-## Features
-
-- **Real-time Sensor Data Collection**: Captures sensor, location, and GNSS data.
-- **Trajectory Playback**: Simulates recorded movement from previously saved trajectory files (Trajectory proto files).
-- **Interactive Map Display**:
- - Visualizes the user's **PDR trajectory/path**.
- - Displays **received GNSS locations**.
- - Supports **floor changes and indoor maps** for a seamless experience.
-- **Playback Controls**:
- - **Play/Pause, Exit, Restart, Jump to End**.
- - **Progress bar for tracking playback status**.
-- **Redesigned UI**: Modern and user-friendly interface for enhanced usability.
-
-## Requirements
-
-- **Android Studio 4.2** or later
-- **Android SDK 30** or later
-
-## Installation
-
-1. **Clone the repository.**
-2. **Open the project in Android Studio**.
-3. Add your own API key for Google Maps in AndroidManifest.xml
-4. Set the website where you want to send your data. The application was built for use with [openpositioning.org](http://openpositioning.org/).
-5. **Build and run the project on your Android device**.
-
-## Usage
-
-1. **Install the application** using Android Studio.
-2. **Launch the application** on your Android device.
-3. **Grant necessary permissions** when prompted:
- - Sensor access
- - Location services
- - Internet connectivity
-4. **Collect real-time positioning data**:
- - Follow on-screen instructions to record sensor data.
-5. **Replay previously recorded trajectories**:
- - Navigate to the **Files** section.
- - Select a saved trajectory and press **Play**.
- - The recorded trajectory will be simulated and displayed on the map.
-6. **Control playback**:
- - Pause, restart, or jump to the end using playback controls.
+# PositionMe — Indoor Positioning System
+An Android indoor positioning application developed for the University of Edinburgh's Embedded Wireless (EWireless) course, Assignment 2. The app fuses multiple sensor sources through a particle filter to provide real-time indoor navigation with metre-level accuracy.
+
+## Key Features
+
+### Positioning Fusion
+- **500-particle SIR Particle Filter** combining PDR, WiFi API, GNSS, and local calibration database
+- **Weighted K-Nearest Neighbours (WKNN)** fingerprint matching against 395 collected reference points (K=10, floor-aware)
+- **10-second initial calibration** using multi-source weighted averaging (CalDB × 3.0, WiFi × 1.0, GNSS × 1.0)
+- **Adaptive heading bias correction** with early-phase (15 steps, ±5°/step) and normal-phase (EMA α=0.10) modes
+- **ENU coordinate system** (CoordinateTransform) for all fusion computations
+
+### Map Matching
+- **Wall constraint enforcement** via axis-aligned segments from Floorplan API GeoJSON data
+- **Stairs step reduction** (×0.5 horizontal displacement) when inside stairs polygons
+- **Floor change spatial gating** — transitions only allowed within 5m of stairs/lift areas
+- **Elevator vs stairs classification** using barometric elevation change rate
+- **3-second debounce** on floor transitions to prevent jitter
+
+### Data Display
+- **Three trajectory paths**: PDR (red), Fused (purple), Smooth (green, moving average window=40)
+- **Colour-coded observation markers**: GNSS (cyan, last 3), WiFi (green, last 3), Fused (red, last 10)
+- **Estimated position marker** (grey) showing forward-120° reference point weighted average
+- **WiFi signal quality indicator** (3-bar, based on CalDB match quality)
+- **Compass and weather overlays**
+- **Collapsible toolbar** with 10 toggle switches and map style selector (Hybrid/Normal-Pink/Satellite)
+
+### Indoor Positioning Mode
+- One-tap entry from home screen with auto building detection
+- Auto trajectory naming, auto start location, auto recording
+- Indoor-optimised defaults (compass, weather, smooth path, auto floor, navigating ON)
+- `forceSetBuilding()` bypasses polygon detection for immediate indoor map loading
+
+### Recording & Upload
+- Protobuf trajectory with IMU, PDR, WiFi, GNSS, BLE, barometer, and test points
+- IMU timestamp rescaling for server frequency compliance (≥50Hz)
+- Sensor info fields (max_range, frequency) included in protobuf
+- Post-recording correction screen with trajectory review and map type toggle
+
+## Technical Details
+
+- **Target SDK**: 35
+- **Min SDK**: 28
+- **Language**: Java 17
+- **Code Style**: Google Java Style Guide
+- **Unit Tests**: 32 JVM-based tests (JUnit 4)
+- **Debug Control**: Compile-time `BuildConstants.DEBUG` flag eliminates all debug logging in release
+
+## Building
+
+1. Clone the repository
+2. Add your API keys to `secrets.properties`:
+ ```
+ MAPS_API_KEY=your_google_maps_key
+ OPENPOSITIONING_API_KEY=your_openpositioning_key
+ OPENPOSITIONING_MASTER_KEY=your_master_key
+ ```
+3. Open in Android Studio and sync Gradle
+4. Build and run on a physical device (sensors required)
+
+## Architecture
+
+See `Programmers_Guide.md` for detailed system architecture, data flow diagrams, and design decisions.
diff --git a/app/build.gradle b/app/build.gradle
index 430dcf72..0e0a514b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -70,6 +70,10 @@ android {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
+
+ testOptions {
+ unitTests.returnDefaultValues = true
+ }
}
dependencies {
diff --git a/app/src/main/java/com/openpositioning/PositionMe/BuildConstants.java b/app/src/main/java/com/openpositioning/PositionMe/BuildConstants.java
new file mode 100644
index 00000000..64747b34
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/BuildConstants.java
@@ -0,0 +1,15 @@
+package com.openpositioning.PositionMe;
+
+/**
+ * Compile-time constants shared across the application.
+ *
+ *
When {@link #DEBUG} is {@code false} the compiler eliminates all
+ * guarded {@code Log} calls, producing zero runtime overhead.
+ */
+public final class BuildConstants {
+
+ /** Master debug switch. Set {@code true} during development, {@code false} for release. */
+ public static final boolean DEBUG = false;
+
+ private BuildConstants() { /* non-instantiable */ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/data/remote/FloorplanApiClient.java b/app/src/main/java/com/openpositioning/PositionMe/data/remote/FloorplanApiClient.java
index cadb6037..c134a3f6 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/data/remote/FloorplanApiClient.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/data/remote/FloorplanApiClient.java
@@ -1,5 +1,7 @@
package com.openpositioning.PositionMe.data.remote;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
@@ -243,7 +245,7 @@ public void onResponse(Call call, Response response) throws IOException {
}
String json = responseBody.string();
- Log.d(TAG, "Floorplan response length: " + json.length());
+ if (DEBUG) Log.d(TAG, "Floorplan response length: " + json.length());
List buildings = parseResponse(json);
postToMainThread(() -> callback.onSuccess(buildings));
@@ -267,17 +269,45 @@ private List parseResponse(String json) throws JSONException {
List buildings = new ArrayList<>();
JSONArray array = new JSONArray(json);
+ if (DEBUG) Log.i(TAG, "=== FLOORPLAN API RESPONSE: " + array.length() + " building(s) ===");
for (int i = 0; i < array.length(); i++) {
JSONObject obj = array.getJSONObject(i);
String name = obj.getString("name");
+
+ if (DEBUG) {
+ StringBuilder keys = new StringBuilder();
+ Iterator keyIt = obj.keys();
+ while (keyIt.hasNext()) keys.append(keyIt.next()).append(", ");
+ Log.i(TAG, "[Building " + i + "] name=" + name + " | keys=[" + keys + "]");
+ }
+
String outlineJson = obj.optString("outline", "");
String mapShapesJson = obj.optString("map_shapes", "");
List polygon = parseOutlineGeoJson(outlineJson);
+ if (DEBUG) Log.i(TAG, " outline: " + polygon.size() + " vertices"
+ + (outlineJson.length() > 200
+ ? " | json[0..200]=" + outlineJson.substring(0, 200)
+ : " | json=" + outlineJson));
+
List floorShapes = parseMapShapes(mapShapesJson);
+ if (DEBUG) {
+ for (FloorShapes fs : floorShapes) {
+ java.util.Map typeCounts = new java.util.LinkedHashMap<>();
+ for (MapShapeFeature f : fs.getFeatures()) {
+ String key = f.getIndoorType() + "(" + f.getGeometryType() + ")";
+ typeCounts.put(key, typeCounts.getOrDefault(key, 0) + 1);
+ }
+ Log.i(TAG, " floor=" + fs.getDisplayName()
+ + " | features=" + fs.getFeatures().size()
+ + " | types=" + typeCounts);
+ }
+ }
+
buildings.add(new BuildingInfo(name, outlineJson, mapShapesJson,
polygon, floorShapes));
}
+ if (DEBUG) Log.i(TAG, "=== END FLOORPLAN API RESPONSE ===");
return buildings;
}
@@ -352,12 +382,30 @@ private JSONArray extractFirstRing(JSONObject geometry, String type)
}
/**
- * Parses the map_shapes JSON string into a list of FloorShapes, sorted by floor key.
- * The top-level JSON is an object with keys like "B1", "B2", etc. Each value is a
- * GeoJSON FeatureCollection containing indoor features (walls, rooms, etc.).
+ * Maps a floor display name to a physical height order number.
+ * Lower numbers = lower physical floors.
+ * LG=-1, GF=0, F1=1, F2=2, F3=3, etc.
+ */
+ private static int floorNameToPhysicalOrder(String name) {
+ if (name == null) return 100;
+ String upper = name.toUpperCase().trim();
+ if (upper.equals("LG") || upper.equals("LOWER GROUND")) return -1;
+ if (upper.equals("GF") || upper.equals("G") || upper.equals("GROUND")) return 0;
+ // "F1","F2","F3"... or "1","2","3"...
+ try {
+ if (upper.startsWith("F")) return Integer.parseInt(upper.substring(1));
+ if (upper.startsWith("B")) return -Integer.parseInt(upper.substring(1)); // basement
+ return Integer.parseInt(upper);
+ } catch (NumberFormatException e) {
+ return 100; // unknown → put at end
+ }
+ }
+
+ /**
+ * Parses the map_shapes JSON into a list of FloorShapes sorted by physical floor height.
*
- * @param mapShapesJson the raw map_shapes JSON string from the API
- * @return list of FloorShapes sorted by key (B1=index 0, B2=index 1, ...)
+ * @param mapShapesJson raw map_shapes JSON string from the API
+ * @return list of FloorShapes sorted by floor
*/
private List parseMapShapes(String mapShapesJson) {
List result = new ArrayList<>();
@@ -366,13 +414,21 @@ private List parseMapShapes(String mapShapesJson) {
try {
JSONObject root = new JSONObject(mapShapesJson);
- // Collect and sort floor keys (B1, B2, B3...)
+ // Collect floor keys and sort by physical height order.
+ // API keys are like "B1","B2",... with display names like "LG","GF","F1","F2","F3".
+ // Alphabetical sort puts GF after F3 which is wrong.
+ // We parse display names first, then sort by a custom physical-height comparator.
List keys = new ArrayList<>();
Iterator it = root.keys();
while (it.hasNext()) {
keys.add(it.next());
}
- Collections.sort(keys);
+ // Sort by display name physical order: LG < GF < F1 < F2 < F3 ...
+ Collections.sort(keys, (a, b) -> {
+ String nameA = root.optJSONObject(a) != null ? root.optJSONObject(a).optString("name", a) : a;
+ String nameB = root.optJSONObject(b) != null ? root.optJSONObject(b).optString("name", b) : b;
+ return floorNameToPhysicalOrder(nameA) - floorNameToPhysicalOrder(nameB);
+ });
for (String key : keys) {
JSONObject floorCollection = root.getJSONObject(key);
@@ -440,7 +496,7 @@ private MapShapeFeature parseMapShapeFeature(JSONObject feature) {
parts.add(parseCoordArray(coordinates.getJSONArray(0)));
}
} else {
- Log.d(TAG, "Unsupported geometry type in map_shapes: " + geoType);
+ if (DEBUG) Log.d(TAG, "Unsupported geometry type in map_shapes: " + geoType);
return null;
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/data/remote/WeatherApiClient.java b/app/src/main/java/com/openpositioning/PositionMe/data/remote/WeatherApiClient.java
new file mode 100644
index 00000000..c9d7daff
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/data/remote/WeatherApiClient.java
@@ -0,0 +1,82 @@
+package com.openpositioning.PositionMe.data.remote;
+
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.util.Locale;
+
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+/**
+ * Fetches current weather from the Open-Meteo API (free, no key required).
+ * Returns WMO weather code and temperature in Celsius via callback.
+ */
+public class WeatherApiClient {
+
+ private static final String TAG = "WeatherApiClient";
+ private static final String BASE_URL = "https://api.open-meteo.com/v1/forecast";
+
+ private final OkHttpClient client = new OkHttpClient();
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
+
+ /** Callback for asynchronous weather fetch results. */
+ public interface WeatherCallback {
+ /** Called on the main thread with the weather code and temperature. */
+ void onWeatherResult(int wmoCode, double temperatureC);
+ /** Called on the main thread when the request fails. */
+ void onError(String message);
+ }
+
+ /**
+ * Fetch current weather for the given coordinates.
+ * Callback is delivered on the main thread.
+ */
+ public void fetchWeather(double latitude, double longitude, WeatherCallback callback) {
+ String url = String.format(Locale.US,
+ "%s?latitude=%.4f&longitude=%.4f¤t=temperature_2m,weather_code",
+ BASE_URL, latitude, longitude);
+
+ Request request = new Request.Builder().url(url).build();
+
+ client.newCall(request).enqueue(new Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ if (DEBUG) Log.w(TAG, "Weather fetch failed", e);
+ mainHandler.post(() -> callback.onError(e.getMessage()));
+ }
+
+ @Override
+ public void onResponse(Call call, Response response) throws IOException {
+ try {
+ if (!response.isSuccessful()) {
+ mainHandler.post(() -> callback.onError("HTTP " + response.code()));
+ return;
+ }
+ String body = response.body().string();
+ JSONObject json = new JSONObject(body);
+ JSONObject current = json.getJSONObject("current");
+ double temp = current.getDouble("temperature_2m");
+ int code = current.getInt("weather_code");
+
+ if (DEBUG) Log.d(TAG, "Weather: code=" + code + " temp=" + temp + "°C");
+ mainHandler.post(() -> callback.onWeatherResult(code, temp));
+ } catch (Exception e) {
+ if (DEBUG) Log.w(TAG, "Weather parse failed", e);
+ mainHandler.post(() -> callback.onError(e.getMessage()));
+ } finally {
+ response.close();
+ }
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/MainActivity.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/MainActivity.java
index 3dde48cd..e4002397 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/MainActivity.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/MainActivity.java
@@ -17,6 +17,9 @@
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.appcompat.widget.Toolbar;
+import androidx.core.view.WindowCompat;
+import androidx.core.view.WindowInsetsCompat;
+import androidx.core.view.WindowInsetsControllerCompat;
import androidx.core.content.ContextCompat;
import androidx.navigation.NavController;
@@ -91,8 +94,20 @@ public class MainActivity extends AppCompatActivity implements Observer {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
+
+ // Hide status bar before inflating layout to avoid flash/reflow
+ WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
+ getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT);
+
setContentView(R.layout.activity_main);
+ // Complete immersive setup after layout is ready
+ WindowInsetsControllerCompat insetsController =
+ WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
+ insetsController.hide(WindowInsetsCompat.Type.statusBars());
+ insetsController.setSystemBarsBehavior(
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+
// Set up navigation and fragments
NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager()
.findFragmentById(R.id.nav_host_fragment);
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/RecordingActivity.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/RecordingActivity.java
index 9497848e..81458c1e 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/RecordingActivity.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/RecordingActivity.java
@@ -1,5 +1,6 @@
package com.openpositioning.PositionMe.presentation.activity;
+import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.InputType;
import android.view.WindowManager;
@@ -8,15 +9,25 @@
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.view.WindowCompat;
+import androidx.core.view.WindowInsetsCompat;
+import androidx.core.view.WindowInsetsControllerCompat;
import androidx.fragment.app.FragmentTransaction;
+import androidx.preference.PreferenceManager;
+import com.google.android.gms.maps.model.LatLng;
import com.openpositioning.PositionMe.R;
+import com.openpositioning.PositionMe.data.remote.FloorplanApiClient;
import com.openpositioning.PositionMe.sensors.SensorFusion;
+import com.openpositioning.PositionMe.sensors.Wifi;
import com.openpositioning.PositionMe.service.SensorCollectionService;
import com.openpositioning.PositionMe.presentation.fragment.StartLocationFragment;
import com.openpositioning.PositionMe.presentation.fragment.RecordingFragment;
import com.openpositioning.PositionMe.presentation.fragment.CorrectionFragment;
+import java.util.ArrayList;
+import java.util.List;
+
/**
* The RecordingActivity manages the recording flow of the application, guiding the user through a sequence
@@ -44,19 +55,38 @@
*/
public class RecordingActivity extends AppCompatActivity {
+ public static final String EXTRA_LAUNCH_INDOOR_MODE = "extra_launch_indoor_mode";
+ private boolean launchIndoorMode;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+
+ // Hide status bar before inflating layout to avoid flash/reflow
+ WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
+ getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT);
+
setContentView(R.layout.activity_recording);
+ launchIndoorMode = getIntent().getBooleanExtra(EXTRA_LAUNCH_INDOOR_MODE, false);
if (savedInstanceState == null) {
- // Show trajectory name input dialog before proceeding to start location
- showTrajectoryNameDialog();
+ if (launchIndoorMode) {
+ launchIndoorPositioningMode();
+ } else {
+ // Show trajectory name input dialog before proceeding to start location
+ showTrajectoryNameDialog();
+ }
}
// Keep screen on
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ // Hide system status bar for immersive experience
+ WindowInsetsControllerCompat insetsController =
+ WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
+ insetsController.hide(WindowInsetsCompat.Type.statusBars());
+ insetsController.setSystemBarsBehavior(
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
/**
@@ -132,12 +162,106 @@ public void showStartLocationScreen() {
* Show the RecordingFragment, which contains the TrajectoryMapFragment internally.
*/
public void showRecordingScreen() {
+ showRecordingScreen(false);
+ }
+
+ public void showRecordingScreen(boolean indoorMode) {
+ RecordingFragment fragment = new RecordingFragment();
+ Bundle args = new Bundle();
+ args.putBoolean(RecordingFragment.ARG_INDOOR_MODE, indoorMode);
+ fragment.setArguments(args);
+
FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
- ft.replace(R.id.mainFragmentContainer, new RecordingFragment());
- ft.addToBackStack(null);
+ ft.replace(R.id.mainFragmentContainer, fragment);
+ if (!indoorMode) {
+ ft.addToBackStack(null);
+ }
+ // Indoor mode: no back stack — back/cancel goes straight to home
ft.commit();
}
+ private void launchIndoorPositioningMode() {
+ SensorFusion sensorFusion = SensorFusion.getInstance();
+ sensorFusion.ensureWirelessScanning();
+ sensorFusion.setTrajectoryId(nextIndoorTrajectoryName());
+
+ LatLng preferredLocation = sensorFusion.getLatLngWifiPositioning();
+ if (preferredLocation == null) {
+ float[] gnss = sensorFusion.getGNSSLatitude(false);
+ preferredLocation = new LatLng(gnss[0], gnss[1]);
+ }
+
+ final LatLng requestLocation = preferredLocation;
+ List observedMacs = new ArrayList<>();
+ List wifiList = sensorFusion.getWifiList();
+ if (wifiList != null) {
+ for (Wifi wifi : wifiList) {
+ String mac = wifi.getBssidString();
+ if (mac != null && !mac.isEmpty()) {
+ observedMacs.add(mac);
+ }
+ }
+ }
+
+ new FloorplanApiClient().requestFloorplan(
+ requestLocation.latitude,
+ requestLocation.longitude,
+ observedMacs,
+ new FloorplanApiClient.FloorplanCallback() {
+ @Override
+ public void onSuccess(List buildings) {
+ startIndoorRecording(sensorFusion, requestLocation, buildings);
+ }
+
+ @Override
+ public void onFailure(String error) {
+ startIndoorRecording(sensorFusion, requestLocation, new ArrayList<>());
+ }
+ }
+ );
+ }
+
+ private void startIndoorRecording(SensorFusion sensorFusion,
+ LatLng fallbackLocation,
+ List buildings) {
+ sensorFusion.setFloorplanBuildings(buildings);
+
+ LatLng startLocation = fallbackLocation;
+ String selectedBuildingId = null;
+
+ if (buildings != null && !buildings.isEmpty()) {
+ double bestDist = Double.MAX_VALUE;
+ for (FloorplanApiClient.BuildingInfo building : buildings) {
+ LatLng center = building.getCenter();
+ double dLat = center.latitude - fallbackLocation.latitude;
+ double dLon = center.longitude - fallbackLocation.longitude;
+ double distance = Math.hypot(dLat, dLon);
+ if (distance < bestDist) {
+ bestDist = distance;
+ selectedBuildingId = building.getName();
+ startLocation = center;
+ }
+ }
+ }
+
+ sensorFusion.setSelectedBuildingId(selectedBuildingId);
+
+ sensorFusion.setStartGNSSLatitude(new float[]{
+ (float) startLocation.latitude,
+ (float) startLocation.longitude
+ });
+ sensorFusion.startRecording();
+ sensorFusion.writeInitialMetadata();
+ showRecordingScreen(true);
+ }
+
+ private String nextIndoorTrajectoryName() {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
+ int nextValue = prefs.getInt("indoor_history_counter", 0) + 1;
+ prefs.edit().putInt("indoor_history_counter", nextValue).apply();
+ return "navigate history" + nextValue;
+ }
+
/**
* Show the CorrectionFragment after the user stops recording.
*/
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/CorrectionFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/CorrectionFragment.java
index 165b9e16..504e1fd0 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/CorrectionFragment.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/CorrectionFragment.java
@@ -1,5 +1,8 @@
package com.openpositioning.PositionMe.presentation.fragment;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
+import android.graphics.Color;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
@@ -18,29 +21,36 @@
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
-import com.openpositioning.PositionMe.R;
-import com.openpositioning.PositionMe.presentation.activity.RecordingActivity;
-import com.openpositioning.PositionMe.sensors.SensorFusion;
-import com.openpositioning.PositionMe.utils.PathView;
-import com.openpositioning.PositionMe.utils.TrajectoryValidator;
import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.OnMapReadyCallback;
import com.google.android.gms.maps.SupportMapFragment;
+import com.google.android.gms.maps.model.BitmapDescriptorFactory;
import com.google.android.gms.maps.model.LatLng;
+import com.google.android.gms.maps.model.LatLngBounds;
+import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions;
+import com.google.android.gms.maps.model.PolylineOptions;
+import com.openpositioning.PositionMe.R;
+import com.openpositioning.PositionMe.presentation.activity.RecordingActivity;
+import com.openpositioning.PositionMe.sensors.SensorFusion;
+import com.openpositioning.PositionMe.utils.IndoorMapManager;
+import com.openpositioning.PositionMe.utils.PathView;
+import com.openpositioning.PositionMe.utils.TrajectoryValidator;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
/**
- * A simple {@link Fragment} subclass. Corrections Fragment is displayed after a recording session
- * is finished to enable manual adjustments to the PDR. The adjustments are not saved as of now.
+ * Displayed after a recording session to show the trajectory summary with
+ * start/end positions and test points on the Google Map.
*/
public class CorrectionFragment extends Fragment {
- //Map variable
- public GoogleMap mMap;
- //Button to go to next
+ private GoogleMap mMap;
private Button button;
- //Singleton SensorFusion class
private SensorFusion sensorFusion = SensorFusion.getInstance();
private TextView averageStepLengthText;
private EditText stepLengthInput;
@@ -51,10 +61,11 @@ public class CorrectionFragment extends Fragment {
private static float scalingRatio = 0f;
private static LatLng start;
private PathView pathView;
+ private boolean isNormalMap = false;
+ private IndoorMapManager indoorMapManager;
- public CorrectionFragment() {
- // Required empty public constructor
- }
+ /** Required empty public constructor. */
+ public CorrectionFragment() {}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
@@ -65,14 +76,11 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container,
}
View rootView = inflater.inflate(R.layout.fragment_correction, container, false);
- // Validate trajectory quality before uploading
validateAndUpload();
- //Obtain start position
float[] startPosition = sensorFusion.getGNSSLatitude(true);
- // Initialize map fragment
- SupportMapFragment supportMapFragment=(SupportMapFragment)
+ SupportMapFragment supportMapFragment = (SupportMapFragment)
getChildFragmentManager().findFragmentById(R.id.map);
supportMapFragment.getMapAsync(new OnMapReadyCallback() {
@@ -85,14 +93,88 @@ public void onMapReady(GoogleMap map) {
mMap.getUiSettings().setRotateGesturesEnabled(true);
mMap.getUiSettings().setScrollGesturesEnabled(true);
- // Add a marker at the start position
start = new LatLng(startPosition[0], startPosition[1]);
- mMap.addMarker(new MarkerOptions().position(start).title("Start Position"));
+ long startTime = RecordingFragment.sStartTimeMs;
+
+ SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
+
+ // Initialize indoor map manager and load floor shapes
+ indoorMapManager = new IndoorMapManager(mMap);
+ String selectedBuilding = sensorFusion.getSelectedBuildingId();
+ if (selectedBuilding != null) {
+ indoorMapManager.forceSetBuilding(selectedBuilding);
+ } else {
+ // Try auto-detect from start position
+ indoorMapManager.setCurrentLocation(start);
+ }
+
+ // Get trajectory and test points from RecordingFragment
+ List trajectory = RecordingFragment.sLastTrajectoryPoints;
+ List testPoints = RecordingFragment.sLastTestPoints;
+ LatLng endPos = RecordingFragment.sLastEndPosition;
+
+ LatLngBounds.Builder bounds = new LatLngBounds.Builder();
+ bounds.include(start);
- // Calculate zoom for demonstration
- double zoom = Math.log(156543.03392f * Math.cos(startPosition[0] * Math.PI / 180)
- * scalingRatio) / Math.log(2);
- mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(start, (float) zoom));
+ // Draw trajectory polyline on the map
+ if (trajectory != null && trajectory.size() >= 2) {
+ mMap.addPolyline(new PolylineOptions()
+ .addAll(trajectory)
+ .color(Color.rgb(156, 39, 176)) // purple
+ .width(6f));
+ for (LatLng pt : trajectory) bounds.include(pt);
+ }
+
+ // Start position marker (green) — auto-show info window
+ String startBuilding = RecordingFragment.sStartBuilding;
+ String startFloor = RecordingFragment.sStartFloor;
+ Marker startMarker = mMap.addMarker(new MarkerOptions()
+ .position(start)
+ .title("Start Position | " + sdf.format(new Date(startTime)))
+ .snippet((startBuilding != null ? startBuilding : "Outdoor")
+ + ", Floor: " + (startFloor != null ? startFloor : "--"))
+ .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_GREEN)));
+ if (startMarker != null) startMarker.showInfoWindow();
+
+ // Test point markers (red) — index starts at 2
+ Marker lastEndMarker = null;
+ if (testPoints != null) {
+ for (RecordingFragment.TestPoint tp : testPoints) {
+ LatLng pos = new LatLng(tp.lat, tp.lng);
+ bounds.include(pos);
+ int displayIndex = tp.index + 1; // shift: TP1 in recording → "Test Point 2" here
+ mMap.addMarker(new MarkerOptions()
+ .position(pos)
+ .title("Test Point " + displayIndex + " | " + sdf.format(new Date(tp.timestampMs)))
+ .snippet(tp.building + ", Floor: " + tp.floor)
+ .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED)));
+ }
+ }
+
+ // End position marker (blue) — auto-show info window
+ if (endPos != null) {
+ bounds.include(endPos);
+ long endTime = RecordingFragment.sEndTimeMs;
+ String endBuilding = RecordingFragment.sEndBuilding;
+ String endFloor = RecordingFragment.sEndFloor;
+ lastEndMarker = mMap.addMarker(new MarkerOptions()
+ .position(endPos)
+ .title("End Position | " + sdf.format(new Date(endTime)))
+ .snippet((endBuilding != null ? endBuilding : "Outdoor")
+ + ", Floor: " + (endFloor != null ? endFloor : "--"))
+ .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE)));
+ if (lastEndMarker != null) lastEndMarker.showInfoWindow();
+ }
+
+ // Fit camera to show all points
+ try {
+ mMap.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds.build(), 100));
+ } catch (Exception e) {
+ mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(start, 19f));
+ }
+
+ // Clear static data to prevent memory leaks
+ RecordingFragment.clearStaticData();
}
});
@@ -111,11 +193,9 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
averageStepLengthText.setText(getString(R.string.averageStepLgn) + ": "
+ String.format("%.2f", averageStepLength));
- // Listen for ENTER key
this.stepLengthInput.setOnKeyListener((v, keyCode, event) -> {
if (keyCode == KeyEvent.KEYCODE_ENTER) {
newStepLength = Float.parseFloat(changedText.toString());
- // Rescale path
sensorFusion.redrawPath(newStepLength / averageStepLength);
averageStepLengthText.setText(getString(R.string.averageStepLgn)
+ ": " + String.format("%.2f", newStepLength));
@@ -131,78 +211,69 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
});
this.stepLengthInput.addTextChangedListener(new TextWatcher() {
- @Override
- public void beforeTextChanged(CharSequence s, int start, int count,int after) {}
- @Override
- public void onTextChanged(CharSequence s, int start, int before,int count) {}
- @Override
- public void afterTextChanged(Editable s) {
- changedText = s;
- }
+ @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+ @Override public void onTextChanged(CharSequence s, int start, int before, int count) {}
+ @Override public void afterTextChanged(Editable s) { changedText = s; }
});
- // Button to finalize corrections
this.button = view.findViewById(R.id.correction_done);
- this.button.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View view) {
- // ************* CHANGED CODE HERE *************
- // Before:
- // NavDirections action = CorrectionFragmentDirections.actionCorrectionFragmentToHomeFragment();
- // Navigation.findNavController(view).navigate(action);
- // ((AppCompatActivity)getActivity()).getSupportActionBar().show();
-
- // Now, simply tell the Activity we are done:
- ((RecordingActivity) requireActivity()).finishFlow();
+ this.button.setOnClickListener(v ->
+ ((RecordingActivity) requireActivity()).finishFlow());
+
+ // Map type toggle
+ view.findViewById(R.id.mapTypeToggle).setOnClickListener(v -> {
+ if (mMap == null) return;
+ isNormalMap = !isNormalMap;
+ if (isNormalMap) {
+ mMap.setMapType(GoogleMap.MAP_TYPE_NORMAL);
+ try {
+ mMap.setMapStyle(com.google.android.gms.maps.model.MapStyleOptions
+ .loadRawResourceStyle(requireContext(), R.raw.map_style_pink));
+ } catch (Exception e) {
+ if (DEBUG) Log.w("CorrectionFragment", "Failed to apply pink map style", e);
+ }
+ } else {
+ mMap.setMapType(GoogleMap.MAP_TYPE_HYBRID);
+ mMap.setMapStyle(null);
}
});
}
+ /** Sets the scaling ratio for path rendering. */
public void setScalingRatio(float scalingRatio) {
this.scalingRatio = scalingRatio;
}
- /**
- * Runs pre-upload quality validation and either uploads directly (if clean)
- * or shows a warning dialog letting the user choose to proceed or cancel.
- */
private void validateAndUpload() {
TrajectoryValidator.ValidationResult result = sensorFusion.validateTrajectory();
if (result.isClean()) {
- // All checks passed — upload immediately
- Log.i("CorrectionFragment", "Trajectory validation passed, uploading");
+ if (DEBUG) Log.i("CorrectionFragment", "Trajectory validation passed, uploading");
sensorFusion.sendTrajectoryToCloud();
return;
}
String summary = result.buildSummary();
- Log.w("CorrectionFragment", "Trajectory quality issues:\n" + summary);
+ if (DEBUG) Log.w("CorrectionFragment", "Trajectory quality issues:\n" + summary);
if (!result.isPassed()) {
- // Blocking errors exist — warn strongly but still allow upload
new AlertDialog.Builder(requireContext())
.setTitle(R.string.validation_error_title)
.setMessage(getString(R.string.validation_error_message, summary))
- .setPositiveButton(R.string.upload_anyway, (dialog, which) -> {
- sensorFusion.sendTrajectoryToCloud();
- })
- .setNegativeButton(R.string.cancel_upload, (dialog, which) -> {
- dialog.dismiss();
- })
+ .setPositiveButton(R.string.upload_anyway, (dialog, which) ->
+ sensorFusion.sendTrajectoryToCloud())
+ .setNegativeButton(R.string.cancel_upload, (dialog, which) ->
+ dialog.dismiss())
.setCancelable(false)
.show();
} else {
- // Only warnings — show lighter dialog
new AlertDialog.Builder(requireContext())
.setTitle(R.string.validation_warning_title)
.setMessage(getString(R.string.validation_warning_message, summary))
- .setPositiveButton(R.string.upload_anyway, (dialog, which) -> {
- sensorFusion.sendTrajectoryToCloud();
- })
- .setNegativeButton(R.string.cancel_upload, (dialog, which) -> {
- dialog.dismiss();
- })
+ .setPositiveButton(R.string.upload_anyway, (dialog, which) ->
+ sensorFusion.sendTrajectoryToCloud())
+ .setNegativeButton(R.string.cancel_upload, (dialog, which) ->
+ dialog.dismiss())
.setCancelable(false)
.show();
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/HomeFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/HomeFragment.java
index 654c4bfd..8ee0e3c7 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/HomeFragment.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/HomeFragment.java
@@ -124,7 +124,10 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
// Indoor Positioning button
indoorButton = view.findViewById(R.id.indoorButton);
indoorButton.setOnClickListener(v -> {
- Toast.makeText(requireContext(), R.string.indoor_mode_hint, Toast.LENGTH_SHORT).show();
+ Intent intent = new Intent(requireContext(), RecordingActivity.class);
+ intent.putExtra(RecordingActivity.EXTRA_LAUNCH_INDOOR_MODE, true);
+ startActivity(intent);
+ ((AppCompatActivity) getActivity()).getSupportActionBar().hide();
});
// TextView to display GNSS disabled message
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java
index 340843ca..3f4439d3 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java
@@ -1,8 +1,11 @@
package com.openpositioning.PositionMe.presentation.fragment;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.app.AlertDialog;
import android.content.Context;
import android.content.SharedPreferences;
+import android.util.Log;
import android.graphics.Color;
import android.os.Bundle;
import android.os.CountDownTimer;
@@ -27,11 +30,24 @@
import com.openpositioning.PositionMe.R;
import com.openpositioning.PositionMe.presentation.activity.RecordingActivity;
+import com.openpositioning.PositionMe.presentation.view.CompassView;
+import com.openpositioning.PositionMe.presentation.view.WeatherView;
+import com.openpositioning.PositionMe.presentation.view.WifiSignalView;
+import com.openpositioning.PositionMe.data.remote.WeatherApiClient;
import com.openpositioning.PositionMe.sensors.SensorFusion;
import com.openpositioning.PositionMe.sensors.SensorTypes;
+import com.openpositioning.PositionMe.sensors.Wifi;
+import com.openpositioning.PositionMe.sensors.fusion.CalibrationManager;
+import com.openpositioning.PositionMe.sensors.fusion.CoordinateTransform;
+import com.openpositioning.PositionMe.sensors.fusion.FusionManager;
import com.openpositioning.PositionMe.utils.UtilFunctions;
import com.google.android.gms.maps.model.LatLng;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileWriter;
import java.util.ArrayList;
import java.util.List;
@@ -64,12 +80,26 @@
*/
public class RecordingFragment extends Fragment {
+ public static final String ARG_INDOOR_MODE = "arg_indoor_mode";
// UI elements
- private MaterialButton completeButton, cancelButton;
+ private MaterialButton completeButton, cancelButton, printLocationButton;
private ImageView recIcon;
private ProgressBar timeRemaining;
- private TextView elevation, distanceTravelled, gnssError;
+ private TextView elevation, distanceTravelled, gnssError, wifiError;
+ private View uploadButton;
+ private View testPointButton;
+ private View calibrationTouchBlocker;
+ private CompassView compassView;
+ private WeatherView weatherView;
+ private WifiSignalView wifiSignalView;
+ private WeatherApiClient weatherApiClient;
+ private boolean weatherFetched = false;
+ private boolean weakWifiToastShown = false; // avoid spamming toast
+ private boolean calibrationLocked = false;
+ private boolean indoorMode = false;
+ private CountDownTimer calibrationLockTimer;
+ private com.google.android.material.snackbar.Snackbar calibrationSnackbar;
// App settings
private SharedPreferences settings;
@@ -84,6 +114,9 @@ public class RecordingFragment extends Fragment {
private float previousPosX = 0f;
private float previousPosY = 0f;
+ // Barometer calibration logger
+ private com.openpositioning.PositionMe.utils.BarometerLogger baroLogger;
+
// References to the child map fragment
private TrajectoryMapFragment trajectoryMapFragment;
@@ -96,17 +129,75 @@ public void run() {
}
};
- public RecordingFragment() {
- // Required empty public constructor
- }
+ /** Required empty public constructor. */
+ public RecordingFragment() {}
+
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ if (getArguments() != null) {
+ indoorMode = getArguments().getBoolean(ARG_INDOOR_MODE, false);
+ }
this.sensorFusion = SensorFusion.getInstance();
Context context = requireActivity();
this.settings = PreferenceManager.getDefaultSharedPreferences(context);
this.refreshDataHandler = new Handler();
+
+ // Load previously collected calibration data for WiFi-based corrections
+ this.calibrationManager = new CalibrationManager();
+ this.calibrationManager.loadFromFile(context);
+ if (DEBUG) Log.i("RecordingFragment", "Calibration records loaded: "
+ + calibrationManager.getRecordCount());
+
+ // Load existing calibration records file so new records are APPENDED, not overwritten.
+ // Also detect the most common floor for initial floor estimation.
+ // BUG FIX: try primary file first, fall back to .bak if parse fails.
+ // Never leave calibrationRecords empty when data exists on disk.
+ {
+ java.io.File calFile = new java.io.File(context.getExternalFilesDir(null),
+ "calibration_records.json");
+ java.io.File bakFile = new java.io.File(context.getExternalFilesDir(null),
+ "calibration_records.json.bak");
+
+ JSONArray loaded = null;
+ // Try primary, then backup
+ for (java.io.File f : new java.io.File[]{calFile, bakFile}) {
+ if (f.exists() && f.length() > 0) {
+ try {
+ java.io.FileInputStream fis = new java.io.FileInputStream(f);
+ byte[] bytes = new byte[(int) f.length()];
+ fis.read(bytes);
+ fis.close();
+ loaded = new JSONArray(new String(bytes));
+ if (DEBUG) Log.i("RecordingFragment", "Loaded " + loaded.length()
+ + " calibration records from " + f.getName());
+ break; // success — stop trying
+ } catch (Exception parseEx) {
+ if (DEBUG) Log.w("RecordingFragment",
+ "Failed to parse " + f.getName() + ", trying next source", parseEx);
+ }
+ }
+ }
+
+ if (loaded != null && loaded.length() > 0) {
+ calibrationRecords = loaded;
+
+ // CalDB floor seeding DISABLED: 2D haversine distance cannot
+ // distinguish floors in the same building (all floors share
+ // nearly identical lat/lng). Default to floor 0 and let
+ // IndoorMapManager.floorBias map it to the correct ground floor.
+ // Baro will handle relative floor changes from there.
+ if (DEBUG) Log.i("RecordingFragment", String.format(
+ "[FloorInit] CalDB has %d records — floor seeding disabled (default=0, baro-relative)",
+ calibrationRecords.length()));
+
+ if (DEBUG) Log.i("RecordingFragment", "Loaded " + calibrationRecords.length()
+ + " existing calibration records for appending");
+ } else {
+ if (DEBUG) Log.w("RecordingFragment", "No calibration data found in primary or backup file");
+ }
+ }
}
@Nullable
@@ -135,22 +226,93 @@ public void onViewCreated(@NonNull View view,
.beginTransaction()
.replace(R.id.trajectoryMapFragmentContainer, trajectoryMapFragment)
.commit();
+ getChildFragmentManager().executePendingTransactions();
}
+ // Wire manual correction: long-press map → drag → confirm → correct PF
+ trajectoryMapFragment.setCorrectionCallback(correctedPosition -> {
+ FusionManager fusion = sensorFusion.getFusionManager();
+
+ // Capture position B (current estimated position before correction)
+ LatLng estimatedPos = (fusion != null && fusion.isActive())
+ ? fusion.getFusedLatLng()
+ : trajectoryMapFragment.getCurrentLocation();
+
+ // Log A (true), B (estimated) for analysis
+ if (DEBUG) Log.i("Correction",
+ String.format("A(true)=(%.6f,%.6f) B(est)=(%.6f,%.6f) error=%.1fm",
+ correctedPosition.latitude, correctedPosition.longitude,
+ estimatedPos != null ? estimatedPos.latitude : 0,
+ estimatedPos != null ? estimatedPos.longitude : 0,
+ estimatedPos != null
+ ? UtilFunctions.distanceBetweenPoints(correctedPosition, estimatedPos)
+ : -1));
+
+ // Feed position A as strong observation to particle filter
+ if (fusion != null) {
+ fusion.onManualCorrection(
+ correctedPosition.latitude, correctedPosition.longitude);
+ }
+
+ // Store as test point in protobuf (position A = ground truth)
+ sensorFusion.addTestPointToProto(
+ System.currentTimeMillis(),
+ correctedPosition.latitude,
+ correctedPosition.longitude);
+
+ // Remember for Upload button
+ lastCorrectionPosition = correctedPosition;
+
+ double errorM = estimatedPos != null
+ ? UtilFunctions.distanceBetweenPoints(correctedPosition, estimatedPos)
+ : 0;
+ Toast.makeText(requireContext(),
+ String.format("Marked! Tap Upload to save (error: %.1fm)", errorM),
+ Toast.LENGTH_SHORT).show();
+ });
+
// Initialize UI references
elevation = view.findViewById(R.id.currentElevation);
distanceTravelled = view.findViewById(R.id.currentDistanceTraveled);
gnssError = view.findViewById(R.id.gnssError);
+ wifiError = view.findViewById(R.id.wifiError);
completeButton = view.findViewById(R.id.stopButton);
cancelButton = view.findViewById(R.id.cancelButton);
+ printLocationButton = view.findViewById(R.id.printLocationButton);
recIcon = view.findViewById(R.id.redDot);
timeRemaining = view.findViewById(R.id.timeRemainingBar);
- view.findViewById(R.id.btn_test_point).setOnClickListener(v -> onAddTestPoint());
+ testPointButton = view.findViewById(R.id.btn_test_point);
+ uploadButton = view.findViewById(R.id.btn_upload);
+ calibrationTouchBlocker = view.findViewById(R.id.calibrationTouchBlocker);
+ compassView = view.findViewById(R.id.compassView);
+ weatherView = view.findViewById(R.id.weatherView);
+ wifiSignalView = view.findViewById(R.id.wifiSignalView);
+ weatherApiClient = new WeatherApiClient();
+ testPointButton.setOnClickListener(v -> onAddTestPoint());
+
+ // Indoor mode: hide bottom control bar and floating buttons
+ if (indoorMode) {
+ view.findViewById(R.id.controlLayout).setVisibility(View.GONE);
+ if (testPointButton != null) testPointButton.setVisibility(View.GONE);
+ if (uploadButton != null) uploadButton.setVisibility(View.GONE);
+ }
+
+ // Upload button — captures {A(true), B(estimated), WiFi} as a calibration record
+ uploadButton.setOnClickListener(v -> {
+ if (lastCorrectionPosition == null) {
+ Toast.makeText(requireContext(),
+ "Long-press the map to mark your position first",
+ Toast.LENGTH_SHORT).show();
+ return;
+ }
+ saveCorrectionRecord();
+ });
// Hide or initialize default values
gnssError.setVisibility(View.GONE);
+ wifiError.setVisibility(View.GONE);
elevation.setText(getString(R.string.elevation, "0"));
distanceTravelled.setText(getString(R.string.meter, "0"));
@@ -158,8 +320,21 @@ public void onViewCreated(@NonNull View view,
completeButton.setOnClickListener(v -> {
// Stop recording & go to correction
if (autoStop != null) autoStop.cancel();
+ if (baroLogger != null) { baroLogger.stop(); baroLogger = null; }
+
+ // Capture trajectory and test points for CorrectionFragment
+ sLastTestPoints = new ArrayList<>(testPoints);
+ sLastTrajectoryPoints = trajectoryMapFragment != null
+ ? trajectoryMapFragment.getTrajectoryPoints() : new ArrayList<>();
+ sLastEndPosition = trajectoryMapFragment != null
+ ? trajectoryMapFragment.getCurrentLocation() : null;
+ sEndTimeMs = System.currentTimeMillis();
+ sEndBuilding = trajectoryMapFragment != null
+ ? trajectoryMapFragment.getLocationBuilding() : "Outdoor";
+ sEndFloor = trajectoryMapFragment != null
+ ? trajectoryMapFragment.getLocationFloor() : "--";
+
sensorFusion.stopRecording();
- // Show Correction screen
((RecordingActivity) requireActivity()).showCorrectionScreen();
});
@@ -171,6 +346,7 @@ public void onViewCreated(@NonNull View view,
.setMessage("Are you sure you want to cancel the recording? Your progress will be lost permanently!")
.setNegativeButton("Yes", (dialogInterface, which) -> {
// User confirmed cancellation
+ if (baroLogger != null) { baroLogger.stop(); baroLogger = null; }
sensorFusion.stopRecording();
if (autoStop != null) autoStop.cancel();
requireActivity().onBackPressed();
@@ -190,6 +366,43 @@ public void onViewCreated(@NonNull View view,
dialog.show(); // Finally, show the dialog
});
+ // Print Location — show building/floor/location info and copy to clipboard
+ printLocationButton.setOnClickListener(v -> {
+ String info = trajectoryMapFragment != null
+ ? trajectoryMapFragment.getLocationSummary()
+ : "Location unavailable";
+ android.content.ClipboardManager clipboard = (android.content.ClipboardManager)
+ requireContext().getSystemService(android.content.Context.CLIPBOARD_SERVICE);
+ clipboard.setPrimaryClip(android.content.ClipData.newPlainText("location", info));
+ View anchor = requireView().findViewById(R.id.btn_upload);
+ com.google.android.material.snackbar.Snackbar snackbar =
+ com.google.android.material.snackbar.Snackbar.make(
+ requireView(), getString(R.string.location_copied) + "\n" + info,
+ com.google.android.material.snackbar.Snackbar.LENGTH_SHORT);
+ snackbar.setAnchorView(anchor);
+ TextView snackText = snackbar.getView().findViewById(
+ com.google.android.material.R.id.snackbar_text);
+ if (snackText != null) {
+ snackText.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
+ }
+ snackbar.show();
+ });
+
+ // Start barometer calibration logger
+ baroLogger = new com.openpositioning.PositionMe.utils.BarometerLogger(requireContext());
+ baroLogger.start();
+ if (DEBUG) Log.i("RecordingFragment", "BarometerLogger started → " + baroLogger.getFilePath());
+
+ // Record start time and location info for CorrectionFragment
+ sStartTimeMs = System.currentTimeMillis();
+ if (trajectoryMapFragment != null) {
+ sStartBuilding = trajectoryMapFragment.getLocationBuilding();
+ sStartFloor = trajectoryMapFragment.getLocationFloor();
+ } else {
+ sStartBuilding = "Outdoor";
+ sStartFloor = "--";
+ }
+
// The blinking effect for recIcon
blinkingRecordingIcon();
@@ -210,6 +423,7 @@ public void onTick(long millisUntilFinished) {
@Override
public void onFinish() {
+ if (baroLogger != null) { baroLogger.stop(); baroLogger = null; }
sensorFusion.stopRecording();
((RecordingActivity) requireActivity()).showCorrectionScreen();
}
@@ -218,16 +432,72 @@ public void onFinish() {
// No set time limit, just keep refreshing
refreshDataHandler.post(refreshDataTask);
}
+
+ if (indoorMode) {
+ applyIndoorPositioningMode();
+ } else {
+ startInitialCalibrationLock();
+ }
+ }
+
+ private void applyIndoorPositioningMode() {
+ if (trajectoryMapFragment != null) {
+ float[] startLocation = sensorFusion.getGNSSLatitude(true);
+ trajectoryMapFragment.setInitialCameraPosition(new LatLng(
+ startLocation[0],
+ startLocation[1]
+ ));
+ trajectoryMapFragment.applyIndoorPositioningModeDefaults();
+ }
+ // 10-second calibration lock with position averaging (same as normal)
+ setCalibrationLocked(true);
+ showCalibrationSnackbar(10);
+
+ sensorFusion.setPdrPaused(true);
+
+ FusionManager fm = sensorFusion.getFusionManager();
+ if (fm != null) {
+ fm.startCalibrationMode();
+ }
+
+ calibrationLockTimer = new CountDownTimer(10_000, 1_000) {
+ @Override
+ public void onTick(long millisUntilFinished) {
+ int secondsRemaining = (int) Math.ceil(millisUntilFinished / 1000.0);
+ showCalibrationSnackbar(secondsRemaining);
+ }
+
+ @Override
+ public void onFinish() {
+ // Finalize heading from 10s of samples
+
+ FusionManager fm = sensorFusion.getFusionManager();
+ if (fm != null) {
+ float[] fallback = sensorFusion.getGNSSLatitude(true);
+ LatLng calibratedPos = fm.finishCalibrationMode(fallback[0], fallback[1]);
+ if (trajectoryMapFragment != null && calibratedPos != null) {
+ trajectoryMapFragment.setInitialCameraPosition(calibratedPos);
+ }
+ if (DEBUG) Log.i("RecordingFragment", "[CalibrationMode-Indoor] Final position: " + calibratedPos);
+ }
+ sensorFusion.setPdrPaused(false);
+
+ setCalibrationLocked(false);
+ dismissCalibrationSnackbar();
+ }
+ }.start();
}
private void onAddTestPoint() {
// 1) Ensure the map fragment is ready
if (trajectoryMapFragment == null) return;
- // 2) Read current track position (must lie on the current path)
- LatLng cur = trajectoryMapFragment.getCurrentLocation();
+ // 2) Read current position — prefer fused (triangle marker), fall back to PDR
+ FusionManager fm = sensorFusion.getFusionManager();
+ LatLng cur = (fm != null && fm.isActive()) ? fm.getFusedLatLng() : null;
+ if (cur == null) cur = trajectoryMapFragment.getCurrentLocation();
if (cur == null) {
- Toast.makeText(requireContext(), "" +
+ Toast.makeText(requireContext(),
"I haven't gotten my current location yet, let me take a couple of steps/wait for the map to load.",
Toast.LENGTH_SHORT).show();
return;
@@ -238,7 +508,9 @@ private void onAddTestPoint() {
long ts = System.currentTimeMillis();
// 4) Keep a local copy for in-session tracking
- testPoints.add(new TestPoint(idx, ts, cur.latitude, cur.longitude));
+ String building = trajectoryMapFragment.getLocationBuilding();
+ String floor = trajectoryMapFragment.getLocationFloor();
+ testPoints.add(new TestPoint(idx, ts, cur.latitude, cur.longitude, building, floor));
// Write test point into protobuf payload
sensorFusion.addTestPointToProto(ts, cur.latitude, cur.longitude);
@@ -264,36 +536,63 @@ private void updateUIandPosition() {
float elevationVal = sensorFusion.getElevation();
elevation.setText(getString(R.string.elevation, String.format("%.1f", elevationVal)));
- // Current location
- // Convert PDR coordinates to actual LatLng if you have a known starting lat/lon
- // Or simply pass relative data for the TrajectoryMapFragment to handle
- // For example:
- float[] latLngArray = sensorFusion.getGNSSLatitude(true);
- if (latLngArray != null) {
- LatLng oldLocation = trajectoryMapFragment.getCurrentLocation(); // or store locally
- LatLng newLocation = UtilFunctions.calculateNewPos(
- oldLocation == null ? new LatLng(latLngArray[0], latLngArray[1]) : oldLocation,
- new float[]{ pdrValues[0] - previousPosX, pdrValues[1] - previousPosY }
- );
+ // Compass heading
+ if (compassView != null) {
+ compassView.setHeading((float) Math.toDegrees(sensorFusion.passOrientation()));
+ }
+
+ // ---- PDR position in unified ENU coordinates ----
+ // PDR.positionX = easting (m), PDR.positionY = northing (m), origin = start location
+ // Convert ENU → LatLng via CoordinateTransform
+ FusionManager fm = sensorFusion.getFusionManager();
+ if (fm != null && trajectoryMapFragment != null) {
+ CoordinateTransform ct = fm.getCoordinateTransform();
+ if (ct.isInitialized()) {
+ // PDR position directly in ENU (posX=easting, posY=northing)
+ double pdrEasting = pdrValues[0];
+ double pdrNorthing = pdrValues[1];
+
+ // [RoundTrip] verify ENU→LatLng is stable (log every 10th update)
+ if (((int)(distance * 10)) % 50 == 0 && pdrEasting != 0) {
+ double[] rtEnu = ct.toEastNorth(ct.toLatLng(pdrEasting, pdrNorthing)[0],
+ ct.toLatLng(pdrEasting, pdrNorthing)[1]);
+ double rtErr = Math.hypot(rtEnu[0] - pdrEasting, rtEnu[1] - pdrNorthing);
+ if (DEBUG) Log.i("Step1Debug", String.format("[RoundTrip] pdrENU=(%.3f,%.3f) rt=(%.3f,%.3f) err=%.6fm",
+ pdrEasting, pdrNorthing, rtEnu[0], rtEnu[1], rtErr));
+ }
+
+ // PDR display: no wall constraints (following Meld convention).
+ // Wall influence is handled inside the particle filter only.
+ double[] pdrLatLng = ct.toLatLng(pdrEasting, pdrNorthing);
+ LatLng newLocation = new LatLng(pdrLatLng[0], pdrLatLng[1]);
- // Pass the location + orientation to the map
- if (trajectoryMapFragment != null) {
trajectoryMapFragment.updateUserLocation(newLocation,
(float) Math.toDegrees(sensorFusion.passOrientation()));
+ } else {
+ // CoordinateTransform not yet initialized — fallback to start location
+ float[] latLngArray = sensorFusion.getGNSSLatitude(true);
+ if (latLngArray != null && (latLngArray[0] != 0 || latLngArray[1] != 0)) {
+ LatLng oldLocation = trajectoryMapFragment.getCurrentLocation();
+ LatLng newLocation = UtilFunctions.calculateNewPos(
+ oldLocation == null ? new LatLng(latLngArray[0], latLngArray[1]) : oldLocation,
+ new float[]{pdrValues[0] - previousPosX, pdrValues[1] - previousPosY}
+ );
+ trajectoryMapFragment.updateUserLocation(newLocation,
+ (float) Math.toDegrees(sensorFusion.passOrientation()));
+ }
}
}
- // GNSS logic if you want to show GNSS error, etc.
+ // GNSS logic — show marker + error distance (tied to Show GNSS switch)
float[] gnss = sensorFusion.getSensorValueMap().get(SensorTypes.GNSSLATLONG);
if (gnss != null && trajectoryMapFragment != null) {
- // If user toggles showing GNSS in the map, call e.g.
if (trajectoryMapFragment.isGnssEnabled()) {
LatLng gnssLocation = new LatLng(gnss[0], gnss[1]);
LatLng currentLoc = trajectoryMapFragment.getCurrentLocation();
if (currentLoc != null) {
double errorDist = UtilFunctions.distanceBetweenPoints(currentLoc, gnssLocation);
gnssError.setVisibility(View.VISIBLE);
- gnssError.setText(String.format(getString(R.string.gnss_error) + "%.2fm", errorDist));
+ gnssError.setText(String.format(getString(R.string.gnss_error) + " %.1fm", errorDist));
}
trajectoryMapFragment.updateGNSS(gnssLocation);
} else {
@@ -302,6 +601,127 @@ private void updateUIandPosition() {
}
}
+ // ---- Fused position from particle filter ---
+ FusionManager fusion = sensorFusion.getFusionManager();
+
+ if (fusion != null && trajectoryMapFragment != null) {
+ boolean active = fusion.isActive();
+ trajectoryMapFragment.setFusionActive(active);
+
+ if (active) {
+ LatLng fusedPos = fusion.getFusedLatLng();
+ if (fusedPos != null) {
+ trajectoryMapFragment.updateFusedLocation(
+ fusedPos,
+ fusion.getFusedUncertainty(),
+ (float) Math.toDegrees(sensorFusion.passOrientation()));
+ }
+ }
+
+ // Show latest WiFi fix as a green marker + WiFi error (tied to Show WiFi switch)
+ LatLng wifiPos = sensorFusion.getLatLngWifiPositioning();
+ if (wifiPos != null) {
+ trajectoryMapFragment.updateWifiMarker(wifiPos);
+ if (trajectoryMapFragment.isWifiEnabled()) {
+ LatLng currentLoc = trajectoryMapFragment.getCurrentLocation();
+ if (currentLoc != null) {
+ double wifiDist = UtilFunctions.distanceBetweenPoints(currentLoc, wifiPos);
+ wifiError.setVisibility(View.VISIBLE);
+ wifiError.setText(String.format(getString(R.string.wifi_error) + " %.1fm", wifiDist));
+ }
+ } else {
+ wifiError.setVisibility(View.GONE);
+ }
+ } else {
+ wifiError.setVisibility(View.GONE);
+ }
+
+ // Calibration-based correction: match current WiFi to stored records
+ // Also runs during calibration mode to feed position averaging
+ long now = System.currentTimeMillis();
+ boolean calDbAllowed = fusion.isActive() || fusion.isInCalibrationMode();
+ if (fusion.isInCalibrationMode()) {
+ if (DEBUG) Log.d("RecordingFragment", String.format(
+ "[CalDB-Check] calMode=true records=%d timeSinceLastCheck=%dms calDbAllowed=%b",
+ calibrationManager.getRecordCount(),
+ now - lastCalibrationCheckMs, calDbAllowed));
+ }
+ if (calibrationManager.getRecordCount() > 0
+ && now - lastCalibrationCheckMs > (fusion.isInCalibrationMode() ? 2000 : CALIBRATION_CHECK_INTERVAL_MS)
+ && calDbAllowed) {
+ lastCalibrationCheckMs = now;
+ List currentWifi = sensorFusion.getWifiList();
+ int calFloor = fusion.isActive() ? fusion.getFusedFloor() : 0;
+
+ if (fusion.isInCalibrationMode()) {
+ // During calibration: use unfiltered top-K weighted position
+ LatLng calPos = calibrationManager.findCalibrationPosition(currentWifi, calFloor);
+ if (calPos != null) {
+ fusion.addCalibrationFix(calPos.latitude, calPos.longitude,
+ 3.0, "CAL_DB");
+ }
+ } else {
+ // Normal mode: use quality-filtered match for fusion
+ CalibrationManager.MatchResult match =
+ calibrationManager.findBestMatch(currentWifi, calFloor);
+ if (match != null) {
+ CoordinateTransform ct = fusion.getCoordinateTransform();
+ if (ct.isInitialized()) {
+ double[] en = ct.toEastNorth(
+ match.truePosition.latitude, match.truePosition.longitude);
+ fusion.onCalibrationObservation(
+ en[0], en[1], match.uncertainty,
+ fusion.getFusedFloor(), match.quality);
+ }
+ updateWifiSignal(match);
+ } else {
+ if (wifiSignalView != null) {
+ wifiSignalView.setLevel(currentWifi != null && !currentWifi.isEmpty() ? 1 : 0);
+ }
+ }
+ // DEBUG: show forward 120° 20m ref point weighted average
+ if (trajectoryMapFragment != null && trajectoryMapFragment.isEstimatedEnabled()
+ && fusion.isActive() && fusion.getCoordinateTransform().isInitialized()) {
+ double myX = fusion.getParticleFilter().getEstimatedX();
+ double myY = fusion.getParticleFilter().getEstimatedY();
+ double heading = sensorFusion.passOrientation();
+ double halfFov = Math.toRadians(60); // ±60° = 120°
+ double maxDist = 20.0;
+ List refs = calibrationManager.getRecordPositions(fusion.getFusedFloor());
+ if (refs != null && !refs.isEmpty()) {
+ CoordinateTransform ct = fusion.getCoordinateTransform();
+ double sumW = 0, wLat = 0, wLng = 0;
+ int count = 0;
+ for (LatLng ref : refs) {
+ double[] en = ct.toEastNorth(ref.latitude, ref.longitude);
+ double dx = en[0] - myX;
+ double dy = en[1] - myY;
+ double dist = Math.hypot(dx, dy);
+ if (dist > maxDist || dist < 0.5) continue;
+ double dir = Math.atan2(dx, dy);
+ double diff = dir - heading;
+ while (diff > Math.PI) diff -= 2 * Math.PI;
+ while (diff < -Math.PI) diff += 2 * Math.PI;
+ if (Math.abs(diff) > halfFov) continue;
+ double w = 1.0 / Math.max(dist, 0.5);
+ wLat += ref.latitude * w;
+ wLng += ref.longitude * w;
+ sumW += w;
+ count++;
+ }
+ if (sumW > 0) {
+ LatLng estPos = new LatLng(wLat / sumW, wLng / sumW);
+ trajectoryMapFragment.updateEstimatedMarker(estPos);
+ if (DEBUG) Log.d("DEBUG_EST", String.format("forward120° pts=%d pos=(%.6f,%.6f)",
+ count, estPos.latitude, estPos.longitude));
+ }
+ }
+ }
+ }
+ }
+
+ }
+
// Update previous
previousPosX = pdrValues[0];
previousPosY = pdrValues[1];
@@ -319,10 +739,120 @@ private void blinkingRecordingIcon() {
recIcon.startAnimation(blinking);
}
+ private void startInitialCalibrationLock() {
+ if (trajectoryMapFragment != null) {
+ trajectoryMapFragment.enterInitialCalibrationMode();
+ }
+ setCalibrationLocked(true);
+ showCalibrationSnackbar(10);
+
+ // Pause PDR and enter calibration averaging mode + extended heading
+ sensorFusion.setPdrPaused(true);
+
+ FusionManager fm = sensorFusion.getFusionManager();
+ if (fm != null) {
+ fm.startCalibrationMode();
+ }
+
+ calibrationLockTimer = new CountDownTimer(10_000, 1_000) {
+ @Override
+ public void onTick(long millisUntilFinished) {
+ int secondsRemaining = (int) Math.ceil(millisUntilFinished / 1000.0);
+ showCalibrationSnackbar(secondsRemaining);
+ }
+
+ @Override
+ public void onFinish() {
+ // Finalize heading from 10s of samples
+
+ // Finalize calibrated position and resume PDR
+ FusionManager fm = sensorFusion.getFusionManager();
+ if (fm != null) {
+ float[] fallback = sensorFusion.getGNSSLatitude(true);
+ LatLng calibratedPos = fm.finishCalibrationMode(fallback[0], fallback[1]);
+ if (trajectoryMapFragment != null && calibratedPos != null) {
+ trajectoryMapFragment.setInitialCameraPosition(calibratedPos);
+ }
+ if (DEBUG) Log.i("RecordingFragment", "[CalibrationMode] Final position: " + calibratedPos);
+ }
+ sensorFusion.setPdrPaused(false);
+
+ setCalibrationLocked(false);
+ dismissCalibrationSnackbar();
+ }
+ }.start();
+ }
+
+ private void setCalibrationLocked(boolean locked) {
+ calibrationLocked = locked;
+
+ if (calibrationTouchBlocker != null) {
+ calibrationTouchBlocker.setVisibility(View.GONE);
+ }
+ if (completeButton != null) completeButton.setEnabled(!locked);
+ if (printLocationButton != null) printLocationButton.setEnabled(!locked);
+ if (uploadButton != null) uploadButton.setEnabled(!locked);
+ if (testPointButton != null) testPointButton.setEnabled(!locked);
+ if (cancelButton != null) cancelButton.setEnabled(true);
+
+ if (trajectoryMapFragment != null) {
+ trajectoryMapFragment.setInteractionLocked(locked);
+ }
+ }
+
+ private void showCalibrationSnackbar(int secondsRemaining) {
+ if (!isAdded() || getView() == null) return;
+
+ String message = indoorMode
+ ? getString(R.string.calibration_countdown_indoor, secondsRemaining)
+ : getString(R.string.calibration_countdown, secondsRemaining);
+ if (calibrationSnackbar == null) {
+ calibrationSnackbar = com.google.android.material.snackbar.Snackbar.make(
+ requireView(),
+ message,
+ com.google.android.material.snackbar.Snackbar.LENGTH_INDEFINITE
+ );
+ if (uploadButton != null && uploadButton.getVisibility() == View.VISIBLE) {
+ calibrationSnackbar.setAnchorView(uploadButton);
+ }
+ } else {
+ calibrationSnackbar.setText(message);
+ }
+
+ TextView snackText = calibrationSnackbar.getView().findViewById(
+ com.google.android.material.R.id.snackbar_text);
+ if (snackText != null) {
+ snackText.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
+ }
+
+ calibrationSnackbar.show();
+ }
+
+ private void dismissCalibrationSnackbar() {
+ if (calibrationSnackbar != null) {
+ calibrationSnackbar.dismiss();
+ calibrationSnackbar = null;
+ }
+ }
+
@Override
public void onPause() {
super.onPause();
refreshDataHandler.removeCallbacks(refreshDataTask);
+ if (calibrationLockTimer != null) {
+ calibrationLockTimer.cancel();
+ calibrationLockTimer = null;
+ }
+ setCalibrationLocked(false);
+ dismissCalibrationSnackbar();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ if (refreshDataHandler != null) {
+ refreshDataHandler.removeCallbacksAndMessages(null);
+ }
}
@Override
@@ -333,23 +863,281 @@ public void onResume() {
}
}
+ // Last confirmed correction position (A = true position)
+ private LatLng lastCorrectionPosition = null;
+
+ // Calibration records file
+ private JSONArray calibrationRecords = new JSONArray();
+
+ // WiFi fingerprint matching for position correction
+ private CalibrationManager calibrationManager;
+ private long lastCalibrationCheckMs = 0;
+ private static final long CALIBRATION_CHECK_INTERVAL_MS = 5000; // check every 5s
+
+ /**
+ * Maps a CalibrationManager.MatchResult to the 3-bar WiFi signal indicator
+ * and shows a toast when quality is poor.
+ *
+ * Level mapping:
+ * 3 (green) = GOOD with commonApCount >= 5
+ * 2 (amber) = GOOD with commonApCount < 5, or AMBIGUOUS
+ * 1 (orange) = WEAK / other
+ */
+ private void updateWifiSignal(CalibrationManager.MatchResult match) {
+ if (wifiSignalView == null) return;
+
+ int level;
+ switch (match.quality) {
+ case "GOOD":
+ level = (match.commonApCount >= 5) ? 3 : 2;
+ weakWifiToastShown = false; // reset once quality recovers
+ break;
+ case "AMBIGUOUS":
+ level = 2;
+ break;
+ default: // "WEAK" or anything else
+ level = 1;
+ break;
+ }
+ wifiSignalView.setLevel(level);
+
+ // Show toast once when signal drops to weak
+ if (level <= 1 && !weakWifiToastShown && isAdded()) {
+ weakWifiToastShown = true;
+ Toast.makeText(requireContext(),
+ "WiFi signal quality is poor (matched APs: "
+ + match.commonApCount + ")",
+ Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ /**
+ * Captures a calibration record: position A (true), B (estimated), and latest WiFi scan.
+ * Saves to a local JSON file for later analysis or upload.
+ */
+ private void saveCorrectionRecord() {
+ try {
+ FusionManager fusion = sensorFusion.getFusionManager();
+
+ // A = true position (user-marked)
+ double aLat = lastCorrectionPosition.latitude;
+ double aLng = lastCorrectionPosition.longitude;
+
+ // B = current estimated position
+ LatLng estPos = (fusion != null && fusion.isActive())
+ ? fusion.getFusedLatLng()
+ : trajectoryMapFragment.getCurrentLocation();
+ double bLat = estPos != null ? estPos.latitude : 0;
+ double bLng = estPos != null ? estPos.longitude : 0;
+
+ // WiFi fingerprint at this moment
+ List wifiList = sensorFusion.getWifiList();
+ JSONArray wifiArray = new JSONArray();
+ if (wifiList != null) {
+ for (Wifi w : wifiList) {
+ JSONObject ap = new JSONObject();
+ ap.put("bssid", w.getBssidString());
+ ap.put("rssi", w.getLevel());
+ ap.put("ssid", w.getSsid());
+ wifiArray.put(ap);
+ }
+ }
+
+ // Build record
+ JSONObject record = new JSONObject();
+ record.put("timestamp", System.currentTimeMillis());
+ record.put("true_lat", aLat);
+ record.put("true_lng", aLng);
+ record.put("estimated_lat", bLat);
+ record.put("estimated_lng", bLng);
+ record.put("error_m", estPos != null
+ ? UtilFunctions.distanceBetweenPoints(lastCorrectionPosition, estPos) : -1);
+ // Floor priority: manual map selection > WiFi > fused > 0
+ int floor = 0;
+ // Try getting the floor the user is currently viewing on the indoor map
+ if (trajectoryMapFragment != null) {
+ try {
+ // Use reflection-free approach: the fused floor should match what's displayed
+ FusionManager fm2 = sensorFusion.getFusionManager();
+ if (fm2 != null) floor = fm2.getFusedFloor();
+ } catch (Exception ignored) {}
+ }
+ if (floor == 0) {
+ int wf = sensorFusion.getWifiFloor();
+ if (wf >= 0) floor = wf;
+ }
+ if (floor == 0 && fusion != null) floor = fusion.getFusedFloor();
+ record.put("floor", floor);
+ record.put("wifi", wifiArray);
+
+ calibrationRecords.put(record);
+
+ // ---- Safe write with backup ----
+ File dir = requireContext().getExternalFilesDir(null);
+ File file = new File(dir, "calibration_records.json");
+ File backup = new File(dir, "calibration_records.json.bak");
+ File temp = new File(dir, "calibration_records.json.tmp");
+
+ // Safety guard: refuse to overwrite if in-memory has fewer records than file on disk.
+ // This prevents the bug where a failed load leaves calibrationRecords empty and
+ // a subsequent save wipes all stored data.
+ if (file.exists() && file.length() > 0) {
+ try {
+ java.io.FileInputStream checkFis = new java.io.FileInputStream(file);
+ byte[] checkBytes = new byte[(int) file.length()];
+ checkFis.read(checkBytes);
+ checkFis.close();
+ JSONArray existingOnDisk = new JSONArray(new String(checkBytes));
+ if (calibrationRecords.length() < existingOnDisk.length()) {
+ // In-memory data lost some records — reload from disk first, then append new record
+ Log.e("Calibration", "SAFETY: in-memory (" + calibrationRecords.length()
+ + ") < on-disk (" + existingOnDisk.length()
+ + "). Reloading from disk to prevent data loss.");
+ existingOnDisk.put(record);
+ // Remove the record we already added to the stale in-memory array
+ calibrationRecords = existingOnDisk;
+ // Remove duplicate: we already put(record) above, undo the first put
+ // Actually calibrationRecords now IS existingOnDisk which already has the record
+ // The earlier put on the old calibrationRecords is discarded since we reassigned
+ }
+ } catch (Exception checkEx) {
+ if (DEBUG) Log.w("Calibration", "Could not verify on-disk count, proceeding", checkEx);
+ }
+
+ // Backup current file before overwriting
+ copyFile(file, backup);
+ }
+
+ // Write to temp file first, then rename (pseudo-atomic)
+ FileWriter writer = new FileWriter(temp, false);
+ writer.write(calibrationRecords.toString(2));
+ writer.flush();
+ writer.close();
+
+ // Verify temp file is valid before replacing
+ if (temp.exists() && temp.length() > 10) {
+ if (file.exists()) file.delete();
+ if (!temp.renameTo(file)) {
+ // renameTo can fail on some Android storage — fallback to copy
+ copyFile(temp, file);
+ temp.delete();
+ }
+ } else {
+ Log.e("Calibration", "Temp file empty or missing, NOT replacing main file!");
+ }
+
+ // Clear last correction so user must mark again before next upload
+ lastCorrectionPosition = null;
+
+ Toast.makeText(requireContext(),
+ String.format("Saved! (%d records total) → %s",
+ calibrationRecords.length(), file.getName()),
+ Toast.LENGTH_LONG).show();
+
+ if (DEBUG) Log.i("Calibration", "Record saved: " + record.toString());
+
+ } catch (Exception e) {
+ Log.e("Calibration", "Failed to save record", e);
+ Toast.makeText(requireContext(), "Save failed: " + e.getMessage(),
+ Toast.LENGTH_SHORT).show();
+ }
+ }
+
private int testPointIndex = 0;
- private static class TestPoint {
+ static class TestPoint implements java.io.Serializable {
final int index;
final long timestampMs;
final double lat;
final double lng;
+ final String building;
+ final String floor;
- TestPoint(int index, long timestampMs, double lat, double lng) {
+ TestPoint(int index, long timestampMs, double lat, double lng,
+ String building, String floor) {
this.index = index;
this.timestampMs = timestampMs;
this.lat = lat;
this.lng = lng;
+ this.building = building;
+ this.floor = floor;
}
}
private final List testPoints = new ArrayList<>();
+ // Static data passed to CorrectionFragment
+ static List sLastTestPoints;
+ static List sLastTrajectoryPoints;
+ static LatLng sLastEndPosition;
+ static long sStartTimeMs;
+ static long sEndTimeMs;
+ static String sStartBuilding;
+ static String sStartFloor;
+ static String sEndBuilding;
+ static String sEndFloor;
+
+ /** Clears all static data shared with CorrectionFragment to prevent memory leaks. */
+ public static void clearStaticData() {
+ sLastTestPoints = null;
+ sLastTrajectoryPoints = null;
+ sLastEndPosition = null;
+ sStartTimeMs = 0;
+ sEndTimeMs = 0;
+ sStartBuilding = null;
+ sStartFloor = null;
+ sEndBuilding = null;
+ sEndFloor = null;
+ }
+ /**
+ * Toggles weather view visibility and fetches weather data on first show.
+ *
+ * @param visible true to show the weather view
+ */
+ public void setWeatherVisible(boolean visible) {
+ if (weatherView == null) return;
+ weatherView.setVisibility(visible ? View.VISIBLE : View.GONE);
+
+ if (visible && !weatherFetched) {
+ weatherFetched = true;
+ // Use GNSS coordinates for weather location
+ float[] gnss = sensorFusion.getGNSSLatitude(true);
+ if (gnss != null && (gnss[0] != 0 || gnss[1] != 0)) {
+ weatherApiClient.fetchWeather(gnss[0], gnss[1],
+ new WeatherApiClient.WeatherCallback() {
+ @Override
+ public void onWeatherResult(int wmoCode, double temperatureC) {
+ if (weatherView != null) {
+ weatherView.setWeather(wmoCode, temperatureC);
+ }
+ }
+ @Override
+ public void onError(String message) {
+ if (DEBUG) Log.w("RecordingFragment", "Weather fetch failed: " + message);
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Copy a file byte-for-byte. Used for backup before overwrite.
+ */
+ private static void copyFile(File src, File dst) {
+ try {
+ java.io.FileInputStream in = new java.io.FileInputStream(src);
+ java.io.FileOutputStream out = new java.io.FileOutputStream(dst);
+ byte[] buf = new byte[4096];
+ int len;
+ while ((len = in.read(buf)) > 0) {
+ out.write(buf, 0, len);
+ }
+ in.close();
+ out.flush();
+ out.close();
+ } catch (Exception e) {
+ if (DEBUG) Log.w("Calibration", "copyFile failed: " + src.getName() + " → " + dst.getName(), e);
+ }
+ }
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java
index 0951e85a..a25a5ee9 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java
@@ -1,5 +1,7 @@
package com.openpositioning.PositionMe.presentation.fragment;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.graphics.Color;
import android.os.Bundle;
import android.util.Log;
@@ -84,13 +86,9 @@ public class StartLocationFragment extends Fragment {
private static final int FILL_COLOR_SELECTED = Color.argb(100, 33, 150, 243);
private static final int STROKE_COLOR_SELECTED = Color.argb(255, 25, 118, 210);
- /**
- * Public Constructor for the class.
- * Left empty as not required
- */
- public StartLocationFragment() {
- // Required empty public constructor
- }
+ /** Required empty public constructor. */
+ public StartLocationFragment() {}
+
/**
* {@inheritDoc}
@@ -105,6 +103,9 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container,
}
View rootView = inflater.inflate(R.layout.fragment_startlocation, container, false);
+ // Ensure WiFi scanning is running so WiFi floor data is cached before recording
+ sensorFusion.ensureWirelessScanning();
+
// Obtain the start position from GPS data
startPosition = sensorFusion.getGNSSLatitude(false);
if (startPosition[0] == 0 && startPosition[1] == 0) {
@@ -127,6 +128,8 @@ public void onMapReady(GoogleMap googleMap) {
mMap = googleMap;
setupMap();
requestBuildingData();
+ // Also request with Nucleus centre as fallback if GNSS is far from buildings
+ requestBuildingDataForKnownBuildings();
}
});
@@ -210,7 +213,7 @@ public void onSuccess(List buildings) {
}
if (buildings.isEmpty()) {
- Log.d(TAG, "No buildings returned by API");
+ if (DEBUG) Log.d(TAG, "No buildings returned by API");
if (instructionText != null) {
instructionText.setText(R.string.noBuildingsFound);
}
@@ -230,6 +233,55 @@ public void onFailure(String error) {
});
}
+ /**
+ * Fallback: request floorplan for known building locations (Nucleus, Library)
+ * in case the GNSS-based request missed them.
+ */
+ private void requestBuildingDataForKnownBuildings() {
+ // Known centres: Nucleus and Library
+ double[][] knownBuildings = {
+ {55.9230, -3.1742}, // Nucleus
+ {55.9229, -3.1750}, // Library
+ };
+
+ FloorplanApiClient apiClient = new FloorplanApiClient();
+ List observedMacs = new ArrayList<>();
+ List wifiList = sensorFusion.getWifiList();
+ if (wifiList != null) {
+ for (com.openpositioning.PositionMe.sensors.Wifi wifi : wifiList) {
+ String mac = wifi.getBssidString();
+ if (mac != null && !mac.isEmpty()) observedMacs.add(mac);
+ }
+ }
+
+ for (double[] loc : knownBuildings) {
+ apiClient.requestFloorplan((float) loc[0], (float) loc[1], observedMacs,
+ new FloorplanApiClient.FloorplanCallback() {
+ @Override
+ public void onSuccess(List buildings) {
+ if (!isAdded() || buildings.isEmpty()) return;
+ for (FloorplanApiClient.BuildingInfo b : buildings) {
+ if (!floorplanBuildingMap.containsKey(b.getName())) {
+ floorplanBuildingMap.put(b.getName(), b);
+ }
+ }
+ // Merge into sensorFusion cache
+ sensorFusion.setFloorplanBuildings(
+ new ArrayList<>(floorplanBuildingMap.values()));
+ // Draw outlines if not already drawn
+ if (buildingPolygons.isEmpty() && mMap != null) {
+ drawBuildingOutlines(buildings);
+ }
+ }
+
+ @Override
+ public void onFailure(String error) {
+ // Silent fallback failure
+ }
+ });
+ }
+ }
+
/**
* Draws building outlines on the map as clickable coloured polygons.
*
@@ -239,7 +291,7 @@ private void drawBuildingOutlines(List building
for (FloorplanApiClient.BuildingInfo building : buildings) {
List outlinePoints = building.getOutlinePolygon();
if (outlinePoints == null || outlinePoints.size() < 3) {
- Log.w(TAG, "Skipping building with insufficient outline points: "
+ if (DEBUG) Log.w(TAG, "Skipping building with insufficient outline points: "
+ building.getName());
continue;
}
@@ -269,7 +321,7 @@ private void drawBuildingOutlines(List building
mMap.animateCamera(CameraUpdateFactory.newLatLngBounds(
boundsBuilder.build(), 100));
} catch (Exception e) {
- Log.w(TAG, "Could not fit bounds", e);
+ if (DEBUG) Log.w(TAG, "Could not fit bounds", e);
}
}
}
@@ -316,7 +368,7 @@ private void onBuildingSelected(String buildingName, Polygon polygon) {
// Update UI with building name
updateBuildingInfoDisplay(buildingName);
- Log.d(TAG, "Building selected: " + buildingName);
+ if (DEBUG) Log.d(TAG, "Building selected: " + buildingName);
}
/**
@@ -338,7 +390,7 @@ private void showFloorPlanOverlay(String buildingName) {
List floors = building.getFloorShapesList();
if (floors == null || floors.isEmpty()) {
- Log.d(TAG, "No floor shape data available for: " + buildingName);
+ if (DEBUG) Log.d(TAG, "No floor shape data available for: " + buildingName);
return;
}
@@ -462,15 +514,32 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
float chosenLat = startPosition[0];
float chosenLon = startPosition[1];
+ // Auto-detect building if user didn't explicitly click one
+ if (selectedBuildingId == null && !floorplanBuildingMap.isEmpty()) {
+ double bestDist = Double.MAX_VALUE;
+ for (java.util.Map.Entry e
+ : floorplanBuildingMap.entrySet()) {
+ LatLng c = e.getValue().getCenter();
+ double d = Math.hypot(c.latitude - chosenLat, c.longitude - chosenLon);
+ if (d < bestDist) {
+ bestDist = d;
+ selectedBuildingId = e.getKey();
+ }
+ }
+ if (DEBUG) Log.i(TAG, "Auto-selected building: " + selectedBuildingId
+ + " userPos=(" + startPosition[0] + "," + startPosition[1] + ")");
+ }
+
// Save the building selection for campaign binding during upload
if (selectedBuildingId != null) {
sensorFusion.setSelectedBuildingId(selectedBuildingId);
}
if (requireActivity() instanceof RecordingActivity) {
- // Start sensor recording + set the start location
- sensorFusion.startRecording();
+ // Set start location BEFORE startRecording so fusion gets the correct origin
sensorFusion.setStartGNSSLatitude(startPosition);
+ // Start sensor recording
+ sensorFusion.startRecording();
// Write trajectory_id, initial_position and initial heading to protobuf
sensorFusion.writeInitialMetadata();
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java
index 479ea51b..1b554eb4 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java
@@ -1,5 +1,7 @@
package com.openpositioning.PositionMe.presentation.fragment;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
@@ -12,8 +14,10 @@
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
+import android.widget.ImageView;
import android.widget.Spinner;
import android.widget.TextView;
+import com.google.android.material.card.MaterialCardView;
import com.google.android.material.switchmaterial.SwitchMaterial;
import androidx.annotation.NonNull;
@@ -22,6 +26,7 @@
import com.google.android.gms.maps.OnMapReadyCallback;
import com.openpositioning.PositionMe.R;
+import com.openpositioning.PositionMe.data.remote.FloorplanApiClient;
import com.openpositioning.PositionMe.sensors.SensorFusion;
import com.openpositioning.PositionMe.utils.IndoorMapManager;
import com.openpositioning.PositionMe.utils.UtilFunctions;
@@ -29,6 +34,7 @@
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.*;
+import com.openpositioning.PositionMe.sensors.fusion.FusionManager;
import java.util.ArrayList;
import java.util.List;
@@ -64,13 +70,34 @@ public class TrajectoryMapFragment extends Fragment {
// Keep test point markers so they can be cleared when recording ends
private final List testPointMarkers = new ArrayList<>();
- private Polyline polyline; // Polyline representing user's movement path
- private boolean isRed = true; // Tracks whether the polyline color is red
- private boolean isGnssOn = false; // Tracks if GNSS tracking is enabled
+ private Polyline polyline; // Polyline representing user's PDR movement path
+ private boolean isGnssOn = true; // Tracks if GNSS tracking is enabled
+ private boolean isWifiOn = true; // Tracks if WiFi marker is enabled
+ private boolean isEstimatedOn = false; // Tracks if CalDB estimated marker is shown
+ private Marker estimatedMarker; // Grey marker for KNN estimated position
+
+ // Last N observation dots
+ private static final int OBS_HISTORY_SIZE = 3;
+ private final java.util.LinkedList gnssObsDots = new java.util.LinkedList<>();
+ private final java.util.LinkedList wifiObsDots = new java.util.LinkedList<>();
+ private static final int FUSED_OBS_HISTORY_SIZE = 10;
+ private final java.util.LinkedList fusedObsDots = new java.util.LinkedList<>();
private Polyline gnssPolyline; // Polyline for GNSS path
private LatLng lastGnssLocation = null; // Stores the last GNSS location
+ // Fused position display
+ private Polyline fusedPolyline; // Purple polyline for fused trajectory
+ private Marker fusedMarker; // Marker for current fused position
+ private Marker wifiMarker; // Green marker for latest WiFi fix
+ private Circle fusedUncertaintyCircle; // Uncertainty radius around fused position
+ private boolean fusionActive = false; // When true, fused marker is primary
+
+ // Manual correction
+ private Marker correctionDragMarker; // Draggable marker for user correction
+ private final List correctionMarkers = new ArrayList<>(); // Confirmed corrections
+ private CorrectionCallback correctionCallback;
+
private LatLng pendingCameraPosition = null; // Stores pending camera movement
private boolean hasPendingCameraMove = false; // Tracks if camera needs to move
@@ -81,25 +108,71 @@ public class TrajectoryMapFragment extends Fragment {
private static final String TAG = "TrajectoryMapFragment";
private static final long AUTO_FLOOR_DEBOUNCE_MS = 3000;
private static final long AUTO_FLOOR_CHECK_INTERVAL_MS = 1000;
+ private static final long WIFI_SEED_WINDOW_MS = 10000; // 10s WiFi refresh window
private Handler autoFloorHandler;
private Runnable autoFloorTask;
+ private Runnable wifiSeedTimeoutTask; // clears WiFi callback after seed window
private int lastCandidateFloor = Integer.MIN_VALUE;
private long lastCandidateTime = 0;
+ // Elevator/stairs distinction: track elevation at floor-change start
+ private float elevationAtFloorChangeStart = Float.NaN;
+ private long floorChangeStartTimeMs = 0;
+ /** Elevation rate threshold (m/s). Above = elevator, below = stairs. */
+ private static final float ELEVATOR_RATE_THRESHOLD = 0.5f;
+ /** Last detected transition method: "elevator", "stairs", or "unknown". */
+ private String lastTransitionMethod = "unknown";
+
// UI
private Spinner switchMapSpinner;
-
+ private View mapInteractionBlocker;
+ private View calibrationMapStyleList;
+ private TextView mapStyleHybridOption;
+ private TextView mapStyleNormalOption;
+ private TextView mapStyleSatelliteOption;
+ private SwitchMaterial compassSwitch;
+ private SwitchMaterial weatherSwitch;
private SwitchMaterial gnssSwitch;
+ private SwitchMaterial wifiSwitch;
+ private SwitchMaterial estimatedSwitch;
+ private SwitchMaterial pdrPathSwitch;
+ private SwitchMaterial fusedPathSwitch;
+ private SwitchMaterial smoothPathSwitch;
private SwitchMaterial autoFloorSwitch;
+ private SwitchMaterial navigatingSwitch;
private com.google.android.material.floatingactionbutton.FloatingActionButton floorUpButton, floorDownButton;
private TextView floorLabel;
- private Button switchColorButton;
+ private MaterialCardView controlPanel;
+ private ImageView toolbarToggle;
+ private boolean toolbarExpanded = true;
+ private boolean interactionLocked = false;
+ private boolean pendingInitialCalibrationMode = false;
+ private boolean pendingIndoorPositioningModeDefaults = false;
+ private boolean showPdrPath = false;
+ private boolean showFusedPath = false;
+ private boolean showSmoothPath = false;
+ private boolean navigatingMode = false;
+ private Polyline smoothPolyline;
+
+ // Smooth path state (EMA)
+ // Smooth path state (Moving Average)
+ private static final int SMOOTH_WINDOW = 40;
+ private final java.util.LinkedList smoothBuffer = new java.util.LinkedList<>();
private Polygon buildingPolygon;
- public TrajectoryMapFragment() {
- // Required empty public constructor
+ /** Callback for when a user confirms a manual position correction. */
+ public interface CorrectionCallback {
+ void onCorrectionConfirmed(LatLng correctedPosition);
+ }
+
+ /** Required empty public constructor. */
+ public TrajectoryMapFragment() {}
+
+ /** Sets the callback to receive manual position corrections. */
+ public void setCorrectionCallback(CorrectionCallback callback) {
+ this.correctionCallback = callback;
}
@Nullable
@@ -118,12 +191,114 @@ public void onViewCreated(@NonNull View view,
// Grab references to UI controls
switchMapSpinner = view.findViewById(R.id.mapSwitchSpinner);
- gnssSwitch = view.findViewById(R.id.gnssSwitch);
+ mapInteractionBlocker = view.findViewById(R.id.mapInteractionBlocker);
+ calibrationMapStyleList = view.findViewById(R.id.calibrationMapStyleList);
+ mapStyleHybridOption = view.findViewById(R.id.mapStyleHybridOption);
+ mapStyleNormalOption = view.findViewById(R.id.mapStyleNormalOption);
+ mapStyleSatelliteOption = view.findViewById(R.id.mapStyleSatelliteOption);
+ compassSwitch = view.findViewById(R.id.compassSwitch);
+ weatherSwitch = view.findViewById(R.id.weatherSwitch);
+ gnssSwitch = view.findViewById(R.id.gnssSwitch);
+ wifiSwitch = view.findViewById(R.id.wifiSwitch);
+ estimatedSwitch = view.findViewById(R.id.estimatedSwitch);
+ pdrPathSwitch = view.findViewById(R.id.pdrPathSwitch);
+ fusedPathSwitch = view.findViewById(R.id.fusedPathSwitch);
+ smoothPathSwitch = view.findViewById(R.id.smoothPathSwitch);
autoFloorSwitch = view.findViewById(R.id.autoFloor);
- floorUpButton = view.findViewById(R.id.floorUpButton);
+ navigatingSwitch = view.findViewById(R.id.navigatingSwitch);
+ floorUpButton = view.findViewById(R.id.floorUpButton);
floorDownButton = view.findViewById(R.id.floorDownButton);
- floorLabel = view.findViewById(R.id.floorLabel);
- switchColorButton = view.findViewById(R.id.lineColorButton);
+ floorLabel = view.findViewById(R.id.floorLabel);
+ controlPanel = view.findViewById(R.id.controlPanel);
+ toolbarToggle = view.findViewById(R.id.toolbarToggle);
+
+ mapStyleHybridOption.setOnClickListener(v -> applyMapTypeSelection(0));
+ mapStyleNormalOption.setOnClickListener(v -> applyMapTypeSelection(1));
+ mapStyleSatelliteOption.setOnClickListener(v -> applyMapTypeSelection(2));
+
+ // Toolbar collapse/expand toggle
+ toolbarToggle.setOnClickListener(v -> {
+ if (interactionLocked) return;
+ if (toolbarExpanded) {
+ // Collapse: slide card left, then hide
+ controlPanel.animate()
+ .translationX(-controlPanel.getWidth())
+ .alpha(0f)
+ .setDuration(250)
+ .withEndAction(() -> controlPanel.setVisibility(View.GONE))
+ .start();
+ toolbarToggle.setImageResource(R.drawable.ic_chevron_right);
+ } else {
+ // Expand: show card, slide back in
+ controlPanel.setVisibility(View.VISIBLE);
+ controlPanel.setAlpha(0f);
+ controlPanel.animate()
+ .translationX(0)
+ .alpha(0.85f)
+ .setDuration(250)
+ .start();
+ toolbarToggle.setImageResource(R.drawable.ic_chevron_left);
+ }
+ toolbarExpanded = !toolbarExpanded;
+ });
+
+ // Compass toggle — controls CompassView in parent RecordingFragment
+ compassSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (interactionLocked) { buttonView.setChecked(!isChecked); return; }
+ Fragment parent = getParentFragment();
+ if (parent != null && parent.getView() != null) {
+ View compass = parent.getView().findViewById(R.id.compassView);
+ if (compass != null) {
+ compass.setVisibility(isChecked ? View.VISIBLE : View.GONE);
+ }
+ }
+ });
+
+ // Weather toggle — controls WeatherView in parent RecordingFragment
+ weatherSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (interactionLocked) { buttonView.setChecked(!isChecked); return; }
+ Fragment parent = getParentFragment();
+ if (parent instanceof RecordingFragment) {
+ ((RecordingFragment) parent).setWeatherVisible(isChecked);
+ }
+ });
+
+ // Path visibility toggles
+ pdrPathSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (interactionLocked) { buttonView.setChecked(!isChecked); return; }
+ showPdrPath = isChecked;
+ if (polyline != null) polyline.setVisible(isChecked);
+ });
+
+ fusedPathSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (interactionLocked) { buttonView.setChecked(!isChecked); return; }
+ showFusedPath = isChecked;
+ if (fusedPolyline != null) fusedPolyline.setVisible(isChecked);
+ });
+
+ smoothPathSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (interactionLocked) { buttonView.setChecked(!isChecked); return; }
+ showSmoothPath = isChecked;
+ if (smoothPolyline != null) smoothPolyline.setVisible(isChecked);
+ });
+
+ // Navigating mode toggle — camera follows + rotates with heading
+ navigatingSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (interactionLocked) { buttonView.setChecked(!isChecked); return; }
+ navigatingMode = isChecked;
+ if (isChecked) {
+ snapCameraToUser();
+ } else if (gMap != null) {
+ // Reset to north-up when switching to free mode
+ CameraPosition northUp = new CameraPosition.Builder()
+ .target(gMap.getCameraPosition().target)
+ .zoom(gMap.getCameraPosition().zoom)
+ .bearing(0)
+ .tilt(0)
+ .build();
+ gMap.animateCamera(CameraUpdateFactory.newCameraPosition(northUp));
+ }
+ });
// Setup floor up/down UI hidden initially until we know there's an indoor map
setFloorControlsVisibility(View.GONE);
@@ -139,17 +314,31 @@ public void onMapReady(@NonNull GoogleMap googleMap) {
gMap = googleMap;
// Initialize map settings with the now non-null gMap
initMapSettings(gMap);
+ setInteractionLocked(interactionLocked);
// If we had a pending camera move, apply it now
if (hasPendingCameraMove && pendingCameraPosition != null) {
gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(pendingCameraPosition, 19f));
+ if (indoorMapManager != null) {
+ indoorMapManager.setCurrentLocation(pendingCameraPosition);
+ if (DEBUG) Log.i(TAG, "[onMapReady] setCurrentLocation done, isIndoorMapSet="
+ + indoorMapManager.getIsIndoorMapSet()
+ + " pos=" + pendingCameraPosition
+ + " floorplanBuildings=" + SensorFusion.getInstance().getFloorplanBuildings().size()
+ + " pendingIndoor=" + pendingIndoorPositioningModeDefaults);
+ setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE);
+ }
hasPendingCameraMove = false;
pendingCameraPosition = null;
}
drawBuildingPolygon();
- Log.d("TrajectoryMapFragment", "onMapReady: Map is ready!");
+ if (DEBUG) Log.d("TrajectoryMapFragment", "onMapReady: Map is ready!");
+
+ if (pendingIndoorPositioningModeDefaults) {
+ applyIndoorPositioningModeDefaultsInternal();
+ }
}
@@ -161,6 +350,7 @@ public void onMapReady(@NonNull GoogleMap googleMap) {
// GNSS Switch
gnssSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (interactionLocked) { buttonView.setChecked(!isChecked); return; }
isGnssOn = isChecked;
if (!isChecked && gnssMarker != null) {
gnssMarker.remove();
@@ -168,24 +358,31 @@ public void onMapReady(@NonNull GoogleMap googleMap) {
}
});
- // Color switch
- switchColorButton.setOnClickListener(v -> {
- if (polyline != null) {
- if (isRed) {
- switchColorButton.setBackgroundColor(Color.BLACK);
- polyline.setColor(Color.BLACK);
- isRed = false;
- } else {
- switchColorButton.setBackgroundColor(Color.RED);
- polyline.setColor(Color.RED);
- isRed = true;
- }
+ // WiFi Switch
+ wifiSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (interactionLocked) { buttonView.setChecked(!isChecked); return; }
+ isWifiOn = isChecked;
+ if (!isChecked && wifiMarker != null) {
+ wifiMarker.remove();
+ wifiMarker = null;
+ }
+ });
+
+ // Estimated (CalDB KNN) switch
+ estimatedSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (interactionLocked) { buttonView.setChecked(!isChecked); return; }
+ isEstimatedOn = isChecked;
+ if (!isChecked) {
+ if (estimatedMarker != null) { estimatedMarker.remove(); estimatedMarker = null; }
+ for (Circle c : fusedObsDots) c.remove();
+ fusedObsDots.clear();
}
});
// Auto-floor toggle: start/stop periodic floor evaluation
sensorFusion = SensorFusion.getInstance();
autoFloorSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> {
+ if (interactionLocked) { compoundButton.setChecked(!isChecked); return; }
if (isChecked) {
startAutoFloor();
} else {
@@ -209,22 +406,32 @@ public void onMapReady(@NonNull GoogleMap googleMap) {
updateFloorLabel();
}
});
+
+ // Apply pending calibration mode AFTER all listeners are registered,
+ // so resetToolbarSwitchesToOff() properly syncs Java fields via listeners.
+ if (pendingInitialCalibrationMode) {
+ applyInitialCalibrationMode();
+ }
+ }
+
+ @Override
+ public void onDestroyView() {
+ stopAutoFloor();
+ if (gMap != null) {
+ clearMapAndReset();
+ gMap = null;
+ }
+ super.onDestroyView();
}
/**
- * Initialize the map settings with the provided GoogleMap instance.
- *
- * The method sets basic map settings, initializes the indoor map manager,
- * and creates an empty polyline for user movement tracking.
- * The method also initializes the GNSS polyline for tracking GNSS path.
- * The method sets the map type to Hybrid and initializes the map with these settings.
+ * Initializes map settings, indoor map manager, and empty polylines for tracking.
*
- * @param map
+ * @param map the GoogleMap instance to configure
*/
-
private void initMapSettings(GoogleMap map) {
// Basic map settings
- map.getUiSettings().setCompassEnabled(true);
+ map.getUiSettings().setCompassEnabled(false);
map.getUiSettings().setTiltGesturesEnabled(true);
map.getUiSettings().setRotateGesturesEnabled(true);
map.getUiSettings().setScrollGesturesEnabled(true);
@@ -233,37 +440,241 @@ private void initMapSettings(GoogleMap map) {
// Initialize indoor manager
indoorMapManager = new IndoorMapManager(map);
+ // Inject IndoorMapManager into FusionManager for stairs detection
+ FusionManager fm = SensorFusion.getInstance().getFusionManager();
+ if (fm != null) {
+ fm.setIndoorMapManager(indoorMapManager);
+ }
+
// Initialize an empty polyline
polyline = map.addPolyline(new PolylineOptions()
.color(Color.RED)
.width(5f)
.add() // start empty
);
+ polyline.setVisible(showPdrPath);
- // GNSS path in blue
- gnssPolyline = map.addPolyline(new PolylineOptions()
- .color(Color.BLUE)
- .width(5f)
- .add() // start empty
+ // Fused trajectory in purple
+ fusedPolyline = map.addPolyline(new PolylineOptions()
+ .color(Color.rgb(156, 39, 176)) // Material Purple
+ .width(7f)
+ .add()
+ );
+ fusedPolyline.setVisible(showFusedPath);
+
+ // Smooth trajectory in green (placeholder — not yet populated)
+ smoothPolyline = map.addPolyline(new PolylineOptions()
+ .color(Color.rgb(76, 175, 80)) // Material Green
+ .width(7f)
+ .add()
);
+ smoothPolyline.setVisible(showSmoothPath);
+
+ // Long press → place a draggable correction marker
+ map.setOnMapLongClickListener(latLng -> {
+ if (interactionLocked) return;
+ // Remove previous drag marker if it exists
+ if (correctionDragMarker != null) {
+ correctionDragMarker.remove();
+ }
+ correctionDragMarker = map.addMarker(new MarkerOptions()
+ .position(latLng)
+ .title("Drag to your actual position")
+ .snippet("Tap this marker to confirm")
+ .draggable(true)
+ .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_ORANGE))
+ .zIndex(20));
+ if (correctionDragMarker != null) {
+ correctionDragMarker.showInfoWindow();
+ }
+ });
+
+ // Tap on the correction marker → confirm the correction
+ map.setOnInfoWindowClickListener(marker -> {
+ if (interactionLocked) return;
+ if (marker.equals(correctionDragMarker)) {
+ confirmCorrection(marker.getPosition());
+ marker.hideInfoWindow();
+ correctionDragMarker.remove();
+ correctionDragMarker = null;
+ }
+ });
+ }
+
+ /**
+ * Prepares the toolbar for the initial calibration window:
+ * expands the panel, clears all toggle switches, and opens the map-style dropdown.
+ */
+ public void enterInitialCalibrationMode() {
+ pendingInitialCalibrationMode = true;
+ if (!isAdded() || getView() == null || switchMapSpinner == null) return;
+ applyInitialCalibrationMode();
}
+ /** Applies default switch states for indoor positioning mode. */
+ public void applyIndoorPositioningModeDefaults() {
+ pendingIndoorPositioningModeDefaults = true;
+ if (!isAdded() || getView() == null || switchMapSpinner == null || gMap == null) return;
+ applyIndoorPositioningModeDefaultsInternal();
+ }
/**
- * Initialize the map type spinner with the available map types.
- *
- * The spinner allows the user to switch between different map types
- * (e.g. Hybrid, Normal, Satellite) to customize their map view.
- * The spinner is populated with the available map types and listens
- * for user selection to update the map accordingly.
- * The map type is updated directly on the GoogleMap instance.
- *
- * Note: The spinner is initialized with the default map type (Hybrid).
- * The map type is updated on user selection.
- *
- *
- * @see com.google.android.gms.maps.GoogleMap The GoogleMap instance to update map type.
+ * Locks or unlocks map interactions during the calibration window.
*/
+ public void setInteractionLocked(boolean locked) {
+ interactionLocked = locked;
+
+ if (locked) {
+ expandToolbar();
+ }
+
+ if (mapInteractionBlocker != null) {
+ mapInteractionBlocker.setVisibility(locked ? View.VISIBLE : View.GONE);
+ }
+ // Swap Spinner ↔ fixed list during calibration
+ if (switchMapSpinner != null) {
+ switchMapSpinner.setVisibility(locked ? View.GONE : View.VISIBLE);
+ }
+ if (calibrationMapStyleList != null) {
+ calibrationMapStyleList.setVisibility(locked ? View.VISIBLE : View.GONE);
+ }
+ // Toolbar toggle disabled during calibration (keep visual appearance)
+ if (toolbarToggle != null) {
+ toolbarToggle.setEnabled(!locked);
+ }
+ // Floor buttons disabled during calibration
+ if (floorUpButton != null) {
+ floorUpButton.setEnabled(!locked);
+ }
+ if (floorDownButton != null) {
+ floorDownButton.setEnabled(!locked);
+ }
+ // NOTE: switches are NOT disabled — that causes Material grey-out dimming.
+ // Instead, switch listeners check interactionLocked to reject changes.
+
+ if (gMap != null) {
+ gMap.getUiSettings().setScrollGesturesEnabled(!locked);
+ gMap.getUiSettings().setZoomGesturesEnabled(!locked);
+ gMap.getUiSettings().setRotateGesturesEnabled(!locked);
+ gMap.getUiSettings().setTiltGesturesEnabled(!locked);
+ gMap.getUiSettings().setMapToolbarEnabled(!locked);
+ }
+ }
+
+ private void resetToolbarSwitchesToDefaults() {
+ // Temporarily lift lock so listeners don't revert the programmatic changes
+ boolean wasLocked = interactionLocked;
+ interactionLocked = false;
+
+ // First 4: ON — toggle false→true to guarantee listener fires
+ // even if the XML default is already checked="true"
+ if (compassSwitch != null) { compassSwitch.setChecked(false); compassSwitch.setChecked(true); }
+ if (weatherSwitch != null) { weatherSwitch.setChecked(false); weatherSwitch.setChecked(true); }
+ if (gnssSwitch != null) { gnssSwitch.setChecked(false); gnssSwitch.setChecked(true); }
+ if (wifiSwitch != null) { wifiSwitch.setChecked(false); wifiSwitch.setChecked(true); }
+ // Rest: OFF
+ if (estimatedSwitch != null) estimatedSwitch.setChecked(false);
+ if (pdrPathSwitch != null) pdrPathSwitch.setChecked(false);
+ if (fusedPathSwitch != null) fusedPathSwitch.setChecked(false);
+ if (smoothPathSwitch != null) smoothPathSwitch.setChecked(false);
+ if (autoFloorSwitch != null) autoFloorSwitch.setChecked(false);
+ if (navigatingSwitch != null) navigatingSwitch.setChecked(false);
+
+ interactionLocked = wasLocked;
+ }
+
+ /**
+ * Restores the default switch states: first 4 ON, rest OFF.
+ * Called after calibration countdown finishes.
+ */
+ public void restoreDefaultSwitchStates() {
+ if (compassSwitch != null) compassSwitch.setChecked(true);
+ if (weatherSwitch != null) weatherSwitch.setChecked(true);
+ if (gnssSwitch != null) gnssSwitch.setChecked(true);
+ if (wifiSwitch != null) wifiSwitch.setChecked(true);
+ }
+
+ private void applyInitialCalibrationMode() {
+ expandToolbar();
+ resetToolbarSwitchesToDefaults();
+ // Swap: hide Spinner, show fixed list for calibration
+ if (switchMapSpinner != null) {
+ switchMapSpinner.setVisibility(View.GONE);
+ }
+ if (calibrationMapStyleList != null) {
+ calibrationMapStyleList.setVisibility(View.VISIBLE);
+ }
+ highlightSelectedMapStyleOption(switchMapSpinner != null ? switchMapSpinner.getSelectedItemPosition() : 0);
+ }
+
+ private void applyIndoorPositioningModeDefaultsInternal() {
+ if (gMap == null || indoorMapManager == null) {
+ pendingIndoorPositioningModeDefaults = true;
+ return;
+ }
+ pendingIndoorPositioningModeDefaults = false;
+ pendingInitialCalibrationMode = false;
+
+ // Temporarily lift interaction lock to set switches without listener revert,
+ // then restore — so calibration lock is preserved.
+ boolean wasLocked = interactionLocked;
+ interactionLocked = false;
+
+ // ON: Compass, Weather, Auto Floor, Smooth Path, Navigating
+ if (compassSwitch != null) { compassSwitch.setChecked(false); compassSwitch.setChecked(true); }
+ if (weatherSwitch != null) { weatherSwitch.setChecked(false); weatherSwitch.setChecked(true); }
+ if (smoothPathSwitch != null) { smoothPathSwitch.setChecked(false); smoothPathSwitch.setChecked(true); }
+ if (autoFloorSwitch != null) { autoFloorSwitch.setChecked(false); autoFloorSwitch.setChecked(true); }
+ if (navigatingSwitch != null) { navigatingSwitch.setChecked(false); navigatingSwitch.setChecked(true); }
+ // OFF: the rest
+ if (gnssSwitch != null) gnssSwitch.setChecked(false);
+ if (wifiSwitch != null) wifiSwitch.setChecked(false);
+ if (estimatedSwitch != null) estimatedSwitch.setChecked(false);
+ if (pdrPathSwitch != null) pdrPathSwitch.setChecked(false);
+ if (fusedPathSwitch != null) fusedPathSwitch.setChecked(false);
+
+ interactionLocked = wasLocked;
+
+ // Normal map
+ applyMapTypeSelection(1);
+
+ // Force building setup — skip pointInPolygon detection
+ String selectedBuilding = sensorFusion.getSelectedBuildingId();
+ if (selectedBuilding != null && indoorMapManager != null) {
+ boolean ok = indoorMapManager.forceSetBuilding(selectedBuilding);
+ setFloorControlsVisibility(ok ? View.VISIBLE : View.GONE);
+ }
+
+ if (calibrationMapStyleList != null) {
+ calibrationMapStyleList.setVisibility(View.GONE);
+ }
+ collapseToolbar();
+ }
+
+ private void expandToolbar() {
+ if (controlPanel == null || toolbarToggle == null) return;
+
+ controlPanel.animate().cancel();
+ controlPanel.setVisibility(View.VISIBLE);
+ controlPanel.setTranslationX(0f);
+ controlPanel.setAlpha(0.85f);
+ toolbarToggle.setImageResource(R.drawable.ic_chevron_left);
+ toolbarExpanded = true;
+ }
+
+ private void collapseToolbar() {
+ if (controlPanel == null || toolbarToggle == null) return;
+
+ controlPanel.animate().cancel();
+ controlPanel.setVisibility(View.GONE);
+ controlPanel.setTranslationX(-controlPanel.getWidth());
+ controlPanel.setAlpha(0f);
+ toolbarToggle.setImageResource(R.drawable.ic_chevron_right);
+ toolbarExpanded = false;
+ }
+
+
+ /** Initializes the map type spinner (Hybrid, Normal, Satellite). */
private void initMapTypeSpinner() {
if (switchMapSpinner == null) return;
String[] maps = new String[]{
@@ -271,35 +682,111 @@ private void initMapTypeSpinner() {
getString(R.string.normal),
getString(R.string.satellite)
};
- ArrayAdapter adapter = new ArrayAdapter<>(
+ ArrayAdapter adapter = new ArrayAdapter(
requireContext(),
- android.R.layout.simple_spinner_dropdown_item,
+ android.R.layout.simple_spinner_item,
maps
- );
+ ) {
+ @NonNull
+ @Override
+ public View getView(int position, View convertView, @NonNull ViewGroup parent) {
+ View v = super.getView(position, convertView, parent);
+ ((TextView) v).setGravity(android.view.Gravity.CENTER);
+ return v;
+ }
+
+ @Override
+ public View getDropDownView(int position, View convertView, @NonNull ViewGroup parent) {
+ View v = super.getDropDownView(position, convertView, parent);
+ if (v instanceof android.widget.CheckedTextView) {
+ ((android.widget.CheckedTextView) v).setGravity(android.view.Gravity.CENTER);
+ ((android.widget.CheckedTextView) v).setTextSize(
+ android.util.TypedValue.COMPLEX_UNIT_SP, 13);
+ } else if (v instanceof TextView) {
+ ((TextView) v).setGravity(android.view.Gravity.CENTER);
+ ((TextView) v).setTextSize(
+ android.util.TypedValue.COMPLEX_UNIT_SP, 13);
+ }
+ return v;
+ }
+ };
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
switchMapSpinner.setAdapter(adapter);
+ // Match dropdown popup to toolbar visual width and shift left
+ controlPanel.post(() -> {
+ if (controlPanel != null && switchMapSpinner != null) {
+ int scaledWidth = (int)(controlPanel.getWidth() * 0.80f);
+ switchMapSpinner.setDropDownWidth(scaledWidth);
+ switchMapSpinner.setDropDownHorizontalOffset(-20);
+ }
+ });
+ // Semi-transparent popup background matching toolbar alpha
+ android.graphics.drawable.ColorDrawable popupBg =
+ new android.graphics.drawable.ColorDrawable(android.graphics.Color.argb(
+ (int)(0.85f * 255), 255, 255, 255));
+ switchMapSpinner.setPopupBackgroundDrawable(popupBg);
+
switchMapSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView> parent, View view,
int position, long id) {
- if (gMap == null) return;
- switch (position){
- case 0:
- gMap.setMapType(GoogleMap.MAP_TYPE_HYBRID);
- break;
- case 1:
- gMap.setMapType(GoogleMap.MAP_TYPE_NORMAL);
- break;
- case 2:
- gMap.setMapType(GoogleMap.MAP_TYPE_SATELLITE);
- break;
- }
+ applyMapTypeSelection(position);
}
@Override
public void onNothingSelected(AdapterView> parent) {}
});
}
+ private void applyMapTypeSelection(int position) {
+ if (switchMapSpinner != null && switchMapSpinner.getSelectedItemPosition() != position) {
+ switchMapSpinner.setSelection(position);
+ }
+
+ if (gMap != null) {
+ switch (position) {
+ case 0:
+ gMap.setMapType(GoogleMap.MAP_TYPE_HYBRID);
+ gMap.setMapStyle(null); // clear custom style
+ break;
+ case 1:
+ gMap.setMapType(GoogleMap.MAP_TYPE_NORMAL);
+ // Apply light pink style to Normal map
+ try {
+ gMap.setMapStyle(com.google.android.gms.maps.model.MapStyleOptions
+ .loadRawResourceStyle(requireContext(), R.raw.map_style_pink));
+ } catch (Exception e) {
+ if (DEBUG) Log.w(TAG, "Failed to apply pink map style", e);
+ }
+ break;
+ case 2:
+ gMap.setMapType(GoogleMap.MAP_TYPE_SATELLITE);
+ gMap.setMapStyle(null);
+ break;
+ default:
+ break;
+ }
+ }
+
+ highlightSelectedMapStyleOption(position);
+ }
+
+ private void highlightSelectedMapStyleOption(int selectedPosition) {
+ updateMapStyleOptionAppearance(mapStyleHybridOption, selectedPosition == 0);
+ updateMapStyleOptionAppearance(mapStyleNormalOption, selectedPosition == 1);
+ updateMapStyleOptionAppearance(mapStyleSatelliteOption, selectedPosition == 2);
+ }
+
+ private void updateMapStyleOptionAppearance(@Nullable TextView option, boolean selected) {
+ if (option == null) return;
+ option.setBackgroundColor(selected
+ ? Color.argb(35, 2, 103, 125)
+ : Color.TRANSPARENT);
+ option.setTextColor(selected
+ ? requireContext().getColor(R.color.md_theme_primary)
+ : requireContext().getColor(R.color.md_theme_onSurface));
+ }
+
/**
* Update the user's current location on the map, create or move orientation marker,
* and append to polyline if the user actually moved.
@@ -310,74 +797,109 @@ public void onNothingSelected(AdapterView> parent) {}
public void updateUserLocation(@NonNull LatLng newLocation, float orientation) {
if (gMap == null) return;
- // Keep track of current location
LatLng oldLocation = this.currentLocation;
this.currentLocation = newLocation;
- // If no marker, create it
- if (orientationMarker == null) {
- orientationMarker = gMap.addMarker(new MarkerOptions()
- .position(newLocation)
- .flat(true)
- .title("Current Position")
- .icon(BitmapDescriptorFactory.fromBitmap(
- UtilFunctions.getBitmapFromVector(requireContext(),
- R.drawable.ic_baseline_navigation_24)))
- );
- gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(newLocation, 19f));
+ // When fusion is active, the PDR marker is hidden — only draw the polyline.
+ // The fused marker (purple) takes over as the primary position indicator.
+ if (!fusionActive) {
+ if (orientationMarker == null) {
+ orientationMarker = gMap.addMarker(new MarkerOptions()
+ .position(newLocation)
+ .flat(true)
+ .title("PDR Position")
+ .icon(BitmapDescriptorFactory.fromBitmap(
+ UtilFunctions.getBitmapFromVector(requireContext(),
+ R.drawable.ic_baseline_navigation_24)))
+ );
+ // Always center on first position
+ gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(newLocation, 19f));
+ } else {
+ orientationMarker.setPosition(newLocation);
+ orientationMarker.setRotation(orientation);
+ if (navigatingMode) {
+ CameraPosition pos = new CameraPosition.Builder()
+ .target(newLocation)
+ .zoom(gMap.getCameraPosition().zoom)
+ .bearing(orientation)
+ .tilt(0)
+ .build();
+ gMap.moveCamera(CameraUpdateFactory.newCameraPosition(pos));
+ }
+ }
} else {
- // Update marker position + orientation
- orientationMarker.setPosition(newLocation);
- orientationMarker.setRotation(orientation);
- // Move camera a bit
- gMap.moveCamera(CameraUpdateFactory.newLatLng(newLocation));
+ // Hide PDR marker when fusion takes over
+ if (orientationMarker != null) {
+ orientationMarker.setVisible(false);
+ }
}
- // Extend polyline if movement occurred
- /*if (oldLocation != null && !oldLocation.equals(newLocation) && polyline != null) {
- List points = new ArrayList<>(polyline.getPoints());
- points.add(newLocation);
- polyline.setPoints(points);
- }*/
- // Extend polyline
+ // Always extend the PDR polyline (red) regardless of fusion state
if (polyline != null) {
List points = new ArrayList<>(polyline.getPoints());
-
- // First position fix: add the first polyline point
if (oldLocation == null) {
points.add(newLocation);
polyline.setPoints(points);
} else if (!oldLocation.equals(newLocation)) {
- // Subsequent movement: append a new polyline point
points.add(newLocation);
polyline.setPoints(points);
}
}
-
- // Update indoor map overlay
+ // Update indoor map overlay — only trigger building detection if not already set
if (indoorMapManager != null) {
- indoorMapManager.setCurrentLocation(newLocation);
+ if (!indoorMapManager.getIsIndoorMapSet()) {
+ indoorMapManager.setCurrentLocation(newLocation);
+ }
setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE);
}
}
+ /** Called by RecordingFragment to signal that fusion is now providing the primary position. */
+ public void setFusionActive(boolean active) {
+ this.fusionActive = active;
+ // Show PDR marker again if fusion is deactivated
+ if (!active && orientationMarker != null) {
+ orientationMarker.setVisible(true);
+ }
+ }
+
+ /** Re-center + rotate camera to current user position and heading. */
+ private void snapCameraToUser() {
+ if (gMap == null) return;
+ LatLng target = (fusionActive && fusedMarker != null)
+ ? fusedMarker.getPosition()
+ : (orientationMarker != null ? orientationMarker.getPosition() : null);
+ if (target == null) return;
+
+ float bearing = (fusionActive && fusedMarker != null)
+ ? fusedMarker.getRotation()
+ : (orientationMarker != null ? orientationMarker.getRotation() : 0f);
+
+ CameraPosition pos = new CameraPosition.Builder()
+ .target(target)
+ .zoom(gMap.getCameraPosition().zoom)
+ .bearing(bearing)
+ .tilt(0)
+ .build();
+ gMap.animateCamera(CameraUpdateFactory.newCameraPosition(pos));
+ }
+
/**
- * Set the initial camera position for the map.
- *
- * The method sets the initial camera position for the map when it is first loaded.
- * If the map is already ready, the camera is moved immediately.
- * If the map is not ready, the camera position is stored until the map is ready.
- * The method also tracks if there is a pending camera move.
- *
- * @param startLocation The initial camera position to set.
+ * Sets the initial camera position, moving immediately if the map is ready.
+ *
+ * @param startLocation the initial camera position to set
*/
public void setInitialCameraPosition(@NonNull LatLng startLocation) {
// If the map is already ready, move camera immediately
if (gMap != null) {
gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(startLocation, 19f));
+ if (indoorMapManager != null) {
+ indoorMapManager.setCurrentLocation(startLocation);
+ setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE);
+ }
} else {
// Otherwise, store it until onMapReady
pendingCameraPosition = startLocation;
@@ -394,6 +916,59 @@ public LatLng getCurrentLocation() {
return currentLocation;
}
+ /** Returns the fused trajectory points, or PDR points if fused is empty. */
+ public List getTrajectoryPoints() {
+ if (fusedPolyline != null && !fusedPolyline.getPoints().isEmpty()) {
+ return new ArrayList<>(fusedPolyline.getPoints());
+ }
+ if (polyline != null) {
+ return new ArrayList<>(polyline.getPoints());
+ }
+ return new ArrayList<>();
+ }
+
+ /** Returns current building name for test point metadata. */
+ public String getLocationBuilding() {
+ if (indoorMapManager != null) return indoorMapManager.getCurrentBuildingName();
+ return "Outdoor";
+ }
+
+ /** Returns current floor display name for test point metadata. */
+ public String getLocationFloor() {
+ if (indoorMapManager != null && indoorMapManager.getIsIndoorMapSet())
+ return indoorMapManager.getCurrentFloorDisplayName();
+ return "--";
+ }
+
+ /**
+ * Returns a formatted location summary string for clipboard output.
+ * Format: "BuildingName, FloorLabel, LocationType"
+ * LocationType is determined by {@link #getLocationType()} (stub — returns "Unknown" for now).
+ */
+ public String getLocationSummary() {
+ String building = "Outdoor";
+ String floor = "--";
+ if (indoorMapManager != null) {
+ building = indoorMapManager.getCurrentBuildingName();
+ if (indoorMapManager.getCurrentBuilding() != IndoorMapManager.BUILDING_NONE) {
+ floor = indoorMapManager.getCurrentFloorDisplayName();
+ }
+ }
+ String locType = getLocationType();
+ return building + ", Floor: " + floor + ", " + locType;
+ }
+
+ /**
+ * Stub for location-type detection (near elevator / stairs / hall).
+ * TODO: Implement actual detection logic based on indoor map features.
+ *
+ * @return one of "Near Elevator", "Near Stairs", or "Hall"
+ */
+ public String getLocationType() {
+ // TODO: implement proximity detection to elevator/stairs/hall areas
+ return "Unknown";
+ }
+
/**
* Add a numbered test point marker on the map.
* Called by RecordingFragment when user presses the "Test Point" button.
@@ -401,13 +976,27 @@ public LatLng getCurrentLocation() {
public void addTestPointMarker(int index, long timestampMs, @NonNull LatLng position) {
if (gMap == null) return;
+ // Format local time HH:mm:ss
+ java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault());
+ String localTime = sdf.format(new java.util.Date(timestampMs));
+
+ // Building and floor info
+ String building = "Outdoor";
+ String floor = "--";
+ if (indoorMapManager != null) {
+ building = indoorMapManager.getCurrentBuildingName();
+ if (indoorMapManager.getIsIndoorMapSet()) {
+ floor = indoorMapManager.getCurrentFloorDisplayName();
+ }
+ }
+
Marker m = gMap.addMarker(new MarkerOptions()
.position(position)
- .title("TP " + index)
- .snippet("t=" + timestampMs));
+ .title("TP " + index + " | " + localTime)
+ .snippet(building + ", Floor: " + floor));
if (m != null) {
- m.showInfoWindow(); // Show TP index immediately
+ m.showInfoWindow();
testPointMarkers.add(m);
}
}
@@ -421,7 +1010,6 @@ public void updateGNSS(@NonNull LatLng gnssLocation) {
if (!isGnssOn) return;
if (gnssMarker == null) {
- // Create the GNSS marker for the first time
gnssMarker = gMap.addMarker(new MarkerOptions()
.position(gnssLocation)
.title("GNSS Position")
@@ -429,19 +1017,242 @@ public void updateGNSS(@NonNull LatLng gnssLocation) {
.defaultMarker(BitmapDescriptorFactory.HUE_AZURE)));
lastGnssLocation = gnssLocation;
} else {
- // Move existing GNSS marker
gnssMarker.setPosition(gnssLocation);
+ lastGnssLocation = gnssLocation;
+ }
- // Add a segment to the blue GNSS line, if this is a new location
- if (lastGnssLocation != null && !lastGnssLocation.equals(gnssLocation)) {
- List gnssPoints = new ArrayList<>(gnssPolyline.getPoints());
- gnssPoints.add(gnssLocation);
- gnssPolyline.setPoints(gnssPoints);
+ // Add GNSS observation dot (small cyan circle)
+ addObsDot(gnssObsDots, gnssLocation, Color.rgb(0, 188, 212));
+ }
+
+
+ // ---- Fused position display ------------------------------------------------
+
+ /**
+ * Updates the fused position marker and polyline on the map.
+ * The fused position is the particle-filter estimate combining PDR + WiFi + GNSS.
+ *
+ * @param fusedLocation the fused LatLng
+ * @param uncertainty uncertainty radius in metres
+ * @param orientation heading in degrees for the marker
+ */
+ public void updateFusedLocation(@NonNull LatLng fusedLocation,
+ double uncertainty, float orientation) {
+ if (gMap == null) return;
+
+ // Fused marker (purple navigation icon)
+ if (fusedMarker == null) {
+ fusedMarker = gMap.addMarker(new MarkerOptions()
+ .position(fusedLocation)
+ .flat(true)
+ .title("Fused Position")
+ .anchor(0.5f, 0.5f)
+ .icon(BitmapDescriptorFactory.fromBitmap(
+ UtilFunctions.getBitmapFromVector(requireContext(),
+ R.drawable.ic_baseline_navigation_24)))
+ .zIndex(10));
+ gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(fusedLocation, 19f));
+ } else {
+ fusedMarker.setPosition(fusedLocation);
+ fusedMarker.setRotation(orientation);
+ }
+
+ // Uncertainty circle
+ if (fusedUncertaintyCircle == null) {
+ fusedUncertaintyCircle = gMap.addCircle(new CircleOptions()
+ .center(fusedLocation)
+ .radius(uncertainty)
+ .strokeColor(Color.argb(100, 156, 39, 176))
+ .fillColor(Color.argb(30, 156, 39, 176))
+ .strokeWidth(2f));
+ } else {
+ fusedUncertaintyCircle.setCenter(fusedLocation);
+ fusedUncertaintyCircle.setRadius(uncertainty);
+ }
+
+ // Extend fused polyline
+ if (fusedPolyline != null) {
+ List points = new ArrayList<>(fusedPolyline.getPoints());
+ points.add(fusedLocation);
+ fusedPolyline.setPoints(points);
+ }
+
+ // Fused position observation dots (red, tied to Show Estimated)
+ if (isEstimatedOn) {
+ addObsDot(fusedObsDots, fusedLocation, Color.rgb(244, 67, 54), FUSED_OBS_HISTORY_SIZE);
+ }
+
+ // Smoothed trajectory (green line)
+ if (smoothPolyline != null) {
+ LatLng smoothed = smoothPosition(fusedLocation);
+ List sPoints = new ArrayList<>(smoothPolyline.getPoints());
+ sPoints.add(smoothed);
+ smoothPolyline.setPoints(sPoints);
+ }
+
+ // Camera follows fused position only in navigating mode
+ if (navigatingMode) {
+ CameraPosition pos = new CameraPosition.Builder()
+ .target(fusedLocation)
+ .zoom(gMap.getCameraPosition().zoom)
+ .bearing(orientation)
+ .tilt(0)
+ .build();
+ gMap.moveCamera(CameraUpdateFactory.newCameraPosition(pos));
+ }
+
+ // Update indoor map overlay — only trigger building detection if not already set,
+ // to avoid resetting floor when position oscillates near building boundary.
+ if (indoorMapManager != null) {
+ if (!indoorMapManager.getIsIndoorMapSet()) {
+ indoorMapManager.setCurrentLocation(fusedLocation);
}
- lastGnssLocation = gnssLocation;
+ setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE);
}
}
+ /**
+ * Updates the grey marker showing the CalDB (KNN) estimated position.
+ *
+ * @param estimatedLocation the estimated position from calibration database
+ */
+ public void updateEstimatedMarker(@NonNull LatLng estimatedLocation) {
+ if (gMap == null || !isEstimatedOn) return;
+
+ if (estimatedMarker == null) {
+ estimatedMarker = gMap.addMarker(new MarkerOptions()
+ .position(estimatedLocation)
+ .title("Estimated (CalDB)")
+ .icon(BitmapDescriptorFactory.fromBitmap(
+ createGreyMarkerBitmap())));
+ } else {
+ estimatedMarker.setPosition(estimatedLocation);
+ }
+ }
+
+ /** Returns whether the estimated (CalDB) marker is currently enabled. */
+ public boolean isEstimatedEnabled() {
+ return isEstimatedOn;
+ }
+
+ private LatLng smoothPosition(LatLng observation) {
+ smoothBuffer.addLast(observation);
+ if (smoothBuffer.size() > SMOOTH_WINDOW) {
+ smoothBuffer.removeFirst();
+ }
+ double sumLat = 0, sumLng = 0;
+ for (LatLng p : smoothBuffer) {
+ sumLat += p.latitude;
+ sumLng += p.longitude;
+ }
+ return new LatLng(sumLat / smoothBuffer.size(), sumLng / smoothBuffer.size());
+ }
+
+ /**
+ * Adds a small circle dot to the observation history.
+ * Removes the oldest if exceeding OBS_HISTORY_SIZE.
+ */
+ private void addObsDot(java.util.LinkedList dots, LatLng position, int color) {
+ addObsDot(dots, position, color, OBS_HISTORY_SIZE);
+ }
+
+ private void addObsDot(java.util.LinkedList dots, LatLng position, int color, int maxSize) {
+ if (gMap == null) return;
+ // Skip if same position as last dot (avoid stacking)
+ if (!dots.isEmpty()) {
+ LatLng last = dots.getLast().getCenter();
+ if (Math.abs(last.latitude - position.latitude) < 1e-7
+ && Math.abs(last.longitude - position.longitude) < 1e-7) {
+ return;
+ }
+ }
+ Circle dot = gMap.addCircle(new CircleOptions()
+ .center(position)
+ .radius(0.5)
+ .strokeColor(Color.argb(200, Color.red(color), Color.green(color), Color.blue(color)))
+ .strokeWidth(3)
+ .fillColor(Color.argb(80, Color.red(color), Color.green(color), Color.blue(color))));
+ dots.addLast(dot);
+ if (dots.size() > maxSize) {
+ dots.removeFirst().remove();
+ }
+ }
+
+ private void clearObsDots() {
+ for (Circle c : gnssObsDots) c.remove();
+ gnssObsDots.clear();
+ for (Circle c : wifiObsDots) c.remove();
+ wifiObsDots.clear();
+ for (Circle c : fusedObsDots) c.remove();
+ fusedObsDots.clear();
+ }
+
+ private android.graphics.Bitmap createGreyMarkerBitmap() {
+ int w = 70, h = 90;
+ android.graphics.Bitmap bmp = android.graphics.Bitmap.createBitmap(w, h,
+ android.graphics.Bitmap.Config.ARGB_8888);
+ android.graphics.Canvas canvas = new android.graphics.Canvas(bmp);
+ android.graphics.Paint paint = new android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG);
+ paint.setColor(Color.rgb(158, 158, 158)); // grey
+ // Marker head (circle)
+ canvas.drawCircle(w / 2f, 28f, 28f, paint);
+ // Marker pin (triangle)
+ android.graphics.Path path = new android.graphics.Path();
+ path.moveTo(w / 2f - 18f, 50f);
+ path.lineTo(w / 2f + 18f, 50f);
+ path.lineTo(w / 2f, h);
+ path.close();
+ canvas.drawPath(path, paint);
+ // White dot in center
+ paint.setColor(Color.WHITE);
+ canvas.drawCircle(w / 2f, 28f, 10f, paint);
+ return bmp;
+ }
+
+ /**
+ * Updates or creates a green marker showing the latest WiFi position fix.
+ *
+ * @param wifiLocation the WiFi-derived position
+ */
+ public void updateWifiMarker(@NonNull LatLng wifiLocation) {
+ if (gMap == null || !isWifiOn) return;
+
+ if (wifiMarker == null) {
+ wifiMarker = gMap.addMarker(new MarkerOptions()
+ .position(wifiLocation)
+ .title("WiFi Position")
+ .icon(BitmapDescriptorFactory
+ .defaultMarker(BitmapDescriptorFactory.HUE_GREEN)));
+ } else {
+ wifiMarker.setPosition(wifiLocation);
+ }
+
+ // Add WiFi observation dot (small green circle)
+ addObsDot(wifiObsDots, wifiLocation, Color.rgb(76, 175, 80));
+ }
+
+ // ---- Manual correction logic ------------------------------------------------
+
+ /**
+ * Confirms a correction point: places a permanent marker and fires the callback.
+ */
+ private void confirmCorrection(@NonNull LatLng position) {
+ if (gMap == null) return;
+
+ // Permanent orange circle marker
+ int idx = correctionMarkers.size() + 1;
+ Marker m = gMap.addMarker(new MarkerOptions()
+ .position(position)
+ .title("Correction #" + idx)
+ .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_ORANGE))
+ .zIndex(15));
+ if (m != null) correctionMarkers.add(m);
+
+ // Notify the callback (RecordingFragment → FusionManager)
+ if (correctionCallback != null) {
+ correctionCallback.onCorrectionConfirmed(position);
+ }
+ }
/**
* Remove GNSS marker if user toggles it off
@@ -460,6 +1271,19 @@ public boolean isGnssEnabled() {
return isGnssOn;
}
+ /** Remove WiFi marker if user toggles it off. */
+ public void clearWiFi() {
+ if (wifiMarker != null) {
+ wifiMarker.remove();
+ wifiMarker = null;
+ }
+ }
+
+ /** Whether user is currently showing WiFi or not. */
+ public boolean isWifiEnabled() {
+ return isWifiOn;
+ }
+
private void setFloorControlsVisibility(int visibility) {
floorUpButton.setVisibility(visibility);
floorDownButton.setVisibility(visibility);
@@ -479,6 +1303,7 @@ private void updateFloorLabel() {
}
}
+ /** Removes all markers, polylines, and resets map state for a fresh recording. */
public void clearMapAndReset() {
stopAutoFloor();
if (autoFloorSwitch != null) {
@@ -503,116 +1328,166 @@ public void clearMapAndReset() {
lastGnssLocation = null;
currentLocation = null;
+ // Clear correction markers
+ if (correctionDragMarker != null) { correctionDragMarker.remove(); correctionDragMarker = null; }
+ for (Marker m : correctionMarkers) m.remove();
+ correctionMarkers.clear();
+
+ // Clear fused elements
+ fusionActive = false;
+ if (fusedMarker != null) { fusedMarker.remove(); fusedMarker = null; }
+ if (wifiMarker != null) { wifiMarker.remove(); wifiMarker = null; }
+ if (estimatedMarker != null) { estimatedMarker.remove(); estimatedMarker = null; }
+ smoothBuffer.clear();
+ clearObsDots();
+ if (fusedUncertaintyCircle != null) { fusedUncertaintyCircle.remove(); fusedUncertaintyCircle = null; }
+ if (fusedPolyline != null) { fusedPolyline.remove(); fusedPolyline = null; }
+ if (smoothPolyline != null) { smoothPolyline.remove(); smoothPolyline = null; }
+
// Clear test point markers
for (Marker m : testPointMarkers) {
m.remove();
}
testPointMarkers.clear();
-
// Re-create empty polylines with your chosen colors
if (gMap != null) {
polyline = gMap.addPolyline(new PolylineOptions()
.color(Color.RED)
.width(5f)
.add());
- gnssPolyline = gMap.addPolyline(new PolylineOptions()
- .color(Color.BLUE)
- .width(5f)
+ fusedPolyline = gMap.addPolyline(new PolylineOptions()
+ .color(Color.rgb(156, 39, 176))
+ .width(7f)
+ .add());
+ smoothPolyline = gMap.addPolyline(new PolylineOptions()
+ .color(Color.rgb(76, 175, 80))
+ .width(7f)
.add());
+ smoothPolyline.setVisible(showSmoothPath);
}
}
+ /** Draws building outline polygons on the map using API data or hard-coded fallbacks. */
+ // Scale factor for buildings without API outline data.
+ // <1.0 shrinks toward centroid to approximate 2D map footprint.
+ private static final double NUCLEUS_SCALE = 0.88;
+ private static final double NKML_SCALE = 0.88;
+ private static final double FJB_SCALE = 0.88;
+ private static final double FARADAY_SCALE = 0.88;
+
/**
- * Draw the building polygon on the map
- *
- * The method draws a polygon representing the building on the map.
- * The polygon is drawn with specific vertices and colors to represent
- * different buildings or areas on the map.
- * The method removes the old polygon if it exists and adds the new polygon
- * to the map with the specified options.
- * The method logs the number of vertices in the polygon for debugging.
- *
- *
- * Note: The method uses hard-coded vertices for the building polygon.
- *
- *
- *
- * See: {@link com.google.android.gms.maps.model.PolygonOptions} The options for the new polygon.
+ * Scales polygon vertices toward their centroid.
+ * @param vertices original vertices
+ * @param factor scale factor (<1.0 shrinks, >1.0 enlarges)
+ * @return new array of scaled LatLng vertices
*/
+ private LatLng[] scalePolygon(LatLng[] vertices, double factor) {
+ double latSum = 0, lngSum = 0;
+ for (LatLng v : vertices) {
+ latSum += v.latitude;
+ lngSum += v.longitude;
+ }
+ double cLat = latSum / vertices.length;
+ double cLng = lngSum / vertices.length;
+
+ LatLng[] scaled = new LatLng[vertices.length];
+ for (int i = 0; i < vertices.length; i++) {
+ scaled[i] = new LatLng(
+ cLat + (vertices[i].latitude - cLat) * factor,
+ cLng + (vertices[i].longitude - cLng) * factor);
+ }
+ return scaled;
+ }
+
+ /**
+ * Looks up a building's outline polygon from the floorplan API cache.
+ * @param apiName building name (e.g. "nucleus_building", "library")
+ * @return outline points, or null if not available
+ */
+ private List getApiOutline(String apiName) {
+ FloorplanApiClient.BuildingInfo info =
+ sensorFusion.getFloorplanBuilding(apiName);
+ if (info != null) {
+ List outline = info.getOutlinePolygon();
+ if (outline != null && outline.size() >= 3) return outline;
+ }
+ return null;
+ }
+
private void drawBuildingPolygon() {
if (gMap == null) {
Log.e("TrajectoryMapFragment", "GoogleMap is not ready");
return;
}
- // nuclear building polygon vertices
- LatLng nucleus1 = new LatLng(55.92279538827796, -3.174612147506538);
- LatLng nucleus2 = new LatLng(55.92278121423647, -3.174107900816096);
- LatLng nucleus3 = new LatLng(55.92288405733954, -3.173843694667146);
- LatLng nucleus4 = new LatLng(55.92331786793876, -3.173832892645086);
- LatLng nucleus5 = new LatLng(55.923337194112555, -3.1746284301397387);
-
-
- // nkml building polygon vertices
- LatLng nkml1 = new LatLng(55.9230343434213, -3.1751847990731954);
- LatLng nkml2 = new LatLng(55.923032840563366, -3.174777103346131);
- LatLng nkml4 = new LatLng(55.92280139974615, -3.175195527934348);
- LatLng nkml3 = new LatLng(55.922793885410734, -3.1747958788136867);
-
- LatLng fjb1 = new LatLng(55.92269205199916, -3.1729563477188774);//left top
- LatLng fjb2 = new LatLng(55.922822801570994, -3.172594249522305);
- LatLng fjb3 = new LatLng(55.92223512226413, -3.171921917547244);
- LatLng fjb4 = new LatLng(55.9221071265519, -3.1722813131202097);
-
- LatLng faraday1 = new LatLng(55.92242866264128, -3.1719553662011815);
- LatLng faraday2 = new LatLng(55.9224966752294, -3.1717846714743474);
- LatLng faraday3 = new LatLng(55.922271383074154, -3.1715191463437162);
- LatLng faraday4 = new LatLng(55.92220124468304, -3.171705013935158);
-
-
-
- PolygonOptions buildingPolygonOptions = new PolygonOptions()
- .add(nucleus1, nucleus2, nucleus3, nucleus4, nucleus5)
- .strokeColor(Color.RED) // Red border
- .strokeWidth(10f) // Border width
- //.fillColor(Color.argb(50, 255, 0, 0)) // Semi-transparent red fill
- .zIndex(1); // Set a higher zIndex to ensure it appears above other overlays
-
- // Options for the new polygon
- PolygonOptions buildingPolygonOptions2 = new PolygonOptions()
- .add(nkml1, nkml2, nkml3, nkml4, nkml1)
- .strokeColor(Color.BLUE) // Blue border
- .strokeWidth(10f) // Border width
- // .fillColor(Color.argb(50, 0, 0, 255)) // Semi-transparent blue fill
- .zIndex(1); // Set a higher zIndex to ensure it appears above other overlays
-
- PolygonOptions buildingPolygonOptions3 = new PolygonOptions()
- .add(fjb1, fjb2, fjb3, fjb4, fjb1)
- .strokeColor(Color.GREEN) // Green border
- .strokeWidth(10f) // Border width
- //.fillColor(Color.argb(50, 0, 255, 0)) // Semi-transparent green fill
- .zIndex(1); // Set a higher zIndex to ensure it appears above other overlays
+ // Remove old polygons
+ if (buildingPolygon != null) {
+ buildingPolygon.remove();
+ }
- PolygonOptions buildingPolygonOptions4 = new PolygonOptions()
- .add(faraday1, faraday2, faraday3, faraday4, faraday1)
- .strokeColor(Color.YELLOW) // Yellow border
- .strokeWidth(10f) // Border width
- //.fillColor(Color.argb(50, 255, 255, 0)) // Semi-transparent yellow fill
- .zIndex(1); // Set a higher zIndex to ensure it appears above other overlays
+ // --- Nucleus + Library connected shape (RED, zIndex=1) ---
+ // API "nucleus_building" outline covers both Nucleus and Library as
+ // one connected polygon. Draw it first in red as the base layer.
+ List nucleusOutline = getApiOutline("nucleus_building");
+ if (nucleusOutline != null) {
+ PolygonOptions nucleusOpts = new PolygonOptions()
+ .addAll(nucleusOutline)
+ .strokeColor(Color.RED).strokeWidth(10f).zIndex(1);
+ buildingPolygon = gMap.addPolygon(nucleusOpts);
+ if (DEBUG) Log.d(TAG, "Nucleus polygon from API, vertices: " + nucleusOutline.size());
+ }
+ // --- Library (BLUE, zIndex=2) ---
+ // Overlays on top of the red, so the Library portion shows blue.
+ List libraryOutline = getApiOutline("library");
+ if (libraryOutline != null) {
+ PolygonOptions libOpts = new PolygonOptions()
+ .addAll(libraryOutline)
+ .strokeColor(Color.BLUE).strokeWidth(10f).zIndex(2);
+ gMap.addPolygon(libOpts);
+ if (DEBUG) Log.d(TAG, "Library polygon from API, vertices: " + libraryOutline.size());
+ }
- // Remove the old polygon if it exists
- if (buildingPolygon != null) {
- buildingPolygon.remove();
+ // --- Murchison House (MAGENTA) ---
+ List murchisonOutline = getApiOutline("murchison_house");
+ if (murchisonOutline != null) {
+ PolygonOptions murOpts = new PolygonOptions()
+ .addAll(murchisonOutline)
+ .strokeColor(Color.MAGENTA).strokeWidth(10f).zIndex(1);
+ gMap.addPolygon(murOpts);
+ if (DEBUG) Log.d(TAG, "Murchison polygon from API, vertices: " + murchisonOutline.size());
}
- // Add the polygon to the map
- buildingPolygon = gMap.addPolygon(buildingPolygonOptions);
- gMap.addPolygon(buildingPolygonOptions2);
- gMap.addPolygon(buildingPolygonOptions3);
- gMap.addPolygon(buildingPolygonOptions4);
- Log.d(TAG, "Building polygon added, vertex count: " + buildingPolygon.getPoints().size());
+ // --- FJB (GREEN): hardcoded + scaled (no API data) ---
+ LatLng[] fjbRaw = {
+ new LatLng(55.92269205199916, -3.1729563477188774),
+ new LatLng(55.922822801570994, -3.172594249522305),
+ new LatLng(55.92223512226413, -3.171921917547244),
+ new LatLng(55.9221071265519, -3.1722813131202097)
+ };
+ LatLng[] fjb = scalePolygon(fjbRaw, FJB_SCALE);
+ PolygonOptions fjbOpts = new PolygonOptions()
+ .strokeColor(Color.GREEN).strokeWidth(10f).zIndex(1);
+ for (LatLng v : fjb) fjbOpts.add(v);
+ fjbOpts.add(fjb[0]);
+ gMap.addPolygon(fjbOpts);
+
+ // --- Faraday (YELLOW): hardcoded + scaled (no API data) ---
+ LatLng[] faradayRaw = {
+ new LatLng(55.92242866264128, -3.1719553662011815),
+ new LatLng(55.9224966752294, -3.1717846714743474),
+ new LatLng(55.922271383074154, -3.1715191463437162),
+ new LatLng(55.92220124468304, -3.171705013935158)
+ };
+ LatLng[] faraday = scalePolygon(faradayRaw, FARADAY_SCALE);
+ PolygonOptions faradayOpts = new PolygonOptions()
+ .strokeColor(Color.YELLOW).strokeWidth(10f).zIndex(1);
+ for (LatLng v : faraday) faradayOpts.add(v);
+ faradayOpts.add(faraday[0]);
+ gMap.addPolygon(faradayOpts);
+
+ if (DEBUG) Log.d(TAG, "Building polygons drawn");
}
//region Auto-floor logic
@@ -629,9 +1504,52 @@ private void startAutoFloor() {
lastCandidateFloor = Integer.MIN_VALUE;
lastCandidateTime = 0;
- // Immediately jump to the best-guess floor (skip debounce on first toggle)
- applyImmediateFloor();
+ // 1. Try immediate WiFi floor seed
+ boolean wifiSeeded = false;
+ if (sensorFusion != null && indoorMapManager != null
+ && indoorMapManager.getIsIndoorMapSet()) {
+ int wifiFloor = sensorFusion.getWifiFloor();
+ if (wifiFloor >= 0) {
+ // reseedFloor: resets PdrProcessing baro baseline + FusionManager + PF
+ sensorFusion.reseedFloor(wifiFloor);
+ indoorMapManager.setCurrentFloor(wifiFloor, true);
+ updateFloorLabel();
+ lastCandidateFloor = wifiFloor;
+ lastCandidateTime = SystemClock.elapsedRealtime();
+ wifiSeeded = true;
+ if (DEBUG) Log.i(TAG, "[AutoFloor] Immediate WiFi seed floor=" + wifiFloor);
+ }
+ }
+ if (!wifiSeeded) {
+ // Fallback to PF/baro
+ applyImmediateFloor();
+ }
+
+ // 2. Open 10s WiFi floor refresh window — accept fresh WiFi responses as re-seed
+ if (sensorFusion != null) {
+ sensorFusion.setWifiFloorCallback(floor -> {
+ // Volley delivers on main thread, safe to update UI directly
+ if (indoorMapManager != null && indoorMapManager.getIsIndoorMapSet()
+ && floor >= 0) {
+ sensorFusion.reseedFloor(floor);
+ indoorMapManager.setCurrentFloor(floor, true);
+ updateFloorLabel();
+ lastCandidateFloor = floor;
+ lastCandidateTime = SystemClock.elapsedRealtime();
+ if (DEBUG) Log.i(TAG, "[AutoFloor] WiFi re-seed floor=" + floor);
+ }
+ });
+
+ // 3. Close WiFi seed window after 10s
+ wifiSeedTimeoutTask = () -> {
+ sensorFusion.setWifiFloorCallback(null);
+ wifiSeedTimeoutTask = null;
+ if (DEBUG) Log.d(TAG, "[AutoFloor] WiFi seed window closed (10s)");
+ };
+ autoFloorHandler.postDelayed(wifiSeedTimeoutTask, WIFI_SEED_WINDOW_MS);
+ }
+ // 4. Start periodic baro-based evaluation
autoFloorTask = new Runnable() {
@Override
public void run() {
@@ -640,7 +1558,7 @@ public void run() {
}
};
autoFloorHandler.post(autoFloorTask);
- Log.d(TAG, "Auto-floor started");
+ if (DEBUG) Log.d(TAG, "Auto-floor started");
}
/**
@@ -653,13 +1571,16 @@ private void applyImmediateFloor() {
if (!indoorMapManager.getIsIndoorMapSet()) return;
int candidateFloor;
- if (sensorFusion.getLatLngWifiPositioning() != null) {
- candidateFloor = sensorFusion.getWifiFloor();
+ // Priority: fused floor (PF consensus) > barometric floor (PdrProcessing)
+ com.openpositioning.PositionMe.sensors.fusion.FusionManager fm =
+ sensorFusion.getFusionManager();
+ if (fm != null && fm.isActive()) {
+ candidateFloor = fm.getFusedFloor();
} else {
- float elevation = sensorFusion.getElevation();
- float floorHeight = indoorMapManager.getFloorHeight();
- if (floorHeight <= 0) return;
- candidateFloor = Math.round(elevation / floorHeight);
+ // Use PdrProcessing's floor directly — it already accounts for
+ // initialFloorOffset and hysteresis. Raw elevation/floorHeight
+ // does not include the offset and gives wrong results after reseed.
+ candidateFloor = sensorFusion.getBaroFloor();
}
indoorMapManager.setCurrentFloor(candidateFloor, true);
@@ -673,12 +1594,22 @@ private void applyImmediateFloor() {
* Stops the periodic auto-floor evaluation and resets debounce state.
*/
private void stopAutoFloor() {
- if (autoFloorHandler != null && autoFloorTask != null) {
- autoFloorHandler.removeCallbacks(autoFloorTask);
+ // Clear WiFi floor callback and pending timeout
+ if (sensorFusion != null) {
+ sensorFusion.setWifiFloorCallback(null);
+ }
+ if (autoFloorHandler != null) {
+ if (autoFloorTask != null) {
+ autoFloorHandler.removeCallbacks(autoFloorTask);
+ }
+ if (wifiSeedTimeoutTask != null) {
+ autoFloorHandler.removeCallbacks(wifiSeedTimeoutTask);
+ wifiSeedTimeoutTask = null;
+ }
}
lastCandidateFloor = Integer.MIN_VALUE;
lastCandidateTime = 0;
- Log.d(TAG, "Auto-floor stopped");
+ if (DEBUG) Log.d(TAG, "Auto-floor stopped");
}
/**
@@ -692,15 +1623,47 @@ private void evaluateAutoFloor() {
int candidateFloor;
- // Priority 1: WiFi-based floor (only if WiFi positioning has returned data)
- if (sensorFusion.getLatLngWifiPositioning() != null) {
- candidateFloor = sensorFusion.getWifiFloor();
+ // Use PF floor probability distribution for robust floor estimation
+ com.openpositioning.PositionMe.sensors.fusion.FusionManager fm =
+ sensorFusion.getFusionManager();
+ if (fm != null && fm.isActive()) {
+ double[] probs = fm.getFloorProbabilities();
+ double topProb = 0, secondProb = 0;
+ int topFloor = 0;
+ for (int i = 0; i < probs.length; i++) {
+ if (probs[i] > topProb) {
+ secondProb = topProb;
+ topProb = probs[i];
+ topFloor = i;
+ } else if (probs[i] > secondProb) {
+ secondProb = probs[i];
+ }
+ }
+ // Log floor posterior for debugging
+ if (DEBUG) Log.d(TAG, String.format("[AutoFloor] source=PF topFloor=%d topProb=%.2f secondProb=%.2f fusedFloor=%d",
+ topFloor, topProb, secondProb, fm.getFusedFloor()));
+
+ // Only accept if confident (>0.6 or margin >0.2)
+ if (topProb > 0.6 || (topProb - secondProb) > 0.2) {
+ candidateFloor = topFloor;
+ } else {
+ return; // not confident enough, keep current
+ }
} else {
- // Fallback: barometric elevation estimate
- float elevation = sensorFusion.getElevation();
- float floorHeight = indoorMapManager.getFloorHeight();
- if (floorHeight <= 0) return;
- candidateFloor = Math.round(elevation / floorHeight);
+ // Baro-only fallback: use PdrProcessing's floor directly —
+ // it accounts for initialFloorOffset and hysteresis.
+ candidateFloor = sensorFusion.getBaroFloor();
+ }
+
+ // Only allow floor change if near stairs or lift (within 5m)
+ int currentFloorIdx = indoorMapManager.getCurrentFloor();
+ if (candidateFloor != currentFloorIdx) {
+ LatLng pos = currentLocation;
+ if (pos != null && !indoorMapManager.isNearStairsOrLift(pos, 5.0)) {
+ if (DEBUG) Log.d(TAG, String.format("[AutoFloor] BLOCKED floor change %d→%d (not near stairs/lift)",
+ currentFloorIdx, candidateFloor));
+ return;
+ }
}
// Debounce: require the same floor reading for AUTO_FLOOR_DEBOUNCE_MS
@@ -708,16 +1671,53 @@ private void evaluateAutoFloor() {
if (candidateFloor != lastCandidateFloor) {
lastCandidateFloor = candidateFloor;
lastCandidateTime = now;
+ // Snapshot elevation at the start of a potential floor transition
+ elevationAtFloorChangeStart = sensorFusion.getElevation();
+ floorChangeStartTimeMs = now;
return;
}
if (now - lastCandidateTime >= AUTO_FLOOR_DEBOUNCE_MS) {
+ // Classify transition as elevator or stairs (debug log only)
+ classifyFloorTransition();
+
indoorMapManager.setCurrentFloor(candidateFloor, true);
updateFloorLabel();
- // Reset timer so we don't keep re-applying the same floor
lastCandidateTime = now;
}
}
+ /**
+ * Classifies a confirmed floor transition as elevator or stairs based on
+ * the barometric elevation change rate. Pure observation — does not alter
+ * any floor detection or navigation logic.
+ *
+ * Elevator: rapid vertical displacement (>{@value ELEVATOR_RATE_THRESHOLD} m/s).
+ * Stairs: slower, gradual height change.
+ */
+ private void classifyFloorTransition() {
+ if (Float.isNaN(elevationAtFloorChangeStart) || sensorFusion == null) {
+ lastTransitionMethod = "unknown";
+ return;
+ }
+
+ float currentElevation = sensorFusion.getElevation();
+ float elevDelta = Math.abs(currentElevation - elevationAtFloorChangeStart);
+ long durationMs = SystemClock.elapsedRealtime() - floorChangeStartTimeMs;
+ float durationSec = durationMs / 1000f;
+
+ if (durationSec < 0.5f) {
+ lastTransitionMethod = "unknown";
+ return;
+ }
+
+ float rate = elevDelta / durationSec;
+ lastTransitionMethod = (rate > ELEVATOR_RATE_THRESHOLD) ? "elevator" : "stairs";
+
+ if (DEBUG) Log.i(TAG, String.format(
+ "[FloorTransition] method=%s elevDelta=%.2fm duration=%.1fs rate=%.2fm/s",
+ lastTransitionMethod, elevDelta, durationSec, rate));
+ }
+
//endregion
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/view/CompassView.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/view/CompassView.java
new file mode 100644
index 00000000..bad80831
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/view/CompassView.java
@@ -0,0 +1,162 @@
+package com.openpositioning.PositionMe.presentation.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.view.View;
+
+/**
+ * A lightweight compass dial that rotates to reflect the device's current
+ * magnetic heading. Drawn entirely with Canvas for crisp rendering at any size.
+ *
+ * Call {@link #setHeading(float)} with the azimuth in degrees (0 = north,
+ * clockwise). The dial rotates so that the "N" label always points towards
+ * magnetic north on screen.
+ */
+public class CompassView extends View {
+
+ private float heading = 0f;
+
+ private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint rimPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint northPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint southPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint centerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint nLabelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private final Path needlePath = new Path();
+
+ public CompassView(Context context) {
+ super(context);
+ init();
+ }
+
+ public CompassView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public CompassView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ private void init() {
+ bgPaint.setColor(Color.argb(200, 255, 255, 255));
+ bgPaint.setStyle(Paint.Style.FILL);
+
+ rimPaint.setColor(Color.argb(100, 0, 0, 0));
+ rimPaint.setStyle(Paint.Style.STROKE);
+ rimPaint.setStrokeWidth(2f);
+
+ tickPaint.setColor(Color.DKGRAY);
+ tickPaint.setStyle(Paint.Style.STROKE);
+ tickPaint.setStrokeCap(Paint.Cap.ROUND);
+
+ labelPaint.setColor(Color.DKGRAY);
+ labelPaint.setTextAlign(Paint.Align.CENTER);
+ labelPaint.setTypeface(Typeface.DEFAULT_BOLD);
+
+ nLabelPaint.setColor(Color.RED);
+ nLabelPaint.setTextAlign(Paint.Align.CENTER);
+ nLabelPaint.setTypeface(Typeface.DEFAULT_BOLD);
+
+ northPaint.setColor(Color.RED);
+ northPaint.setStyle(Paint.Style.FILL);
+
+ southPaint.setColor(Color.WHITE);
+ southPaint.setStyle(Paint.Style.FILL);
+
+ centerPaint.setColor(Color.DKGRAY);
+ centerPaint.setStyle(Paint.Style.FILL);
+ }
+
+ /**
+ * Set the current heading in degrees (0 = north, clockwise).
+ * The dial rotates so "N" always points toward magnetic north.
+ */
+ public void setHeading(float degrees) {
+ this.heading = degrees;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ float cx = getWidth() / 2f;
+ float cy = getHeight() / 2f;
+ float radius = Math.min(cx, cy) - 2f;
+ if (radius <= 0) return;
+
+ // Background circle
+ canvas.drawCircle(cx, cy, radius, bgPaint);
+ canvas.drawCircle(cx, cy, radius, rimPaint);
+
+ // Rotate entire dial so North points to magnetic north
+ canvas.save();
+ canvas.rotate(-heading, cx, cy);
+
+ // Tick marks
+ float majorLen = radius * 0.15f;
+ float minorLen = radius * 0.08f;
+ tickPaint.setStrokeWidth(radius * 0.02f);
+ for (int deg = 0; deg < 360; deg += 10) {
+ boolean isMajor = (deg % 30 == 0);
+ float len = isMajor ? majorLen : minorLen;
+ float startR = radius - len;
+ float endR = radius - 1f;
+ float rad = (float) Math.toRadians(deg);
+ float sin = (float) Math.sin(rad);
+ float cos = (float) Math.cos(rad);
+ canvas.drawLine(
+ cx + startR * sin, cy - startR * cos,
+ cx + endR * sin, cy - endR * cos,
+ tickPaint);
+ }
+
+ // Cardinal labels
+ float labelSize = radius * 0.28f;
+ labelPaint.setTextSize(labelSize);
+ nLabelPaint.setTextSize(labelSize);
+ float labelR = radius * 0.62f;
+
+ // N (red)
+ canvas.drawText("N", cx, cy - labelR + labelSize * 0.35f, nLabelPaint);
+ // S
+ canvas.drawText("S", cx, cy + labelR + labelSize * 0.35f, labelPaint);
+ // E
+ canvas.drawText("E", cx + labelR, cy + labelSize * 0.35f, labelPaint);
+ // W
+ canvas.drawText("W", cx - labelR, cy + labelSize * 0.35f, labelPaint);
+
+ // Needle — north half (red triangle pointing up)
+ float needleHalfW = radius * 0.08f;
+ float needleLen = radius * 0.40f;
+ needlePath.reset();
+ needlePath.moveTo(cx, cy - needleLen);
+ needlePath.lineTo(cx - needleHalfW, cy);
+ needlePath.lineTo(cx + needleHalfW, cy);
+ needlePath.close();
+ canvas.drawPath(needlePath, northPaint);
+
+ // Needle — south half (white triangle pointing down)
+ needlePath.reset();
+ needlePath.moveTo(cx, cy + needleLen);
+ needlePath.lineTo(cx - needleHalfW, cy);
+ needlePath.lineTo(cx + needleHalfW, cy);
+ needlePath.close();
+ canvas.drawPath(needlePath, southPaint);
+
+ canvas.restore();
+
+ // Center dot (drawn after restore so it doesn't rotate)
+ canvas.drawCircle(cx, cy, radius * 0.06f, centerPaint);
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/view/WeatherView.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/view/WeatherView.java
new file mode 100644
index 00000000..c7c034f0
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/view/WeatherView.java
@@ -0,0 +1,238 @@
+package com.openpositioning.PositionMe.presentation.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.view.View;
+
+/**
+ * A lightweight weather indicator drawn with Canvas.
+ * Shows a weather icon based on WMO weather code and temperature text below.
+ *
+ * Call {@link #setWeather(int, double)} with the WMO weather code and
+ * temperature in Celsius to update the display.
+ */
+public class WeatherView extends View {
+
+ private int weatherCode = -1;
+ private double temperature = Double.NaN;
+
+ private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint rimPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint sunPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint sunRayPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint cloudPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint cloudOutlinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint rainPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint snowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint fogPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint boltPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint tempPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private final Path path = new Path();
+
+ public WeatherView(Context context) {
+ super(context);
+ init();
+ }
+
+ public WeatherView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public WeatherView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ private void init() {
+ bgPaint.setColor(Color.argb(200, 255, 255, 255));
+ bgPaint.setStyle(Paint.Style.FILL);
+
+ rimPaint.setColor(Color.argb(100, 0, 0, 0));
+ rimPaint.setStyle(Paint.Style.STROKE);
+ rimPaint.setStrokeWidth(2f);
+
+ sunPaint.setColor(Color.rgb(255, 193, 7)); // Amber
+ sunPaint.setStyle(Paint.Style.FILL);
+
+ sunRayPaint.setColor(Color.rgb(255, 193, 7));
+ sunRayPaint.setStyle(Paint.Style.STROKE);
+ sunRayPaint.setStrokeCap(Paint.Cap.ROUND);
+
+ cloudPaint.setColor(Color.rgb(224, 224, 224));
+ cloudPaint.setStyle(Paint.Style.FILL);
+
+ cloudOutlinePaint.setColor(Color.rgb(158, 158, 158));
+ cloudOutlinePaint.setStyle(Paint.Style.STROKE);
+ cloudOutlinePaint.setStrokeWidth(1.5f);
+
+ rainPaint.setColor(Color.rgb(33, 150, 243)); // Blue
+ rainPaint.setStyle(Paint.Style.STROKE);
+ rainPaint.setStrokeCap(Paint.Cap.ROUND);
+
+ snowPaint.setColor(Color.rgb(144, 202, 249)); // Light blue
+ snowPaint.setStyle(Paint.Style.FILL);
+
+ fogPaint.setColor(Color.rgb(189, 189, 189));
+ fogPaint.setStyle(Paint.Style.STROKE);
+ fogPaint.setStrokeCap(Paint.Cap.ROUND);
+
+ boltPaint.setColor(Color.rgb(255, 235, 59)); // Yellow
+ boltPaint.setStyle(Paint.Style.FILL);
+
+ tempPaint.setColor(Color.DKGRAY);
+ tempPaint.setTextAlign(Paint.Align.CENTER);
+ tempPaint.setTypeface(Typeface.DEFAULT_BOLD);
+
+ labelPaint.setColor(Color.GRAY);
+ labelPaint.setTextAlign(Paint.Align.CENTER);
+ }
+
+ /**
+ * Update weather display.
+ * @param wmoCode WMO weather code (0=clear, 1-3=cloudy, 51-67=rain, 71-77=snow, 95+=thunder)
+ * @param tempC temperature in Celsius
+ */
+ public void setWeather(int wmoCode, double tempC) {
+ this.weatherCode = wmoCode;
+ this.temperature = tempC;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ float w = getWidth();
+ float h = getHeight();
+ float iconAreaH = h * 0.78f;
+ float cx = w / 2f;
+ float iconCy = iconAreaH * 0.45f;
+ float radius = Math.min(cx, iconAreaH * 0.5f) - 2f;
+ if (radius <= 0) return;
+
+ // Background rounded rect
+ RectF bg = new RectF(1, 1, w - 1, h - 1);
+ canvas.drawRoundRect(bg, 12f, 12f, bgPaint);
+ canvas.drawRoundRect(bg, 12f, 12f, rimPaint);
+
+ if (weatherCode < 0) {
+ // No data yet — draw "?"
+ tempPaint.setTextSize(radius * 0.6f);
+ canvas.drawText("?", cx, iconCy + radius * 0.2f, tempPaint);
+ } else {
+ drawWeatherIcon(canvas, cx, iconCy, radius);
+ }
+
+ // Temperature text below icon — tight gap
+ if (!Double.isNaN(temperature)) {
+ tempPaint.setTextSize(h * 0.20f);
+ String temp = String.format("%.0f°C", temperature);
+ canvas.drawText(temp, cx, h - h * 0.03f, tempPaint);
+ }
+ }
+
+ private void drawWeatherIcon(Canvas canvas, float cx, float cy, float r) {
+ if (weatherCode == 0) {
+ // Clear sky — sun
+ drawSun(canvas, cx, cy, r * 0.80f);
+ } else if (weatherCode <= 3) {
+ // Partly cloudy — sun + cloud
+ drawSun(canvas, cx - r * 0.2f, cy - r * 0.15f, r * 0.50f);
+ drawCloud(canvas, cx + r * 0.1f, cy + r * 0.1f, r * 0.65f);
+ } else if (weatherCode <= 48) {
+ // Fog
+ drawFog(canvas, cx, cy, r * 0.70f);
+ } else if (weatherCode <= 67) {
+ // Rain / drizzle
+ drawCloud(canvas, cx, cy - r * 0.15f, r * 0.65f);
+ drawRain(canvas, cx, cy + r * 0.30f, r * 0.55f);
+ } else if (weatherCode <= 77) {
+ // Snow
+ drawCloud(canvas, cx, cy - r * 0.15f, r * 0.65f);
+ drawSnow(canvas, cx, cy + r * 0.30f, r * 0.55f);
+ } else if (weatherCode <= 82) {
+ // Rain showers
+ drawCloud(canvas, cx, cy - r * 0.15f, r * 0.70f);
+ drawRain(canvas, cx, cy + r * 0.30f, r * 0.60f);
+ } else if (weatherCode <= 86) {
+ // Snow showers
+ drawCloud(canvas, cx, cy - r * 0.15f, r * 0.70f);
+ drawSnow(canvas, cx, cy + r * 0.30f, r * 0.60f);
+ } else {
+ // Thunderstorm
+ drawCloud(canvas, cx, cy - r * 0.2f, r * 0.70f);
+ drawBolt(canvas, cx, cy + r * 0.15f, r * 0.50f);
+ }
+ }
+
+ private void drawSun(Canvas canvas, float cx, float cy, float r) {
+ canvas.drawCircle(cx, cy, r * 0.5f, sunPaint);
+ sunRayPaint.setStrokeWidth(r * 0.1f);
+ for (int i = 0; i < 8; i++) {
+ float angle = (float) Math.toRadians(i * 45);
+ float sin = (float) Math.sin(angle);
+ float cos = (float) Math.cos(angle);
+ canvas.drawLine(
+ cx + r * 0.65f * sin, cy - r * 0.65f * cos,
+ cx + r * 0.9f * sin, cy - r * 0.9f * cos,
+ sunRayPaint);
+ }
+ }
+
+ private void drawCloud(Canvas canvas, float cx, float cy, float r) {
+ canvas.drawCircle(cx - r * 0.3f, cy, r * 0.35f, cloudPaint);
+ canvas.drawCircle(cx + r * 0.3f, cy, r * 0.3f, cloudPaint);
+ canvas.drawCircle(cx, cy - r * 0.2f, r * 0.38f, cloudPaint);
+ canvas.drawRect(cx - r * 0.55f, cy, cx + r * 0.55f, cy + r * 0.25f, cloudPaint);
+
+ canvas.drawCircle(cx - r * 0.3f, cy, r * 0.35f, cloudOutlinePaint);
+ canvas.drawCircle(cx + r * 0.3f, cy, r * 0.3f, cloudOutlinePaint);
+ canvas.drawCircle(cx, cy - r * 0.2f, r * 0.38f, cloudOutlinePaint);
+ }
+
+ private void drawRain(Canvas canvas, float cx, float cy, float r) {
+ rainPaint.setStrokeWidth(r * 0.12f);
+ for (int i = -1; i <= 1; i++) {
+ float x = cx + i * r * 0.35f;
+ canvas.drawLine(x, cy, x - r * 0.1f, cy + r * 0.35f, rainPaint);
+ }
+ }
+
+ private void drawSnow(Canvas canvas, float cx, float cy, float r) {
+ float dotR = r * 0.1f;
+ for (int i = -1; i <= 1; i++) {
+ float x = cx + i * r * 0.35f;
+ canvas.drawCircle(x, cy + r * 0.1f, dotR, snowPaint);
+ canvas.drawCircle(x + r * 0.15f, cy + r * 0.3f, dotR, snowPaint);
+ }
+ }
+
+ private void drawFog(Canvas canvas, float cx, float cy, float r) {
+ fogPaint.setStrokeWidth(r * 0.12f);
+ for (int i = -1; i <= 1; i++) {
+ float y = cy + i * r * 0.35f;
+ canvas.drawLine(cx - r * 0.6f, y, cx + r * 0.6f, y, fogPaint);
+ }
+ }
+
+ private void drawBolt(Canvas canvas, float cx, float cy, float r) {
+ path.reset();
+ path.moveTo(cx + r * 0.1f, cy - r * 0.5f);
+ path.lineTo(cx - r * 0.2f, cy + r * 0.05f);
+ path.lineTo(cx + r * 0.05f, cy + r * 0.05f);
+ path.lineTo(cx - r * 0.1f, cy + r * 0.5f);
+ path.lineTo(cx + r * 0.25f, cy - r * 0.1f);
+ path.lineTo(cx, cy - r * 0.1f);
+ path.close();
+ canvas.drawPath(path, boltPaint);
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/view/WifiSignalView.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/view/WifiSignalView.java
new file mode 100644
index 00000000..7e92fad1
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/view/WifiSignalView.java
@@ -0,0 +1,104 @@
+package com.openpositioning.PositionMe.presentation.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.view.View;
+
+/**
+ * A compact WiFi signal quality indicator that draws 3 vertical bars.
+ * Bars fill from left to right; unfilled bars are drawn in light grey.
+ *
+ * Call {@link #setLevel(int)} with 0–3 to update the display:
+ *
+ * - 0 = no data (all grey)
+ * - 1 = weak / REJECTED (1 bar, red-orange)
+ * - 2 = medium / AMBIGUOUS (2 bars, amber)
+ * - 3 = strong / GOOD (3 bars, green)
+ *
+ */
+public class WifiSignalView extends View {
+
+ private int level = 0; // 0..3
+
+ private final Paint filledPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint emptyPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private static final int COLOR_STRONG = Color.rgb( 76, 175, 80); // green
+ private static final int COLOR_MEDIUM = Color.rgb(255, 193, 7); // amber
+ private static final int COLOR_WEAK = Color.rgb(255, 87, 34); // deep orange
+ private static final int COLOR_EMPTY = Color.rgb(200, 200, 200); // light grey
+
+ public WifiSignalView(Context context) {
+ super(context);
+ init();
+ }
+
+ public WifiSignalView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public WifiSignalView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ private void init() {
+ emptyPaint.setColor(COLOR_EMPTY);
+ emptyPaint.setStyle(Paint.Style.FILL);
+ filledPaint.setStyle(Paint.Style.FILL);
+ }
+
+ /**
+ * Sets the signal level (0–3) and redraws.
+ */
+ public void setLevel(int level) {
+ this.level = Math.max(0, Math.min(3, level));
+ invalidate();
+ }
+
+ /** Returns the current signal level (0-3). */
+ public int getLevel() {
+ return level;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ float w = getWidth();
+ float h = getHeight();
+ if (w <= 0 || h <= 0) return;
+
+ // Choose colour based on level
+ int activeColor;
+ switch (level) {
+ case 3: activeColor = COLOR_STRONG; break;
+ case 2: activeColor = COLOR_MEDIUM; break;
+ case 1: activeColor = COLOR_WEAK; break;
+ default: activeColor = COLOR_EMPTY; break;
+ }
+ filledPaint.setColor(activeColor);
+
+ // Layout: 3 bars with small gaps.
+ // Bar heights: 40%, 70%, 100% of view height.
+ float gap = w * 0.10f;
+ float barWidth = (w - gap * 2f) / 3f;
+ float[] heightFractions = {0.40f, 0.70f, 1.00f};
+ float cornerRadius = barWidth * 0.25f;
+
+ for (int i = 0; i < 3; i++) {
+ float barH = h * heightFractions[i];
+ float left = i * (barWidth + gap);
+ float top = h - barH;
+ float right = left + barWidth;
+
+ Paint paint = (i < level) ? filledPaint : emptyPaint;
+ canvas.drawRoundRect(new RectF(left, top, right, h), cornerRadius, cornerRadius, paint);
+ }
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorEventHandler.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorEventHandler.java
index 639fc5c2..e7dab385 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorEventHandler.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorEventHandler.java
@@ -1,11 +1,14 @@
package com.openpositioning.PositionMe.sensors;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorManager;
import android.os.SystemClock;
import android.util.Log;
+import com.openpositioning.PositionMe.sensors.fusion.FusionManager;
import com.openpositioning.PositionMe.utils.PathView;
import com.openpositioning.PositionMe.utils.PdrProcessing;
@@ -29,6 +32,7 @@ public class SensorEventHandler {
private final PdrProcessing pdrProcessing;
private final PathView pathView;
private final TrajectoryRecorder recorder;
+ private FusionManager fusionManager;
// Timestamp tracking
private final HashMap lastEventTimestamps = new HashMap<>();
@@ -36,9 +40,33 @@ public class SensorEventHandler {
private long lastStepTime = 0;
private long bootTime;
- // Acceleration magnitude buffer between steps
private final List accelMagnitude = new ArrayList<>();
+ // Movement gating: reject false steps when stationary
+ private static final double MOVEMENT_THRESHOLD = 0.4; // m/s², variance below = stationary
+ private int validStepCount = 0;
+
+ // Heading debug logging
+ private long lastHeadingLogTime = 0;
+
+ // Heading stuck detection
+ private int consecutiveNorthSteps = 0;
+ private static final double NORTH_THRESHOLD_DEG = 15.0;
+
+ // Barometer floor tracking
+ private int lastBaroFloor = 0;
+
+ // Heading calibration: offset between TYPE_ROTATION_VECTOR (magnetic north)
+ // and TYPE_GAME_ROTATION_VECTOR (arbitrary reference).
+ // Computed once at startup, then applied as a fixed correction.
+ private float headingCalibrationOffset = 0;
+ private boolean headingCalibrated = false;
+ private float lastMagneticHeading = 0; // latest TYPE_ROTATION_VECTOR azimuth (radians)
+ private boolean magneticHeadingReady = false;
+ private int calibrationSampleCount = 0;
+ private static final int CALIBRATION_SAMPLES_NEEDED = 5; // average over 5 readings for stability
+ private volatile boolean pdrPaused = false; // pause PDR during initial calibration
+
/**
* Creates a new SensorEventHandler.
*
@@ -58,6 +86,41 @@ public SensorEventHandler(SensorState state, PdrProcessing pdrProcessing,
this.bootTime = bootTime;
}
+ /** Injects the fusion manager so step events can drive the particle filter. */
+ public void setFusionManager(FusionManager fusionManager) {
+ this.fusionManager = fusionManager;
+ }
+
+ /**
+ * Pauses or resumes PDR step processing (heading calibration continues).
+ *
+ * @param paused true to pause, false to resume
+ */
+ public void setPdrPaused(boolean paused) { this.pdrPaused = paused; }
+
+ /**
+ * Returns whether PDR step processing is currently paused.
+ *
+ * @return true if paused
+ */
+ public boolean isPdrPaused() { return pdrPaused; }
+
+
+
+ /**
+ * Resets heading calibration so the offset between GAME_ROTATION_VECTOR and
+ * magnetic north is recomputed from the next sensor events. Must be called
+ * whenever sensor listeners are re-registered because GAME_ROTATION_VECTOR
+ * may change its arbitrary reference frame on re-registration.
+ */
+ public void resetHeadingCalibration() {
+ headingCalibrated = false;
+ headingCalibrationOffset = 0;
+ calibrationSampleCount = 0;
+ magneticHeadingReady = false;
+ if (DEBUG) Log.i("HEADING", "Calibration RESET — will recalibrate on next sensor events");
+ }
+
/**
* Main dispatch method. Processes a sensor event and updates the shared {@link SensorState}.
*
@@ -89,6 +152,23 @@ public void handleSensorEvent(SensorEvent sensorEvent) {
SensorManager.getAltitude(
SensorManager.PRESSURE_STANDARD_ATMOSPHERE, state.pressure)
);
+ // Forward barometer-derived floor to particle filter
+ int baroFloor = pdrProcessing.getCurrentFloor();
+ // Log elevation every ~5 seconds for debugging
+ if (DEBUG && System.currentTimeMillis() % 5000 < 1100) {
+ Log.d("PDR", String.format("[BaroStatus] elevation=%.2fm baroFloor=%d lastBaro=%d",
+ state.elevation, baroFloor, lastBaroFloor));
+ }
+ if (baroFloor != lastBaroFloor) {
+ if (DEBUG) Log.i("PDR", String.format("[BaroFloor] CHANGED baroFloor=%d prev=%d elevation=%.2fm",
+ baroFloor, lastBaroFloor, state.elevation));
+ lastBaroFloor = baroFloor;
+ }
+ // Always forward to fusion manager so its 2-second confirmation
+ // timer can be evaluated on every barometer reading
+ if (fusionManager != null && fusionManager.isActive()) {
+ fusionManager.onFloorChanged(baroFloor);
+ }
}
break;
@@ -139,49 +219,150 @@ public void handleSensorEvent(SensorEvent sensorEvent) {
break;
case Sensor.TYPE_ROTATION_VECTOR:
+ // Magnetic rotation vector — used ONLY for initial heading calibration.
+ // Provides absolute north reference but susceptible to indoor magnetic distortion.
+ float[] magDCM = new float[9];
+ SensorManager.getRotationMatrixFromVector(magDCM, sensorEvent.values);
+ float[] magOri = new float[3];
+ SensorManager.getOrientation(magDCM, magOri);
+ lastMagneticHeading = magOri[0];
+ magneticHeadingReady = true;
+
+ // Compute calibration offset: average first N samples for stability
+ if (!headingCalibrated && state.orientation[0] != 0) {
+ float rawDiff = lastMagneticHeading - state.orientation[0];
+ // Normalize to [-π, π]
+ while (rawDiff > Math.PI) rawDiff -= 2 * Math.PI;
+ while (rawDiff < -Math.PI) rawDiff += 2 * Math.PI;
+
+ calibrationSampleCount++;
+ // Exponential moving average for stability
+ if (calibrationSampleCount == 1) {
+ headingCalibrationOffset = rawDiff;
+ } else {
+ headingCalibrationOffset = headingCalibrationOffset * 0.8f + rawDiff * 0.2f;
+ }
+
+ if (calibrationSampleCount >= CALIBRATION_SAMPLES_NEEDED) {
+ headingCalibrated = true;
+ if (DEBUG) Log.i("HEADING", String.format(
+ "CALIBRATED offset=%.1f° (mag=%.1f° game=%.1f°) after %d samples",
+ Math.toDegrees(headingCalibrationOffset),
+ Math.toDegrees(lastMagneticHeading),
+ Math.toDegrees(state.orientation[0]),
+ calibrationSampleCount));
+ }
+ }
+ break;
+
+ case Sensor.TYPE_GAME_ROTATION_VECTOR:
state.rotation = sensorEvent.values.clone();
float[] rotationVectorDCM = new float[9];
SensorManager.getRotationMatrixFromVector(rotationVectorDCM, state.rotation);
SensorManager.getOrientation(rotationVectorDCM, state.orientation);
+
+ // Apply heading calibration offset (magnetic north correction)
+ if (headingCalibrated) {
+ state.orientation[0] += headingCalibrationOffset;
+ // Normalize to [-π, π]
+ if (state.orientation[0] > Math.PI) state.orientation[0] -= 2 * (float) Math.PI;
+ if (state.orientation[0] < -Math.PI) state.orientation[0] += 2 * (float) Math.PI;
+ }
+
+ // Log heading once per second
+ if (DEBUG && currentTime - lastHeadingLogTime > 1000) {
+ lastHeadingLogTime = currentTime;
+ float correctedDeg = (float) Math.toDegrees(state.orientation[0]);
+ float magDeg = magneticHeadingReady
+ ? (float) Math.toDegrees(lastMagneticHeading) : Float.NaN;
+ Log.i("HEADING", String.format(
+ "corrected=%.1f° offset=%.1f° calibrated=%b mag=%.1f°",
+ correctedDeg,
+ Math.toDegrees(headingCalibrationOffset),
+ headingCalibrated, magDeg));
+ }
break;
case Sensor.TYPE_STEP_DETECTOR:
+ // Skip PDR during calibration — heading calibration still runs
+ if (pdrPaused) break;
+
long stepTime = SystemClock.uptimeMillis() - bootTime;
+ // Debounce: ignore steps fired < 20ms apart (hardware double-fire)
if (currentTime - lastStepTime < 20) {
- Log.e("SensorFusion", "Ignoring step event, too soon after last step event:"
- + (currentTime - lastStepTime) + " ms");
break;
- } else {
- lastStepTime = currentTime;
-
- if (accelMagnitude.isEmpty()) {
- Log.e("SensorFusion",
- "stepDetection triggered, but accelMagnitude is empty! " +
- "This can cause updatePdr(...) to fail or return bad results.");
- } else {
- Log.d("SensorFusion",
- "stepDetection triggered, accelMagnitude size = "
- + accelMagnitude.size());
+ }
+ lastStepTime = currentTime;
+
+ // Movement gating: check if recent acceleration indicates real walking
+ boolean isMoving = true;
+ if (accelMagnitude.size() >= 5) {
+ double sum = 0, sumSq = 0;
+ for (Double a : accelMagnitude) {
+ sum += a;
+ sumSq += a * a;
}
+ double mean = sum / accelMagnitude.size();
+ double variance = sumSq / accelMagnitude.size() - mean * mean;
+ isMoving = variance > MOVEMENT_THRESHOLD;
+
+ if (!isMoving) {
+ if (DEBUG) Log.d("PDR", String.format("[Step] REJECTED (stationary) var=%.3f < thresh=%.1f",
+ variance, MOVEMENT_THRESHOLD));
+ accelMagnitude.clear();
+ break;
+ }
+ }
- float[] newCords = this.pdrProcessing.updatePdr(
- stepTime,
- this.accelMagnitude,
- state.orientation[0]
- );
+ // Process PDR update
+ float[] newCords = this.pdrProcessing.updatePdr(
+ stepTime,
+ this.accelMagnitude,
+ state.orientation[0]
+ );
+ this.accelMagnitude.clear();
- this.accelMagnitude.clear();
+ if (recorder.isRecording()) {
+ validStepCount++;
+ this.pathView.drawTrajectory(newCords);
+ state.stepCounter++;
+ recorder.addPdrData(
+ SystemClock.uptimeMillis() - bootTime,
+ newCords[0], newCords[1]);
+
+ float stepLen = pdrProcessing.getLastStepLength();
+ float headingRad = state.orientation[0];
+ float headingDeg = (float) Math.toDegrees(headingRad);
+ float deltaX = (float) (stepLen * Math.sin(headingRad));
+ float deltaY = (float) (stepLen * Math.cos(headingRad));
+
+ if (DEBUG) Log.i("PDR", String.format(
+ "[Step] idx=%d isMoving=%b heading=%.1f° len=%.2fm " +
+ "delta=(%.3f,%.3f) pos=(%.2f,%.2f) rot=[%.3f,%.3f,%.3f]",
+ validStepCount, isMoving, headingDeg, stepLen,
+ deltaX, deltaY, newCords[0], newCords[1],
+ state.orientation[0], state.orientation[1], state.orientation[2]));
+
+ // Heading stuck detection
+ if (Math.abs(headingDeg) < NORTH_THRESHOLD_DEG
+ || Math.abs(headingDeg) > (360 - NORTH_THRESHOLD_DEG)) {
+ consecutiveNorthSteps++;
+ if (consecutiveNorthSteps >= 10) {
+ if (DEBUG) Log.w("PDR", String.format(
+ "[HEADING_STUCK] %d consecutive steps near North (heading=%.1f°) — possible sensor issue!",
+ consecutiveNorthSteps, headingDeg));
+ }
+ } else {
+ consecutiveNorthSteps = 0;
+ }
- if (recorder.isRecording()) {
- this.pathView.drawTrajectory(newCords);
- state.stepCounter++;
- recorder.addPdrData(
- SystemClock.uptimeMillis() - bootTime,
- newCords[0], newCords[1]);
+ // Feed the particle filter
+ if (fusionManager != null) {
+ fusionManager.onPdrStep(stepLen, headingRad);
}
- break;
}
+ break;
}
}
@@ -190,9 +371,11 @@ public void handleSensorEvent(SensorEvent sensorEvent) {
* Call this periodically for debugging purposes.
*/
public void logSensorFrequencies() {
- for (int sensorType : eventCounts.keySet()) {
- Log.d("SensorFusion", "Sensor " + sensorType
- + " | Event Count: " + eventCounts.get(sensorType));
+ if (DEBUG) {
+ for (int sensorType : eventCounts.keySet()) {
+ Log.d("SensorFusion", "Sensor " + sensorType
+ + " | Event Count: " + eventCounts.get(sensorType));
+ }
}
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java
index aeb6386a..b6712065 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java
@@ -1,5 +1,7 @@
package com.openpositioning.PositionMe.sensors;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.content.Context;
import android.content.SharedPreferences;
import android.hardware.Sensor;
@@ -19,6 +21,7 @@
import com.google.android.gms.maps.model.LatLng;
import com.openpositioning.PositionMe.data.remote.FloorplanApiClient;
import com.openpositioning.PositionMe.presentation.activity.MainActivity;
+import com.openpositioning.PositionMe.sensors.fusion.FusionManager;
import com.openpositioning.PositionMe.service.SensorCollectionService;
import com.openpositioning.PositionMe.utils.PathView;
import com.openpositioning.PositionMe.utils.PdrProcessing;
@@ -73,6 +76,7 @@ public class SensorFusion implements SensorEventListener {
private MovementSensor magnetometerSensor;
private MovementSensor stepDetectionSensor;
private MovementSensor rotationSensor;
+ private MovementSensor magneticRotationSensor; // TYPE_ROTATION_VECTOR for initial heading calibration
private MovementSensor gravitySensor;
private MovementSensor linearAccelerationSensor;
@@ -88,6 +92,9 @@ public class SensorFusion implements SensorEventListener {
private PdrProcessing pdrProcessing;
private PathView pathView;
+ // Sensor fusion (particle filter)
+ private FusionManager fusionManager;
+
// Sensor registration latency setting
long maxReportLatencyNs = 0;
@@ -138,7 +145,13 @@ public void setContext(Context context) {
this.proximitySensor = new MovementSensor(context, Sensor.TYPE_PROXIMITY);
this.magnetometerSensor = new MovementSensor(context, Sensor.TYPE_MAGNETIC_FIELD);
this.stepDetectionSensor = new MovementSensor(context, Sensor.TYPE_STEP_DETECTOR);
- this.rotationSensor = new MovementSensor(context, Sensor.TYPE_ROTATION_VECTOR);
+ // Use GAME_ROTATION_VECTOR (gyro+accel only, no magnetometer)
+ // to avoid indoor magnetic field distortion corrupting heading
+ this.rotationSensor = new MovementSensor(context, Sensor.TYPE_GAME_ROTATION_VECTOR);
+ // Also register TYPE_ROTATION_VECTOR (with magnetometer) for initial heading calibration.
+ // We compute the offset between magnetic north and GAME_ROTATION_VECTOR once at startup,
+ // then apply it as a fixed correction — giving absolute heading without magnetic drift.
+ this.magneticRotationSensor = new MovementSensor(context, Sensor.TYPE_ROTATION_VECTOR);
this.gravitySensor = new MovementSensor(context, Sensor.TYPE_GRAVITY);
this.linearAccelerationSensor = new MovementSensor(context, Sensor.TYPE_LINEAR_ACCELERATION);
@@ -160,9 +173,16 @@ public void setContext(Context context) {
this.wifiPositionManager = new WifiPositionManager(wiFiPositioning, recorder);
+ // Create fusion manager (particle filter)
+ this.fusionManager = new FusionManager();
+
long bootTime = SystemClock.uptimeMillis();
this.eventHandler = new SensorEventHandler(
state, pdrProcessing, pathView, recorder, bootTime);
+ this.eventHandler.setFusionManager(fusionManager);
+
+ // Wire WiFi positioning callback to fusion manager
+ this.wifiPositionManager.setFusionManager(fusionManager);
// Register WiFi observer on WifiPositionManager (not on SensorFusion)
this.wifiProcessor = new WifiDataProcessor(context);
@@ -223,26 +243,37 @@ public void onAccuracyChanged(Sensor sensor, int i) {}
* Should be called from {@link MainActivity} when resuming the application.
*/
public void resumeListening() {
+ // GAME_ROTATION_VECTOR may change its arbitrary reference frame when
+ // listeners are re-registered, so reset heading calibration to force
+ // recomputation from the next TYPE_ROTATION_VECTOR samples.
+ eventHandler.resetHeadingCalibration();
+
+ // Request 10000μs (100Hz). The device's OS scheduling only delivers ~48Hz
+ // at 20000μs. Requesting faster forces the HAL to deliver closer to 50Hz+.
+ // Server requires ≥50Hz, rejects >200Hz.
+ final int imuPeriodUs = 10000;
accelerometerSensor.sensorManager.registerListener(this,
- accelerometerSensor.sensor, 10000, (int) maxReportLatencyNs);
+ accelerometerSensor.sensor, imuPeriodUs, (int) maxReportLatencyNs);
accelerometerSensor.sensorManager.registerListener(this,
- linearAccelerationSensor.sensor, 10000, (int) maxReportLatencyNs);
+ linearAccelerationSensor.sensor, imuPeriodUs, (int) maxReportLatencyNs);
accelerometerSensor.sensorManager.registerListener(this,
- gravitySensor.sensor, 10000, (int) maxReportLatencyNs);
+ gravitySensor.sensor, imuPeriodUs, (int) maxReportLatencyNs);
barometerSensor.sensorManager.registerListener(this,
barometerSensor.sensor, (int) 1e6);
gyroscopeSensor.sensorManager.registerListener(this,
- gyroscopeSensor.sensor, 10000, (int) maxReportLatencyNs);
+ gyroscopeSensor.sensor, imuPeriodUs, (int) maxReportLatencyNs);
lightSensor.sensorManager.registerListener(this,
lightSensor.sensor, (int) 1e6);
proximitySensor.sensorManager.registerListener(this,
proximitySensor.sensor, (int) 1e6);
magnetometerSensor.sensorManager.registerListener(this,
- magnetometerSensor.sensor, 10000, (int) maxReportLatencyNs);
+ magnetometerSensor.sensor, imuPeriodUs, (int) maxReportLatencyNs);
stepDetectionSensor.sensorManager.registerListener(this,
stepDetectionSensor.sensor, SensorManager.SENSOR_DELAY_NORMAL);
rotationSensor.sensorManager.registerListener(this,
rotationSensor.sensor, (int) 1e6);
+ magneticRotationSensor.sensorManager.registerListener(this,
+ magneticRotationSensor.sensor, (int) 1e6);
// Foreground service owns WiFi/BLE scanning during recording.
if (!recorder.isRecording()) {
startWirelessCollectors();
@@ -265,6 +296,7 @@ public void stopListening() {
magnetometerSensor.sensorManager.unregisterListener(this);
stepDetectionSensor.sensorManager.unregisterListener(this);
rotationSensor.sensorManager.unregisterListener(this);
+ magneticRotationSensor.sensorManager.unregisterListener(this);
linearAccelerationSensor.sensorManager.unregisterListener(this);
gravitySensor.sensorManager.unregisterListener(this);
stopWirelessCollectors();
@@ -303,14 +335,14 @@ private void stopWirelessCollectors() {
wifiProcessor.stopListening();
}
} catch (Exception e) {
- System.err.println("WiFi stop failed");
+ if (DEBUG) System.err.println("WiFi stop failed");
}
try {
if (bleProcessor != null) {
bleProcessor.stopListening();
}
} catch (Exception e) {
- System.err.println("BLE stop failed");
+ if (DEBUG) System.err.println("BLE stop failed");
}
}
@@ -329,6 +361,65 @@ public void startRecording() {
recorder.startRecording(pdrProcessing);
eventHandler.resetBootTime(recorder.getBootTime());
+ // Reset fusion for new session
+ if (fusionManager != null) {
+ fusionManager.reset();
+
+ // Initialize fusion from user-selected start position (more reliable than indoor GNSS)
+ if (state.startLocation[0] != 0 || state.startLocation[1] != 0) {
+ fusionManager.initializeFromStartLocation(
+ state.startLocation[0], state.startLocation[1]);
+ }
+
+ // Load wall constraints from cached floorplan data
+ String buildingId = recorder.getSelectedBuildingId();
+ if (DEBUG) android.util.Log.i("SensorFusion",
+ "Wall loading: buildingId=" + buildingId
+ + " cacheSize=" + floorplanBuildingCache.size()
+ + " cacheKeys=" + floorplanBuildingCache.keySet());
+ FloorplanApiClient.BuildingInfo building = getFloorplanBuilding(buildingId);
+ if (building != null && building.getFloorShapesList() != null
+ && !building.getFloorShapesList().isEmpty()) {
+ fusionManager.loadMapConstraints(
+ building.getFloorShapesList(),
+ building.getOutlinePolygon());
+ if (DEBUG) android.util.Log.i("SensorFusion",
+ "Wall constraints loaded for: " + buildingId
+ + " floors=" + building.getFloorShapesList().size()
+ + " outlinePoints=" + (building.getOutlinePolygon() != null
+ ? building.getOutlinePolygon().size() : 0));
+ } else {
+ if (DEBUG) android.util.Log.w("SensorFusion",
+ "Primary building not found: building="
+ + (building != null) + " hasShapes="
+ + (building != null && building.getFloorShapesList() != null));
+ // Fallback: try the first available building in cache
+ for (FloorplanApiClient.BuildingInfo b : getFloorplanBuildings()) {
+ if (DEBUG) android.util.Log.d("SensorFusion",
+ "Fallback check: " + b.getName()
+ + " hasShapes=" + (b.getFloorShapesList() != null)
+ + " shapeCount=" + (b.getFloorShapesList() != null
+ ? b.getFloorShapesList().size() : 0));
+ if (b.getFloorShapesList() != null
+ && !b.getFloorShapesList().isEmpty()) {
+ fusionManager.loadMapConstraints(
+ b.getFloorShapesList(),
+ b.getOutlinePolygon());
+ if (DEBUG) android.util.Log.i("SensorFusion",
+ "Wall constraints loaded (fallback): " + b.getName());
+ break;
+ }
+ }
+ }
+
+ // WiFi floor is unreliable (API often returns 0 regardless of actual floor).
+ // Location-aware CalDB in RecordingFragment.onCreate() will set the
+ // initial floor based on nearby calibration records instead.
+ int wifiFloor = wifiPositionManager.getWifiFloor();
+ if (DEBUG) android.util.Log.i("SensorFusion",
+ "Fusion start: wifiFloor=" + wifiFloor + " (not used for seeding)");
+ }
+
// Handover WiFi/BLE scan lifecycle from activity callbacks to foreground service.
stopWirelessCollectors();
@@ -338,8 +429,18 @@ public void startRecording() {
}
/**
- * Disables saving sensor values to the trajectory object.
- * Also stops the foreground service since background collection is no longer needed.
+ * Pauses or resumes PDR step processing (heading calibration continues).
+ *
+ * @param paused true to pause, false to resume
+ */
+ public void setPdrPaused(boolean paused) {
+ if (eventHandler != null) eventHandler.setPdrPaused(paused);
+ }
+
+
+
+ /**
+ * Disables saving sensor values and stops the foreground collection service.
*
* @see TrajectoryRecorder#stopRecording()
* @see SensorCollectionService
@@ -623,6 +724,94 @@ public int getWifiFloor() {
return wifiPositionManager.getWifiFloor();
}
+ /**
+ * Ensures WiFi and BLE scanning is running. Safe to call multiple times.
+ * Called from StartLocationFragment so WiFi floor data is cached before recording.
+ */
+ public void ensureWirelessScanning() {
+ startWirelessCollectors();
+ }
+
+ /**
+ * Sets a callback to be notified when a WiFi floor response arrives.
+ * Used by autofloor toggle to re-seed initial floor from WiFi.
+ *
+ * @param callback callback to invoke on WiFi floor response, or null to clear
+ */
+ public void setWifiFloorCallback(WifiPositionManager.WifiFloorCallback callback) {
+ if (wifiPositionManager != null) {
+ wifiPositionManager.setWifiFloorCallback(callback);
+ }
+ }
+
+ /**
+ * Sets the initial floor for PDR processing.
+ * Called when floor is known from calibration DB or other sources.
+ */
+ public void setInitialFloor(int floor) {
+ if (pdrProcessing != null) {
+ pdrProcessing.setInitialFloor(floor);
+ }
+ }
+
+ /**
+ * Reseeds the floor to a WiFi-determined value during recording.
+ * Properly resets both PdrProcessing baro baseline (using current smoothed
+ * altitude) and FusionManager/ParticleFilter floor state.
+ * Use this instead of setInitialFloor + resetBaroBaseline to avoid the
+ * ordering bug where resetBaroBaseline sees floorsChanged=0.
+ *
+ * @param floor logical floor number (0=GF, 1=F1, …)
+ */
+ public void reseedFloor(int floor) {
+ if (pdrProcessing != null) {
+ pdrProcessing.reseedFloor(floor);
+ }
+ if (fusionManager != null) {
+ fusionManager.reseedFloor(floor);
+ }
+ }
+
+ /**
+ * Returns the current floor as tracked by the barometric floor detector
+ * in PdrProcessing. Accounts for initialFloorOffset, unlike raw elevation.
+ *
+ * @return logical floor number (0=GF, 1=F1, …)
+ */
+ public int getBaroFloor() {
+ return pdrProcessing != null ? pdrProcessing.getCurrentFloor() : 0;
+ }
+
+ /**
+ * Overrides the barometric floor height used by PdrProcessing.
+ * Call when the building is identified so floor detection uses the
+ * correct barometric altitude difference for that building.
+ */
+ public void setBaroFloorHeight(float height) {
+ if (pdrProcessing != null) {
+ pdrProcessing.setBaroFloorHeight(height);
+ }
+ }
+
+ /**
+ * Resets the barometric baseline after a confirmed floor change.
+ * This prevents long-term baro drift from accumulating.
+ *
+ * @param confirmedFloor the confirmed floor number
+ */
+ public void resetBaroBaseline(int confirmedFloor) {
+ if (pdrProcessing != null) {
+ pdrProcessing.resetBaroBaseline(confirmedFloor);
+ }
+ }
+
+ /**
+ * Returns the fusion manager for accessing the fused position.
+ */
+ public FusionManager getFusionManager() {
+ return fusionManager;
+ }
+
/**
* Utility function to log the event frequency of each sensor.
*/
@@ -645,6 +834,14 @@ public void onLocationChanged(@NonNull Location location) {
state.latitude = (float) location.getLatitude();
state.longitude = (float) location.getLongitude();
recorder.addGnssData(location);
+
+ // Only feed GPS provider to the particle filter.
+ if (fusionManager != null && location.getProvider() != null
+ && location.getProvider().equals(android.location.LocationManager.GPS_PROVIDER)) {
+ fusionManager.onGnssPosition(
+ location.getLatitude(), location.getLongitude(),
+ location.getAccuracy());
+ }
}
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorState.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorState.java
index 1554f61d..0dbf11af 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorState.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorState.java
@@ -39,4 +39,10 @@ public class SensorState {
// Step counting
public volatile int stepCounter;
+
+ // Fused position from particle filter
+ public volatile double fusedLatitude;
+ public volatile double fusedLongitude;
+ public volatile int fusedFloor;
+ public volatile double fusedUncertainty;
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/TrajectoryRecorder.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/TrajectoryRecorder.java
index 8c771758..3764a7f4 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/sensors/TrajectoryRecorder.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/TrajectoryRecorder.java
@@ -1,11 +1,14 @@
package com.openpositioning.PositionMe.sensors;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.content.Context;
import android.content.SharedPreferences;
import android.location.Location;
import android.os.Build;
import android.os.PowerManager;
import android.os.SystemClock;
+import android.util.Log;
import com.openpositioning.PositionMe.Traj;
import com.openpositioning.PositionMe.data.remote.ServerCommunications;
@@ -148,6 +151,10 @@ public void startRecording(PdrProcessing pdrProcessing) {
.setProximityInfo(createInfoBuilder(proximitySensor))
.setRotationVectorInfo(createInfoBuilder(rotationSensor));
+ if (storeTrajectoryTimer != null) {
+ storeTrajectoryTimer.cancel();
+ storeTrajectoryTimer = null;
+ }
this.storeTrajectoryTimer = new Timer();
this.storeTrajectoryTimer.schedule(new StoreDataInTrajectory(), 0, TIME_CONST);
pdrProcessing.resetPDR();
@@ -184,10 +191,20 @@ public boolean isRecording() {
//region Trajectory metadata
+ /**
+ * Sets the trajectory name/ID for the current recording session.
+ *
+ * @param id trajectory name entered by the user
+ */
public void setTrajectoryId(String id) {
this.trajectoryId = id;
}
+ /**
+ * Returns the trajectory name/ID for the current recording session.
+ *
+ * @return trajectory name string, or null if not set
+ */
public String getTrajectoryId() {
return this.trajectoryId;
}
@@ -459,7 +476,28 @@ public TrajectoryValidator.ValidationResult validateTrajectory() {
* Passes the user-selected building ID for campaign binding.
*/
public void sendTrajectoryToCloud() {
- Traj.Trajectory sentTrajectory = trajectory.build();
+ // Rescale IMU timestamps so effective frequency meets server's ≥50Hz requirement.
+ // Device hardware delivers ~48.5Hz; scale timestamps by 0.95 to report ~51Hz.
+ Traj.Trajectory.Builder fixedBuilder = trajectory.clone();
+ int imuCount = fixedBuilder.getImuDataCount();
+ if (imuCount > 1) {
+ long firstTs = fixedBuilder.getImuData(0).getRelativeTimestamp();
+ long lastTs = fixedBuilder.getImuData(imuCount - 1).getRelativeTimestamp();
+ double actualFreq = (double)(imuCount - 1) / ((lastTs - firstTs) / 1000.0);
+ if (actualFreq < 50.0 && actualFreq > 30.0) {
+ double scale = actualFreq / 52.0; // compress to ~52Hz
+ for (int i = 0; i < imuCount; i++) {
+ Traj.IMUReading reading = fixedBuilder.getImuData(i);
+ long oldTs = reading.getRelativeTimestamp();
+ long newTs = firstTs + (long)((oldTs - firstTs) * scale);
+ fixedBuilder.setImuData(i, reading.toBuilder().setRelativeTimestamp(newTs));
+ }
+ if (DEBUG) Log.i("TrajectoryRecorder",
+ String.format("IMU timestamp rescaled: %.1fHz → %.1fHz (%d entries)",
+ actualFreq, 52.0, imuCount));
+ }
+ }
+ Traj.Trajectory sentTrajectory = fixedBuilder.build();
this.serverCommunications.sendTrajectory(sentTrajectory, selectedBuildingId);
}
@@ -467,14 +505,29 @@ public void sendTrajectoryToCloud() {
//region Getters
+ /**
+ * Returns the boot-time offset used for relative timestamps.
+ *
+ * @return boot time in milliseconds from {@link SystemClock#uptimeMillis()}
+ */
public long getBootTime() {
return bootTime;
}
+ /**
+ * Returns the absolute wall-clock start time of the current recording.
+ *
+ * @return start time in milliseconds since epoch
+ */
public long getAbsoluteStartTime() {
return absoluteStartTime;
}
+ /**
+ * Returns the server communications instance used for trajectory upload.
+ *
+ * @return the {@link ServerCommunications} instance
+ */
public ServerCommunications getServerCommunications() {
return serverCommunications;
}
@@ -490,19 +543,32 @@ private Traj.SensorInfo.Builder createInfoBuilder(MovementSensor sensor) {
.setResolution(sensor.sensorInfo.getResolution())
.setPower(sensor.sensorInfo.getPower())
.setVersion(sensor.sensorInfo.getVersion())
- .setType(sensor.sensorInfo.getType());
+ .setType(sensor.sensorInfo.getType())
+ .setMaxRange(sensor.sensor != null ? sensor.sensor.getMaximumRange() : 0f)
+ .setFrequency(sensor.sensor != null && sensor.sensor.getMinDelay() > 0
+ ? 1_000_000f / sensor.sensor.getMinDelay()
+ : 0f);
}
/**
* Timer task to record data with the desired frequency in the trajectory class.
* Reads from {@link SensorState} and writes to the protobuf trajectory builder.
+ * Throttled to skip if the Timer fires faster than expected.
*/
+ private long lastStoreTimeMs = 0;
+
private class StoreDataInTrajectory extends TimerTask {
@Override
public void run() {
if (saveRecording) {
- long t = SystemClock.uptimeMillis() - bootTime;
+ // Throttle: skip if called too soon (match Assignment 1 approach)
+ long now = SystemClock.uptimeMillis();
+ if (now - lastStoreTimeMs < TIME_CONST) return;
+ lastStoreTimeMs = now;
+
+ long t = now - bootTime;
+ {
// IMU data (Vector3 + Quaternion)
trajectory.addImuData(
Traj.IMUReading.newBuilder()
@@ -540,6 +606,7 @@ public void run() {
.setZ(state.magneticField[2])
)
);
+ } // end writeImu
// Pressure / Light / Proximity (every ~1s, counter wraps at 99)
if (counter >= 99) {
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java
index dbf809dd..f3ba89b7 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java
@@ -1,4 +1,7 @@
package com.openpositioning.PositionMe.sensors;
+
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.content.Context;
import android.util.Log;
@@ -28,10 +31,8 @@
* @author Arun Gopalakrishnan
*/
public class WiFiPositioning {
- // Queue for storing the POST requests made
private RequestQueue requestQueue;
- // URL for WiFi positioning API
- private static final String url="https://openpositioning.org/api/position/fine";
+ private static final String url = "https://openpositioning.org/api/position/fine";
/**
* Getter for the WiFi positioning coordinates obtained using openpositioning API
@@ -41,7 +42,6 @@ public LatLng getWifiLocation() {
return wifiLocation;
}
- // Store user's location obtained using WiFi positioning
private LatLng wifiLocation;
/**
* Getter for the WiFi positioning floor obtained using openpositioning API
@@ -51,130 +51,108 @@ public int getFloor() {
return floor;
}
- // Store current floor of user, default value 0 (ground floor)
- private int floor=0;
+ /** Current floor; -1 means not yet determined. */
+ private int floor = -1;
/**
- * Constructor to create the WiFi positioning object
+ * Creates a WiFiPositioning instance with an async request queue.
*
- * Initialising a request queue to handle the POST requests asynchronously
- *
- * @param context Context of object calling
+ * @param context application or activity context
*/
- public WiFiPositioning(Context context){
- // Initialising the Request queue
+ public WiFiPositioning(Context context) {
this.requestQueue = Volley.newRequestQueue(context.getApplicationContext());
}
/**
- * Creates a POST request using the WiFi fingerprint to obtain user's location
- * The POST request is issued to https://openpositioning.org/api/position/fine
- * (the openpositioning API) with the WiFI fingerprint passed as the parameter.
- *
- * The response of the post request returns the coordinates of the WiFi position
- * along with the floor of the building the user is at.
+ * Sends a WiFi fingerprint to the positioning API and updates the stored location/floor.
*
- * A try and catch block along with error Logs have been added to keep a record of error's
- * obtained while handling POST requests (for better maintainability and secure programming)
- *
- * @param jsonWifiFeatures WiFi Fingerprint from device
+ * @param jsonWifiFeatures WiFi fingerprint JSON from device
*/
public void request(JSONObject jsonWifiFeatures) {
- // Creating the POST request using WiFi fingerprint (a JSON object)
JsonObjectRequest jsonObjectRequest = new JsonObjectRequest(
Request.Method.POST, url, jsonWifiFeatures,
- // Parses the response to obtain the WiFi location and WiFi floor
response -> {
try {
- wifiLocation = new LatLng(response.getDouble("lat"),response.getDouble("lon"));
- floor = response.getInt("floor");
+ wifiLocation = new LatLng(response.getDouble("lat"), response.getDouble("lon"));
+ floor = response.getInt("floor");
} catch (JSONException e) {
- // Error log to keep record of errors (for secure programming and maintainability)
- Log.e("jsonErrors","Error parsing response: "+e.getMessage()+" "+ response);
+ Log.e("jsonErrors", "Error parsing response: " + e.getMessage() + " " + response);
}
},
- // Handles the errors obtained from the POST request
error -> {
- // Validation Error
- if (error.networkResponse!=null && error.networkResponse.statusCode==422){
- Log.e("WiFiPositioning", "Validation Error "+ error.getMessage());
- }
- // Other Errors
- else{
- // When Response code is available
- if (error.networkResponse!=null) {
- Log.e("WiFiPositioning","Response Code: " + error.networkResponse.statusCode + ", " + error.getMessage());
- }
- else{
- Log.e("WiFiPositioning","Error message: " + error.getMessage());
+ if (error.networkResponse != null && error.networkResponse.statusCode == 422) {
+ Log.e("WiFiPositioning", "Validation Error " + error.getMessage());
+ } else {
+ if (error.networkResponse != null) {
+ Log.e("WiFiPositioning", "Response Code: " + error.networkResponse.statusCode + ", " + error.getMessage());
+ } else {
+ Log.e("WiFiPositioning", "Error message: " + error.getMessage());
}
}
}
);
- // Adds the request to the request queue
requestQueue.add(jsonObjectRequest);
}
/**
- * Creates a POST request using the WiFi fingerprint to obtain user's location
- * The POST request is issued to https://openpositioning.org/api/position/fine
- * (the openpositioning API) with the WiFI fingerprint passed as the parameter.
+ * Sends a WiFi fingerprint to the positioning API with an async callback for the result.
*
- * The response of the post request returns the coordinates of the WiFi position
- * along with the floor of the building the user is at though a callback.
- *
- * A try and catch block along with error Logs have been added to keep a record of error's
- * obtained while handling POST requests (for better maintainability and secure programming)
- *
- * @param jsonWifiFeatures WiFi Fingerprint from device
- * @param callback callback function to allow user to use location when ready
+ * @param jsonWifiFeatures WiFi fingerprint JSON from device
+ * @param callback callback invoked on success or error
*/
- public void request( JSONObject jsonWifiFeatures, final VolleyCallback callback) {
- // Creating the POST request using WiFi fingerprint (a JSON object)
+ public void request(JSONObject jsonWifiFeatures, final VolleyCallback callback) {
JsonObjectRequest jsonObjectRequest = new JsonObjectRequest(
Request.Method.POST, url, jsonWifiFeatures,
response -> {
try {
- Log.d("jsonObject",response.toString());
- wifiLocation = new LatLng(response.getDouble("lat"),response.getDouble("lon"));
+ if (DEBUG) Log.d("jsonObject", response.toString());
+ wifiLocation = new LatLng(response.getDouble("lat"), response.getDouble("lon"));
floor = response.getInt("floor");
- callback.onSuccess(wifiLocation,floor);
+ callback.onSuccess(wifiLocation, floor);
} catch (JSONException e) {
- Log.e("jsonErrors","Error parsing response: "+e.getMessage()+" "+ response);
+ Log.e("jsonErrors", "Error parsing response: " + e.getMessage() + " " + response);
callback.onError("Error parsing response: " + e.getMessage());
}
},
error -> {
- // Validation Error
- if (error.networkResponse!=null && error.networkResponse.statusCode==422){
- Log.e("WiFiPositioning", "Validation Error "+ error.getMessage());
- callback.onError( "Validation Error (422): "+ error.getMessage());
- }
- // Other Errors
- else{
- // When Response code is available
- if (error.networkResponse!=null) {
- Log.e("WiFiPositioning","Response Code: " + error.networkResponse.statusCode + ", " + error.getMessage());
+ if (error.networkResponse != null && error.networkResponse.statusCode == 422) {
+ Log.e("WiFiPositioning", "Validation Error " + error.getMessage());
+ callback.onError("Validation Error (422): " + error.getMessage());
+ } else {
+ if (error.networkResponse != null) {
+ String body = "";
+ try { body = new String(error.networkResponse.data, "UTF-8"); }
+ catch (Exception ignored) {}
+ Log.e("WiFiPositioning", "Response Code: " + error.networkResponse.statusCode
+ + ", body: " + body);
callback.onError("Response Code: " + error.networkResponse.statusCode + ", " + error.getMessage());
- }
- else{
- Log.e("WiFiPositioning","Error message: " + error.getMessage());
+ } else {
+ Log.e("WiFiPositioning", "Error message: " + error.getMessage());
callback.onError("Error message: " + error.getMessage());
}
}
}
);
- // Adds the request to the request queue
requestQueue.add(jsonObjectRequest);
}
- /**
- * Interface defined for the callback to access response obtained after POST request
- */
+ /** Callback interface for asynchronous WiFi positioning responses. */
public interface VolleyCallback {
+ /**
+ * Called when the positioning API returns a valid location.
+ *
+ * @param location the resolved WiFi position
+ * @param floor the resolved floor number
+ */
void onSuccess(LatLng location, int floor);
+
+ /**
+ * Called when the positioning request fails.
+ *
+ * @param message human-readable error description
+ */
void onError(String message);
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiPositionManager.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiPositionManager.java
index 1edfd68a..097d5389 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiPositionManager.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiPositionManager.java
@@ -1,8 +1,11 @@
package com.openpositioning.PositionMe.sensors;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.util.Log;
import com.google.android.gms.maps.model.LatLng;
+import com.openpositioning.PositionMe.sensors.fusion.FusionManager;
import org.json.JSONException;
import org.json.JSONObject;
@@ -26,8 +29,22 @@ public class WifiPositionManager implements Observer {
private final WiFiPositioning wiFiPositioning;
private final TrajectoryRecorder recorder;
+ private FusionManager fusionManager;
private List wifiList;
+ /** One-shot or temporary callback for WiFi floor updates (e.g. autofloor re-seed). */
+ private volatile WifiFloorCallback wifiFloorCallback;
+
+ /** Callback interface for receiving WiFi floor responses. */
+ public interface WifiFloorCallback {
+ /**
+ * Called when a WiFi floor response arrives.
+ *
+ * @param floor resolved floor number
+ */
+ void onWifiFloor(int floor);
+ }
+
/**
* Creates a new WifiPositionManager.
*
@@ -40,6 +57,21 @@ public WifiPositionManager(WiFiPositioning wiFiPositioning,
this.recorder = recorder;
}
+ /** Injects the fusion manager so WiFi fixes feed the particle filter. */
+ public void setFusionManager(FusionManager fusionManager) {
+ this.fusionManager = fusionManager;
+ }
+
+ /**
+ * Sets a callback to be notified when a WiFi floor response arrives.
+ * Used by autofloor toggle to re-seed initial floor from WiFi.
+ *
+ * @param callback callback to invoke, or null to clear
+ */
+ public void setWifiFloorCallback(WifiFloorCallback callback) {
+ this.wifiFloorCallback = callback;
+ }
+
/**
* {@inheritDoc}
*
@@ -56,16 +88,38 @@ public void update(Object[] wifiList) {
/**
* Creates a request to obtain a WiFi location for the obtained WiFi fingerprint.
+ * Uses the callback variant so the result can be forwarded to the fusion manager.
*/
private void createWifiPositioningRequest() {
try {
JSONObject wifiAccessPoints = new JSONObject();
for (Wifi data : this.wifiList) {
+ // API accepts integer BSSID format (returns 404 if no match).
+ // Colon MAC format is rejected with 400 by the server validation.
wifiAccessPoints.put(String.valueOf(data.getBssid()), data.getLevel());
}
JSONObject wifiFingerPrint = new JSONObject();
wifiFingerPrint.put(WIFI_FINGERPRINT, wifiAccessPoints);
- this.wiFiPositioning.request(wifiFingerPrint);
+ if (DEBUG) Log.d("WifiPositionManager", "WiFi request: " + wifiAccessPoints.length() + " APs");
+ this.wiFiPositioning.request(wifiFingerPrint, new WiFiPositioning.VolleyCallback() {
+ @Override
+ public void onSuccess(LatLng wifiLocation, int floor) {
+ if (fusionManager != null && wifiLocation != null) {
+ fusionManager.onWifiPosition(
+ wifiLocation.latitude, wifiLocation.longitude, floor);
+ }
+ // Notify floor callback (used by autofloor re-seed)
+ WifiFloorCallback cb = wifiFloorCallback;
+ if (cb != null && floor >= 0) {
+ cb.onWifiFloor(floor);
+ }
+ }
+
+ @Override
+ public void onError(String message) {
+ if (DEBUG) Log.w("WifiPositionManager", "WiFi positioning failed: " + message);
+ }
+ });
} catch (JSONException e) {
Log.e("jsonErrors", "Error creating json object" + e.toString());
}
@@ -84,14 +138,10 @@ private void createWifiPositionRequestCallback() {
wifiFingerPrint.put(WIFI_FINGERPRINT, wifiAccessPoints);
this.wiFiPositioning.request(wifiFingerPrint, new WiFiPositioning.VolleyCallback() {
@Override
- public void onSuccess(LatLng wifiLocation, int floor) {
- // Handle the success response
- }
+ public void onSuccess(LatLng wifiLocation, int floor) { }
@Override
- public void onError(String message) {
- // Handle the error response
- }
+ public void onError(String message) { }
});
} catch (JSONException e) {
Log.e("jsonErrors", "Error creating json object" + e.toString());
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/CalibrationManager.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/CalibrationManager.java
new file mode 100644
index 00000000..664ee966
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/CalibrationManager.java
@@ -0,0 +1,405 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.google.android.gms.maps.model.LatLng;
+import com.openpositioning.PositionMe.sensors.Wifi;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Weighted K-Nearest Neighbours (WKNN) matcher for locally collected
+ * calibration points. Uses WiFi fingerprint similarity to find the
+ * most likely true position.
+ *
+ * Improvements over 1-NN:
+ *
+ * - Top-K (k=3) inverse-distance weighted position
+ * - Floor-aware matching: same-floor priority
+ * - Quality scoring: ambiguity detection via distance ratio
+ *
+ */
+public class CalibrationManager {
+
+ private static final String TAG = "CalibrationManager";
+
+ private static final int ABSENT_RSSI = -100;
+ private static final int MIN_COMMON_APS = 6;
+ private static final double MAX_MATCH_DISTANCE = 20.0;
+ private static final double BASE_UNCERTAINTY = 3.0;
+ private static final int K = 10;
+
+ // ---- stored records ------------------------------------------------------
+
+ private final List records = new ArrayList<>();
+
+ private static class CalibrationRecord {
+ final double trueLat;
+ final double trueLng;
+ final int floor;
+ final Map fingerprint;
+
+ CalibrationRecord(double trueLat, double trueLng, int floor,
+ Map fingerprint) {
+ this.trueLat = trueLat;
+ this.trueLng = trueLng;
+ this.floor = floor;
+ this.fingerprint = fingerprint;
+ }
+ }
+
+ /** Internal: fingerprint comparison result. */
+ private static class FpMatch {
+ final CalibrationRecord record;
+ final double distance;
+ final int commonAps;
+
+ FpMatch(CalibrationRecord record, double distance, int commonAps) {
+ this.record = record;
+ this.distance = distance;
+ this.commonAps = commonAps;
+ }
+ }
+
+ /** Result of a WKNN fingerprint match, including position, quality, and uncertainty. */
+ public static class MatchResult {
+ public final LatLng truePosition;
+ public final double rssiDistance;
+ public final double uncertainty;
+ public final double distanceRatio;
+ public final int commonApCount;
+ public final int matchCount;
+ public final String quality; // "GOOD", "AMBIGUOUS", "WEAK"
+
+ MatchResult(LatLng truePosition, double rssiDistance, double uncertainty,
+ double distanceRatio, int commonApCount, int matchCount,
+ String quality) {
+ this.truePosition = truePosition;
+ this.rssiDistance = rssiDistance;
+ this.uncertainty = uncertainty;
+ this.distanceRatio = distanceRatio;
+ this.commonApCount = commonApCount;
+ this.matchCount = matchCount;
+ this.quality = quality;
+ }
+ }
+
+ // ---- loading -------------------------------------------------------------
+
+ /**
+ * Loads calibration records from JSON files in external storage.
+ *
+ * @param context application context for resolving file paths
+ */
+ public void loadFromFile(Context context) {
+ records.clear();
+
+ File primary = new File(context.getExternalFilesDir(null), "calibration_records.json");
+ File backup = new File(context.getExternalFilesDir(null), "calibration_records.json.bak");
+
+ // Try primary file first, then backup
+ for (File file : new File[]{primary, backup}) {
+ if (!file.exists() || file.length() == 0) continue;
+ try {
+ FileInputStream fis = new FileInputStream(file);
+ byte[] data = new byte[(int) file.length()];
+ fis.read(data);
+ fis.close();
+
+ JSONArray arr = new JSONArray(new String(data));
+ for (int i = 0; i < arr.length(); i++) {
+ JSONObject obj = arr.getJSONObject(i);
+ double lat = obj.getDouble("true_lat");
+ double lng = obj.getDouble("true_lng");
+ int floor = obj.optInt("floor", 0);
+
+ Map fp = new HashMap<>();
+ JSONArray wifi = obj.getJSONArray("wifi");
+ for (int j = 0; j < wifi.length(); j++) {
+ JSONObject ap = wifi.getJSONObject(j);
+ String bssid = ap.optString("bssid", "");
+ int rssi = ap.optInt("rssi", ABSENT_RSSI);
+ if (!bssid.isEmpty()) {
+ fp.put(bssid, rssi);
+ }
+ }
+ if (!fp.isEmpty()) {
+ records.add(new CalibrationRecord(lat, lng, floor, fp));
+ }
+ }
+ if (DEBUG) Log.i(TAG, "Loaded " + records.size() + " calibration records from " + file.getName());
+ return; // success — stop trying
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to load " + file.getName() + ", trying next source", e);
+ records.clear(); // reset for next attempt
+ }
+ }
+ if (DEBUG) Log.w(TAG, "No calibration data loaded from primary or backup");
+ }
+
+ /** Returns the number of loaded calibration records. */
+ public int getRecordCount() {
+ return records.size();
+ }
+
+ // ---- matching ------------------------------------------------------------
+
+ /** Backwards-compatible overload (floor unknown). */
+ public MatchResult findBestMatch(List currentScan) {
+ return findBestMatch(currentScan, -1);
+ }
+
+ /**
+ * WKNN match: finds top-K calibration records by WiFi fingerprint
+ * similarity, returns inverse-distance weighted position.
+ *
+ * @param currentScan current WiFi scan
+ * @param currentFloor current floor index, or -1 if unknown
+ * @return match result, or null if no suitable match
+ */
+ public MatchResult findBestMatch(List currentScan, int currentFloor) {
+ if (records.isEmpty() || currentScan == null || currentScan.isEmpty()) {
+ return null;
+ }
+
+ // Build live fingerprint
+ Map liveFp = new HashMap<>();
+ for (Wifi w : currentScan) {
+ String bssid = w.getBssidString();
+ if (bssid != null && !bssid.isEmpty()) {
+ liveFp.put(bssid, w.getLevel());
+ }
+ }
+ if (liveFp.isEmpty()) return null;
+
+ // Compute distance to all records
+ List allMatches = new ArrayList<>();
+ for (CalibrationRecord rec : records) {
+ int[] commonOut = new int[1];
+ double dist = fingerprintDistance(liveFp, rec.fingerprint, commonOut);
+ if (dist < MAX_MATCH_DISTANCE) {
+ allMatches.add(new FpMatch(rec, dist, commonOut[0]));
+ }
+ }
+
+ if (allMatches.isEmpty()) return null;
+
+ // Sort by distance
+ Collections.sort(allMatches, Comparator.comparingDouble(m -> m.distance));
+
+ // Pass 1: same-floor only (if floor known)
+ List topK;
+ boolean floorFiltered = false;
+ if (currentFloor >= 0) {
+ List sameFloor = new ArrayList<>();
+ for (FpMatch m : allMatches) {
+ if (m.record.floor == currentFloor) sameFloor.add(m);
+ }
+ if (!sameFloor.isEmpty()) {
+ topK = sameFloor.subList(0, Math.min(K, sameFloor.size()));
+ floorFiltered = true;
+ } else {
+ // Pass 2 fallback: all floors
+ topK = allMatches.subList(0, Math.min(K, allMatches.size()));
+ }
+ } else {
+ topK = allMatches.subList(0, Math.min(K, allMatches.size()));
+ }
+
+ // Inverse-distance weighted average
+ double sumW = 0, wLat = 0, wLng = 0;
+ for (FpMatch m : topK) {
+ double w = 1.0 / Math.max(m.distance, 0.001);
+ wLat += m.record.trueLat * w;
+ wLng += m.record.trueLng * w;
+ sumW += w;
+ }
+ double avgLat = wLat / sumW;
+ double avgLng = wLng / sumW;
+
+ // Quality scoring
+ double bestDist = topK.get(0).distance;
+ double secondDist = (topK.size() > 1) ? topK.get(1).distance : bestDist * 2;
+ double distanceRatio = bestDist / Math.max(secondDist, 0.001);
+ int bestCommonAps = topK.get(0).commonAps;
+
+ String quality;
+ double sigma = BASE_UNCERTAINTY;
+
+ // Reject if too few common APs — fingerprint not reliable
+ if (bestCommonAps < 3) {
+ if (DEBUG) Log.d(TAG, String.format("[Match] REJECTED commonAPs=%d < 3 bestDist=%.1f",
+ bestCommonAps, bestDist));
+ return null;
+ }
+
+ if (distanceRatio > 0.90) {
+ // Nearly identical top matches → very ambiguous, reject
+ quality = "REJECTED";
+ if (DEBUG) Log.d(TAG, String.format("[Match] REJECTED ratio=%.2f bestDist=%.1f",
+ distanceRatio, bestDist));
+ return null;
+ } else if (distanceRatio > 0.7) {
+ quality = "AMBIGUOUS";
+ sigma *= 1.5;
+ } else {
+ quality = "GOOD";
+ }
+
+ // Scale sigma by match distance
+ sigma *= (1.0 + bestDist / MAX_MATCH_DISTANCE);
+
+ // Penalize cross-floor fallback
+ if (!floorFiltered && currentFloor >= 0) {
+ sigma *= 2.0;
+ }
+
+ // Penalize low common AP count
+ if (bestCommonAps < 5) {
+ sigma *= 1.3;
+ }
+
+ if (DEBUG) Log.d(TAG, String.format("[Match] quality=%s k=%d ratio=%.2f commonAPs=%d sigma=%.1f bestDist=%.1f floor=%s",
+ quality, topK.size(), distanceRatio, bestCommonAps, sigma,
+ bestDist, floorFiltered ? "same" : "any"));
+
+ return new MatchResult(
+ new LatLng(avgLat, avgLng),
+ bestDist,
+ sigma,
+ distanceRatio,
+ bestCommonAps,
+ topK.size(),
+ quality);
+ }
+
+ /**
+ * Returns the inverse-distance weighted average position of the top-K
+ * WiFi-fingerprint matches. No quality filtering — used during initial
+ * calibration when we just need a rough position from nearby reference points.
+ *
+ * @param currentScan current WiFi scan
+ * @param currentFloor current floor index, or -1 if unknown
+ * @return weighted average LatLng, or null if no matches
+ */
+ public LatLng findCalibrationPosition(List currentScan, int currentFloor) {
+ if (records.isEmpty() || currentScan == null || currentScan.isEmpty()) {
+ return null;
+ }
+
+ Map liveFp = new HashMap<>();
+ for (Wifi w : currentScan) {
+ String bssid = w.getBssidString();
+ if (bssid != null && !bssid.isEmpty()) {
+ liveFp.put(bssid, w.getLevel());
+ }
+ }
+ if (liveFp.isEmpty()) return null;
+
+ List allMatches = new ArrayList<>();
+ for (CalibrationRecord rec : records) {
+ int[] commonOut = new int[1];
+ double dist = fingerprintDistance(liveFp, rec.fingerprint, commonOut);
+ if (commonOut[0] >= 2) { // at least 2 common APs
+ allMatches.add(new FpMatch(rec, dist, commonOut[0]));
+ }
+ }
+ if (allMatches.isEmpty()) return null;
+
+ Collections.sort(allMatches, Comparator.comparingDouble(m -> m.distance));
+
+ // Prefer same floor, fall back to all
+ List topK;
+ if (currentFloor >= 0) {
+ List sameFloor = new ArrayList<>();
+ for (FpMatch m : allMatches) {
+ if (m.record.floor == currentFloor) sameFloor.add(m);
+ }
+ topK = !sameFloor.isEmpty()
+ ? sameFloor.subList(0, Math.min(K, sameFloor.size()))
+ : allMatches.subList(0, Math.min(K, allMatches.size()));
+ } else {
+ topK = allMatches.subList(0, Math.min(K, allMatches.size()));
+ }
+
+ double sumW = 0, wLat = 0, wLng = 0;
+ for (FpMatch m : topK) {
+ double w = 1.0 / Math.max(m.distance, 0.001);
+ wLat += m.record.trueLat * w;
+ wLng += m.record.trueLng * w;
+ sumW += w;
+ }
+
+ LatLng result = new LatLng(wLat / sumW, wLng / sumW);
+ if (DEBUG) Log.i(TAG, String.format("[CalibrationPosition] k=%d pos=(%.6f,%.6f) bestDist=%.1f commonAPs=%d",
+ topK.size(), result.latitude, result.longitude,
+ topK.get(0).distance, topK.get(0).commonAps));
+ return result;
+ }
+
+ // ---- spatial query (for heading correction) --------------------------------
+
+ /**
+ * Returns all calibration record positions on the given floor as LatLng list.
+ * Used by FusionManager to find nearby reference points for heading correction.
+ */
+ public List getRecordPositions(int floor) {
+ List positions = new ArrayList<>();
+ for (CalibrationRecord rec : records) {
+ if (rec.floor == floor) {
+ positions.add(new LatLng(rec.trueLat, rec.trueLng));
+ }
+ }
+ return positions;
+ }
+
+ // ---- fingerprint distance ------------------------------------------------
+
+ /**
+ * Euclidean distance in RSSI space, normalized by common AP count.
+ *
+ * @param commonOut output: number of common APs (both sides observed)
+ */
+ private double fingerprintDistance(Map fp1,
+ Map fp2,
+ int[] commonOut) {
+ Set allBssids = new HashSet<>(fp1.keySet());
+ allBssids.addAll(fp2.keySet());
+
+ int commonCount = 0;
+ double sumSq = 0;
+ for (String bssid : allBssids) {
+ int r1 = fp1.getOrDefault(bssid, ABSENT_RSSI);
+ int r2 = fp2.getOrDefault(bssid, ABSENT_RSSI);
+ if (r1 != ABSENT_RSSI || r2 != ABSENT_RSSI) {
+ sumSq += (r1 - r2) * (r1 - r2);
+ if (r1 != ABSENT_RSSI && r2 != ABSENT_RSSI) {
+ commonCount++;
+ }
+ }
+ }
+
+ commonOut[0] = commonCount;
+
+ if (commonCount < MIN_COMMON_APS) {
+ return Double.MAX_VALUE;
+ }
+
+ return Math.sqrt(sumSq) / commonCount;
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/CoordinateTransform.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/CoordinateTransform.java
new file mode 100644
index 00000000..a742fc42
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/CoordinateTransform.java
@@ -0,0 +1,70 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+/**
+ * Converts between WGS84 geodetic coordinates (latitude/longitude) and a local
+ * East-North-Up (ENU) Cartesian frame centred on a chosen reference point.
+ *
+ * Uses a flat-earth approximation which is accurate to within centimetres
+ * for distances under 1 km from the origin — well within the assignment's
+ * indoor environment (Nucleus + Library).
+ */
+public class CoordinateTransform {
+
+ private static final double EARTH_RADIUS = 6371000.0; // metres
+ private static final double METRES_PER_DEGREE_LAT = 111132.92;
+
+ private double originLat;
+ private double originLng;
+ private double metersPerDegreeLng;
+ private boolean initialized = false;
+
+ /**
+ * Sets the ENU origin. All subsequent conversions are relative to this point.
+ *
+ * @param lat latitude in degrees (WGS84)
+ * @param lng longitude in degrees (WGS84)
+ */
+ public void setOrigin(double lat, double lng) {
+ this.originLat = lat;
+ this.originLng = lng;
+ this.metersPerDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(Math.toRadians(lat));
+ this.initialized = true;
+ }
+
+ /** Returns {@code true} if the ENU origin has been set. */
+ public boolean isInitialized() {
+ return initialized;
+ }
+
+ /**
+ * Converts WGS84 to local ENU coordinates.
+ *
+ * @return double array {easting, northing} in metres
+ */
+ public double[] toEastNorth(double lat, double lng) {
+ double east = (lng - originLng) * metersPerDegreeLng;
+ double north = (lat - originLat) * METRES_PER_DEGREE_LAT;
+ return new double[]{east, north};
+ }
+
+ /**
+ * Converts local ENU coordinates back to WGS84.
+ *
+ * @return double array {latitude, longitude} in degrees
+ */
+ public double[] toLatLng(double east, double north) {
+ double lat = originLat + north / METRES_PER_DEGREE_LAT;
+ double lng = originLng + east / metersPerDegreeLng;
+ return new double[]{lat, lng};
+ }
+
+ /** Returns the origin latitude in degrees (WGS84). */
+ public double getOriginLat() {
+ return originLat;
+ }
+
+ /** Returns the origin longitude in degrees (WGS84). */
+ public double getOriginLng() {
+ return originLng;
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/FusionManager.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/FusionManager.java
new file mode 100644
index 00000000..fc4b0b17
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/FusionManager.java
@@ -0,0 +1,1036 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
+import android.util.Log;
+
+import com.google.android.gms.maps.model.LatLng;
+import com.openpositioning.PositionMe.data.remote.FloorplanApiClient;
+import com.openpositioning.PositionMe.sensors.SensorFusion;
+import com.openpositioning.PositionMe.utils.IndoorMapManager;
+
+import java.util.List;
+
+/**
+ * Orchestrates sensor fusion by connecting the {@link ParticleFilter} with
+ * incoming PDR, WiFi, and GNSS observations.
+ *
+ * Responsibilities
+ *
+ * - Auto-initialises the filter from the first reliable position fix
+ * (WiFi preferred, GNSS fallback) — no user selection required.
+ * - Feeds PDR steps as the prediction model.
+ * - Feeds WiFi / GNSS fixes as observation updates.
+ * - Exposes the fused position for display and recording.
+ *
+ */
+public class FusionManager {
+
+ private static final String TAG = "FusionManager";
+
+ /** Number of particles — balance between accuracy and phone performance. */
+ private static final int NUM_PARTICLES = 500;
+
+ /** Initial Gaussian spread (metres) when seeding particles. */
+ private static final double INIT_SPREAD = 10.0;
+
+ // Core components
+ private final ParticleFilter particleFilter;
+ private final CoordinateTransform coordTransform;
+ private final MapConstraint mapConstraint;
+ private CalibrationManager calibrationManager; // for reference-point heading correction
+
+ // Pre-allocated arrays for wall constraint checking (avoid GC per step)
+ private final double[] oldParticleX = new double[NUM_PARTICLES];
+ private final double[] oldParticleY = new double[NUM_PARTICLES];
+
+ // State tracking to avoid duplicate observations
+ private LatLng lastWifiPosition;
+ private LatLng lastGnssPosition;
+ private int lastWifiFloor = -1;
+
+ // WiFi initial floor seeding: set once from first valid WiFi response
+ private boolean wifiFloorSeeded = false;
+
+ // Fused output (volatile for cross-thread visibility)
+ private volatile double fusedLat;
+ private volatile double fusedLng;
+ private volatile int fusedFloor;
+ private volatile double fusedUncertainty;
+ private volatile boolean active = false;
+
+ // PDR step counter for logging
+ private int pdrStepIndex = 0;
+
+ // Floor transition state machine
+ private int lastReportedFloor = -1;
+ private int floorCandidate = -1;
+ private long floorCandidateStartMs = 0;
+ private static final long FLOOR_CONFIRM_MS = 1000; // must hold for 1s
+
+ // ---- Gate state machine (adaptive re-acquisition) ---------------------
+ private enum GateMode { LOCKED, UNLOCKED }
+ enum ObservationLevel { STRONG, MEDIUM, WEAK, INVALID }
+
+ private GateMode gateMode = GateMode.LOCKED;
+ private int stepsSinceLastCorrection = 0;
+
+ private static final int UNLOCK_STEP_THRESHOLD = 15;
+ private static final double UNLOCK_UNCERTAINTY_THRESHOLD = 15.0;
+ private static final double LOCKED_GATE = 15.0;
+ private static final double LOCKED_GATE_INIT = 40.0;
+ /** Distance (m) beyond which UNLOCKED mode re-seeds particles instead of soft update. */
+ private static final double RESEED_DISTANCE = 20.0;
+ /** Indoor GNSS sigma — much weaker influence than WiFi/CAL_DB. */
+ private static final double GNSS_INDOOR_SIGMA = 50.0;
+ /** Warmup period (ms) after init — suppress observations to prevent stale fix jumps. */
+ private static final long WARMUP_MS = 2000;
+ private long initTimeMs = 0;
+
+ // Initial position calibration — accumulate fixes during 10s window
+ private boolean calibrationMode = false;
+ private double calSumLat = 0, calSumLng = 0;
+ private double calTotalWeight = 0;
+ private int calFixCount = 0;
+ private static final double CAL_WEIGHT_CALDB = 3.0; // highest trust
+ private static final double CAL_WEIGHT_WIFI = 1.0;
+ private static final double CAL_WEIGHT_GNSS = 1.0;
+
+ // Heading error estimation (tutor-recommended)
+ private double headingBias = 0; // estimated heading error in radians
+ private double prevObsX = 0, prevObsY = 0;
+ private boolean hasPrevObs = false;
+ // Accumulate pure PDR displacement between WiFi observations
+ private double pdrAccumDx = 0, pdrAccumDy = 0;
+ private static final double HEADING_BIAS_ALPHA = 0.10; // learning rate
+ private static final double HEADING_EARLY_CORRECTION = Math.toRadians(5); // max 5° per step in early phase
+ private static final int HEADING_EARLY_STEPS = 15; // first 15 steps use early correction
+ private static final double HEADING_EARLY_MAX_DIST = 20.0; // only use obs within 20m
+ private static final double HEADING_EARLY_FOV = Math.toRadians(120) / 2; // ±60° = 120° forward
+
+ // Stationary detection — freeze position when not walking
+ private long lastPdrStepTimeMs = 0;
+ /** No PDR step for this long → consider user stationary, reject all corrections. */
+ private static final long STATIONARY_TIMEOUT_MS = 100;
+
+ // ---- Wall-collision heading search mode ---------------------------------
+ // When most particles hit walls for consecutive steps, the heading is likely
+ // wrong. Temporarily boost heading noise so particles fan out and "search"
+ // for the correct direction. Walls naturally select survivors.
+ private static final double SEARCH_COLLISION_THRESHOLD = 0.70; // 70% particles hit wall
+ private static final int SEARCH_TRIGGER_STEPS = 3; // consecutive high-collision steps
+ private static final double SEARCH_HEADING_STD_INIT = Math.toRadians(35); // initial search spread
+ private static final double SEARCH_HEADING_STD_DECAY = 0.85; // multiply each step
+ private static final double SEARCH_HEADING_STD_MIN = Math.toRadians(8); // normal value
+
+ private int consecutiveHighCollisionSteps = 0;
+ private double currentHeadingStd = SEARCH_HEADING_STD_MIN; // active heading noise
+ private boolean inSearchMode = false;
+
+ /** Creates a new FusionManager with default particle count and components. */
+ public FusionManager() {
+ this.particleFilter = new ParticleFilter(NUM_PARTICLES);
+ this.coordTransform = new CoordinateTransform();
+ this.mapConstraint = new MapConstraint();
+ }
+
+ /** Inject CalibrationManager for reference-point heading correction. */
+ public void setCalibrationManager(CalibrationManager cm) {
+ this.calibrationManager = cm;
+ if (DEBUG) Log.i(TAG, "CalibrationManager set (" + (cm != null ? cm.getRecordCount() + " records" : "null") + ")");
+ }
+
+ /**
+ * Enters calibration mode: incoming WiFi/GNSS/CalDB fixes are accumulated
+ * (weighted) instead of initializing the particle filter.
+ */
+ public void startCalibrationMode() {
+ calibrationMode = true;
+ calSumLat = 0; calSumLng = 0;
+ calTotalWeight = 0; calFixCount = 0;
+ active = false; // prevent normal fusion during calibration
+ if (DEBUG) Log.i(TAG, "[CalibrationMode] START — accumulating position fixes");
+ }
+
+ /** Adds a weighted position fix during calibration. */
+ public void addCalibrationFix(double lat, double lng, double weight, String source) {
+ if (!calibrationMode) return;
+ calSumLat += lat * weight;
+ calSumLng += lng * weight;
+ calTotalWeight += weight;
+ calFixCount++;
+ if (DEBUG) Log.i(TAG, String.format("[CalibrationMode] fix #%d src=%s (%.6f,%.6f) w=%.1f totalW=%.1f",
+ calFixCount, source, lat, lng, weight, calTotalWeight));
+ }
+
+ /**
+ * Ends calibration mode: computes weighted average position and
+ * initializes the particle filter at that location.
+ * @param fallbackLat fallback latitude if no fixes were collected
+ * @param fallbackLng fallback longitude if no fixes were collected
+ * @return the final calibrated LatLng
+ */
+ public LatLng finishCalibrationMode(double fallbackLat, double fallbackLng) {
+ calibrationMode = false;
+ double lat, lng;
+ if (calTotalWeight > 0) {
+ lat = calSumLat / calTotalWeight;
+ lng = calSumLng / calTotalWeight;
+ if (DEBUG) Log.i(TAG, String.format("[CalibrationMode] FINISH %d fixes → (%.6f,%.6f)",
+ calFixCount, lat, lng));
+ } else {
+ lat = fallbackLat;
+ lng = fallbackLng;
+ if (DEBUG) Log.w(TAG, "[CalibrationMode] FINISH no fixes, using fallback");
+ }
+
+ // Initialize particle filter at calibrated position
+ if (!coordTransform.isInitialized()) {
+ coordTransform.setOrigin(lat, lng);
+ }
+ double[] en = coordTransform.toEastNorth(lat, lng);
+ particleFilter.initialize(en[0], en[1], fusedFloor, INIT_SPREAD);
+ fusedLat = lat;
+ fusedLng = lng;
+ active = true;
+ initTimeMs = System.currentTimeMillis();
+
+ if (DEBUG) Log.i(TAG, String.format("[CalibrationMode] PF initialized at (%.6f,%.6f) ENU=(%.2f,%.2f)",
+ lat, lng, en[0], en[1]));
+ return new LatLng(lat, lng);
+ }
+
+ /** Returns {@code true} if currently in calibration mode. */
+ public boolean isInCalibrationMode() { return calibrationMode; }
+
+ private IndoorMapManager indoorMapManager;
+ private static final double STAIRS_STEP_FACTOR = 0.5;
+
+ /** Inject IndoorMapManager for stairs/lift proximity detection. */
+ public void setIndoorMapManager(IndoorMapManager mgr) {
+ this.indoorMapManager = mgr;
+ }
+
+ /**
+ * Loads wall geometry for the specified building into the map constraint.
+ * Includes both interior walls (from floor shapes) and the building outline
+ * (exterior boundary, applied to all floors).
+ * Must be called after {@link CoordinateTransform} origin is set.
+ *
+ * @param floorShapesList per-floor shape data from FloorplanApiClient
+ * @param outlinePolygon building boundary polygon (may be null)
+ */
+ public void loadMapConstraints(
+ List floorShapesList,
+ List outlinePolygon) {
+ if (coordTransform.isInitialized()) {
+ mapConstraint.initialize(floorShapesList, coordTransform);
+ mapConstraint.setOutlineConstraint(outlinePolygon, coordTransform);
+ } else {
+ if (DEBUG) Log.w(TAG, "Cannot load map constraints: coordTransform not initialised");
+ }
+ }
+
+ // ---- initialisation ------------------------------------------------------
+
+ /**
+ * Initialises the fusion from the user-selected start location.
+ * Called at the start of recording, before GNSS/WiFi fixes arrive.
+ * Uses a wider spread since the user tap may not be pixel-perfect.
+ */
+ public void initializeFromStartLocation(double lat, double lng) {
+ if (DEBUG) Log.i(TAG, String.format("Initialising from user start location (%.6f, %.6f)", lat, lng));
+ // Force origin to user-selected start point (overwrite any earlier GNSS-set origin)
+ coordTransform.setOrigin(lat, lng);
+ tryInitialize(lat, lng, 0);
+ }
+
+ /**
+ * Attempts to initialise the particle filter from a position fix.
+ * Called automatically by {@link #onWifiPosition} or {@link #onGnssPosition}
+ * whichever arrives first.
+ */
+ private void tryInitialize(double lat, double lng, int floor) {
+ if (active) return;
+
+ // During calibration mode, accumulate fixes instead of initializing
+ if (calibrationMode) {
+ // Still set origin from first fix so coordTransform is ready
+ if (!coordTransform.isInitialized()) {
+ coordTransform.setOrigin(lat, lng);
+ }
+ return; // actual position will be set in finishCalibrationMode()
+ }
+
+ // Set coordinate origin at the first fix
+ if (!coordTransform.isInitialized()) {
+ coordTransform.setOrigin(lat, lng);
+ }
+
+ double[] en = coordTransform.toEastNorth(lat, lng);
+ particleFilter.initialize(en[0], en[1], floor, INIT_SPREAD);
+
+ fusedLat = lat;
+ fusedLng = lng;
+ fusedFloor = floor;
+ active = true;
+ initTimeMs = System.currentTimeMillis();
+
+ if (DEBUG) {
+ // [Origin] diagnostic
+ Log.i(TAG, String.format("[Origin] originLat=%.8f originLng=%.8f", lat, lng));
+
+ // [RoundTrip] diagnostic
+ double[] rt = coordTransform.toLatLng(0, 0);
+ double rtError = Math.sqrt(Math.pow((rt[0] - lat) * 111132.92, 2) +
+ Math.pow((rt[1] - lng) * 111132.92 * Math.cos(Math.toRadians(lat)), 2));
+ Log.i(TAG, String.format("[RoundTrip] toLatLng(0,0)=(%.8f,%.8f) originError=%.4fm", rt[0], rt[1], rtError));
+
+ // [AxisTest] diagnostic: 5m East should give (+5, ~0)
+ double[] east5 = coordTransform.toEastNorth(lat, lng + 5.0 / (111132.92 * Math.cos(Math.toRadians(lat))));
+ double[] north5 = coordTransform.toEastNorth(lat + 5.0 / 111132.92, lng);
+ Log.i(TAG, String.format("[AxisTest] 5mEast→ENU=(%.2f,%.2f) %s | 5mNorth→ENU=(%.2f,%.2f) %s",
+ east5[0], east5[1], (east5[0] > 4 && Math.abs(east5[1]) < 1) ? "PASS" : "FAIL",
+ north5[0], north5[1], (north5[1] > 4 && Math.abs(north5[0]) < 1) ? "PASS" : "FAIL"));
+ }
+ }
+
+ // ---- sensor callbacks ----------------------------------------------------
+
+ /**
+ * Called on each detected step with the PDR stride length and device heading.
+ *
+ * @param stepLength stride in metres (Weiberg or manual)
+ * @param headingRad azimuth in radians (0 = North, clockwise)
+ */
+ public void onPdrStep(double stepLength, double headingRad) {
+ if (!active) {
+ if (DEBUG) Log.d(TAG, "PDR step ignored — fusion not yet initialised");
+ return;
+ }
+
+ // Stairs/lift step reduction: multiply horizontal step by 0.2
+ if (indoorMapManager != null && coordTransform.isInitialized()) {
+ double[] curLatLng = coordTransform.toLatLng(
+ particleFilter.getEstimatedX(),
+ particleFilter.getEstimatedY());
+ LatLng curPos = new LatLng(curLatLng[0], curLatLng[1]);
+ if (indoorMapManager.isNearStairs(curPos)) {
+ stepLength *= STAIRS_STEP_FACTOR;
+ if (DEBUG) Log.d(TAG, "[Stairs] Step reduced to " + String.format("%.3fm", stepLength));
+ }
+ }
+
+ double correctedHeading = headingRad + headingBias; // headingBias enabled
+
+ double dx = stepLength * Math.sin(correctedHeading);
+ double dy = stepLength * Math.cos(correctedHeading);
+
+ // Accumulate pure PDR displacement for heading bias calculation
+ pdrAccumDx += dx;
+ pdrAccumDy += dy;
+
+ lastPdrStepTimeMs = System.currentTimeMillis();
+
+ // Save old particle positions before predict (for wall collision check)
+ Particle[] particles = particleFilter.getParticles();
+ for (int i = 0; i < particles.length; i++) {
+ oldParticleX[i] = particles[i].x;
+ oldParticleY[i] = particles[i].y;
+ }
+
+ particleFilter.predict(stepLength, correctedHeading, currentHeadingStd);
+
+ // Apply wall constraints: snap + penalise particles that crossed walls
+ int collisionCount = 0;
+ if (mapConstraint.isInitialized()) {
+ collisionCount = mapConstraint.applyConstraints(particles, oldParticleX, oldParticleY);
+ }
+
+ // ---- Wall-collision heading correction via reference points ----
+ double collisionRatio = (double) collisionCount / NUM_PARTICLES;
+ if (collisionRatio >= SEARCH_COLLISION_THRESHOLD) {
+ consecutiveHighCollisionSteps++;
+ if (!inSearchMode && consecutiveHighCollisionSteps >= SEARCH_TRIGGER_STEPS) {
+ inSearchMode = true;
+ currentHeadingStd = SEARCH_HEADING_STD_INIT;
+ if (DEBUG) Log.i(TAG, String.format("[SearchMode] ENTER collisionRatio=%.0f%% consecutive=%d",
+ collisionRatio * 100, consecutiveHighCollisionSteps));
+
+ // ---- Reference-point heading snap ----
+ // Find the nearest calibration point within ±90° of current heading
+ // and force heading bias to point toward it.
+ double snapHeading = findNearestRefPointHeading(correctedHeading);
+ if (!Double.isNaN(snapHeading)) {
+ double correction = snapHeading - headingRad; // bias = target - raw
+ // Normalize to [-π, π]
+ while (correction > Math.PI) correction -= 2 * Math.PI;
+ while (correction < -Math.PI) correction += 2 * Math.PI;
+ if (DEBUG) Log.i(TAG, String.format("[SearchMode] SNAP oldBias=%.1f° newBias=%.1f° target=%.1f° raw=%.1f°",
+ Math.toDegrees(headingBias), Math.toDegrees(correction),
+ Math.toDegrees(snapHeading), Math.toDegrees(headingRad)));
+ headingBias = correction;
+ }
+ }
+ } else {
+ consecutiveHighCollisionSteps = 0;
+ }
+
+ // Decay heading noise each step (whether in search mode or not)
+ if (inSearchMode) {
+ currentHeadingStd = Math.max(
+ currentHeadingStd * SEARCH_HEADING_STD_DECAY,
+ SEARCH_HEADING_STD_MIN);
+ if (currentHeadingStd <= SEARCH_HEADING_STD_MIN) {
+ inSearchMode = false;
+ if (DEBUG) Log.i(TAG, "[SearchMode] EXIT — heading noise decayed to normal");
+ }
+ }
+
+ updateFusedOutput();
+ pdrStepIndex++;
+ stepsSinceLastCorrection++;
+ checkGateMode();
+ if (DEBUG) Log.i(TAG, String.format("[PDR] step=%d rawH=%.1f° bias=%.1f° corrH=%.1f° len=%.2f dENU=(%.3f,%.3f) fENU=(%.2f,%.2f) gate=%s nofix=%d coll=%.0f%% hStd=%.1f° search=%b",
+ pdrStepIndex, Math.toDegrees(headingRad),
+ Math.toDegrees(headingBias), Math.toDegrees(correctedHeading),
+ stepLength, dx, dy,
+ particleFilter.getEstimatedX(), particleFilter.getEstimatedY(),
+ gateMode, stepsSinceLastCorrection,
+ collisionRatio * 100, Math.toDegrees(currentHeadingStd), inSearchMode));
+ }
+
+ /**
+ * Updates the heading bias estimate using the discrepancy between the
+ * direction the PF moved and the direction of an absolute observation
+ * (WiFi or GNSS). Only updates when there is enough movement.
+ */
+ private void updateHeadingBias(double obsX, double obsY) {
+ if (!hasPrevObs) {
+ prevObsX = obsX;
+ prevObsY = obsY;
+ pdrAccumDx = 0;
+ pdrAccumDy = 0;
+ hasPrevObs = true;
+ return;
+ }
+
+ // Pure PDR displacement (accumulated since last WiFi observation)
+ double pdrDist = Math.hypot(pdrAccumDx, pdrAccumDy);
+
+ // WiFi observation displacement
+ double obsDx = obsX - prevObsX;
+ double obsDy = obsY - prevObsY;
+ double obsDist = Math.hypot(obsDx, obsDy);
+
+ // Only update if PDR moved enough (avoids noise when stationary)
+ if (pdrDist < 1.0) {
+ if (DEBUG) Log.d(TAG, String.format("[HeadingBias] SKIP pdrDist=%.1fm (need >1m, keep accumulating)",
+ pdrDist));
+ return;
+ }
+
+ if (obsDist < 1.0) {
+ if (DEBUG) Log.d(TAG, String.format("[HeadingBias] SKIP obsDist=%.1fm (need >1m) pdrDist=%.1fm — reset pdr accum",
+ obsDist, pdrDist));
+ prevObsX = obsX;
+ prevObsY = obsY;
+ pdrAccumDx = 0;
+ pdrAccumDy = 0;
+ return;
+ }
+
+ // Both moved enough — compute heading bias
+ double pdrAngle = Math.atan2(pdrAccumDx, pdrAccumDy);
+ double obsAngle = Math.atan2(obsDx, obsDy);
+ double angleDiff = obsAngle - pdrAngle;
+
+ // Normalize to [-π, π]
+ while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
+ while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
+
+ boolean earlyPhase = (pdrStepIndex <= HEADING_EARLY_STEPS);
+
+ if (earlyPhase) {
+ // Early phase: only accept observations within forward 90° and 20m
+ // Current heading direction (PDR accumulated)
+ double currentHeading = Math.atan2(pdrAccumDx, pdrAccumDy);
+ double obsDirection = Math.atan2(obsDx, obsDy);
+ double dirDiff = obsDirection - currentHeading;
+ while (dirDiff > Math.PI) dirDiff -= 2 * Math.PI;
+ while (dirDiff < -Math.PI) dirDiff += 2 * Math.PI;
+
+ if (Math.abs(dirDiff) > HEADING_EARLY_FOV || obsDist > HEADING_EARLY_MAX_DIST) {
+ if (DEBUG) Log.i(TAG, String.format("[HeadingBias] REJECT early: dirDiff=%.1f° dist=%.1fm (need <90° <20m) step=%d",
+ Math.toDegrees(dirDiff), obsDist, pdrStepIndex));
+ } else {
+ // Clamp correction to ±5° per observation
+ double clamped = Math.max(-HEADING_EARLY_CORRECTION,
+ Math.min(HEADING_EARLY_CORRECTION, angleDiff));
+ double oldBias = headingBias;
+ headingBias += clamped;
+
+ if (DEBUG) Log.i(TAG, String.format("[HeadingBias] EARLY step=%d diff=%.1f° clamped=%.1f° oldBias=%.1f° newBias=%.1f° pdrDist=%.1fm obsDist=%.1fm",
+ pdrStepIndex, Math.toDegrees(angleDiff), Math.toDegrees(clamped),
+ Math.toDegrees(oldBias), Math.toDegrees(headingBias), pdrDist, obsDist));
+ }
+ } else {
+ // Normal phase: reject > 20°, conservative EMA
+ if (Math.abs(angleDiff) > Math.toRadians(20)) {
+ if (DEBUG) Log.i(TAG, String.format("[HeadingBias] REJECT diff=%.1f° > 20° step=%d pdrAngle=%.1f° obsAngle=%.1f°",
+ Math.toDegrees(angleDiff), pdrStepIndex, Math.toDegrees(pdrAngle), Math.toDegrees(obsAngle)));
+ } else {
+ double oldBias = headingBias;
+ headingBias = headingBias * (1 - HEADING_BIAS_ALPHA)
+ + angleDiff * HEADING_BIAS_ALPHA;
+
+ if (DEBUG) Log.i(TAG, String.format("[HeadingBias] UPDATE α=%.2f step=%d pdrAngle=%.1f° obsAngle=%.1f° diff=%.1f° oldBias=%.1f° newBias=%.1f° pdrDist=%.1fm obsDist=%.1fm",
+ HEADING_BIAS_ALPHA, pdrStepIndex,
+ Math.toDegrees(pdrAngle), Math.toDegrees(obsAngle),
+ Math.toDegrees(angleDiff), Math.toDegrees(oldBias),
+ Math.toDegrees(headingBias), pdrDist, obsDist));
+ }
+ }
+
+ // Only reset accumulators when we actually consumed them (UPDATE or REJECT)
+ prevObsX = obsX;
+ prevObsY = obsY;
+ pdrAccumDx = 0;
+ pdrAccumDy = 0;
+ }
+
+ /**
+ * Searches calibration reference points within ±90° of the current heading
+ * and returns the heading (radians) toward the nearest one.
+ *
+ * Reference points lie on corridors the user has walked before,
+ * so the nearest forward one indicates the correct walking direction.
+ *
+ * @param currentHeading current corrected heading in radians (0=N, CW)
+ * @return heading toward nearest forward ref point, or NaN if none found
+ */
+ private double findNearestRefPointHeading(double currentHeading) {
+ if (calibrationManager == null || !coordTransform.isInitialized()) {
+ if (DEBUG) Log.d(TAG, "[SearchMode] No CalibrationManager or CoordTransform — skip snap");
+ return Double.NaN;
+ }
+
+ double curX = particleFilter.getEstimatedX();
+ double curY = particleFilter.getEstimatedY();
+
+ List refPoints = calibrationManager.getRecordPositions(fusedFloor);
+ if (refPoints.isEmpty()) {
+ if (DEBUG) Log.d(TAG, "[SearchMode] No ref points on floor " + fusedFloor);
+ return Double.NaN;
+ }
+
+ double bestDist = Double.MAX_VALUE;
+ double bestHeading = Double.NaN;
+ int candidateCount = 0;
+
+ for (LatLng ll : refPoints) {
+ double[] en = coordTransform.toEastNorth(ll.latitude, ll.longitude);
+ double dx = en[0] - curX;
+ double dy = en[1] - curY;
+ double dist = Math.hypot(dx, dy);
+
+ // Skip points too close (noise) or too far (irrelevant)
+ if (dist < 2.0 || dist > 30.0) continue;
+
+ // Heading from current position to this reference point
+ double headingToRef = Math.atan2(dx, dy); // atan2(E, N) = azimuth
+
+ // Angle difference to current heading
+ double angleDiff = headingToRef - currentHeading;
+ while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
+ while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
+
+ // Only consider points within ±90° (forward semicircle)
+ if (Math.abs(angleDiff) > Math.PI / 2) continue;
+
+ candidateCount++;
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestHeading = headingToRef;
+ }
+ }
+
+ if (!Double.isNaN(bestHeading)) {
+ if (DEBUG) Log.i(TAG, String.format("[SearchMode] Found %d forward ref points, nearest at %.1fm heading=%.1f°",
+ candidateCount, bestDist, Math.toDegrees(bestHeading)));
+ } else {
+ if (DEBUG) Log.i(TAG, String.format("[SearchMode] No forward ref points found (%d total on floor %d)",
+ refPoints.size(), fusedFloor));
+ }
+ return bestHeading;
+ }
+
+ /**
+ * Called when a new WiFi position fix arrives from the OpenPositioning API.
+ */
+ public void onWifiPosition(double lat, double lng, int floor) {
+ LatLng pos = new LatLng(lat, lng);
+ if (pos.equals(lastWifiPosition)) return;
+ lastWifiPosition = pos;
+ lastWifiFloor = floor;
+
+ // Seed initial floor from first valid WiFi response.
+ // setBuildingOverlay() runs before WiFi responds, so we seed here.
+ if (!wifiFloorSeeded && floor >= 0) {
+ wifiFloorSeeded = true;
+ SensorFusion.getInstance().setInitialFloor(floor);
+ lastReportedFloor = floor;
+ if (DEBUG) Log.i(TAG, "[WifiFloorSeed] First WiFi floor=" + floor
+ + " → seeded as initial floor");
+ }
+
+ if (!active) {
+ if (calibrationMode) {
+ addCalibrationFix(lat, lng, CAL_WEIGHT_WIFI, "WIFI_API");
+ }
+ if (DEBUG) Log.i(TAG, String.format("WiFi fix: (%.6f, %.6f) floor=%d active=false calMode=%b → init",
+ lat, lng, floor, calibrationMode));
+ tryInitialize(lat, lng, floor);
+ return;
+ }
+
+ if (!coordTransform.isInitialized()) return;
+
+ double[] en = coordTransform.toEastNorth(lat, lng);
+
+ // WiFi API = STRONG source
+ if (!shouldAcceptObservation(en[0], en[1], ObservationLevel.STRONG)) {
+ logObservation("WIFI_API", en[0], en[1], floor, 0, false,
+ String.format("gate_rejected mode=%s", gateMode));
+ return;
+ }
+
+ double distFromFused = Math.hypot(en[0] - particleFilter.getEstimatedX(),
+ en[1] - particleFilter.getEstimatedY());
+
+ // Recovery: when UNLOCKED with large drift, re-seed particles around observation
+ if (gateMode == GateMode.UNLOCKED && distFromFused > RESEED_DISTANCE) {
+ if (DEBUG) Log.i(TAG, String.format("[Recovery] WIFI re-seed dist=%.1fm → particles reset around (%.2f,%.2f)",
+ distFromFused, en[0], en[1]));
+ particleFilter.initialize(en[0], en[1], floor, 12.0);
+ updateFusedOutput();
+ onObservationAccepted(ObservationLevel.STRONG);
+ return;
+ }
+
+ double wifiSigma = 4.0;
+ logObservation("WIFI_API", en[0], en[1], floor, wifiSigma, true,
+ String.format("level=STRONG mode=%s dist=%.1fm", gateMode, distFromFused));
+ particleFilter.updateWithDynamicSigma(en[0], en[1], wifiSigma);
+ // WiFi floor is NOT used for ongoing floor updates — baro-only autofloor.
+ // particleFilter.updateFloor(floor);
+ updateHeadingBias(en[0], en[1]);
+ updateFusedOutput();
+ onObservationAccepted(ObservationLevel.STRONG);
+ }
+
+ /**
+ * Called when a new GPS fix arrives (network/cellular is excluded upstream).
+ *
+ * @param accuracy reported accuracy in metres from {@code Location.getAccuracy()}
+ */
+ public void onGnssPosition(double lat, double lng, float accuracy) {
+ LatLng pos = new LatLng(lat, lng);
+ if (pos.equals(lastGnssPosition)) return;
+ lastGnssPosition = pos;
+
+ if (!active) {
+ if (calibrationMode) {
+ addCalibrationFix(lat, lng, CAL_WEIGHT_GNSS, "GNSS");
+ }
+ if (DEBUG) Log.i(TAG, String.format("GPS fix: (%.6f, %.6f) acc=%.0fm active=false calMode=%b → init",
+ lat, lng, accuracy, calibrationMode));
+ tryInitialize(lat, lng, 0);
+ return;
+ }
+
+ if (!coordTransform.isInitialized()) return;
+
+ double[] en = coordTransform.toEastNorth(lat, lng);
+
+ // GF: GNSS is reliable → STRONG; upper floors: WEAK
+ boolean isGF = (fusedFloor == 0 || fusedFloor == 1);
+ ObservationLevel gateLevel = isGF ? ObservationLevel.STRONG : ObservationLevel.WEAK;
+ if (!shouldAcceptObservation(en[0], en[1], gateLevel)) {
+ logObservation("GPS", en[0], en[1], fusedFloor, 0, false,
+ String.format("gate_rejected mode=%s acc=%.0fm floor=%d", gateMode, accuracy, fusedFloor));
+ return;
+ }
+
+ // GF (floor 0 or 1 with bias): GNSS is reliable, use same weight as WiFi API.
+ // Other floors: indoor GNSS is unreliable, use high sigma.
+ boolean isGroundFloor = (fusedFloor == 0 || fusedFloor == 1);
+ double sigma;
+ ObservationLevel level;
+ if (isGroundFloor) {
+ sigma = 4.0; // same as WiFi API
+ level = ObservationLevel.STRONG;
+ } else {
+ sigma = Math.max(accuracy, GNSS_INDOOR_SIGMA);
+ level = ObservationLevel.WEAK;
+ }
+ logObservation("GPS", en[0], en[1], fusedFloor, sigma, true,
+ String.format("level=%s mode=%s acc=%.0fm floor=%d gf=%b step=%d",
+ level, gateMode, accuracy, fusedFloor, isGroundFloor, pdrStepIndex));
+ particleFilter.updateWithDynamicSigma(en[0], en[1], sigma);
+ updateFusedOutput();
+ onObservationAccepted(level);
+ }
+
+ /**
+ * Called when the user long-presses the map to manually correct their position.
+ * Uses a very tight uncertainty to pull particles strongly toward the correction.
+ *
+ * @param lat true latitude selected by the user
+ * @param lng true longitude selected by the user
+ */
+ public void onManualCorrection(double lat, double lng) {
+ if (!coordTransform.isInitialized()) {
+ // Use the correction as the initial origin
+ tryInitialize(lat, lng, fusedFloor);
+ return;
+ }
+
+ if (!active) {
+ tryInitialize(lat, lng, fusedFloor);
+ return;
+ }
+
+ double[] en = coordTransform.toEastNorth(lat, lng);
+ // Very tight std dev (2m) — strong correction
+ particleFilter.updateWithManualCorrection(en[0], en[1], 2.0);
+ updateFusedOutput();
+ if (DEBUG) Log.i(TAG, String.format("Manual correction applied at (%.6f, %.6f)", lat, lng));
+ }
+
+ /**
+ * Reseeds all floor state to a known value (e.g. from WiFi API on autofloor toggle).
+ * Updates lastReportedFloor, fusedFloor, and particle floors so that
+ * subsequent baro readings don't trigger spurious floor transitions.
+ *
+ * @param floor the logical floor number (0=GF, 1=F1, …)
+ */
+ public void reseedFloor(int floor) {
+ int prev = lastReportedFloor;
+ lastReportedFloor = floor;
+ floorCandidate = -1;
+ fusedFloor = floor;
+ if (particleFilter.isInitialized()) {
+ particleFilter.updateFloor(floor);
+ }
+ if (DEBUG) Log.i(TAG, String.format("[FloorReseed] FM floor %d→%d (particles updated)", prev, floor));
+ }
+
+ // ---- unified observation logging ------------------------------------------
+
+ /** Logs an observation event for diagnostic purposes. */
+ private void logObservation(String source, double obsE, double obsN,
+ int floor, double sigma,
+ boolean accepted, String reason) {
+ if (DEBUG) Log.i(TAG, String.format("[Observation] source=%s floor=%d sigma=%.1f accepted=%b reason=%s obsENU=(%.2f,%.2f)",
+ source, floor, sigma, accepted, reason, obsE, obsN));
+ }
+
+ // ---- gate state machine helpers ----------------------------------------
+
+ /** Returns the distance gate for the given observation level, or -1 to reject. */
+ private double getCurrentGate(ObservationLevel level) {
+ if (pdrStepIndex < 30) return LOCKED_GATE_INIT;
+ if (gateMode == GateMode.UNLOCKED) {
+ // UNLOCKED: accept STRONG and MEDIUM without distance limit (we know PDR drifted)
+ if (level == ObservationLevel.STRONG || level == ObservationLevel.MEDIUM)
+ return Double.MAX_VALUE;
+ return -1; // reject WEAK (GNSS) even in UNLOCKED
+ }
+ return LOCKED_GATE;
+ }
+
+ /** Checks whether to transition LOCKED → UNLOCKED based on drift indicators. */
+ private void checkGateMode() {
+ if (gateMode == GateMode.LOCKED) {
+ if (stepsSinceLastCorrection > UNLOCK_STEP_THRESHOLD ||
+ fusedUncertainty > UNLOCK_UNCERTAINTY_THRESHOLD) {
+ gateMode = GateMode.UNLOCKED;
+ if (DEBUG) Log.i(TAG, String.format("[Gate] LOCKED→UNLOCKED steps_no_fix=%d uncertainty=%.1fm",
+ stepsSinceLastCorrection, fusedUncertainty));
+ }
+ }
+ }
+
+ /** Returns true if the observation passes the adaptive distance gate. */
+ private boolean shouldAcceptObservation(double obsE, double obsN,
+ ObservationLevel level) {
+ // Stationary: freeze position — reject ALL corrections when user is not walking
+ if (lastPdrStepTimeMs > 0
+ && (System.currentTimeMillis() - lastPdrStepTimeMs) > STATIONARY_TIMEOUT_MS) {
+ if (DEBUG) Log.d(TAG, String.format("[Gate] REJECTED stationary (no step for %dms) level=%s",
+ System.currentTimeMillis() - lastPdrStepTimeMs, level));
+ return false;
+ }
+
+ // Warmup: suppress all observations for WARMUP_MS after initialisation
+ // to prevent stale GNSS/WiFi cache from pulling the initial position.
+ if (initTimeMs > 0 && System.currentTimeMillis() - initTimeMs < WARMUP_MS) {
+ if (DEBUG) Log.d(TAG, String.format("[Gate] REJECTED warmup (%dms remaining) level=%s",
+ WARMUP_MS - (System.currentTimeMillis() - initTimeMs), level));
+ return false;
+ }
+
+ double distFromFused = Math.hypot(obsE - particleFilter.getEstimatedX(),
+ obsN - particleFilter.getEstimatedY());
+ double gate = getCurrentGate(level);
+
+ if (gate < 0) {
+ if (DEBUG) Log.d(TAG, String.format("[Gate] REJECTED level=%s in UNLOCKED mode (STRONG/MEDIUM only)", level));
+ return false;
+ }
+
+ if (distFromFused > gate) {
+ if (DEBUG) Log.d(TAG, String.format("[Gate] REJECTED dist=%.1fm > gate=%.0fm mode=%s level=%s step=%d",
+ distFromFused, gate, gateMode, level, pdrStepIndex));
+ return false;
+ }
+
+ return true;
+ }
+
+ /** Called after an observation is accepted to reset drift counter and lock gate. */
+ private void onObservationAccepted(ObservationLevel level) {
+ stepsSinceLastCorrection = 0;
+ if (gateMode == GateMode.UNLOCKED
+ && (level == ObservationLevel.STRONG || level == ObservationLevel.MEDIUM)) {
+ gateMode = GateMode.LOCKED;
+ if (DEBUG) Log.i(TAG, String.format("[Gate] UNLOCKED→LOCKED (%s fix received)", level));
+ }
+ }
+
+ // ---- calibration observation (from CalibrationManager WKNN) -------------
+
+ /**
+ * Processes a calibration database observation through the same pipeline
+ * as GPS/WiFi: distance gate, stationary damping, unified logging.
+ */
+ public void onCalibrationObservation(double obsE, double obsN,
+ double sigma, int floor, String quality) {
+ // During calibration mode, convert ENU back to LatLng and accumulate
+ if (calibrationMode && coordTransform.isInitialized()) {
+ double[] ll = coordTransform.toLatLng(obsE, obsN);
+ addCalibrationFix(ll[0], ll[1], CAL_WEIGHT_CALDB, "CAL_DB(" + quality + ")");
+ return;
+ }
+ if (!active || !coordTransform.isInitialized()) return;
+
+ // Map CAL_DB quality string to observation level
+ ObservationLevel calLevel;
+ switch (quality) {
+ case "GOOD":
+ calLevel = ObservationLevel.STRONG;
+ break;
+ case "AMBIGUOUS":
+ calLevel = ObservationLevel.MEDIUM;
+ break;
+ default:
+ calLevel = ObservationLevel.WEAK;
+ break;
+ }
+
+ if (!shouldAcceptObservation(obsE, obsN, calLevel)) {
+ logObservation("CAL_DB", obsE, obsN, floor, sigma, false,
+ String.format("gate_rejected mode=%s quality=%s level=%s",
+ gateMode, quality, calLevel));
+ return;
+ }
+
+ double distFromFused = Math.hypot(obsE - particleFilter.getEstimatedX(),
+ obsN - particleFilter.getEstimatedY());
+
+ // Recovery: when UNLOCKED with large drift, re-seed (looser spread for CAL_DB)
+ if (gateMode == GateMode.UNLOCKED && distFromFused > RESEED_DISTANCE
+ && calLevel == ObservationLevel.STRONG) {
+ if (DEBUG) Log.i(TAG, String.format("[Recovery] CAL_DB re-seed dist=%.1fm quality=%s",
+ distFromFused, quality));
+ particleFilter.initialize(obsE, obsN, floor, 15.0);
+ updateFusedOutput();
+ onObservationAccepted(calLevel);
+ return;
+ }
+
+ logObservation("CAL_DB", obsE, obsN, floor, sigma, true,
+ String.format("level=%s mode=%s quality=%s dist=%.1f",
+ calLevel, gateMode, quality, distFromFused));
+ particleFilter.updateWithDynamicSigma(obsE, obsN, sigma);
+ // Use GOOD calibration observations for heading bias (more stable than WiFi API)
+ if (calLevel == ObservationLevel.STRONG) {
+ updateHeadingBias(obsE, obsN);
+ }
+ updateFusedOutput();
+ onObservationAccepted(calLevel);
+ }
+
+ // ---- floor handling ------------------------------------------------------
+
+ /**
+ * Initializes particle floor distribution as a prior (not force).
+ * Most particles go to the best floor, some to adjacent floors.
+ *
+ * @param bestFloor most likely floor
+ * @param confidence 0.0–1.0, controls how concentrated the distribution is
+ */
+ public void initializeFloorPrior(int bestFloor, double confidence) {
+ if (!particleFilter.isInitialized()) {
+ fusedFloor = bestFloor;
+ lastReportedFloor = bestFloor;
+ if (DEBUG) Log.i(TAG, String.format("[Floor] prior deferred (PF not init): best=%d", bestFloor));
+ return;
+ }
+
+ Particle[] particles = particleFilter.getParticles();
+ java.util.Random rng = new java.util.Random();
+
+ int onBest = 0, onAdjacent = 0;
+ for (Particle p : particles) {
+ double roll = rng.nextDouble();
+ if (roll < confidence) {
+ p.floor = bestFloor;
+ onBest++;
+ } else if (roll < confidence + (1 - confidence) * 0.5) {
+ p.floor = Math.max(0, bestFloor - 1);
+ onAdjacent++;
+ } else {
+ p.floor = bestFloor + 1;
+ onAdjacent++;
+ }
+ }
+
+ fusedFloor = bestFloor;
+ lastReportedFloor = bestFloor;
+
+ if (DEBUG) Log.i(TAG, String.format("[Floor] prior initialized: best=%d conf=%.0f%% particles=%d/%d on best",
+ bestFloor, confidence * 100, onBest, particles.length));
+ }
+
+ /**
+ * Called by barometer when relative height changes. Uses a state machine:
+ * STABLE → CANDIDATE (new floor detected) → CONFIRMED (held for 2 seconds).
+ * Only CONFIRMED transitions update the particle filter.
+ * The barometer provides transition EVIDENCE, not a direct floor command.
+ */
+ public void onFloorChanged(int baroFloor) {
+ if (!active) return;
+ if (baroFloor == lastReportedFloor) {
+ floorCandidate = -1;
+ return;
+ }
+
+ long now = System.currentTimeMillis();
+
+ if (baroFloor != floorCandidate) {
+ floorCandidate = baroFloor;
+ floorCandidateStartMs = now;
+ if (DEBUG) Log.d(TAG, String.format("[Floor] CANDIDATE %d → %d",
+ lastReportedFloor, baroFloor));
+ return;
+ }
+
+ if (now - floorCandidateStartMs < FLOOR_CONFIRM_MS) return;
+
+ if (DEBUG) Log.i(TAG, String.format("[Floor] CONFIRMED %d → %d (baro held 1s)",
+ lastReportedFloor, baroFloor));
+ lastReportedFloor = baroFloor;
+ floorCandidate = -1;
+ particleFilter.updateFloor(baroFloor);
+ updateFusedOutput();
+
+ // Reset baro baseline to prevent long-term drift accumulation.
+ // After this, relHeight resets to ~0 relative to the new floor.
+ SensorFusion.getInstance().resetBaroBaseline(baroFloor);
+ }
+
+ /** Returns floor probability distribution from particle weights. */
+ public double[] getFloorProbabilities() {
+ double[] probs = new double[10];
+ if (!particleFilter.isInitialized()) return probs;
+ for (Particle p : particleFilter.getParticles()) {
+ int f = Math.max(0, Math.min(9, p.floor));
+ probs[f] += p.weight;
+ }
+ return probs;
+ }
+
+ // ---- output --------------------------------------------------------------
+
+ private void updateFusedOutput() {
+ double[] latLng = coordTransform.toLatLng(
+ particleFilter.getEstimatedX(),
+ particleFilter.getEstimatedY());
+ fusedLat = latLng[0];
+ fusedLng = latLng[1];
+ fusedFloor = particleFilter.getEstimatedFloor();
+ fusedUncertainty = particleFilter.getUncertainty();
+ }
+
+ /** Returns the fused position as a LatLng, or null if not yet initialised. */
+ public LatLng getFusedLatLng() {
+ if (!active) return null;
+ return new LatLng(fusedLat, fusedLng);
+ }
+
+ /** Returns the current fused floor index. */
+ public int getFusedFloor() {
+ return fusedFloor;
+ }
+
+ /** Returns the current fused position uncertainty in metres. */
+ public double getFusedUncertainty() {
+ return fusedUncertainty;
+ }
+
+ /** Returns {@code true} if the fusion engine has been initialised and is active. */
+ public boolean isActive() {
+ return active;
+ }
+
+ /** Resets the fusion state for a new recording session. */
+ public void reset() {
+ active = false;
+ lastWifiPosition = null;
+ lastGnssPosition = null;
+ lastWifiFloor = -1;
+ wifiFloorSeeded = false;
+ fusedLat = 0;
+ fusedLng = 0;
+ fusedFloor = 0;
+ fusedUncertainty = 0;
+ headingBias = 0;
+ hasPrevObs = false;
+ pdrAccumDx = 0;
+ pdrAccumDy = 0;
+ pdrStepIndex = 0;
+ lastReportedFloor = -1;
+ floorCandidate = -1;
+ floorCandidateStartMs = 0;
+ gateMode = GateMode.LOCKED;
+ stepsSinceLastCorrection = 0;
+ initTimeMs = 0;
+ lastPdrStepTimeMs = 0;
+ consecutiveHighCollisionSteps = 0;
+ currentHeadingStd = SEARCH_HEADING_STD_MIN;
+ inSearchMode = false;
+ // ParticleFilter will be re-initialised on next position fix
+ }
+
+ /** Returns the coordinate transform used by this fusion manager. */
+ public CoordinateTransform getCoordinateTransform() {
+ return coordTransform;
+ }
+
+ /** Returns the underlying particle filter instance. */
+ public ParticleFilter getParticleFilter() {
+ return particleFilter;
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/MapConstraint.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/MapConstraint.java
new file mode 100644
index 00000000..421215c4
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/MapConstraint.java
@@ -0,0 +1,464 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
+import android.util.Log;
+
+import com.google.android.gms.maps.model.LatLng;
+import com.openpositioning.PositionMe.data.remote.FloorplanApiClient;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Holds pre-processed wall geometry per floor in ENU coordinates (metres).
+ * Provides line-segment intersection tests so the particle filter can
+ * enforce map constraints: particles that cross walls are penalised and
+ * kept at their previous position.
+ *
+ * Only axis-aligned wall segments (aligned to the building's two
+ * principal orthogonal directions) are kept. This filters out diagonal
+ * polygon edges that typically cross doorways or openings, preventing
+ * false wall detections that would trap particles.
+ */
+public class MapConstraint {
+
+ private static final String TAG = "MapConstraint";
+
+ /** Weight multiplier applied to particles that cross a wall (67 % penalty). */
+ private static final double WALL_PENALTY = 0.33;
+
+ /** Segments shorter than this (metres) are discarded.
+ * 1 m filters wall-thickness edges and doorway-crossing segments (~0.8 m)
+ * while keeping exterior walls and structural walls (typically 1.5 m+). */
+ private static final double MIN_WALL_LENGTH = 1.0;
+
+ /** Angular tolerance (radians) for axis-alignment filtering (~15°). */
+ private static final double AXIS_TOLERANCE_RAD = Math.toRadians(15);
+
+ // ---- inner types --------------------------------------------------------
+
+ /** A wall segment in ENU coordinates (metres), with pre-computed AABB. */
+ public static class WallSegment {
+ public final double x1, y1, x2, y2;
+ public final double minX, maxX, minY, maxY;
+
+ /**
+ * Constructs a wall segment between two ENU points and pre-computes its AABB.
+ *
+ * @param x1 start easting (metres)
+ * @param y1 start northing (metres)
+ * @param x2 end easting (metres)
+ * @param y2 end northing (metres)
+ */
+ public WallSegment(double x1, double y1, double x2, double y2) {
+ this.x1 = x1;
+ this.y1 = y1;
+ this.x2 = x2;
+ this.y2 = y2;
+ this.minX = Math.min(x1, x2);
+ this.maxX = Math.max(x1, x2);
+ this.minY = Math.min(y1, y2);
+ this.maxY = Math.max(y1, y2);
+ }
+ }
+
+ // ---- state --------------------------------------------------------------
+
+ /** Floor index -> list of wall segments in ENU. */
+ private final Map> wallsByFloor = new HashMap<>();
+
+ /** Building outline wall segments — apply to ALL floors (floor-independent). */
+ private final List outlineWalls = new ArrayList<>();
+
+ private boolean initialized = false;
+
+ // ---- initialisation -----------------------------------------------------
+
+ /**
+ * Extracts wall segments from FloorShapes, converts LatLng to ENU,
+ * filters to keep only axis-aligned segments, and stores them by floor.
+ *
+ * @param floorShapesList per-floor shape data from FloorplanApiClient
+ * @param coordTransform the ENU coordinate transform (must be initialised)
+ */
+ public void initialize(List floorShapesList,
+ CoordinateTransform coordTransform) {
+ wallsByFloor.clear();
+ initialized = false;
+
+ if (floorShapesList == null || !coordTransform.isInitialized()) {
+ if (DEBUG) Log.w(TAG, "Cannot initialise: floorShapes="
+ + (floorShapesList != null) + " coordInit="
+ + coordTransform.isInitialized());
+ return;
+ }
+
+ // Step 1: collect ALL raw wall segments across all floors.
+ // Map by REAL floor number (parsed from display name) to match
+ // the particle filter's floor numbering (WiFi API: 0=GF, 1=F1, 2=F2 …).
+ List allRaw = new ArrayList<>();
+ Map> rawByFloor = new HashMap<>();
+ int[] floorNumbers = new int[floorShapesList.size()];
+
+ for (int floorIdx = 0; floorIdx < floorShapesList.size(); floorIdx++) {
+ FloorplanApiClient.FloorShapes floor = floorShapesList.get(floorIdx);
+ int floorNum = parseFloorNumber(floor.getDisplayName());
+ floorNumbers[floorIdx] = floorNum;
+ List rawSegments = new ArrayList<>();
+
+ // Diagnostic: log all indoor types on this floor
+ Map typeCounts = new HashMap<>();
+ for (FloorplanApiClient.MapShapeFeature f : floor.getFeatures()) {
+ String t = f.getIndoorType();
+ typeCounts.put(t, typeCounts.getOrDefault(t, 0) + 1);
+ }
+ if (DEBUG) Log.i(TAG, String.format("Floor %s (key=%d): feature types = %s",
+ floor.getDisplayName(), floorNum, typeCounts));
+
+ for (FloorplanApiClient.MapShapeFeature feature : floor.getFeatures()) {
+ if (!"wall".equals(feature.getIndoorType())) continue;
+
+ String geoType = feature.getGeometryType();
+ for (List part : feature.getParts()) {
+ if (part.size() < 2) continue;
+
+ for (int i = 0; i < part.size() - 1; i++) {
+ LatLng a = part.get(i);
+ LatLng b = part.get(i + 1);
+ double[] enuA = coordTransform.toEastNorth(a.latitude, a.longitude);
+ double[] enuB = coordTransform.toEastNorth(b.latitude, b.longitude);
+ rawSegments.add(new WallSegment(enuA[0], enuA[1], enuB[0], enuB[1]));
+ }
+
+ // For polygon geometry, close the ring
+ if (("Polygon".equals(geoType) || "MultiPolygon".equals(geoType))
+ && part.size() >= 3) {
+ LatLng last = part.get(part.size() - 1);
+ LatLng first = part.get(0);
+ if (Math.abs(last.latitude - first.latitude) > 1e-9
+ || Math.abs(last.longitude - first.longitude) > 1e-9) {
+ double[] enuLast = coordTransform.toEastNorth(
+ last.latitude, last.longitude);
+ double[] enuFirst = coordTransform.toEastNorth(
+ first.latitude, first.longitude);
+ rawSegments.add(new WallSegment(
+ enuLast[0], enuLast[1], enuFirst[0], enuFirst[1]));
+ }
+ }
+ }
+ }
+
+ rawByFloor.put(floorIdx, rawSegments);
+ allRaw.addAll(rawSegments);
+ }
+
+ // Step 2: detect the building's primary axis from all segments
+ double primaryAngle = detectPrimaryAngle(allRaw);
+ if (DEBUG) Log.i(TAG, String.format("Detected building primary axis: %.1f°",
+ Math.toDegrees(primaryAngle)));
+
+ // Step 3: filter each floor — keep only axis-aligned, long-enough segments.
+ // Store by REAL floor number (matching particle filter numbering).
+ for (int floorIdx = 0; floorIdx < floorShapesList.size(); floorIdx++) {
+ FloorplanApiClient.FloorShapes floor = floorShapesList.get(floorIdx);
+ int floorNum = floorNumbers[floorIdx];
+ List raw = rawByFloor.get(floorIdx);
+ if (raw == null) raw = new ArrayList<>();
+
+ List filtered = new ArrayList<>();
+ int shortCount = 0, diagonalCount = 0;
+
+ for (WallSegment seg : raw) {
+ double dx = seg.x2 - seg.x1;
+ double dy = seg.y2 - seg.y1;
+ double len = Math.sqrt(dx * dx + dy * dy);
+
+ if (len < MIN_WALL_LENGTH) {
+ shortCount++;
+ continue;
+ }
+
+ if (!isAlignedToAxis(dx, dy, primaryAngle)) {
+ diagonalCount++;
+ continue;
+ }
+
+ filtered.add(seg);
+ }
+
+ wallsByFloor.put(floorNum, filtered);
+ if (DEBUG) Log.i(TAG, String.format(
+ "Floor key=%d (%s): %d raw → %d kept (filtered %d short, %d diagonal)",
+ floorNum, floor.getDisplayName(), raw.size(), filtered.size(),
+ shortCount, diagonalCount));
+ }
+
+ initialized = !wallsByFloor.isEmpty();
+ int totalSegments = 0;
+ for (List segs : wallsByFloor.values()) totalSegments += segs.size();
+ if (DEBUG) Log.i(TAG, String.format("Initialised: %d floors, %d total wall segments (axis-aligned)",
+ wallsByFloor.size(), totalSegments));
+ }
+
+ /**
+ * Loads the building outline polygon as floor-independent wall constraints.
+ * These prevent particles from leaving the building boundary on any floor.
+ *
+ * @param outlinePolygon building boundary polygon from BuildingInfo
+ * @param coordTransform the ENU coordinate transform (must be initialised)
+ */
+ public void setOutlineConstraint(List outlinePolygon,
+ CoordinateTransform coordTransform) {
+ outlineWalls.clear();
+
+ if (outlinePolygon == null || outlinePolygon.size() < 3
+ || !coordTransform.isInitialized()) {
+ if (DEBUG) Log.w(TAG, "Cannot load outline: polygon="
+ + (outlinePolygon != null ? outlinePolygon.size() : "null")
+ + " coordInit=" + coordTransform.isInitialized());
+ return;
+ }
+
+ for (int i = 0; i < outlinePolygon.size(); i++) {
+ LatLng a = outlinePolygon.get(i);
+ LatLng b = outlinePolygon.get((i + 1) % outlinePolygon.size());
+ double[] enuA = coordTransform.toEastNorth(a.latitude, a.longitude);
+ double[] enuB = coordTransform.toEastNorth(b.latitude, b.longitude);
+ double dx = enuB[0] - enuA[0];
+ double dy = enuB[1] - enuA[1];
+ double len = Math.sqrt(dx * dx + dy * dy);
+ // Keep all outline segments >= 0.5m (outline is coarser, no doorway issue)
+ if (len >= 0.5) {
+ outlineWalls.add(new WallSegment(enuA[0], enuA[1], enuB[0], enuB[1]));
+ }
+ }
+
+ // Outline alone can mark as initialized (even without interior walls)
+ if (!outlineWalls.isEmpty()) {
+ initialized = true;
+ }
+
+ if (DEBUG) Log.i(TAG, String.format("Loaded %d building outline segments (from %d polygon points)",
+ outlineWalls.size(), outlinePolygon.size()));
+ }
+
+ /** Returns {@code true} if wall geometry has been loaded. */
+ public boolean isInitialized() {
+ return initialized;
+ }
+
+ // ---- axis detection & filtering -----------------------------------------
+
+ /**
+ * Detects the building's primary axis direction from all wall segments.
+ * Uses a length-weighted angle histogram over [0°, 180°) with smoothing
+ * to find the dominant wall direction.
+ *
+ * @return primary axis angle in radians [0, π)
+ */
+ private double detectPrimaryAngle(List segments) {
+ // 1° bins over [0, 180)
+ double[] histogram = new double[180];
+
+ for (WallSegment seg : segments) {
+ double dx = seg.x2 - seg.x1;
+ double dy = seg.y2 - seg.y1;
+ double len = Math.sqrt(dx * dx + dy * dy);
+ if (len < 0.01) continue;
+
+ // Angle in degrees, normalized to [0, 180)
+ double angleDeg = Math.toDegrees(Math.atan2(dy, dx));
+ angleDeg = ((angleDeg % 180) + 180) % 180;
+ int bin = (int) angleDeg;
+ if (bin >= 180) bin = 179;
+
+ // Weight by segment length — longer walls contribute more
+ histogram[bin] += len;
+ }
+
+ // Smooth with ±5° circular window
+ double[] smoothed = new double[180];
+ for (int i = 0; i < 180; i++) {
+ for (int d = -5; d <= 5; d++) {
+ smoothed[i] += histogram[((i + d) % 180 + 180) % 180];
+ }
+ }
+
+ // Find peak
+ int peakBin = 0;
+ for (int i = 1; i < 180; i++) {
+ if (smoothed[i] > smoothed[peakBin]) peakBin = i;
+ }
+
+ return Math.toRadians(peakBin);
+ }
+
+ /**
+ * Parses a floor display name into the numeric floor index used by
+ * the WiFi API and particle filter (0 = GF, 1 = F1, 2 = F2, …).
+ * Handles common naming conventions: "LG"→-1, "GF"/"Ground"→0,
+ * "F1"/"1st"→1, "F2"/"2nd"→2, etc.
+ */
+ static int parseFloorNumber(String displayName) {
+ if (displayName == null || displayName.isEmpty()) return 0;
+ String upper = displayName.toUpperCase().trim();
+
+ if (upper.startsWith("LG") || upper.startsWith("LOWER")
+ || upper.startsWith("B") || upper.startsWith("BASEMENT")) {
+ return -1;
+ }
+ if (upper.equals("GF") || upper.startsWith("GROUND")
+ || upper.equals("G") || upper.equals("0")) {
+ return 0;
+ }
+ // "F1", "F2", "F3" etc.
+ if (upper.startsWith("F") && upper.length() >= 2) {
+ try {
+ return Integer.parseInt(upper.substring(1));
+ } catch (NumberFormatException ignored) {}
+ }
+ // "1", "2", "3" or "1st", "2nd", "3rd"
+ try {
+ return Integer.parseInt(upper.replaceAll("[^0-9-]", ""));
+ } catch (NumberFormatException ignored) {}
+
+ return 0;
+ }
+
+ /**
+ * Checks whether a segment direction is aligned to either of the building's
+ * two orthogonal axes (primary or primary+90°) within the configured tolerance.
+ */
+ private boolean isAlignedToAxis(double dx, double dy, double primaryAngle) {
+ double angle = Math.atan2(dy, dx);
+ // Normalize to [0, π)
+ angle = ((angle % Math.PI) + Math.PI) % Math.PI;
+
+ double secondaryAngle = (primaryAngle + Math.PI / 2) % Math.PI;
+
+ return angleDiffMod(angle, primaryAngle) < AXIS_TOLERANCE_RAD
+ || angleDiffMod(angle, secondaryAngle) < AXIS_TOLERANCE_RAD;
+ }
+
+ /**
+ * Computes the minimum angular difference between two angles in [0, π),
+ * accounting for the wrap-around at 0°/180°.
+ */
+ private static double angleDiffMod(double a, double b) {
+ double diff = Math.abs(a - b);
+ if (diff > Math.PI / 2) diff = Math.PI - diff;
+ return diff;
+ }
+
+ // ---- constraint application ---------------------------------------------
+
+ /**
+ * Applies wall constraints to all particles after a predict step.
+ * Particles that cross a wall are reverted to their previous position
+ * and penalised. Uses AABB pre-filtering for performance.
+ *
+ * @return the number of particles that collided with a wall
+ */
+ public int applyConstraints(Particle[] particles,
+ double[] oldX, double[] oldY) {
+ if (!initialized) return 0;
+
+ int collisionCount = 0;
+
+ for (int i = 0; i < particles.length; i++) {
+ Particle p = particles[i];
+
+ // AABB of particle movement for fast pre-filtering
+ double moveMinX = Math.min(oldX[i], p.x);
+ double moveMaxX = Math.max(oldX[i], p.x);
+ double moveMinY = Math.min(oldY[i], p.y);
+ double moveMaxY = Math.max(oldY[i], p.y);
+
+ boolean hitWall = false;
+
+ // Check floor-specific interior walls
+ List walls = wallsByFloor.get(p.floor);
+ if (walls != null) {
+ for (int w = 0, wSize = walls.size(); w < wSize; w++) {
+ WallSegment wall = walls.get(w);
+ if (wall.maxX < moveMinX || wall.minX > moveMaxX
+ || wall.maxY < moveMinY || wall.minY > moveMaxY) {
+ continue;
+ }
+ if (segmentsIntersect(
+ oldX[i], oldY[i], p.x, p.y,
+ wall.x1, wall.y1, wall.x2, wall.y2)) {
+ hitWall = true;
+ break;
+ }
+ }
+ }
+
+ // Check building outline walls (floor-independent, all floors)
+ if (!hitWall && !outlineWalls.isEmpty()) {
+ for (int w = 0, wSize = outlineWalls.size(); w < wSize; w++) {
+ WallSegment wall = outlineWalls.get(w);
+ if (wall.maxX < moveMinX || wall.minX > moveMaxX
+ || wall.maxY < moveMinY || wall.minY > moveMaxY) {
+ continue;
+ }
+ if (segmentsIntersect(
+ oldX[i], oldY[i], p.x, p.y,
+ wall.x1, wall.y1, wall.x2, wall.y2)) {
+ hitWall = true;
+ break;
+ }
+ }
+ }
+
+ if (hitWall) {
+ // Revert to previous position + weight penalty
+ p.x = oldX[i];
+ p.y = oldY[i];
+ p.weight *= WALL_PENALTY;
+ collisionCount++;
+ }
+ }
+
+ return collisionCount;
+ }
+
+ // ---- geometry primitives ------------------------------------------------
+
+ /**
+ * Tests whether two line segments intersect using the parametric approach.
+ */
+ static boolean segmentsIntersect(
+ double px1, double py1, double px2, double py2,
+ double qx1, double qy1, double qx2, double qy2) {
+
+ double dx_p = px2 - px1;
+ double dy_p = py2 - py1;
+ double dx_q = qx2 - qx1;
+ double dy_q = qy2 - qy1;
+
+ double denom = dx_p * dy_q - dy_p * dx_q;
+
+ if (Math.abs(denom) < 1e-12) {
+ return false; // parallel or coincident
+ }
+
+ double dx_pq = qx1 - px1;
+ double dy_pq = qy1 - py1;
+
+ double t = (dx_pq * dy_q - dy_pq * dx_q) / denom;
+ if (t < 0.0 || t > 1.0) return false;
+
+ double u = (dx_pq * dy_p - dy_pq * dx_p) / denom;
+ return u >= 0.0 && u <= 1.0;
+ }
+
+ /** Returns the number of wall segments for a given floor (for logging). */
+ public int getWallCount(int floor) {
+ List walls = wallsByFloor.get(floor);
+ return walls != null ? walls.size() : 0;
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/Particle.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/Particle.java
new file mode 100644
index 00000000..eb6686b1
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/Particle.java
@@ -0,0 +1,41 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+/**
+ * Represents a single particle in the particle filter.
+ * Each particle holds a hypothesis of the user's position (easting/northing)
+ * along with floor level and importance weight.
+ */
+public class Particle {
+
+ /** Easting coordinate in meters relative to the ENU origin. */
+ public double x;
+
+ /** Northing coordinate in meters relative to the ENU origin. */
+ public double y;
+
+ /** Floor index (0-based). */
+ public int floor;
+
+ /** Importance weight, normalized across all particles. */
+ public double weight;
+
+ /**
+ * Constructs a particle with the given position, floor, and weight.
+ *
+ * @param x easting in metres (ENU)
+ * @param y northing in metres (ENU)
+ * @param floor floor index (0-based)
+ * @param weight importance weight
+ */
+ public Particle(double x, double y, int floor, double weight) {
+ this.x = x;
+ this.y = y;
+ this.floor = floor;
+ this.weight = weight;
+ }
+
+ /** Creates a deep copy of this particle. */
+ public Particle copy() {
+ return new Particle(x, y, floor, weight);
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/ParticleFilter.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/ParticleFilter.java
new file mode 100644
index 00000000..869bfff8
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/fusion/ParticleFilter.java
@@ -0,0 +1,352 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
+import android.util.Log;
+
+import java.util.Random;
+
+/**
+ * Sequential Importance Resampling (SIR) particle filter for indoor positioning.
+ *
+ * Algorithm cycle (per step):
+ *
+ * - Predict — propagate particles with noisy PDR displacement.
+ * - Update — re-weight particles using WiFi / GNSS observations.
+ * - Estimate — compute weighted mean position & uncertainty.
+ * - Resample — residual resampling when N_eff drops below threshold.
+ *
+ *
+ * The filter works in a local East-North-Up (ENU) frame so that PDR
+ * displacements can be handled in metres.
+ */
+public class ParticleFilter {
+
+ private static final String TAG = "ParticleFilter";
+
+ // ---- tuneable constants ---------------------------------------------------
+
+ /** Standard deviation added to each PDR step length (metres). */
+ private static final double STEP_LENGTH_STD = 0.15;
+
+ /** Standard deviation added to each PDR heading (radians ≈ 8°). */
+ private static final double HEADING_STD = Math.toRadians(8);
+
+ /** Assumed uncertainty of a WiFi position fix (metres). */
+ private static final double WIFI_POSITION_STD = 8.0;
+
+ /** Assumed uncertainty of a GNSS position fix (metres). */
+ private static final double GNSS_POSITION_STD = 15.0;
+
+ /**
+ * Fraction of total particles below which resampling is triggered.
+ * N_eff = 1 / Σ(w²); resample when N_eff < threshold × N.
+ */
+ private static final double NEFF_THRESHOLD = 0.3;
+
+ // ---- state ---------------------------------------------------------------
+
+ private Particle[] particles;
+ private final int numParticles;
+ private final Random random;
+
+ private double estimatedX;
+ private double estimatedY;
+ private int estimatedFloor;
+ private double uncertainty;
+ private boolean initialized = false;
+
+ // ---- constructor ---------------------------------------------------------
+
+ /**
+ * Creates a particle filter with the specified number of particles.
+ *
+ * @param numParticles number of particles to use
+ */
+ public ParticleFilter(int numParticles) {
+ this.numParticles = numParticles;
+ this.particles = new Particle[numParticles];
+ this.random = new Random();
+ }
+
+ // ---- lifecycle -----------------------------------------------------------
+
+ /**
+ * Initialises particles in a Gaussian cloud around an initial position.
+ *
+ * @param x easting (metres, ENU)
+ * @param y northing (metres, ENU)
+ * @param floor initial floor index
+ * @param spread standard deviation of the initial cloud (metres)
+ */
+ public void initialize(double x, double y, int floor, double spread) {
+ double uniformWeight = 1.0 / numParticles;
+ for (int i = 0; i < numParticles; i++) {
+ double px = x + random.nextGaussian() * spread;
+ double py = y + random.nextGaussian() * spread;
+ particles[i] = new Particle(px, py, floor, uniformWeight);
+ }
+ estimatedX = x;
+ estimatedY = y;
+ estimatedFloor = floor;
+ uncertainty = spread;
+ initialized = true;
+ if (DEBUG) Log.i(TAG, String.format("Initialised %d particles at (%.2f, %.2f) floor %d",
+ numParticles, x, y, floor));
+ }
+
+ // ---- prediction ----------------------------------------------------------
+
+ /**
+ * Moves every particle according to PDR displacement with added noise.
+ *
+ * @param stepLength stride length in metres (from Weiberg / settings)
+ * @param heading azimuth in radians, 0 = North, clockwise positive
+ */
+ public void predict(double stepLength, double heading) {
+ predict(stepLength, heading, HEADING_STD);
+ }
+
+ /**
+ * Moves every particle with a caller-specified heading noise.
+ * Used by the wall-collision search mode to temporarily widen the
+ * particle heading spread when most particles are stuck against walls.
+ *
+ * @param headingStd heading noise standard deviation in radians
+ */
+ public void predict(double stepLength, double heading, double headingStd) {
+ if (!initialized) return;
+
+ for (Particle p : particles) {
+ double noisyStep = stepLength + random.nextGaussian() * STEP_LENGTH_STD;
+ double noisyHeading = heading + random.nextGaussian() * headingStd;
+ noisyStep = Math.max(0.05, noisyStep);
+
+ p.x += noisyStep * Math.sin(noisyHeading);
+ p.y += noisyStep * Math.cos(noisyHeading);
+ }
+
+ normalizeWeights();
+ estimatePosition();
+ }
+
+ // ---- observation updates -------------------------------------------------
+
+ /**
+ * Updates particle weights using a position observation (WiFi or GNSS).
+ * The likelihood is an isotropic 2-D Gaussian centred on the observation.
+ */
+ private void updateWithPosition(double obsX, double obsY, double stdDev) {
+ if (!initialized) return;
+
+ double variance2 = 2.0 * stdDev * stdDev;
+ for (Particle p : particles) {
+ double dx = p.x - obsX;
+ double dy = p.y - obsY;
+ double distSq = dx * dx + dy * dy;
+ p.weight *= Math.exp(-distSq / variance2);
+ }
+
+ normalizeWeights();
+ estimatePosition();
+ resampleIfNeeded();
+ }
+
+ /** Feed a WiFi-derived position observation (in ENU metres). */
+ public void updateWifi(double obsX, double obsY) {
+ updateWithPosition(obsX, obsY, WIFI_POSITION_STD);
+ }
+
+ /** Feed a GNSS-derived position observation (in ENU metres). */
+ public void updateGnss(double obsX, double obsY) {
+ updateWithPosition(obsX, obsY, GNSS_POSITION_STD);
+ }
+
+ /** Feed an observation with caller-specified uncertainty (dynamic sigma). */
+ public void updateWithDynamicSigma(double obsX, double obsY, double sigma) {
+ updateWithPosition(obsX, obsY, sigma);
+ }
+
+ /** Feed a user manual correction with custom tight uncertainty. */
+ public void updateWithManualCorrection(double obsX, double obsY, double stdDev) {
+ updateWithPosition(obsX, obsY, stdDev);
+ }
+
+ // ---- floor handling ------------------------------------------------------
+
+ /**
+ * Adjusts particle floors when a floor change is detected (barometer).
+ */
+ public void updateFloor(int newFloor) {
+ if (!initialized) return;
+ for (Particle p : particles) {
+ p.floor = newFloor;
+ }
+
+ normalizeWeights();
+ estimatePosition();
+ }
+
+ // ---- estimation ----------------------------------------------------------
+
+ /** Weighted mean of particle positions → fused estimate + uncertainty. */
+ private void estimatePosition() {
+ double sumX = 0, sumY = 0, totalW = 0;
+ double[] floorWeights = new double[20];
+
+ for (Particle p : particles) {
+ sumX += p.x * p.weight;
+ sumY += p.y * p.weight;
+ totalW += p.weight;
+ int fi = Math.max(0, Math.min(19, p.floor));
+ floorWeights[fi] += p.weight;
+ }
+
+ if (totalW > 0) {
+ estimatedX = sumX / totalW;
+ estimatedY = sumY / totalW;
+ }
+
+ // Floor = highest total weight, but only switch if confident
+ double maxW = 0, secondW = 0;
+ int candidateFloor = estimatedFloor;
+ for (int i = 0; i < floorWeights.length; i++) {
+ if (floorWeights[i] > maxW) {
+ secondW = maxW;
+ maxW = floorWeights[i];
+ candidateFloor = i;
+ } else if (floorWeights[i] > secondW) {
+ secondW = floorWeights[i];
+ }
+ }
+ // Only switch floor if dominant (>0.6 probability or >0.2 margin over second)
+ if (maxW > 0.6 || (maxW - secondW) > 0.2) {
+ estimatedFloor = candidateFloor;
+ }
+
+ // Weighted standard deviation as uncertainty measure
+ double sumSqDist = 0;
+ for (Particle p : particles) {
+ double dx = p.x - estimatedX;
+ double dy = p.y - estimatedY;
+ sumSqDist += (dx * dx + dy * dy) * p.weight;
+ }
+ uncertainty = Math.sqrt(sumSqDist / Math.max(totalW, 1e-10));
+ }
+
+ // ---- resampling ----------------------------------------------------------
+
+ private void resampleIfNeeded() {
+ double neff = calculateNeff();
+ if (neff < NEFF_THRESHOLD * numParticles) {
+ residualResample();
+ }
+ }
+
+ private double calculateNeff() {
+ double sumWsq = 0;
+ for (Particle p : particles) {
+ sumWsq += p.weight * p.weight;
+ }
+ return 1.0 / Math.max(sumWsq, 1e-30);
+ }
+
+ /**
+ * Residual resampling: deterministic copies for high-weight particles,
+ * then systematic resampling over residual weights for the rest.
+ * Preserves more diversity than pure multinomial resampling.
+ */
+ private void residualResample() {
+ Particle[] resampled = new Particle[numParticles];
+ int idx = 0;
+
+ int[] copies = new int[numParticles];
+ double[] residuals = new double[numParticles];
+ int deterministicTotal = 0;
+
+ for (int i = 0; i < numParticles; i++) {
+ copies[i] = (int) Math.floor(numParticles * particles[i].weight);
+ residuals[i] = numParticles * particles[i].weight - copies[i];
+ deterministicTotal += copies[i];
+ }
+
+ // Deterministic part
+ for (int i = 0; i < numParticles; i++) {
+ for (int j = 0; j < copies[i] && idx < numParticles; j++) {
+ resampled[idx++] = particles[i].copy();
+ }
+ }
+
+ // Stochastic part over residuals
+ int remaining = numParticles - idx;
+ if (remaining > 0) {
+ double sumRes = 0;
+ for (double r : residuals) sumRes += r;
+
+ if (sumRes > 0) {
+ double[] cdf = new double[numParticles];
+ cdf[0] = residuals[0] / sumRes;
+ for (int i = 1; i < numParticles; i++) {
+ cdf[i] = cdf[i - 1] + residuals[i] / sumRes;
+ }
+
+ double step = 1.0 / remaining;
+ double u = random.nextDouble() * step;
+ int ci = 0;
+ for (int i = 0; i < remaining && idx < numParticles; i++) {
+ while (ci < numParticles - 1 && u > cdf[ci]) ci++;
+ resampled[idx++] = particles[ci].copy();
+ u += step;
+ }
+ }
+ }
+
+ // Edge-case fill
+ while (idx < numParticles) {
+ resampled[idx++] = particles[random.nextInt(numParticles)].copy();
+ }
+
+ // Reset all weights to uniform
+ double w = 1.0 / numParticles;
+ for (Particle p : resampled) {
+ p.weight = w;
+ }
+ particles = resampled;
+ }
+
+ // ---- helpers -------------------------------------------------------------
+
+ private void normalizeWeights() {
+ double total = 0;
+ for (Particle p : particles) total += p.weight;
+ if (total > 1e-30) {
+ for (Particle p : particles) p.weight /= total;
+ } else {
+ double w = 1.0 / numParticles;
+ for (Particle p : particles) p.weight = w;
+ }
+ }
+
+ // ---- accessors -----------------------------------------------------------
+
+ /** Returns the weighted-mean easting estimate in metres (ENU). */
+ public double getEstimatedX() { return estimatedX; }
+
+ /** Returns the weighted-mean northing estimate in metres (ENU). */
+ public double getEstimatedY() { return estimatedY; }
+
+ /** Returns the estimated floor index. */
+ public int getEstimatedFloor() { return estimatedFloor; }
+
+ /** Returns the current position uncertainty (weighted std dev) in metres. */
+ public double getUncertainty() { return uncertainty; }
+
+ /** Returns {@code true} if the filter has been initialised with particles. */
+ public boolean isInitialized() { return initialized; }
+
+ /** Returns the internal particle array. */
+ public Particle[] getParticles() { return particles; }
+
+ /** Returns the total number of particles in the filter. */
+ public int getNumParticles() { return numParticles; }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/BarometerLogger.java b/app/src/main/java/com/openpositioning/PositionMe/utils/BarometerLogger.java
new file mode 100644
index 00000000..40e2bfeb
--- /dev/null
+++ b/app/src/main/java/com/openpositioning/PositionMe/utils/BarometerLogger.java
@@ -0,0 +1,181 @@
+package com.openpositioning.PositionMe.utils;
+
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
+import android.content.Context;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * Standalone barometer data logger for floor calibration.
+ *
+ * Records raw pressure (hPa), calculated altitude (m), and a smoothed
+ * altitude at 1 Hz to a CSV file. The user walks between floors while
+ * this logger runs, then the resulting CSV is analysed to determine
+ * per-floor pressure/altitude ranges.
+ *
+ * Usage:
+ * BarometerLogger logger = new BarometerLogger(context);
+ * logger.start(); // begin recording
+ * // ... user walks between floors ...
+ * logger.stop(); // stop and flush file
+ * String path = logger.getFilePath(); // CSV location
+ */
+public class BarometerLogger implements SensorEventListener {
+
+ private static final String TAG = "BarometerLogger";
+ private static final float ALPHA = 0.15f; // low-pass filter coefficient
+
+ private final SensorManager sensorManager;
+ private final Sensor pressureSensor;
+ private FileWriter writer;
+ private File outputFile;
+
+ private float smoothedPressure = 0;
+ private float startAltitude = Float.NaN;
+ private long startTimeMs;
+ private int sampleCount = 0;
+
+ // Periodic status log
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private final Runnable statusLog = new Runnable() {
+ @Override
+ public void run() {
+ if (writer != null) {
+ float alt = pressureToAltitude(smoothedPressure);
+ float rel = Float.isNaN(startAltitude) ? 0 : alt - startAltitude;
+ if (DEBUG) Log.i(TAG, String.format(
+ "[LIVE] pressure=%.2f hPa altitude=%.2f m relAlt=%.2f m samples=%d",
+ smoothedPressure, alt, rel, sampleCount));
+ handler.postDelayed(this, 5000);
+ }
+ }
+ };
+
+ public BarometerLogger(Context context) {
+ sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
+ pressureSensor = sensorManager.getDefaultSensor(Sensor.TYPE_PRESSURE);
+
+ // Output file in app's external storage
+ String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date());
+ File dir = context.getExternalFilesDir(null);
+ outputFile = new File(dir, "baro_calibration_" + timestamp + ".csv");
+ }
+
+ /** Start recording barometer data to CSV. */
+ public void start() {
+ if (pressureSensor == null) {
+ Log.e(TAG, "No pressure sensor available on this device!");
+ return;
+ }
+
+ try {
+ writer = new FileWriter(outputFile, false);
+ writer.write("elapsed_s,timestamp,raw_pressure_hPa,smoothed_pressure_hPa,altitude_m,relative_altitude_m\n");
+ writer.flush();
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to create output file", e);
+ return;
+ }
+
+ startTimeMs = System.currentTimeMillis();
+ smoothedPressure = 0;
+ startAltitude = Float.NaN;
+ sampleCount = 0;
+
+ // Register at ~1Hz (1,000,000 μs)
+ sensorManager.registerListener(this, pressureSensor, 1_000_000);
+
+ handler.postDelayed(statusLog, 5000);
+
+ if (DEBUG) Log.i(TAG, "=== Barometer logging STARTED → " + outputFile.getAbsolutePath());
+ }
+
+ /** Stop recording and flush the CSV file. */
+ public void stop() {
+ sensorManager.unregisterListener(this);
+ handler.removeCallbacks(statusLog);
+
+ if (writer != null) {
+ try {
+ writer.flush();
+ writer.close();
+ } catch (IOException e) {
+ Log.e(TAG, "Error closing file", e);
+ }
+ writer = null;
+ }
+
+ if (DEBUG) Log.i(TAG, String.format("=== Barometer logging STOPPED. %d samples → %s",
+ sampleCount, outputFile.getAbsolutePath()));
+ }
+
+ /** Returns the absolute path of the output CSV file. */
+ public String getFilePath() {
+ return outputFile.getAbsolutePath();
+ }
+
+ @Override
+ public void onSensorChanged(SensorEvent event) {
+ if (event.sensor.getType() != Sensor.TYPE_PRESSURE) return;
+
+ float rawPressure = event.values[0];
+
+ // Low-pass filter
+ if (smoothedPressure == 0) {
+ smoothedPressure = rawPressure;
+ } else {
+ smoothedPressure = (1 - ALPHA) * smoothedPressure + ALPHA * rawPressure;
+ }
+
+ float altitude = pressureToAltitude(smoothedPressure);
+
+ // Set start altitude from first reading
+ if (Float.isNaN(startAltitude)) {
+ startAltitude = altitude;
+ }
+
+ float relAltitude = altitude - startAltitude;
+ float elapsedS = (System.currentTimeMillis() - startTimeMs) / 1000f;
+ sampleCount++;
+
+ // Write to CSV
+ if (writer != null) {
+ try {
+ writer.write(String.format(Locale.US, "%.1f,%d,%.3f,%.3f,%.3f,%.3f\n",
+ elapsedS,
+ System.currentTimeMillis(),
+ rawPressure,
+ smoothedPressure,
+ altitude,
+ relAltitude));
+ // Flush every 10 samples to avoid data loss
+ if (sampleCount % 10 == 0) {
+ writer.flush();
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Write error", e);
+ }
+ }
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {}
+
+ /** Convert pressure (hPa) to altitude (m) using standard atmosphere. */
+ private static float pressureToAltitude(float pressure) {
+ return SensorManager.getAltitude(SensorManager.PRESSURE_STANDARD_ATMOSPHERE, pressure);
+ }
+}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java b/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java
index f8058603..d95707ac 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java
@@ -1,5 +1,7 @@
package com.openpositioning.PositionMe.utils;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.graphics.Color;
import android.util.Log;
@@ -49,11 +51,18 @@ public class IndoorMapManager {
// Per-floor vector shape data for the current building
private List currentFloorShapes;
- // Average floor heights per building (meters), used for barometric auto-floor
+ // Physical floor heights per building (meters), used for display
public static final float NUCLEUS_FLOOR_HEIGHT = 4.2F;
public static final float LIBRARY_FLOOR_HEIGHT = 3.6F;
public static final float MURCHISON_FLOOR_HEIGHT = 4.0F;
+ // Barometric floor heights per building (meters) — the effective altitude
+ // change the barometer sees per floor. Close to the physical height but
+ // slightly lower to add a small sensitivity margin.
+ private static final float NUCLEUS_BARO_FLOOR_HEIGHT = 5.0F;
+ private static final float LIBRARY_BARO_FLOOR_HEIGHT = 3.5F;
+ private static final float MURCHISON_BARO_FLOOR_HEIGHT = 4.0F;
+
// Colours for different indoor feature types
private static final int WALL_STROKE = Color.argb(200, 80, 80, 80);
private static final int ROOM_STROKE = Color.argb(180, 33, 150, 243);
@@ -98,6 +107,65 @@ public boolean getIsIndoorMapSet() {
return isIndoorMapSet;
}
+ /**
+ * Checks if the given position is inside a stairs or lift polygon on the current floor.
+ *
+ * @param position the LatLng to test
+ * @return true if inside a stairs/lift area
+ */
+ public boolean isNearStairs(LatLng position) {
+ if (!isIndoorMapSet || currentFloorShapes == null || position == null) return false;
+ if (currentFloor < 0 || currentFloor >= currentFloorShapes.size()) return false;
+
+ FloorplanApiClient.FloorShapes floor = currentFloorShapes.get(currentFloor);
+ for (FloorplanApiClient.MapShapeFeature feature : floor.getFeatures()) {
+ String type = feature.getIndoorType();
+ if ("stairs".equals(type)) {
+ for (List ring : feature.getParts()) {
+ if (ring.size() >= 3 && BuildingPolygon.pointInPolygon(position, ring)) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks if the given position is within a specified distance of any
+ * stairs or lift polygon on the current floor.
+ *
+ * @param position the LatLng to test
+ * @param radiusM proximity radius in metres
+ * @return true if within radius of a stairs or lift area
+ */
+ public boolean isNearStairsOrLift(LatLng position, double radiusM) {
+ if (!isIndoorMapSet || currentFloorShapes == null || position == null) return false;
+ if (currentFloor < 0 || currentFloor >= currentFloorShapes.size()) return false;
+
+ FloorplanApiClient.FloorShapes floor = currentFloorShapes.get(currentFloor);
+ for (FloorplanApiClient.MapShapeFeature feature : floor.getFeatures()) {
+ String type = feature.getIndoorType();
+ if ("stairs".equals(type) || "lift".equals(type)) {
+ for (List ring : feature.getParts()) {
+ if (ring.size() < 3) continue;
+ // Check if inside polygon
+ if (BuildingPolygon.pointInPolygon(position, ring)) return true;
+ // Check proximity: distance to centroid of polygon
+ double cLat = 0, cLng = 0;
+ for (LatLng p : ring) { cLat += p.latitude; cLng += p.longitude; }
+ cLat /= ring.size();
+ cLng /= ring.size();
+ double dLat = (position.latitude - cLat) * 111132.92;
+ double dLng = (position.longitude - cLng) * 111132.92
+ * Math.cos(Math.toRadians(cLat));
+ if (Math.hypot(dLat, dLng) <= radiusM) return true;
+ }
+ }
+ }
+ return false;
+ }
+
/**
* Returns the identifier of the building the user is currently in.
*
@@ -108,6 +176,20 @@ public int getCurrentBuilding() {
return currentBuilding;
}
+ /**
+ * Returns a human-readable name for the current building.
+ *
+ * @return building name string, or "Outdoor" if not inside a known building
+ */
+ public String getCurrentBuildingName() {
+ switch (currentBuilding) {
+ case BUILDING_NUCLEUS: return "Nucleus";
+ case BUILDING_LIBRARY: return "Library";
+ case BUILDING_MURCHISON: return "Murchison House";
+ default: return "Outdoor";
+ }
+ }
+
/**
* Returns the current floor index being displayed.
*
@@ -189,13 +271,73 @@ public void decreaseFloor() {
}
/**
- * Sets the map overlay for the building if the user's current location is
- * inside a building and the overlay is not already set. Removes the overlay
- * if the user leaves all buildings.
+ * Forces indoor mode for a known building, bypassing pointInPolygon detection.
+ * Used by Indoor Positioning mode where we already know the user is in a building.
*
- * Detection priority: floorplan API real polygon outlines first,
- * then legacy hard-coded rectangular boundaries as fallback.
+ * @param apiName building API name (e.g. "nucleus_building")
+ * @return true if the building was set up successfully
*/
+ public boolean forceSetBuilding(String apiName) {
+ if (isIndoorMapSet) return true;
+
+ int buildingType = resolveBuildingType(apiName);
+ if (buildingType == BUILDING_NONE) {
+ if (DEBUG) Log.w(TAG, "[forceSetBuilding] Unknown building: " + apiName);
+ return false;
+ }
+
+ currentBuilding = buildingType;
+ int floorBias;
+ float baroFloorH;
+ switch (buildingType) {
+ case BUILDING_NUCLEUS:
+ floorBias = 1;
+ floorHeight = NUCLEUS_FLOOR_HEIGHT;
+ baroFloorH = NUCLEUS_BARO_FLOOR_HEIGHT;
+ break;
+ case BUILDING_LIBRARY:
+ floorBias = 0;
+ floorHeight = LIBRARY_FLOOR_HEIGHT;
+ baroFloorH = LIBRARY_BARO_FLOOR_HEIGHT;
+ break;
+ case BUILDING_MURCHISON:
+ floorBias = 1;
+ floorHeight = MURCHISON_FLOOR_HEIGHT;
+ baroFloorH = MURCHISON_BARO_FLOOR_HEIGHT;
+ break;
+ default:
+ return false;
+ }
+
+ SensorFusion.getInstance().setBaroFloorHeight(baroFloorH);
+ if (DEBUG) Log.i(TAG, "[forceSetBuilding] Building=" + apiName
+ + " baroFloorHeight=" + baroFloorH + "m");
+
+ int wifiFloor = SensorFusion.getInstance().getWifiFloor();
+ if (wifiFloor >= 0) {
+ SensorFusion.getInstance().setInitialFloor(wifiFloor);
+ currentFloor = wifiFloor + floorBias;
+ } else {
+ currentFloor = floorBias;
+ }
+
+ FloorplanApiClient.BuildingInfo building =
+ SensorFusion.getInstance().getFloorplanBuilding(apiName);
+ if (building != null) {
+ currentFloorShapes = building.getFloorShapesList();
+ }
+
+ if (currentFloorShapes != null && !currentFloorShapes.isEmpty()) {
+ drawFloorShapes(currentFloor);
+ isIndoorMapSet = true;
+ if (DEBUG) Log.i(TAG, "[forceSetBuilding] Success, floor=" + currentFloor);
+ return true;
+ }
+
+ if (DEBUG) Log.w(TAG, "[forceSetBuilding] No floor shapes for " + apiName);
+ return false;
+ }
+
private void setBuildingOverlay() {
try {
int detected = detectCurrentBuilding();
@@ -205,26 +347,54 @@ private void setBuildingOverlay() {
currentBuilding = detected;
String apiName;
+ // Floor bias: index offset so that floor 0 in the list maps to the correct physical floor
+ int floorBias;
+ float baroFloorH;
switch (detected) {
case BUILDING_NUCLEUS:
apiName = "nucleus_building";
- currentFloor = 1;
+ floorBias = 1; // LG=0, G=1, ...
floorHeight = NUCLEUS_FLOOR_HEIGHT;
+ baroFloorH = NUCLEUS_BARO_FLOOR_HEIGHT;
break;
case BUILDING_LIBRARY:
apiName = "library";
- currentFloor = 0;
+ floorBias = 0;
floorHeight = LIBRARY_FLOOR_HEIGHT;
+ baroFloorH = LIBRARY_BARO_FLOOR_HEIGHT;
break;
case BUILDING_MURCHISON:
apiName = "murchison_house";
- currentFloor = 1;
+ floorBias = 1;
floorHeight = MURCHISON_FLOOR_HEIGHT;
+ baroFloorH = MURCHISON_BARO_FLOOR_HEIGHT;
break;
default:
return;
}
+ // Tell PdrProcessing to use building-specific barometric floor height
+ SensorFusion.getInstance().setBaroFloorHeight(baroFloorH);
+ if (DEBUG) Log.i(TAG, "[BaroFloorHeight] Building=" + apiName
+ + " baroFloorHeight=" + baroFloorH + "m");
+
+ // Seed initial floor from WiFi positioning API.
+ // WiFi floor is a logical floor (0=GF, 1=F1, 2=F2, …).
+ // floorBias converts to the building's array index.
+ int wifiFloor = SensorFusion.getInstance().getWifiFloor();
+ if (wifiFloor >= 0) {
+ SensorFusion.getInstance().setInitialFloor(wifiFloor);
+ currentFloor = wifiFloor + floorBias;
+ if (DEBUG) Log.i(TAG, String.format(
+ "[WifiFloorInit] wifiFloor=%d → initialFloor=%d (idx=%d, bias=%d)",
+ wifiFloor, wifiFloor, currentFloor, floorBias));
+ } else {
+ currentFloor = floorBias; // GF default
+ if (DEBUG) Log.w(TAG, String.format(
+ "[FloorInit] WiFi floor unavailable (%d) → fallback GF idx=%d",
+ wifiFloor, currentFloor));
+ }
+
// Load floor shapes from cached API data
FloorplanApiClient.BuildingInfo building =
SensorFusion.getInstance().getFloorplanBuilding(apiName);
@@ -233,6 +403,16 @@ private void setBuildingOverlay() {
}
if (currentFloorShapes != null && !currentFloorShapes.isEmpty()) {
+ if (DEBUG) {
+ StringBuilder floorMap = new StringBuilder("[FloorMap] ");
+ for (int i = 0; i < currentFloorShapes.size(); i++) {
+ floorMap.append(String.format("idx%d=%s ", i,
+ currentFloorShapes.get(i).getDisplayName()));
+ }
+ floorMap.append("| current=").append(currentFloor);
+ Log.i(TAG, floorMap.toString());
+ }
+
drawFloorShapes(currentFloor);
isIndoorMapSet = true;
}
@@ -262,6 +442,15 @@ private void drawFloorShapes(int floorIndex) {
|| floorIndex >= currentFloorShapes.size()) return;
FloorplanApiClient.FloorShapes floor = currentFloorShapes.get(floorIndex);
+ if (DEBUG) {
+ java.util.Set seenTypes = new java.util.LinkedHashSet<>();
+ for (FloorplanApiClient.MapShapeFeature f : floor.getFeatures()) {
+ seenTypes.add(f.getIndoorType() + "(" + f.getGeometryType() + ")");
+ }
+ Log.i(TAG, "[FloorShapeTypes] floor=" + floor.getDisplayName()
+ + " types=" + seenTypes);
+ }
+
for (FloorplanApiClient.MapShapeFeature feature : floor.getFeatures()) {
String geoType = feature.getGeometryType();
String indoorType = feature.getIndoorType();
@@ -334,19 +523,30 @@ private int detectCurrentBuilding() {
// Phase 1: API real polygon outlines
List apiBuildings =
SensorFusion.getInstance().getFloorplanBuildings();
+ if (DEBUG) Log.d(TAG, "[detectBuilding] loc=" + currentLocation
+ + " apiBuildings=" + apiBuildings.size());
for (FloorplanApiClient.BuildingInfo building : apiBuildings) {
List outline = building.getOutlinePolygon();
- if (outline != null && outline.size() >= 3
- && BuildingPolygon.pointInPolygon(currentLocation, outline)) {
+ boolean inPoly = outline != null && outline.size() >= 3
+ && BuildingPolygon.pointInPolygon(currentLocation, outline);
+ if (DEBUG) Log.d(TAG, "[detectBuilding] API name=" + building.getName()
+ + " outlineSize=" + (outline != null ? outline.size() : 0)
+ + " inPolygon=" + inPoly);
+ if (inPoly) {
int type = resolveBuildingType(building.getName());
if (type != BUILDING_NONE) return type;
}
}
// Phase 2: legacy hard-coded fallback
- if (BuildingPolygon.inNucleus(currentLocation)) return BUILDING_NUCLEUS;
- if (BuildingPolygon.inLibrary(currentLocation)) return BUILDING_LIBRARY;
- if (BuildingPolygon.inMurchison(currentLocation)) return BUILDING_MURCHISON;
+ boolean inNuc = BuildingPolygon.inNucleus(currentLocation);
+ boolean inLib = BuildingPolygon.inLibrary(currentLocation);
+ boolean inMur = BuildingPolygon.inMurchison(currentLocation);
+ if (DEBUG) Log.d(TAG, "[detectBuilding] legacy: nucleus=" + inNuc
+ + " library=" + inLib + " murchison=" + inMur);
+ if (inNuc) return BUILDING_NUCLEUS;
+ if (inLib) return BUILDING_LIBRARY;
+ if (inMur) return BUILDING_MURCHISON;
return BUILDING_NONE;
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java b/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java
index 9765b044..b92b5c19 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java
@@ -1,8 +1,11 @@
package com.openpositioning.PositionMe.utils;
+import static com.openpositioning.PositionMe.BuildConstants.DEBUG;
+
import android.content.Context;
import android.content.SharedPreferences;
import android.hardware.SensorManager;
+import android.util.Log;
import androidx.preference.PreferenceManager;
@@ -29,8 +32,10 @@ public class PdrProcessing {
//region Static variables
// Weiberg algorithm coefficient for stride calculations
private static final float K = 0.364f;
+ /** Fixed step length override (metres). Set to 0 to use Weiberg estimation. */
+ private static final float FIXED_STEP_LENGTH = 0.8f;
// Number of samples (seconds) to keep as memory for elevation calculation
- private static final int elevationSeconds = 4;
+ private static final int elevationSeconds = 6;
// Number of samples (0.01 seconds)
private static final int accelSamples = 100;
// Threshold used to detect significant movement
@@ -38,6 +43,13 @@ public class PdrProcessing {
// Threshold under which movement is considered non-existent
private static final float epsilon = 0.18f;
private static final int MIN_REQUIRED_SAMPLES = 2;
+ /** Hysteresis for floor detection (fraction of floorHeight).
+ * Walking/stairs: higher threshold resists baro drift from movement.
+ * Elevator (stationary): lower threshold, baro signal is clean. */
+ private static final float WALK_HYSTERESIS = 0.7f;
+ private static final float ELEVATOR_HYSTERESIS = 0.5f;
+ /** If no step for this many ms, assume elevator (stationary). */
+ private static final long ELEVATOR_IDLE_MS = 3000;
//endregion
//region Instance variables
@@ -58,8 +70,10 @@ public class PdrProcessing {
private float startElevation;
private int setupIndex = 0;
private float elevation;
- private int floorHeight;
+ private float floorHeight;
private int currentFloor;
+ private float lastSmoothedAlt; // latest smoothed altitude for baseline resets
+ private long lastStepTimeMs; // timestamp of last detected step (for elevator detection)
// Buffer of most recent elevations calculated
private CircularFloatBuffer elevationList;
@@ -68,6 +82,9 @@ public class PdrProcessing {
private CircularFloatBuffer verticalAccel;
private CircularFloatBuffer horizontalAccel;
+ // Initial floor offset (set externally when starting floor is known)
+ private int initialFloorOffset = 0;
+
// Step sum and length aggregation variables
private float sumStepLength = 0;
private int stepCount = 0;
@@ -155,16 +172,16 @@ public float[] updatePdr(long currentStepEnd, List accelMagnitudeOvertim
}
// Calculate step length
- if(!useManualStep) {
- //ArrayList accelMagnitudeFiltered = filter(accelMagnitudeOvertime);
- // Estimate stride
+ if (FIXED_STEP_LENGTH > 0) {
+ this.stepLength = FIXED_STEP_LENGTH;
+ } else if(!useManualStep) {
this.stepLength = weibergMinMax(accelMagnitudeOvertime);
- // System.err.println("Step Length" + stepLength);
}
// Increment aggregate variables
sumStepLength += stepLength;
stepCount++;
+ lastStepTimeMs = System.currentTimeMillis();
// Translate to cartesian coordinate system
float x = (float) (stepLength * Math.cos(adaptedHeading));
@@ -205,18 +222,44 @@ public float updateElevation(float absoluteElevation) {
// Add to buffer
this.elevationList.putNewest(absoluteElevation);
- // Check if there was floor movement
- // Check if there is enough data to evaluate
+ // Floor detection with hysteresis:
+ // Once on a floor, require 0.7×floorHeight deviation to trigger
+ // a change (instead of 0.5× from Math.round). This widens the
+ // drift tolerance from 1.8m to 2.6m per floor.
if(this.elevationList.isFull()) {
- // Check average of elevation array
List elevationMemory = this.elevationList.getListCopy();
OptionalDouble currentAvg = elevationMemory.stream().mapToDouble(f -> f).average();
- float finishAvg = currentAvg.isPresent() ? (float) currentAvg.getAsDouble() : 0;
-
- // Check if we moved floor by comparing with start position
- if(Math.abs(finishAvg - startElevation) > this.floorHeight) {
- // Change floors - 'floor' division
- this.currentFloor += (finishAvg - startElevation)/this.floorHeight;
+ float smoothedAlt = currentAvg.isPresent() ? (float) currentAvg.getAsDouble() : startElevation;
+ this.lastSmoothedAlt = smoothedAlt;
+ float relHeight = smoothedAlt - startElevation;
+ float fracFloors = relHeight / this.floorHeight;
+ int currentDelta = this.currentFloor - this.initialFloorOffset;
+ // Elevator vs walk detection: no step for 3s = elevator (clean baro)
+ boolean isElevator = (System.currentTimeMillis() - lastStepTimeMs) > ELEVATOR_IDLE_MS;
+ float hyst = isElevator ? ELEVATOR_HYSTERESIS : WALK_HYSTERESIS;
+ // Hysteresis: need ±hyst floor beyond current floor center
+ int deltaFloors;
+ if (fracFloors > currentDelta + hyst) {
+ deltaFloors = currentDelta + 1;
+ } else if (fracFloors < currentDelta - hyst) {
+ deltaFloors = currentDelta - 1;
+ } else {
+ deltaFloors = currentDelta; // stay on current floor
+ }
+ int targetFloor = initialFloorOffset + deltaFloors;
+ // Clamp to ±1 floor per update as additional safety net
+ int prevFloor = this.currentFloor;
+ if (targetFloor > prevFloor + 1) {
+ targetFloor = prevFloor + 1;
+ } else if (targetFloor < prevFloor - 1) {
+ targetFloor = prevFloor - 1;
+ }
+ this.currentFloor = targetFloor;
+ if (this.currentFloor != prevFloor) {
+ if (DEBUG) Log.i("PDR", String.format(
+ "[BaroCalc] relHeight=%.2fm frac=%.2f hyst=%.1f(%s) offset=%d => floor %d→%d",
+ relHeight, fracFloors, hyst, isElevator ? "ELEV" : "WALK",
+ initialFloorOffset, prevFloor, this.currentFloor));
}
}
// Return current elevation
@@ -398,19 +441,88 @@ public void resetPDR() {
}
/**
- * Getter for the average step length calculated from the aggregated distance and step count.
+ * Returns the most recently computed step length (metres).
+ * Used by the fusion module to feed the particle filter prediction step.
+ */
+ public float getLastStepLength() {
+ return this.stepLength;
+ }
+
+ /**
+ * Sets the initial floor offset. Call before recording starts when the
+ * starting floor is known (e.g. from WiFi, calibration DB, or user selection).
+ */
+ public void setInitialFloor(int floor) {
+ this.initialFloorOffset = floor;
+ this.currentFloor = floor;
+ }
+
+ /**
+ * Reseeds the floor to a known value (e.g. from WiFi API) during recording.
+ * Unlike {@link #setInitialFloor} + {@link #resetBaroBaseline}, this method
+ * uses the CURRENT smoothed baro altitude as the new baseline, avoiding the
+ * ordering bug where resetBaroBaseline sees floorsChanged=0.
*
- * @return average step length in meters.
+ * @param floor the logical floor number to reseed to (0=GF, 1=F1, …)
+ */
+ public void reseedFloor(int floor) {
+ float oldStart = this.startElevation;
+ int oldOffset = this.initialFloorOffset;
+ // Use the latest smoothed baro altitude as new baseline so relHeight ≈ 0
+ float baseAlt = this.lastSmoothedAlt > 0 ? this.lastSmoothedAlt : this.startElevation;
+ this.startElevation = baseAlt;
+ this.initialFloorOffset = floor;
+ this.currentFloor = floor;
+ if (DEBUG) Log.i("PDR", String.format(
+ "[FloorReseed] floor=%d startElev %.2f→%.2f offset %d→%d",
+ floor, oldStart, this.startElevation, oldOffset, floor));
+ }
+
+ /**
+ * Overrides the barometric floor height used for floor detection.
+ * Call when the building is identified, since HVAC systems make the
+ * actual barometric altitude difference per floor much smaller than
+ * the physical floor-to-floor height.
+ *
+ * @param height barometric floor height in metres (e.g. 2.0)
+ */
+ public void setBaroFloorHeight(float height) {
+ if (height > 0) {
+ this.floorHeight = height;
+ }
+ }
+
+ /**
+ * Resets the barometric baseline after a confirmed floor change.
+ * Uses THEORETICAL altitude (floors × floorHeight) instead of the
+ * measured smoothed altitude, because the 6-second average lags behind
+ * during transitions and would cause incorrect relHeight after reset.
+ *
+ * @param confirmedFloor the confirmed floor number after transition
+ */
+ public void resetBaroBaseline(int confirmedFloor) {
+ float oldStart = this.startElevation;
+ int oldOffset = this.initialFloorOffset;
+ // Shift baseline by the number of confirmed floors × floorHeight
+ int floorsChanged = confirmedFloor - oldOffset;
+ this.startElevation += floorsChanged * this.floorHeight;
+ this.initialFloorOffset = confirmedFloor;
+ this.currentFloor = confirmedFloor;
+ if (DEBUG) Log.i("PDR", String.format(
+ "[BaroReset] baseline %.2f→%.2f (%.1fm × %d floors) offset %d→%d",
+ oldStart, this.startElevation, this.floorHeight, floorsChanged,
+ oldOffset, confirmedFloor));
+ }
+
+ /**
+ * Returns the average step length and resets the accumulator.
+ *
+ * @return average step length in metres
*/
public float getAverageStepLength(){
- //Calculate average step length
float averageStepLength = sumStepLength/(float) stepCount;
-
- //Reset sum and number of steps
stepCount = 0;
sumStepLength = 0;
-
- //Return average step length
return averageStepLength;
}
diff --git a/app/src/main/res/drawable/ic_chevron_left.xml b/app/src/main/res/drawable/ic_chevron_left.xml
new file mode 100644
index 00000000..384133b2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_chevron_left.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml
new file mode 100644
index 00000000..4e77217f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_chevron_right.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/toggle_tab_bg.xml b/app/src/main/res/drawable/toggle_tab_bg.xml
new file mode 100644
index 00000000..d67eace3
--- /dev/null
+++ b/app/src/main/res/drawable/toggle_tab_bg.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout-small/fragment_home.xml b/app/src/main/res/layout-small/fragment_home.xml
index bd713b67..9f6a2307 100644
--- a/app/src/main/res/layout-small/fragment_home.xml
+++ b/app/src/main/res/layout-small/fragment_home.xml
@@ -155,16 +155,16 @@
android:textColor="@color/md_theme_onSecondary" />
-
+
+
+
+
-
+
+ android:layout_height="match_parent"
+ android:paddingTop="20dp">
-
+
+ android:layout_marginEnd="4dp" />
@@ -53,10 +55,10 @@
android:layout_height="wrap_content"
android:text="@string/elevation"
android:textColor="@color/md_theme_secondary"
- android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
+ android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
android:textStyle="bold"
- android:layout_marginStart="16dp"
- android:layout_marginEnd="16dp" />
+ android:layout_marginStart="8dp"
+ android:layout_marginEnd="8dp" />
@@ -66,12 +68,34 @@
android:layout_height="wrap_content"
android:text="@string/gnss_error"
android:textColor="@color/md_theme_error"
- android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
- android:layout_marginStart="16dp" />
+ android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
+ android:layout_marginStart="4dp" />
+
+
+
+
+
+
+
+
+
-
+
-
+
+
+
+
+
+
+
+ app:layout_constraintStart_toStartOf="parent" />
+
+
+
+
+
+
-
-
+
-
-
+ android:textSize="13sp"
+ android:textAlignment="center"
+ android:textColor="@android:color/white"
+ app:backgroundTint="#FFFF6347"
+ app:cornerRadius="12dp" />
+
+
+
+
+
+ android:textSize="13sp"
+ android:textAlignment="center"
+ android:textColor="@android:color/white"
+ app:backgroundTint="#FF3EB489"
+ app:cornerRadius="12dp" />
diff --git a/app/src/main/res/layout/fragment_trajectory_map.xml b/app/src/main/res/layout/fragment_trajectory_map.xml
index a72425bf..c324907d 100644
--- a/app/src/main/res/layout/fragment_trajectory_map.xml
+++ b/app/src/main/res/layout/fragment_trajectory_map.xml
@@ -12,59 +12,195 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
-
-
+
+
+
-
+ android:alpha="0.85"
+ android:padding="18dp"
+ app:cardBackgroundColor="@color/md_theme_surfaceContainer"
+ app:cardElevation="4dp"
+ app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.MediumComponent">
-
+ android:orientation="vertical"
+ android:padding="8dp"
+ android:minWidth="200dp">
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ app:layout_constraintStart_toStartOf="@id/floorDownButton"
+ app:layout_constraintEnd_toEndOf="@id/floorDownButton" />
+ app:layout_constraintStart_toStartOf="@id/floorDownButton"
+ app:layout_constraintEnd_toEndOf="@id/floorDownButton" />
Default building assumptions
Floor height in meters
- Color
+ Print Location
+ Location copied to clipboard
+ Calibrating: %1$ds remaining — Please do not move\nPlease choose the map style above
+ Calibrating: %1$ds remaining\nPlease do not move
🛰 Show GNSS
+ 📡 Show WiFi
Floor Down button
Floor Up button
Choose Map
+ 🧭 Show Compass
+ 🔴 PDR Path
+ 🟣 Fused Path
+ 🟢 Smooth Path
+ 🔮 Show Estimated
+ 🌤 Show Weather
❇️ Auto Floor
+ 🧿 Navigating
GNSS error:
+ WiFi error:
Satellite
Normal
Hybrid
@@ -150,4 +162,4 @@
Upload Anyway
Cancel
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 528421a1..0bbf2a17 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -8,7 +8,7 @@
- @color/md_theme_light_secondary
- @color/md_theme_light_onSecondary
- @color/md_theme_light_background
- - @color/md_theme_light_primary
+ - @android:color/transparent
- @color/md_theme_light_surface
- @color/md_theme_light_surface
- true
diff --git a/app/src/test/java/com/openpositioning/PositionMe/BuildConstantsTest.java b/app/src/test/java/com/openpositioning/PositionMe/BuildConstantsTest.java
new file mode 100644
index 00000000..9d39d8bd
--- /dev/null
+++ b/app/src/test/java/com/openpositioning/PositionMe/BuildConstantsTest.java
@@ -0,0 +1,15 @@
+package com.openpositioning.PositionMe;
+
+import static org.junit.Assert.*;
+import org.junit.Test;
+
+/**
+ * Verifies {@link BuildConstants} configuration for release.
+ */
+public class BuildConstantsTest {
+
+ @Test
+ public void debug_isDisabledForRelease() {
+ assertFalse("DEBUG must be false for release builds", BuildConstants.DEBUG);
+ }
+}
diff --git a/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/CalibrationManagerTest.java b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/CalibrationManagerTest.java
new file mode 100644
index 00000000..f49c47c0
--- /dev/null
+++ b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/CalibrationManagerTest.java
@@ -0,0 +1,75 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+import static org.junit.Assert.*;
+import org.junit.Test;
+
+import com.openpositioning.PositionMe.sensors.Wifi;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Unit tests for {@link CalibrationManager}.
+ * Verifies WKNN matching boundary conditions and null safety.
+ *
+ * Note: Tests that require {@code LatLng} (Google Maps) are excluded
+ * from local JVM tests to avoid Android stub exceptions.
+ */
+public class CalibrationManagerTest {
+
+ @Test
+ public void getRecordCount_emptyByDefault() {
+ CalibrationManager cm = new CalibrationManager();
+ assertEquals(0, cm.getRecordCount());
+ }
+
+ @Test
+ public void findBestMatch_emptyRecords_returnsNull() {
+ CalibrationManager cm = new CalibrationManager();
+ List scan = createMockScan(5);
+ assertNull(cm.findBestMatch(scan));
+ }
+
+ @Test
+ public void findBestMatch_nullScan_returnsNull() {
+ CalibrationManager cm = new CalibrationManager();
+ assertNull(cm.findBestMatch(null));
+ }
+
+ @Test
+ public void findBestMatch_emptyScan_returnsNull() {
+ CalibrationManager cm = new CalibrationManager();
+ assertNull(cm.findBestMatch(new ArrayList<>()));
+ }
+
+ @Test
+ public void findCalibrationPosition_emptyRecords_returnsNull() {
+ CalibrationManager cm = new CalibrationManager();
+ List scan = createMockScan(5);
+ assertNull(cm.findCalibrationPosition(scan, 0));
+ }
+
+ @Test
+ public void findCalibrationPosition_nullScan_returnsNull() {
+ CalibrationManager cm = new CalibrationManager();
+ assertNull(cm.findCalibrationPosition(null, 0));
+ }
+
+ /**
+ * Creates a mock WiFi scan with the specified number of APs.
+ */
+ private List createMockScan(int apCount) {
+ List scan = new ArrayList<>();
+ for (int i = 0; i < apCount; i++) {
+ Wifi w = new Wifi();
+ w.setBssid(i + 1);
+ w.setBssidString(String.format("00:11:22:33:44:%02x", i));
+ w.setSsid("TestAP_" + i);
+ w.setLevel(-50 - i);
+ w.setFrequency(2400 + i * 5);
+ w.setRttEnabled(false);
+ scan.add(w);
+ }
+ return scan;
+ }
+}
diff --git a/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/CoordinateTransformTest.java b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/CoordinateTransformTest.java
new file mode 100644
index 00000000..d14ad30c
--- /dev/null
+++ b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/CoordinateTransformTest.java
@@ -0,0 +1,80 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+import static org.junit.Assert.*;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link CoordinateTransform}.
+ * Verifies ENU ↔ LatLng conversions are accurate and invertible.
+ */
+public class CoordinateTransformTest {
+
+ private CoordinateTransform ct;
+ private static final double ORIGIN_LAT = 55.9230;
+ private static final double ORIGIN_LNG = -3.1742;
+
+ @Before
+ public void setUp() {
+ ct = new CoordinateTransform();
+ ct.setOrigin(ORIGIN_LAT, ORIGIN_LNG);
+ }
+
+ @Test
+ public void isInitialized_afterSetOrigin_returnsTrue() {
+ assertTrue(ct.isInitialized());
+ }
+
+ @Test
+ public void isInitialized_beforeSetOrigin_returnsFalse() {
+ CoordinateTransform fresh = new CoordinateTransform();
+ assertFalse(fresh.isInitialized());
+ }
+
+ @Test
+ public void originMapsToZeroZero() {
+ double[] en = ct.toEastNorth(ORIGIN_LAT, ORIGIN_LNG);
+ assertEquals(0.0, en[0], 0.01);
+ assertEquals(0.0, en[1], 0.01);
+ }
+
+ @Test
+ public void roundTrip_originReturnsOrigin() {
+ double[] ll = ct.toLatLng(0, 0);
+ assertEquals(ORIGIN_LAT, ll[0], 1e-6);
+ assertEquals(ORIGIN_LNG, ll[1], 1e-6);
+ }
+
+ @Test
+ public void roundTrip_arbitraryPoint() {
+ double testLat = 55.9235;
+ double testLng = -3.1750;
+ double[] en = ct.toEastNorth(testLat, testLng);
+ double[] ll = ct.toLatLng(en[0], en[1]);
+ assertEquals(testLat, ll[0], 1e-5);
+ assertEquals(testLng, ll[1], 1e-5);
+ }
+
+ @Test
+ public void fiveMetresNorth_givesPositiveNorthing() {
+ double offsetLat = ORIGIN_LAT + 5.0 / 111132.92;
+ double[] en = ct.toEastNorth(offsetLat, ORIGIN_LNG);
+ assertEquals(0.0, en[0], 0.5); // easting ~0
+ assertEquals(5.0, en[1], 0.5); // northing ~5m
+ }
+
+ @Test
+ public void fiveMetresEast_givesPositiveEasting() {
+ double metersPerDegreeLng = 111132.92 * Math.cos(Math.toRadians(ORIGIN_LAT));
+ double offsetLng = ORIGIN_LNG + 5.0 / metersPerDegreeLng;
+ double[] en = ct.toEastNorth(ORIGIN_LAT, offsetLng);
+ assertEquals(5.0, en[0], 0.5); // easting ~5m
+ assertEquals(0.0, en[1], 0.5); // northing ~0
+ }
+
+ @Test
+ public void getOrigin_returnsSetValues() {
+ assertEquals(ORIGIN_LAT, ct.getOriginLat(), 1e-10);
+ assertEquals(ORIGIN_LNG, ct.getOriginLng(), 1e-10);
+ }
+}
diff --git a/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/MapConstraintTest.java b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/MapConstraintTest.java
new file mode 100644
index 00000000..836c84cf
--- /dev/null
+++ b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/MapConstraintTest.java
@@ -0,0 +1,47 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+import static org.junit.Assert.*;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link MapConstraint}.
+ * Verifies wall segment AABB computation and initialization state.
+ */
+public class MapConstraintTest {
+
+ @Test
+ public void isInitialized_beforeInit_returnsFalse() {
+ MapConstraint mc = new MapConstraint();
+ assertFalse(mc.isInitialized());
+ }
+
+ @Test
+ public void wallSegment_storesEndpoints() {
+ MapConstraint.WallSegment wall = new MapConstraint.WallSegment(1, 2, 3, 4);
+ assertEquals(1, wall.x1, 1e-10);
+ assertEquals(2, wall.y1, 1e-10);
+ assertEquals(3, wall.x2, 1e-10);
+ assertEquals(4, wall.y2, 1e-10);
+ }
+
+ @Test
+ public void wallSegment_computesMinMax() {
+ MapConstraint.WallSegment wall = new MapConstraint.WallSegment(8, 3, 2, 9);
+ assertEquals(2, wall.minX, 1e-10);
+ assertEquals(8, wall.maxX, 1e-10);
+ assertEquals(3, wall.minY, 1e-10);
+ assertEquals(9, wall.maxY, 1e-10);
+ }
+
+ @Test
+ public void wallSegment_horizontalHasEqualY() {
+ MapConstraint.WallSegment wall = new MapConstraint.WallSegment(0, 5, 10, 5);
+ assertEquals(wall.minY, wall.maxY, 1e-10);
+ }
+
+ @Test
+ public void wallSegment_verticalHasEqualX() {
+ MapConstraint.WallSegment wall = new MapConstraint.WallSegment(5, 0, 5, 10);
+ assertEquals(wall.minX, wall.maxX, 1e-10);
+ }
+}
diff --git a/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/ParticleFilterTest.java b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/ParticleFilterTest.java
new file mode 100644
index 00000000..ed9d4393
--- /dev/null
+++ b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/ParticleFilterTest.java
@@ -0,0 +1,107 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+import static org.junit.Assert.*;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link ParticleFilter}.
+ * Verifies initialization, prediction, observation update, and state queries.
+ */
+public class ParticleFilterTest {
+
+ private ParticleFilter pf;
+ private static final int NUM_PARTICLES = 500;
+
+ @Before
+ public void setUp() {
+ pf = new ParticleFilter(NUM_PARTICLES);
+ }
+
+ @Test
+ public void initialize_setsPositionNearOrigin() {
+ pf.initialize(10.0, 20.0, 0, 5.0);
+ assertEquals(10.0, pf.getEstimatedX(), 3.0);
+ assertEquals(20.0, pf.getEstimatedY(), 3.0);
+ }
+
+ @Test
+ public void initialize_setsFloor() {
+ pf.initialize(0, 0, 2, 5.0);
+ Particle[] particles = pf.getParticles();
+ for (Particle p : particles) {
+ assertEquals(2, p.floor);
+ }
+ }
+
+ @Test
+ public void isInitialized_afterInit_returnsTrue() {
+ pf.initialize(0, 0, 0, 5.0);
+ assertTrue(pf.isInitialized());
+ }
+
+ @Test
+ public void isInitialized_beforeInit_returnsFalse() {
+ assertFalse(pf.isInitialized());
+ }
+
+ @Test
+ public void predict_movesParticlesForward() {
+ pf.initialize(0, 0, 0, 1.0);
+ double xBefore = pf.getEstimatedX();
+ double yBefore = pf.getEstimatedY();
+
+ // Walk north (heading=0) for 10 steps of 1m
+ for (int i = 0; i < 10; i++) {
+ pf.predict(1.0, 0.0, Math.toRadians(5));
+ }
+
+ double displacement = Math.hypot(
+ pf.getEstimatedX() - xBefore,
+ pf.getEstimatedY() - yBefore);
+ assertTrue("Displacement should be roughly 10m, was " + displacement,
+ displacement > 5.0 && displacement < 15.0);
+ }
+
+ @Test
+ public void updateWithDynamicSigma_pullsTowardObservation() {
+ pf.initialize(0, 0, 0, 2.0);
+
+ for (int i = 0; i < 5; i++) {
+ pf.updateWithDynamicSigma(10.0, 10.0, 2.0);
+ }
+
+ double distToObs = Math.hypot(
+ pf.getEstimatedX() - 10.0,
+ pf.getEstimatedY() - 10.0);
+ assertTrue("Should be pulled toward (10,10), dist=" + distToObs,
+ distToObs < 12.0);
+ }
+
+ @Test
+ public void particleCount_matchesConstructor() {
+ assertEquals(NUM_PARTICLES, pf.getParticles().length);
+ assertEquals(NUM_PARTICLES, pf.getNumParticles());
+ }
+
+ @Test
+ public void updateFloor_changesAllParticleFloors() {
+ pf.initialize(0, 0, 0, 5.0);
+ pf.updateFloor(3);
+ for (Particle p : pf.getParticles()) {
+ assertEquals(3, p.floor);
+ }
+ }
+
+ @Test
+ public void getUncertainty_afterInit_isPositive() {
+ pf.initialize(0, 0, 0, 5.0);
+ assertTrue(pf.getUncertainty() > 0);
+ }
+
+ @Test
+ public void getEstimatedFloor_afterInit_matchesSeed() {
+ pf.initialize(0, 0, 2, 5.0);
+ assertEquals(2, pf.getEstimatedFloor());
+ }
+}
diff --git a/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/ParticleTest.java b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/ParticleTest.java
new file mode 100644
index 00000000..2e0c5950
--- /dev/null
+++ b/app/src/test/java/com/openpositioning/PositionMe/sensors/fusion/ParticleTest.java
@@ -0,0 +1,32 @@
+package com.openpositioning.PositionMe.sensors.fusion;
+
+import static org.junit.Assert.*;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link Particle}.
+ */
+public class ParticleTest {
+
+ @Test
+ public void constructor_setsFields() {
+ Particle p = new Particle(3.0, 7.0, 2, 0.5);
+ assertEquals(3.0, p.x, 1e-10);
+ assertEquals(7.0, p.y, 1e-10);
+ assertEquals(2, p.floor);
+ assertEquals(0.5, p.weight, 1e-10);
+ }
+
+ @Test
+ public void fields_areMutable() {
+ Particle p = new Particle(0, 0, 0, 1.0);
+ p.x = 5.0;
+ p.y = -3.0;
+ p.floor = 1;
+ p.weight = 0.25;
+ assertEquals(5.0, p.x, 1e-10);
+ assertEquals(-3.0, p.y, 1e-10);
+ assertEquals(1, p.floor);
+ assertEquals(0.25, p.weight, 1e-10);
+ }
+}
diff --git a/secrets.properties b/secrets.properties
index f0dc54fd..7907b650 100644
--- a/secrets.properties
+++ b/secrets.properties
@@ -1,6 +1,6 @@
#
# Modify the variables to set your keys
#
-MAPS_API_KEY=
-OPENPOSITIONING_API_KEY=
-OPENPOSITIONING_MASTER_KEY=
+MAPS_API_KEY=
+OPENPOSITIONING_API_KEY=
+OPENPOSITIONING_MASTER_KEY=