Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
.cxx
local.properties
/.idea/
.claude/
107 changes: 63 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ android {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}

testOptions {
unitTests.returnDefaultValues = true
}
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.openpositioning.PositionMe;

/**
* Compile-time constants shared across the application.
*
* <p>When {@link #DEBUG} is {@code false} the compiler eliminates all
* guarded {@code Log} calls, producing zero runtime overhead.</p>
*/
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 */ }
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<BuildingInfo> buildings = parseResponse(json);
postToMainThread(() -> callback.onSuccess(buildings));
Expand All @@ -267,17 +269,45 @@ private List<BuildingInfo> parseResponse(String json) throws JSONException {
List<BuildingInfo> 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<String> 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<LatLng> 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> floorShapes = parseMapShapes(mapShapesJson);
if (DEBUG) {
for (FloorShapes fs : floorShapes) {
java.util.Map<String, Integer> 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;
}
Expand Down Expand Up @@ -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<FloorShapes> parseMapShapes(String mapShapesJson) {
List<FloorShapes> result = new ArrayList<>();
Expand All @@ -366,13 +414,21 @@ private List<FloorShapes> 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<String> keys = new ArrayList<>();
Iterator<String> 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);
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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&current=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();
}
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading