From c40b4964a2f4a2279fdad6ff0556ad96b5eddc37 Mon Sep 17 00:00:00 2001 From: Lidong Guo Date: Tue, 10 Feb 2026 16:35:06 +0000 Subject: [PATCH 01/19] Update project configuration, dependencies and permissions --- app/build.gradle | 49 ++++++++------ app/src/main/AndroidManifest.xml | 109 ++++++++++++------------------- 2 files changed, 71 insertions(+), 87 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3e29b13f..2359f4ea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,8 +3,10 @@ plugins { id 'com.google.gms.google-services' id 'androidx.navigation.safeargs' id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' + id 'com.google.protobuf' version '0.9.4' } + // (Optional) load local secrets file: def localProperties = new Properties() def localPropertiesFile = rootProject.file('secrets.properties') @@ -14,12 +16,12 @@ if (localPropertiesFile.exists()) { android { namespace "com.openpositioning.PositionMe" - compileSdk 34 + compileSdk 35 defaultConfig { applicationId "com.openpositioning.PositionMe" minSdk 28 - targetSdk 34 + targetSdk 35 versionCode 1 versionName "1.0" @@ -35,12 +37,8 @@ android { } buildFeatures { - // For example: // compose true // if you want Jetpack Compose // viewBinding true - } - - buildFeatures { buildConfig true } @@ -57,9 +55,27 @@ android { } } +// Protobuf compilation configuration +// Configure the Gradle compiler for generating Java code +protobuf { + protoc { + // Download compiler matching dependency version below + artifact = 'com.google.protobuf:protoc:3.25.3' + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + // Generate standard Java code + } + } + } + } +} + dependencies { // Core AndroidX - implementation 'androidx.appcompat:appcompat:1.7.0-alpha03' // or stable: 1.6.1 + implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation 'androidx.preference:preference:1.2.1' @@ -67,29 +83,24 @@ dependencies { implementation 'com.android.volley:volley:1.2.1' implementation 'androidx.gridlayout:gridlayout:1.0.0' - // Material Components (Material 3 support is in 1.12.0+) - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + // Material Components implementation 'com.google.android.material:material:1.12.0' - implementation 'com.google.protobuf:protobuf-java:3.0.0' + implementation 'com.google.protobuf:protobuf-java:3.25.3' + implementation "com.google.protobuf:protobuf-java-util:3.25.3" + implementation 'com.squareup.okhttp3:okhttp:4.10.0' - implementation "com.google.protobuf:protobuf-java-util:3.0.0" implementation "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava" implementation 'com.google.android.gms:play-services-maps:19.0.0' + implementation 'com.google.android.gms:play-services-location:21.0.1' // Navigation components def nav_version = "2.8.6" implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" - // Optional: Jetpack Compose (Material 3) - // implementation "androidx.compose.material3:material3:1.3.1" - // implementation "androidx.activity:activity-compose:1.7.2" - // Testing testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' -} + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 678711fd..96091abe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,68 +1,41 @@ - + - - - - - + + - - - - - - - - - + - - + + - + + - - - - - - - - - - - - - - - - - + android:usesCleartextTraffic="true" + > + + + + + + + + + + + + + + + + \ No newline at end of file From e8b04afc4d6f90acb9ef0c31c8675426f36baa60 Mon Sep 17 00:00:00 2001 From: Lidong Guo Date: Tue, 10 Feb 2026 16:35:24 +0000 Subject: [PATCH 02/19] Implement updated sensor data collection (BLE/WiFi) and API integration --- .../PositionMe/data/local/TrajParser.java | 388 ++--- .../PositionMe/data/remote/IndoorMapAPI.java | 263 ++++ .../data/remote/ServerCommunications.java | 651 ++++---- .../PositionMe/sensors/BleDevice.java | 35 + .../PositionMe/sensors/GNSSDataProcessor.java | 136 +- .../PositionMe/sensors/SensorFusion.java | 1325 +++++++++-------- .../PositionMe/sensors/SensorTypes.java | 6 +- .../PositionMe/sensors/WifiDataProcessor.java | 315 ++-- .../PositionMe/utils/GeometryUtils.java | 207 +++ .../PositionMe/utils/IndoorBuilding.java | 27 + .../PositionMe/utils/IndoorMapManager.java | 1109 ++++++++++++-- .../PositionMe/utils/PathView.java | 20 +- .../PositionMe/utils/PdrProcessing.java | 44 +- .../PositionMe/utils/TrajectoryVerifier.java | 161 ++ .../PositionMe/utils/WifiApObservation.java | 13 + 15 files changed, 3119 insertions(+), 1581 deletions(-) create mode 100644 app/src/main/java/com/openpositioning/PositionMe/data/remote/IndoorMapAPI.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/sensors/BleDevice.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/utils/GeometryUtils.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/utils/IndoorBuilding.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/utils/TrajectoryVerifier.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/utils/WifiApObservation.java diff --git a/app/src/main/java/com/openpositioning/PositionMe/data/local/TrajParser.java b/app/src/main/java/com/openpositioning/PositionMe/data/local/TrajParser.java index 2d2b1cbf..95d65834 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/data/local/TrajParser.java +++ b/app/src/main/java/com/openpositioning/PositionMe/data/local/TrajParser.java @@ -1,256 +1,194 @@ package com.openpositioning.PositionMe.data.local; import android.content.Context; -import android.hardware.SensorManager; import android.util.Log; import com.google.android.gms.maps.model.LatLng; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.openpositioning.PositionMe.presentation.fragment.ReplayFragment; -import com.openpositioning.PositionMe.sensors.SensorFusion; +import com.openpositioning.PositionMe.Traj; +import com.openpositioning.PositionMe.sensors.SensorTypes; -import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; +import java.io.FileInputStream; +import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; import java.util.List; /** - * Handles parsing of trajectory data stored in JSON files, combining IMU, PDR, and GNSS data - * to reconstruct motion paths. - * - *

- * The **TrajParser** is primarily responsible for processing recorded trajectory data and - * reconstructing motion information, including estimated positions, GNSS coordinates, speed, and orientation. - * It does this by reading a JSON file containing: - *

- *
    - *
  • IMU (Inertial Measurement Unit) data
  • - *
  • PDR (Pedestrian Dead Reckoning) position data
  • - *
  • GNSS (Global Navigation Satellite System) location data
  • - *
- * - *

- * **Usage in Module 'PositionMe.app.main':** - *

- *
    - *
  • **ReplayFragment** - Calls `parseTrajectoryData()` to read recorded trajectory files and process movement.
  • - *
  • Stores parsed trajectory data as `ReplayPoint` objects.
  • - *
  • Provides data for updating map visualizations in `ReplayFragment`.
  • - *
- * - * @see ReplayFragment which uses parsed trajectory data for visualization. - * @see SensorFusion for motion processing and sensor integration. - * @see com.openpositioning.PositionMe.presentation.fragment.ReplayFragment for implementation details. - * - * @author Shu Gu - * @author Lin Cheng + * TrajParser (Updated for Assignment 1 / Proto v2) */ public class TrajParser { private static final String TAG = "TrajParser"; + private Traj.Trajectory trajectory; - /** - * Represents a single replay point containing estimated PDR position, GNSS location, - * orientation, speed, and timestamp. - */ - public static class ReplayPoint { - public LatLng pdrLocation; // PDR-derived location estimate - public LatLng gnssLocation; // GNSS location (may be null if unavailable) - public float orientation; // Orientation in degrees - public float speed; // Speed in meters per second - public long timestamp; // Relative timestamp - - /** - * Constructs a ReplayPoint. - * - * @param pdrLocation The pedestrian dead reckoning (PDR) location. - * @param gnssLocation The GNSS location, or null if unavailable. - * @param orientation The orientation angle in degrees. - * @param speed The speed in meters per second. - * @param timestamp The timestamp associated with this point. - */ - public ReplayPoint(LatLng pdrLocation, LatLng gnssLocation, float orientation, float speed, long timestamp) { - this.pdrLocation = pdrLocation; - this.gnssLocation = gnssLocation; - this.orientation = orientation; - this.speed = speed; - this.timestamp = timestamp; - } + public TrajParser(Traj.Trajectory trajectory) { + this.trajectory = trajectory; } - /** Represents an IMU (Inertial Measurement Unit) data record used for orientation calculations. */ - private static class ImuRecord { - public long relativeTimestamp; - public float accX, accY, accZ; // Accelerometer values - public float gyrX, gyrY, gyrZ; // Gyroscope values - public float rotationVectorX, rotationVectorY, rotationVectorZ, rotationVectorW; // Rotation quaternion - } - - /** Represents a Pedestrian Dead Reckoning (PDR) data record storing position shifts over time. */ - private static class PdrRecord { - public long relativeTimestamp; - public float x, y; // Position relative to the starting point - } - - /** Represents a GNSS (Global Navigation Satellite System) data record with latitude/longitude. */ - private static class GnssRecord { - public long relativeTimestamp; - public double latitude, longitude; // GNSS coordinates + public static class ReplayPoint { + public LatLng pdrLocation; + public float orientation; + public LatLng gnssLocation; + + public ReplayPoint(LatLng pdr, float ori, LatLng gnss) { + this.pdrLocation = pdr; + this.orientation = ori; + this.gnssLocation = gnss; + } } - /** - * Parses trajectory data from a JSON file and reconstructs a list of replay points. - * - *

- * This method processes a trajectory log file, extracting IMU, PDR, and GNSS records, - * and uses them to generate **ReplayPoint** objects. Each point contains: - *

- *
    - *
  • Estimated PDR-based position.
  • - *
  • GNSS location (if available).
  • - *
  • Computed orientation using rotation vectors.
  • - *
  • Speed estimation based on movement data.
  • - *
- * - * @param filePath Path to the JSON file containing trajectory data. - * @param context Android application context (used for sensor processing). - * @param originLat Latitude of the reference origin. - * @param originLng Longitude of the reference origin. - * @return A list of parsed {@link ReplayPoint} objects. - */ - public static List parseTrajectoryData(String filePath, Context context, - double originLat, double originLng) { - List result = new ArrayList<>(); - - try { - File file = new File(filePath); - if (!file.exists()) { - Log.e(TAG, "File does NOT exist: " + filePath); - return result; - } - if (!file.canRead()) { - Log.e(TAG, "File is NOT readable: " + filePath); - return result; - } - - BufferedReader br = new BufferedReader(new FileReader(file)); - JsonObject root = new JsonParser().parse(br).getAsJsonObject(); - br.close(); - - Log.i(TAG, "Successfully read trajectory file: " + filePath); - - long startTimestamp = root.has("startTimestamp") ? root.get("startTimestamp").getAsLong() : 0; - - List imuList = parseImuData(root.getAsJsonArray("imuData")); - List pdrList = parsePdrData(root.getAsJsonArray("pdrData")); - List gnssList = parseGnssData(root.getAsJsonArray("gnssData")); - - Log.i(TAG, "Parsed data - IMU: " + imuList.size() + " records, PDR: " - + pdrList.size() + " records, GNSS: " + gnssList.size() + " records"); + public static List parseTrajectoryData(String filePath, Context context, float startLat, float startLon) { + List replayPoints = new ArrayList<>(); + File file = new File(filePath); - for (int i = 0; i < pdrList.size(); i++) { - PdrRecord pdr = pdrList.get(i); - - ImuRecord closestImu = findClosestImuRecord(imuList, pdr.relativeTimestamp); - float orientationDeg = closestImu != null ? computeOrientationFromRotationVector( - closestImu.rotationVectorX, - closestImu.rotationVectorY, - closestImu.rotationVectorZ, - closestImu.rotationVectorW, - context - ) : 0f; + if (!file.exists()) { + Log.e(TAG, "File not found: " + filePath); + return replayPoints; + } - float speed = 0f; - if (i > 0) { - PdrRecord prev = pdrList.get(i - 1); - double dt = (pdr.relativeTimestamp - prev.relativeTimestamp) / 1000.0; - double dx = pdr.x - prev.x; - double dy = pdr.y - prev.y; - double distance = Math.sqrt(dx * dx + dy * dy); - if (dt > 0) speed = (float) (distance / dt); + try (FileInputStream fis = new FileInputStream(file)) { + Traj.Trajectory traj = Traj.Trajectory.parseFrom(fis); + List pdrList = traj.getPdrDataList(); + List gnssList = traj.getGnssDataList(); + + Log.d(TAG, "Parsing trajectory: PDR points=" + pdrList.size() + ", GNSS points=" + gnssList.size()); + + // STRATEGY: Prefer PDR data for accurate step-based trajectory display (Red Line) + // If PDR data exists, use it as primary. + // Map GNSS data (Blue Dots) to the closest PDR point based on timestamp. + + if (!pdrList.isEmpty()) { + Log.d(TAG, "Using PDR data as primary trajectory (Fixed Priority)"); + + // Calculate meters per degree for this latitude (approximation) + double metersPerDegLat = 111132.954 - 559.822 * Math.cos(2 * startLat * Math.PI / 180); + double metersPerDegLon = 111412.84 * Math.cos(startLat * Math.PI / 180); + + int gnssIndex = 0; + + for (int i = 0; i < pdrList.size(); i++) { + Traj.RelativePosition pdr = pdrList.get(i); + + // 1. Calculate Lat/Lon from PDR X/Y + double deltaLat = pdr.getY() / metersPerDegLat; // y is North/South + double deltaLon = pdr.getX() / metersPerDegLon; // x is East/West + LatLng currentPdrLatLng = new LatLng(startLat + deltaLat, startLon + deltaLon); + + // 2. Calculate Orientation + float orientation = 0f; + if (i > 0) { + Traj.RelativePosition prev = pdrList.get(i - 1); + double dx = pdr.getX() - prev.getX(); + double dy = pdr.getY() - prev.getY(); + orientation = (float) Math.toDegrees(Math.atan2(dx, dy)); + } + + // 3. Find matching GNSS point (closest in time, assuming sorted) + LatLng currentGnssLatLng = null; + if (!gnssList.isEmpty()) { + long pdrTime = pdr.getRelativeTimestamp(); + // Advance gnssIndex to find closest + while (gnssIndex < gnssList.size() - 1) { + Traj.GNSSReading curr = gnssList.get(gnssIndex); + Traj.GNSSReading next = gnssList.get(gnssIndex + 1); + if (Math.abs(next.getPosition().getRelativeTimestamp() - pdrTime) < + Math.abs(curr.getPosition().getRelativeTimestamp() - pdrTime)) { + gnssIndex++; + } else { + break; + } + } + // Check if within reasonable time window (e.g. 2 seconds) + Traj.GNSSReading bestGnss = gnssList.get(gnssIndex); + if (Math.abs(bestGnss.getPosition().getRelativeTimestamp() - pdrTime) < 2000) { + currentGnssLatLng = new LatLng( + bestGnss.getPosition().getLatitude(), + bestGnss.getPosition().getLongitude() + ); + } + } + + replayPoints.add(new ReplayPoint(currentPdrLatLng, orientation, currentGnssLatLng)); } - - - double lat = originLat + pdr.y * 1E-5; - double lng = originLng + pdr.x * 1E-5; - LatLng pdrLocation = new LatLng(lat, lng); - - GnssRecord closestGnss = findClosestGnssRecord(gnssList, pdr.relativeTimestamp); - LatLng gnssLocation = closestGnss != null ? - new LatLng(closestGnss.latitude, closestGnss.longitude) : null; - - result.add(new ReplayPoint(pdrLocation, gnssLocation, orientationDeg, - 0f, pdr.relativeTimestamp)); + Log.d(TAG, "Created " + replayPoints.size() + " replay points from PDR data"); + + } else if (!gnssList.isEmpty()) { + // Fallback: No PDR data, use GNSS + Log.d(TAG, "No PDR data, falling back to GNSS as primary trajectory"); + for (int i = 0; i < gnssList.size(); i++) { + Traj.GNSSReading gnss = gnssList.get(i); + LatLng gnssLatLng = new LatLng( + gnss.getPosition().getLatitude(), + gnss.getPosition().getLongitude() + ); + + float orientation = gnss.getBearing(); + if (i > 0 && orientation == 0) { + Traj.GNSSReading prev = gnssList.get(i - 1); + double dx = gnss.getPosition().getLongitude() - prev.getPosition().getLongitude(); + double dy = gnss.getPosition().getLatitude() - prev.getPosition().getLatitude(); + orientation = (float) Math.toDegrees(Math.atan2(dx, dy)); + } + + replayPoints.add(new ReplayPoint(gnssLatLng, orientation, gnssLatLng)); + } + Log.d(TAG, "Created " + replayPoints.size() + " replay points from GNSS data"); + } else { + Log.e(TAG, "No PDR or GNSS data found in trajectory!"); } - - Collections.sort(result, Comparator.comparingLong(rp -> rp.timestamp)); - - Log.i(TAG, "Final ReplayPoints count: " + result.size()); - - } catch (Exception e) { - Log.e(TAG, "Error parsing trajectory file!", e); + } catch (IOException e) { + Log.e(TAG, "Error parsing trajectory file", e); } - - return result; + return replayPoints; } -/** Parses IMU data from JSON. */ -private static List parseImuData(JsonArray imuArray) { - List imuList = new ArrayList<>(); - if (imuArray == null) return imuList; - Gson gson = new Gson(); - for (int i = 0; i < imuArray.size(); i++) { - ImuRecord record = gson.fromJson(imuArray.get(i), ImuRecord.class); - imuList.add(record); - } - return imuList; -}/** Parses PDR data from JSON. */ -private static List parsePdrData(JsonArray pdrArray) { - List pdrList = new ArrayList<>(); - if (pdrArray == null) return pdrList; - Gson gson = new Gson(); - for (int i = 0; i < pdrArray.size(); i++) { - PdrRecord record = gson.fromJson(pdrArray.get(i), PdrRecord.class); - pdrList.add(record); - } - return pdrList; -}/** Parses GNSS data from JSON. */ -private static List parseGnssData(JsonArray gnssArray) { - List gnssList = new ArrayList<>(); - if (gnssArray == null) return gnssList; - Gson gson = new Gson(); - for (int i = 0; i < gnssArray.size(); i++) { - GnssRecord record = gson.fromJson(gnssArray.get(i), GnssRecord.class); - gnssList.add(record); - } - return gnssList; -}/** Finds the closest IMU record to the given timestamp. */ -private static ImuRecord findClosestImuRecord(List imuList, long targetTimestamp) { - return imuList.stream().min(Comparator.comparingLong(imu -> Math.abs(imu.relativeTimestamp - targetTimestamp))) - .orElse(null); - -}/** Finds the closest GNSS record to the given timestamp. */ -private static GnssRecord findClosestGnssRecord(List gnssList, long targetTimestamp) { - return gnssList.stream().min(Comparator.comparingLong(gnss -> Math.abs(gnss.relativeTimestamp - targetTimestamp))) - .orElse(null); - -}/** Computes the orientation from a rotation vector. */ -private static float computeOrientationFromRotationVector(float rx, float ry, float rz, float rw, Context context) { - float[] rotationVector = new float[]{rx, ry, rz, rw}; - float[] rotationMatrix = new float[9]; - float[] orientationAngles = new float[3]; - SensorManager sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); - SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector); - SensorManager.getOrientation(rotationMatrix, orientationAngles); + public List parse(SensorTypes type) { + List dataList = new ArrayList<>(); + if (trajectory == null) return dataList; - float azimuthDeg = (float) Math.toDegrees(orientationAngles[0]); - return azimuthDeg < 0 ? azimuthDeg + 360.0f : azimuthDeg; -} + // Key fix: ensure switch case uses enum constant names, not fully qualified names + switch (type) { + case ACCELEROMETER: + for (Traj.IMUReading r : trajectory.getImuDataList()) { + if (r.hasAcc()) dataList.add(new Object[]{r.getRelativeTimestamp(), r.getAcc().getX(), r.getAcc().getY(), r.getAcc().getZ()}); + } + break; + case GYRO: + for (Traj.IMUReading r : trajectory.getImuDataList()) { + if (r.hasGyr()) dataList.add(new Object[]{r.getRelativeTimestamp(), r.getGyr().getX(), r.getGyr().getY(), r.getGyr().getZ()}); + } + break; + case GNSSLATLONG: + for (Traj.GNSSReading r : trajectory.getGnssDataList()) { + if (r.hasPosition()) dataList.add(new Object[]{r.getPosition().getRelativeTimestamp(), r.getPosition().getLatitude(), r.getPosition().getLongitude()}); + } + break; + // Assuming WIFI exists in SensorTypes + case WIFI: + for (Traj.Fingerprint fp : trajectory.getWifiFingerprintsList()) { + dataList.add(new Object[]{fp.getRelativeTimestamp(), (float) fp.getRfScansCount()}); + } + break; + case PRESSURE: + for (Traj.BarometerReading r : trajectory.getPressureDataList()) { + dataList.add(new Object[]{r.getRelativeTimestamp(), r.getPressure()}); + } + break; + case LIGHT: + for (Traj.LightReading r : trajectory.getLightDataList()) { + dataList.add(new Object[]{r.getRelativeTimestamp(), r.getLight()}); + } + break; + case PDR: + for (Traj.RelativePosition r : trajectory.getPdrDataList()) { + dataList.add(new Object[]{r.getRelativeTimestamp(), r.getX(), r.getY()}); + } + break; + } + return dataList; + } + public long getStartTimestamp() { + return trajectory != null ? trajectory.getStartTimestamp() : 0; + } } \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/data/remote/IndoorMapAPI.java b/app/src/main/java/com/openpositioning/PositionMe/data/remote/IndoorMapAPI.java new file mode 100644 index 00000000..4365acae --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/data/remote/IndoorMapAPI.java @@ -0,0 +1,263 @@ +package com.openpositioning.PositionMe.data.remote; + +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +import java.util.Locale; + +/** + * IndoorMapAPI - Handles API calls to fetch indoor map data + * Communicates with servers to get building info, floor plans, etc. + */ +public class IndoorMapAPI { + private static final String TAG = "IndoorMapAPI"; + + // API endpoints - Update these with your actual server endpoints + private static final String BASE_URL = "https://openpositioning.org/api/live"; + + private final OkHttpClient httpClient; + + public IndoorMapAPI() { + this.httpClient = new OkHttpClient(); + } + + /** + * Building data class to represent building information + */ + public static class BuildingInfo { + public String buildingId; + public String buildingName; + public double latitude; + public double longitude; + public List floorNames; + public int floorCount; + + public BuildingInfo() { + this.floorNames = new ArrayList<>(); + } + } + + /** + * Floor plan data class + */ + public static class FloorPlan { + public String floorId; + public String floorName; + public int floorNumber; + public String imageUrl; // URL to floor plan image + public double minLat; + public double maxLat; + public double minLon; + public double maxLon; + } + + /** + * Fetch buildings near a coordinate + * @param latitude User current latitude + * @param longitude User current longitude + * @param radiusMeters Search radius in meters + * @param callback Callback to handle results + */ + public void fetchNearbyBuildings(double latitude, double longitude, double radiusMeters, + BuildingsCallback callback) { + new Thread(() -> { + try { + String url = String.format(Locale.US, "%s/buildings/nearby?lat=%f&lon=%f&radius=%f", + BASE_URL, latitude, longitude, radiusMeters); + + Request request = new Request.Builder() + .url(url) + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) { + callback.onError("API Error: " + response.code()); + return; + } + + String jsonData = response.body().string(); + try { + List buildings = parseBuildings(jsonData); + callback.onSuccess(buildings); + } catch (JSONException e) { + Log.e(TAG, "JSON parse error", e); + callback.onError("Parse error: " + e.getMessage()); + } + } + } catch (IOException e) { + Log.e(TAG, "Network error", e); + callback.onError("Network error: " + e.getMessage()); + } + }).start(); + } + + /** + * Fetch floor plans for a specific building + * @param buildingId Building ID to fetch floors for + * @param callback Callback to handle results + */ + public void fetchBuildingFloors(String buildingId, FloorsCallback callback) { + new Thread(() -> { + try { + String url = String.format(Locale.US, "%s/buildings/%s/floors", BASE_URL, buildingId); + + Request request = new Request.Builder() + .url(url) + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) { + callback.onError("API Error: " + response.code()); + return; + } + + String jsonData = response.body().string(); + try { + List floors = parseFloors(jsonData); + callback.onSuccess(floors); + } catch (JSONException e) { + Log.e(TAG, "JSON parse error", e); + callback.onError("Parse error: " + e.getMessage()); + } + } + } catch (IOException e) { + Log.e(TAG, "Network error", e); + callback.onError("Network error: " + e.getMessage()); + } + }).start(); + } + + /** + * Fetch building outline/boundary polygon + * @param buildingId Building ID + * @param callback Callback with coordinate array + */ + public void fetchBuildingOutline(String buildingId, OutlineCallback callback) { + new Thread(() -> { + try { + String url = String.format(Locale.US, "%s/buildings/%s/outline", BASE_URL, buildingId); + + Request request = new Request.Builder() + .url(url) + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) { + callback.onError("API Error: " + response.code()); + return; + } + + String jsonData = response.body().string(); + try { + double[][] coordinates = parseOutlineCoordinates(jsonData); + callback.onSuccess(coordinates); + } catch (JSONException e) { + Log.e(TAG, "JSON parse error", e); + callback.onError("Parse error: " + e.getMessage()); + } + } + } catch (IOException e) { + Log.e(TAG, "Network error", e); + callback.onError("Network error: " + e.getMessage()); + } + }).start(); + } + + // ============= Parsing Methods ============= + + private List parseBuildings(String jsonData) throws JSONException { + List buildings = new ArrayList<>(); + JSONArray array = new JSONArray(jsonData); + + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + BuildingInfo building = new BuildingInfo(); + + building.buildingId = obj.optString("building_id", ""); + building.buildingName = obj.optString("name", "Unknown Building"); + building.latitude = obj.optDouble("latitude", 0.0); + building.longitude = obj.optDouble("longitude", 0.0); + building.floorCount = obj.optInt("floor_count", 1); + + // Parse floor names if available + JSONArray floors = obj.optJSONArray("floors"); + if (floors != null) { + for (int j = 0; j < floors.length(); j++) { + building.floorNames.add(floors.getString(j)); + } + } + + buildings.add(building); + } + + return buildings; + } + + private List parseFloors(String jsonData) throws JSONException { + List floors = new ArrayList<>(); + JSONArray array = new JSONArray(jsonData); + + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + FloorPlan floor = new FloorPlan(); + + floor.floorId = obj.optString("floor_id", ""); + floor.floorName = obj.optString("name", "Floor " + i); + floor.floorNumber = obj.optInt("floor_number", i); + floor.imageUrl = obj.optString("image_url", ""); + floor.minLat = obj.optDouble("bounds_min_lat", 0.0); + floor.maxLat = obj.optDouble("bounds_max_lat", 0.0); + floor.minLon = obj.optDouble("bounds_min_lon", 0.0); + floor.maxLon = obj.optDouble("bounds_max_lon", 0.0); + + floors.add(floor); + } + + return floors; + } + + private double[][] parseOutlineCoordinates(String jsonData) throws JSONException { + JSONObject obj = new JSONObject(jsonData); + JSONArray coords = obj.getJSONArray("coordinates"); + + double[][] result = new double[coords.length()][2]; + for (int i = 0; i < coords.length(); i++) { + JSONArray coord = coords.getJSONArray(i); + result[i][0] = coord.getDouble(0); // latitude + result[i][1] = coord.getDouble(1); // longitude + } + + return result; + } + + // ============= Callbacks ============= + + public interface BuildingsCallback { + void onSuccess(List buildings); + void onError(String error); + } + + public interface FloorsCallback { + void onSuccess(List floors); + void onError(String error); + } + + public interface OutlineCallback { + void onSuccess(double[][] coordinates); + void onError(String error); + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/data/remote/ServerCommunications.java b/app/src/main/java/com/openpositioning/PositionMe/data/remote/ServerCommunications.java index 7f7e74b2..fce64ff3 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/data/remote/ServerCommunications.java +++ b/app/src/main/java/com/openpositioning/PositionMe/data/remote/ServerCommunications.java @@ -1,16 +1,4 @@ package com.openpositioning.PositionMe.data.remote; -import android.util.Log; -import java.util.Map; -import java.util.HashMap; -import java.util.Iterator; -import java.io.BufferedReader; -import java.io.FileReader; -import org.json.JSONObject; - -import android.os.Environment; - -import java.io.FileInputStream; -import java.io.OutputStream; import android.content.Context; import android.content.SharedPreferences; @@ -20,31 +8,39 @@ import android.os.Environment; import android.os.Handler; import android.os.Looper; +import android.util.Log; import android.widget.Toast; -import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; import com.google.protobuf.util.JsonFormat; import com.openpositioning.PositionMe.BuildConfig; import com.openpositioning.PositionMe.Traj; -import com.openpositioning.PositionMe.presentation.fragment.FilesFragment; import com.openpositioning.PositionMe.presentation.activity.MainActivity; +import com.openpositioning.PositionMe.presentation.fragment.FilesFragment; import com.openpositioning.PositionMe.sensors.Observable; import com.openpositioning.PositionMe.sensors.Observer; +import org.json.JSONObject; + +import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; +import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.nio.file.Files; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; import java.util.List; -import java.util.zip.ZipEntry; +import java.util.Map; import java.util.zip.ZipInputStream; import okhttp3.Call; @@ -52,7 +48,6 @@ import okhttp3.Headers; import okhttp3.MediaType; import okhttp3.MultipartBody; -import okhttp3.OkHttp; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; @@ -60,22 +55,14 @@ import okhttp3.ResponseBody; /** - * This class handles communications with the server through HTTPs. The class uses an - * {@link OkHttpClient} for making requests to the server. The class includes methods for sending - * a recorded trajectory, uploading locally-stored trajectories, downloading trajectories from the - * server and requesting information about the uploaded trajectories. - * - * Keys and URLs are hardcoded strings, given the simple and academic nature of the project. - * - * @author Michal Dvorak - * @author Mate Stodulka + * ServerCommunications (Key Sanitized Version) + * 1. Auto-sanitize API Key and Master Key (remove < >) + * 2. Fixed URL concatenation errors to ensure upload/download works */ public class ServerCommunications implements Observable { public static Map downloadRecords = new HashMap<>(); - // Application context for handling permissions and devices private final Context context; - // Network status checking private ConnectivityManager connMgr; private boolean isWifiConn; private boolean isMobileConn; @@ -85,30 +72,30 @@ public class ServerCommunications implements Observable { private boolean success; private List observers; - // Static constants necessary for communications - private static final String userKey = BuildConfig.OPENPOSITIONING_API_KEY; - private static final String masterKey = BuildConfig.OPENPOSITIONING_MASTER_KEY; - private static final String uploadURL = - "https://openpositioning.org/api/live/trajectory/upload/" + userKey - + "/?key=" + masterKey; + // ============================================================ + // Core fix: Key sanitization logic + // ============================================================ + + // 1. Get raw Keys + private static final String RAW_USER_KEY = BuildConfig.OPENPOSITIONING_API_KEY; + private static final String RAW_MASTER_KEY = BuildConfig.OPENPOSITIONING_MASTER_KEY; + + // 2. Sanitize Keys (remove angle brackets and spaces) + private static final String userKey = RAW_USER_KEY.replace("<", "").replace(">", "").trim(); + private static final String masterKey = RAW_MASTER_KEY.replace("<", "").replace(">", "").trim(); + + // 3. Base upload URL (up to upload/, excluding campaign) + private static final String BASE_UPLOAD_URL = "https://openpositioning.org/api/live/trajectory/upload/"; + private static final String downloadURL = - "https://openpositioning.org/api/live/trajectory/download/" + userKey - + "?skip=0&limit=30&key=" + masterKey; + "https://openpositioning.org/api/live/trajectory/download/" + userKey + "?skip=0&limit=30&key=" + masterKey; + private static final String infoRequestURL = - "https://openpositioning.org/api/live/users/trajectories/" + userKey - + "?key=" + masterKey; + "https://openpositioning.org/api/live/users/trajectories/" + userKey + "?key=" + masterKey; + private static final String PROTOCOL_CONTENT_TYPE = "multipart/form-data"; private static final String PROTOCOL_ACCEPT_TYPE = "application/json"; - - - /** - * Public default constructor of {@link ServerCommunications}. The constructor saves context, - * initialises a {@link ConnectivityManager}, {@link Observer} and gets the user preferences. - * Boolean variables storing WiFi and Mobile Data connection status are initialised to false. - * - * @param context application context for handling permissions and devices. - */ public ServerCommunications(Context context) { this.context = context; this.connMgr = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); @@ -121,232 +108,274 @@ public ServerCommunications(Context context) { } /** - * Outgoing communication request with a {@link Traj trajectory} object. The recorded - * trajectory is passed to the method. It is processed into the right format for sending - * to the API server. - * - * @param trajectory Traj object matching all the timing and formal restrictions. + * Send trajectory data (with crash protection) + * @param trajectory Trajectory data + * @param campaign Building name (e.g. "murchison_house"), empty string for no campaign */ - public void sendTrajectory(Traj.Trajectory trajectory){ - logDataSize(trajectory); + public void sendTrajectory(Traj.Trajectory trajectory, String campaign){ + // 1. URL construction - dynamically append campaign (upload to user root if empty) + String dynamicUrl; + if (campaign != null && !campaign.isEmpty()) { + dynamicUrl = BASE_UPLOAD_URL + campaign + "/" + userKey + "/?key=" + masterKey; + } else { + String defaultCampaign = "murchison_house"; // <--- Enter your default building name here + dynamicUrl = BASE_UPLOAD_URL + defaultCampaign + "/" + userKey + "/?key=" + masterKey; + } - // Convert the trajectory to byte array - byte[] binaryTrajectory = trajectory.toByteArray(); + Log.e("SERVER_DEBUG", "--------------------------------------------------"); + Log.e("SERVER_DEBUG", "Campaign Passed: " + campaign); + Log.e("SERVER_DEBUG", "Dynamic Upload URL: " + dynamicUrl); + Log.e("SERVER_DEBUG", "--------------------------------------------------"); - File path = null; - // for android 13 or higher use dedicated external storage - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - path = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS); + // Crash protection: wrap all dangerous operations + try { + logDataSize(trajectory); + + // Convert the trajectory to byte array + byte[] binaryTrajectory = trajectory.toByteArray(); + Log.e("SERVER_DEBUG", "Trajectory Byte Size: " + binaryTrajectory.length); + + // Critical section 2: file path retrieval + File path = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + path = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS); + } + // Fallback: if Documents unavailable or version low, use internal storage if (path == null) { path = context.getFilesDir(); } - } else { // for android 12 or lower use internal storage - path = context.getFilesDir(); - } - System.out.println(path.toString()); + if (path == null) { + throw new IOException("Fatal: Could not determine any file storage path."); + } + + System.out.println(path.toString()); - // Format the file name according to date + // Format the file name according to date AND user input name SimpleDateFormat dateFormat = new SimpleDateFormat("dd-MM-yy-HH-mm-ss"); Date date = new Date(); - File file = new File(path, "trajectory_" + dateFormat.format(date) + ".txt"); - try { - // Write the binary data to the file - FileOutputStream stream = new FileOutputStream(file); - stream.write(binaryTrajectory); - stream.close(); - System.out.println("Recorded binary trajectory for debugging stored in: " + path); - } catch (IOException ee) { - // Catch and print if writing to the file fails - System.err.println("Storing of recorded binary trajectory failed: " + ee.getMessage()); + String safeName = trajectory.getTrajectoryId(); + if (safeName == null || safeName.isEmpty()) { + safeName = "trajectory"; } - // Check connections available before sending data - checkNetworkStatus(); + // File name format: traj__.txt + String fileName = "traj_" + safeName + "_" + dateFormat.format(date) + ".txt"; - // Check if user preference allows for syncing with mobile data - // TODO: add sync delay and enforce settings - boolean enableMobileData = this.settings.getBoolean("mobile_sync", false); - // Check if device is connected to WiFi or to mobile data with enabled preference - if(this.isWifiConn || (enableMobileData && isMobileConn)) { - // Instantiate client for HTTP requests - OkHttpClient client = new OkHttpClient(); - - // Creaet a equest body with a file to upload in multipart/form-data format - RequestBody requestBody = new MultipartBody.Builder().setType(MultipartBody.FORM) - .addFormDataPart("file", file.getName(), - RequestBody.create(MediaType.parse("text/plain"), file)) - .build(); - - // Create a POST request with the required headers - Request request = new Request.Builder().url(uploadURL).post(requestBody) - .addHeader("accept", PROTOCOL_ACCEPT_TYPE) - .addHeader("Content-Type", PROTOCOL_CONTENT_TYPE).build(); - - // Enqueue the request to be executed asynchronously and handle the response - client.newCall(request).enqueue(new Callback() { - - // Handle failure to get response from the server - @Override public void onFailure(Call call, IOException e) { - e.printStackTrace(); - System.err.println("Failure to get response"); - // Delete the local file and set success to false - //file.delete(); - success = false; - notifyObservers(1); - } + File file = new File(path, fileName); + Log.e("SERVER_DEBUG", "Saving temp file to: " + file.getAbsolutePath()); - private void copyFile(File src, File dst) throws IOException { - try (InputStream in = new FileInputStream(src); - OutputStream out = new FileOutputStream(dst)) { - byte[] buf = new byte[1024]; - int len; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); + // Critical section 3: file writing + FileOutputStream stream = new FileOutputStream(file); + stream.write(binaryTrajectory); + stream.close(); + System.out.println("Recorded binary trajectory stored in: " + path + "/" + fileName); + + checkNetworkStatus(); + + boolean enableMobileData = this.settings.getBoolean("mobile_sync", false); + if(this.isWifiConn || (enableMobileData && isMobileConn)) { + + // Log detailed trajectory information before upload + Log.e("SERVER_DEBUG", "==================== UPLOAD REQUEST ===================="); + Log.e("SERVER_DEBUG", "File: " + file.getName()); + Log.e("SERVER_DEBUG", "File Size: " + file.length() + " bytes"); + Log.e("SERVER_DEBUG", "Campaign: " + (campaign != null && !campaign.isEmpty() ? campaign : "[empty - user directory]")); + Log.e("SERVER_DEBUG", "URL: " + dynamicUrl); + Log.e("SERVER_DEBUG", "---- Trajectory Data Statistics ----"); + Log.e("SERVER_DEBUG", "IMU Data count: " + trajectory.getImuDataCount()); + Log.e("SERVER_DEBUG", "Magnetometer Data count: " + trajectory.getMagnetometerDataCount()); + Log.e("SERVER_DEBUG", "Pressure Data count: " + trajectory.getPressureDataCount()); + Log.e("SERVER_DEBUG", "GNSS Data count: " + trajectory.getGnssDataCount()); + Log.e("SERVER_DEBUG", "WiFi Fingerprints count: " + trajectory.getWifiFingerprintsCount()); + Log.e("SERVER_DEBUG", "BLE Data count: " + trajectory.getBleDataCount()); + Log.e("SERVER_DEBUG", "PDR Data count: " + trajectory.getPdrDataCount()); + Log.e("SERVER_DEBUG", "Test Points count: " + trajectory.getTestPointsCount()); + Log.e("SERVER_DEBUG", "========================================================"); + + OkHttpClient client = new OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .build(); + + // Use application/octet-stream + RequestBody requestBody = new MultipartBody.Builder().setType(MultipartBody.FORM) + .addFormDataPart("file", file.getName(), + RequestBody.create(MediaType.parse("application/octet-stream"), file)) + .build(); + + Request request = new Request.Builder() + .url(dynamicUrl) + .post(requestBody) + .addHeader("accept", PROTOCOL_ACCEPT_TYPE) + .build(); + + client.newCall(request).enqueue(new Callback() { + @Override public void onFailure(Call call, IOException e) { + e.printStackTrace(); + Log.e("SERVER_DEBUG", "==================== NETWORK FAILURE ===================="); + Log.e("SERVER_DEBUG", "Exception Type: " + e.getClass().getSimpleName()); + Log.e("SERVER_DEBUG", "Error Message: " + e.getMessage()); + Log.e("SERVER_DEBUG", "Stack Trace:"); + for (StackTraceElement element : e.getStackTrace()) { + Log.e("SERVER_DEBUG", " " + element.toString()); } + Log.e("SERVER_DEBUG", "========================================================"); + success = false; + notifyObservers(1); } - } - // Process the server's response - @Override public void onResponse(Call call, Response response) throws IOException { - try (ResponseBody responseBody = response.body()) { - // If the response is unsuccessful, delete the local file and throw an - // exception - if (!response.isSuccessful()) { - //file.delete(); -// System.err.println("POST error response: " + responseBody.string()); - - String errorBody = responseBody.string(); - infoResponse = "Upload failed: " + errorBody; - new Handler(Looper.getMainLooper()).post(() -> - Toast.makeText(context, infoResponse, Toast.LENGTH_SHORT).show()); // show error message to users - - System.err.println("POST error response: " + errorBody); - success = false; + @Override public void onResponse(Call call, Response response) throws IOException { + try (ResponseBody responseBody = response.body()) { + Log.e("SERVER_DEBUG", ">>> Response Code: " + response.code()); + Log.e("SERVER_DEBUG", ">>> Response Message: " + response.message()); + + // Log response headers + Log.e("SERVER_DEBUG", ">>> Response Headers:"); + for (String headerName : response.headers().names()) { + Log.e("SERVER_DEBUG", " " + headerName + ": " + response.headers().get(headerName)); + } + + if (!response.isSuccessful()) { + String errorBody = responseBody.string(); + infoResponse = "Upload failed (" + response.code() + "): " + errorBody; + + // Enhanced error logging - split long messages to avoid truncation + Log.e("SERVER_DEBUG", "==================== UPLOAD FAILED ===================="); + Log.e("SERVER_DEBUG", "Response Code: " + response.code()); + Log.e("SERVER_DEBUG", "Response Message: " + response.message()); + Log.e("SERVER_DEBUG", "Error Body Length: " + errorBody.length() + " characters"); + Log.e("SERVER_DEBUG", "----------- ERROR BODY START -----------"); + + // Split error body into chunks to avoid logcat truncation (max ~4000 chars per log) + int chunkSize = 3000; + for (int i = 0; i < errorBody.length(); i += chunkSize) { + int end = Math.min(errorBody.length(), i + chunkSize); + String chunk = errorBody.substring(i, end); + Log.e("SERVER_DEBUG", "ERROR CHUNK [" + (i/chunkSize + 1) + "]: " + chunk); + } + Log.e("SERVER_DEBUG", "------------ ERROR BODY END ------------"); + Log.e("SERVER_DEBUG", "======================================================"); + + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(context, infoResponse, Toast.LENGTH_SHORT).show()); + success = false; + notifyObservers(1); + return; + } + + // Success + System.out.println("Successful post response: " + responseBody.string()); + Log.d("SERVER_DEBUG", "UPLOAD SUCCESS!"); + + // Success + System.out.println("Successful post response: " + responseBody.string()); + Log.d("SERVER_DEBUG", "UPLOAD SUCCESS!"); + + // Copy to Downloads + File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + File downloadFile = new File(downloadsDir, file.getName()); + try { + copyFile(file, downloadFile); + } catch (IOException e) { + e.printStackTrace(); + } + + success = file.delete(); notifyObservers(1); - throw new IOException("Unexpected code " + response); } + } - // Print the response headers - Headers responseHeaders = response.headers(); - for (int i = 0, size = responseHeaders.size(); i < size; i++) { - System.out.println(responseHeaders.name(i) + ": " + responseHeaders.value(i)); - } - // Print a confirmation of a successful POST to API - System.out.println("Successful post response: " + responseBody.string()); - - System.out.println("Get file: " + file.getName()); - String originalPath = file.getAbsolutePath(); - System.out.println("Original trajectory file saved at: " + originalPath); - - // Copy the file to the Downloads folder - File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - File downloadFile = new File(downloadsDir, file.getName()); - try { - copyFile(file, downloadFile); - System.out.println("Trajectory file copied to Downloads: " + downloadFile.getAbsolutePath()); - } catch (IOException e) { - e.printStackTrace(); - System.err.println("Failed to copy file to Downloads: " + e.getMessage()); + private void copyFile(File src, File dst) throws IOException { + try (InputStream in = new FileInputStream(src); + OutputStream out = new FileOutputStream(dst)) { + byte[] buf = new byte[1024]; + int len; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + } } - - // Delete local file and set success to true - success = file.delete(); - notifyObservers(1); } - } - }); - } - else { - // If the device is not connected to network or allowed to send, do not send trajectory - // and notify observers and user - System.err.println("No uploading allowed right now!"); - success = false; - notifyObservers(1); + }); + } else { + Log.e("SERVER_DEBUG", "No Network Connection available for upload."); + success = false; + notifyObservers(1); + } + + } catch (Exception e) { + // Catch all exceptions to prevent crash + Log.e("SERVER_DEBUG", "CRITICAL ERROR during sendTrajectory: ", e); + e.printStackTrace(); + + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(context, "Error: " + e.getMessage(), Toast.LENGTH_LONG).show()); } } - /** - * Uploads a local trajectory file to the API server in the specified format. - * {@link OkHttp} library is used for the asynchronous POST request. - * - * @param localTrajectory the File object of the local trajectory to be uploaded - */ - public void uploadLocalTrajectory(File localTrajectory) { + public void uploadLocalTrajectory(File localTrajectory, String campaign) { + String dynamicUrl; + if (campaign != null && !campaign.isEmpty()) { + dynamicUrl = BASE_UPLOAD_URL + campaign + "/" + userKey + "/?key=" + masterKey; + } else { + // Empty campaign: upload directly to user directory + dynamicUrl = BASE_UPLOAD_URL + userKey + "/?key=" + masterKey; + } - // Instantiate client for HTTP requests - OkHttpClient client = new OkHttpClient(); + Log.e("SERVER_DEBUG", "Local Upload URL: " + dynamicUrl); + + OkHttpClient client = new OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .build(); - // robustness improvement RequestBody fileRequestBody; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { try { byte[] fileBytes = Files.readAllBytes(localTrajectory.toPath()); - fileRequestBody = RequestBody.create(MediaType.parse("text/plain"), fileBytes); + fileRequestBody = RequestBody.create(MediaType.parse("application/octet-stream"), fileBytes); } catch (IOException e) { e.printStackTrace(); - // if failed, use File object to construct RequestBody - fileRequestBody = RequestBody.create(MediaType.parse("text/plain"), localTrajectory); + fileRequestBody = RequestBody.create(MediaType.parse("application/octet-stream"), localTrajectory); } } else { - fileRequestBody = RequestBody.create(MediaType.parse("text/plain"), localTrajectory); + fileRequestBody = RequestBody.create(MediaType.parse("application/octet-stream"), localTrajectory); } - // Create request body with a file to upload in multipart/form-data format RequestBody requestBody = new MultipartBody.Builder().setType(MultipartBody.FORM) .addFormDataPart("file", localTrajectory.getName(), fileRequestBody) .build(); - // Create a POST request with the required headers - okhttp3.Request request = new okhttp3.Request.Builder().url(uploadURL).post(requestBody) + Request request = new Request.Builder() + .url(dynamicUrl) + .post(requestBody) .addHeader("accept", PROTOCOL_ACCEPT_TYPE) - .addHeader("Content-Type", PROTOCOL_CONTENT_TYPE).build(); + .build(); - // Enqueue the request to be executed asynchronously and handle the response client.newCall(request).enqueue(new okhttp3.Callback() { @Override public void onFailure(Call call, IOException e) { - // Print error message, set success to false and notify observers e.printStackTrace(); -// localTrajectory.delete(); success = false; - System.err.println("UPLOAD: Failure to get response"); notifyObservers(1); - infoResponse = "Upload failed: " + e.getMessage(); // Store error message + infoResponse = "Upload failed: " + e.getMessage(); new Handler(Looper.getMainLooper()).post(() -> - Toast.makeText(context, infoResponse, Toast.LENGTH_SHORT).show()); // show error message to users + Toast.makeText(context, infoResponse, Toast.LENGTH_SHORT).show()); } @Override public void onResponse(Call call, Response response) throws IOException { try (ResponseBody responseBody = response.body()) { if (!response.isSuccessful()) { - // Print error message, set success to false and throw an exception success = false; -// System.err.println("UPLOAD unsuccessful: " + responseBody.string()); notifyObservers(1); -// localTrajectory.delete(); - assert responseBody != null; String errorBody = responseBody.string(); - System.err.println("UPLOAD unsuccessful: " + errorBody); infoResponse = "Upload failed: " + errorBody; new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(context, infoResponse, Toast.LENGTH_SHORT).show()); - throw new IOException("UPLOAD failed with code " + response); + return; } - - // Print the response headers - Headers responseHeaders = response.headers(); - for (int i = 0, size = responseHeaders.size(); i < size; i++) { - System.out.println(responseHeaders.name(i) + ": " + responseHeaders.value(i)); - } - - // Print a confirmation of a successful POST to API - assert responseBody != null; - System.out.println("UPLOAD SUCCESSFUL: " + responseBody.string()); - - // Delete local file, set success to true and notify observers success = localTrajectory.delete(); notifyObservers(1); } @@ -354,15 +383,9 @@ public void onResponse(Call call, Response response) throws IOException { }); } - /** - * Loads download records from a JSON file and updates the downloadRecords map. - * If the file exists, it reads the JSON content and populates the map. - */ private void loadDownloadRecords() { - // Point to the app-specific Downloads folder File recordsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); File recordsFile = new File(recordsDir, "download_records.json"); - if (recordsFile.exists()) { try (BufferedReader reader = new BufferedReader(new FileReader(recordsFile))) { StringBuilder json = new StringBuilder(); @@ -370,7 +393,6 @@ private void loadDownloadRecords() { while ((line = reader.readLine()) != null) { json.append(line); } - JSONObject jsonObject = new JSONObject(json.toString()); for (Iterator it = jsonObject.keys(); it.hasNext(); ) { String key = it.next(); @@ -378,239 +400,141 @@ private void loadDownloadRecords() { JSONObject record = jsonObject.getJSONObject(key); String id = record.getString("id"); downloadRecords.put(id, record); - } catch (Exception e) { - System.err.println("Error loading record with key: " + key); - e.printStackTrace(); - } + } catch (Exception e) {} } - - System.out.println("Loaded downloadRecords: " + downloadRecords); - } catch (Exception e) { e.printStackTrace(); } - } else { - System.out.println("Download_records.json not found in app-specific directory."); } } - /** - * Saves a download record to a JSON file. - * The method creates or updates the JSON file with the provided details. - * - * @param startTimestamp the start timestamp of the trajectory - * @param fileName the name of the file - * @param id the ID of the trajectory - * @param dateSubmitted the date the trajectory was submitted - */ private void saveDownloadRecord(long startTimestamp, String fileName, String id, String dateSubmitted) { File recordsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); File recordsFile = new File(recordsDir, "download_records.json"); JSONObject jsonObject; - try { - // Ensure the directory exists - if (recordsDir != null && !recordsDir.exists()) { - recordsDir.mkdirs(); - } - - // If the file does not exist, create it + if (recordsDir != null && !recordsDir.exists()) recordsDir.mkdirs(); if (!recordsFile.exists()) { - if (recordsFile.createNewFile()) { - jsonObject = new JSONObject(); - } else { - System.err.println("Failed to create file: " + recordsFile.getAbsolutePath()); - return; - } + if (recordsFile.createNewFile()) jsonObject = new JSONObject(); + else return; } else { - // Read the existing contents StringBuilder jsonBuilder = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new FileReader(recordsFile))) { String line; - while ((line = reader.readLine()) != null) { - jsonBuilder.append(line); - } + while ((line = reader.readLine()) != null) jsonBuilder.append(line); } - // If file is empty or invalid JSON, use a fresh JSONObject - jsonObject = jsonBuilder.length() > 0 - ? new JSONObject(jsonBuilder.toString()) - : new JSONObject(); + jsonObject = jsonBuilder.length() > 0 ? new JSONObject(jsonBuilder.toString()) : new JSONObject(); } - - // Create the new record details JSONObject recordDetails = new JSONObject(); recordDetails.put("file_name", fileName); recordDetails.put("startTimeStamp", startTimestamp); recordDetails.put("date_submitted", dateSubmitted); recordDetails.put("id", id); - - // Insert or update in the main JSON jsonObject.put(id, recordDetails); - - // Write updated JSON to file try (FileWriter writer = new FileWriter(recordsFile)) { writer.write(jsonObject.toString(4)); writer.flush(); } - - System.out.println("Download record saved successfully at: " + recordsFile.getAbsolutePath()); - } catch (Exception e) { e.printStackTrace(); - System.err.println("Error saving download record: " + e.getMessage()); } } - /** - * Perform API request for downloading a Trajectory uploaded to the server. The trajectory is - * retrieved from a zip file, with the method accepting a position argument specifying the - * trajectory to be downloaded. The trajectory is then converted to a protobuf object and - * then to a JSON string to be downloaded to the device's Downloads folder. - * - * @param position the position of the trajectory in the zip file to retrieve - * @param id the ID of the trajectory - * @param dateSubmitted the date the trajectory was submitted - */ public void downloadTrajectory(int position, String id, String dateSubmitted) { - loadDownloadRecords(); // Load existing records from app-specific directory - - // Initialise OkHttp client + loadDownloadRecords(); OkHttpClient client = new OkHttpClient(); - - // Create GET request with required header okhttp3.Request request = new okhttp3.Request.Builder() .url(downloadURL) .addHeader("accept", PROTOCOL_ACCEPT_TYPE) .get() .build(); - // Enqueue the GET request for asynchronous execution client.newCall(request).enqueue(new okhttp3.Callback() { - @Override - public void onFailure(Call call, IOException e) { - e.printStackTrace(); - } + @Override public void onFailure(Call call, IOException e) { e.printStackTrace(); } - @Override - public void onResponse(Call call, Response response) throws IOException { + @Override public void onResponse(Call call, Response response) throws IOException { try (ResponseBody responseBody = response.body()) { if (!response.isSuccessful()) throw new IOException("Unexpected code " + response); - // Extract the nth entry from the zip + // 1. Unzip to get data stream InputStream inputStream = responseBody.byteStream(); ZipInputStream zipInputStream = new ZipInputStream(inputStream); - java.util.zip.ZipEntry zipEntry; int zipCount = 0; while ((zipEntry = zipInputStream.getNextEntry()) != null) { - if (zipCount == position) { - // break if zip entry position matches the desired position - break; - } + if (zipCount == position) break; zipCount++; } - // Initialise a byte array output stream + // 2. Read binary data into memory (byte array) ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - - // Read the zipped data and write it to the byte array output stream byte[] buffer = new byte[1024]; int bytesRead; while ((bytesRead = zipInputStream.read(buffer)) != -1) { byteArrayOutputStream.write(buffer, 0, bytesRead); } - - - // Convert the byte array to protobuf byte[] byteArray = byteArrayOutputStream.toByteArray(); - Traj.Trajectory receivedTrajectory = Traj.Trajectory.parseFrom(byteArray); - - // Inspect the size of the received trajectory - logDataSize(receivedTrajectory); - // Print a message in the console + // 3. Parse to get start timestamp + Traj.Trajectory receivedTrajectory = Traj.Trajectory.parseFrom(byteArray); long startTimestamp = receivedTrajectory.getStartTimestamp(); - String fileName = "trajectory_" + dateSubmitted + ".txt"; - // Place the file in your app-specific "Downloads" folder + // ========================================== + // ✅ Core Fix: Save as .protobuf binary file + // ========================================== + String fileName = "trajectory_" + dateSubmitted + ".protobuf"; // Changed suffix + File appSpecificDownloads = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); if (appSpecificDownloads != null && !appSpecificDownloads.exists()) { appSpecificDownloads.mkdirs(); } File file = new File(appSpecificDownloads, fileName); - try (FileWriter fileWriter = new FileWriter(file)) { - String receivedTrajectoryString = JsonFormat.printer().print(receivedTrajectory); - fileWriter.write(receivedTrajectoryString); - fileWriter.flush(); - System.err.println("Received trajectory stored in: " + file.getAbsolutePath()); + + // ⚠️ Key Point: Use FileOutputStream to write bytes directly, do not convert to JSON! + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(byteArray); + fos.flush(); } catch (IOException ee) { System.err.println("Trajectory download failed"); + ee.printStackTrace(); } finally { - // Close all streams and entries to release resources zipInputStream.closeEntry(); byteArrayOutputStream.close(); zipInputStream.close(); inputStream.close(); } - // Save the download record + // 4. 保存记录 saveDownloadRecord(startTimestamp, fileName, id, dateSubmitted); loadDownloadRecords(); } } }); - } - /** - * API request for information about submitted trajectories. If the response is successful, - * the {@link ServerCommunications#infoResponse} field is updated and observes notified. - * - */ public void sendInfoRequest() { - // Create a new OkHttpclient OkHttpClient client = new OkHttpClient(); - - // Create GET info request with appropriate URL and header okhttp3.Request request = new okhttp3.Request.Builder() .url(infoRequestURL) .addHeader("accept", PROTOCOL_ACCEPT_TYPE) .get() .build(); - // Enqueue the GET request for asynchronous execution client.newCall(request).enqueue(new okhttp3.Callback() { - @Override public void onFailure(Call call, IOException e) { - e.printStackTrace(); - } - + @Override public void onFailure(Call call, IOException e) { e.printStackTrace(); } @Override public void onResponse(Call call, Response response) throws IOException { try (ResponseBody responseBody = response.body()) { - // Check if the response is successful - if (!response.isSuccessful()) throw new IOException("Unexpected code " + - response); - - // Get the requested information from the response body and save it in a string - // TODO: add printing to the screen somewhere + if (!response.isSuccessful()) throw new IOException("Unexpected code " + response); infoResponse = responseBody.string(); - // Print a message in the console and notify observers - System.out.println("Response received"); notifyObservers(0); } } }); } - /** - * This method checks the device's connection status. It sets boolean variables depending on - * the type of active network connection. - */ private void checkNetworkStatus() { - // Get active network information NetworkInfo activeInfo = connMgr.getActiveNetworkInfo(); - - // Check for active connection and set flags accordingly if (activeInfo != null && activeInfo.isConnected()) { isWifiConn = activeInfo.getType() == ConnectivityManager.TYPE_WIFI; isMobileConn = activeInfo.getType() == ConnectivityManager.TYPE_MOBILE; @@ -620,39 +544,42 @@ private void checkNetworkStatus() { } } - private void logDataSize(Traj.Trajectory trajectory) { + Log.i("ServerCommunications", "========== TRAJECTORY DATA SIZE =========="); Log.i("ServerCommunications", "IMU Data size: " + trajectory.getImuDataCount()); - Log.i("ServerCommunications", "Position Data size: " + trajectory.getPositionDataCount()); + Log.i("ServerCommunications", "Magnetometer Data size: " + trajectory.getMagnetometerDataCount()); Log.i("ServerCommunications", "Pressure Data size: " + trajectory.getPressureDataCount()); Log.i("ServerCommunications", "Light Data size: " + trajectory.getLightDataCount()); - Log.i("ServerCommunications", "GNSS Data size: " + trajectory.getGnssDataCount()); - Log.i("ServerCommunications", "WiFi Data size: " + trajectory.getWifiDataCount()); + Log.i("ServerCommunications", "Proximity Data size: " + trajectory.getProximityDataCount()); + + // Highlight critical trajectory data + int gnssCount = trajectory.getGnssDataCount(); + int pdrCount = trajectory.getPdrDataCount(); + + if (gnssCount > 0) { + Log.i("ServerCommunications", "✓ GNSS Data size: " + gnssCount + " (OK)"); + } else { + Log.e("ServerCommunications", "✗ GNSS Data size: 0 (NO TRAJECTORY!)"); + } + + if (pdrCount > 0) { + Log.i("ServerCommunications", "✓ PDR Data size: " + pdrCount + " (OK)"); + } else { + Log.w("ServerCommunications", "⚠ PDR Data size: 0 (No PDR)"); + } + + Log.i("ServerCommunications", "WiFi Fingerprints size: " + trajectory.getWifiFingerprintsCount()); + Log.i("ServerCommunications", "BLE Data size: " + trajectory.getBleDataCount()); Log.i("ServerCommunications", "APS Data size: " + trajectory.getApsDataCount()); - Log.i("ServerCommunications", "PDR Data size: " + trajectory.getPdrDataCount()); + Log.i("ServerCommunications", "Test Points size: " + trajectory.getTestPointsCount()); + Log.i("ServerCommunications", "=========================================="); } - /** - * {@inheritDoc} - * - * Implement default method from Observable Interface to add new observers to the list of - * registered observers. - * - * @param o Classes which implement the Observer interface to receive updates from the class. - */ @Override public void registerObserver(Observer o) { this.observers.add(o); } - /** - * {@inheritDoc} - * - * Method for notifying all registered observers. The observer is notified based on the index - * passed to the method. - * - * @param index Index for identifying the observer to be notified. - */ @Override public void notifyObservers(int index) { for(Observer o : observers) { diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/BleDevice.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/BleDevice.java new file mode 100644 index 00000000..53be24e9 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/BleDevice.java @@ -0,0 +1,35 @@ +package com.openpositioning.PositionMe.sensors; + +/** + * Data class for Bluetooth Low Energy (BLE) devices. + * + * Holds MAC address, device name, and signal strength (RSSI) for discovered BLE devices. + * + * @author GitHub Copilot + */ +public class BleDevice { + private String macAddress; + private String name; + private int rssi; + + public BleDevice() {} + + public BleDevice(String macAddress, String name, int rssi) { + this.macAddress = macAddress; + this.name = name; + this.rssi = rssi; + } + + public String getMacAddress() { return macAddress; } + public String getName() { return name; } + public int getRssi() { return rssi; } + + public void setMacAddress(String macAddress) { this.macAddress = macAddress; } + public void setName(String name) { this.name = name; } + public void setRssi(int rssi) { this.rssi = rssi; } + + @Override + public String toString() { + return "MAC: " + macAddress + ", RSSI: " + rssi + " dBm"; + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/GNSSDataProcessor.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/GNSSDataProcessor.java index 579e344c..4551a72b 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/GNSSDataProcessor.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/GNSSDataProcessor.java @@ -4,77 +4,89 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.pm.PackageManager; +import android.location.Location; import android.location.LocationListener; import android.location.LocationManager; +import android.os.Looper; +import android.util.Log; import android.widget.Toast; import androidx.core.app.ActivityCompat; +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationCallback; +import com.google.android.gms.location.LocationRequest; +import com.google.android.gms.location.LocationResult; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.location.Priority; + /** * Class for handling and recording location data. * - * The class is responsibly for handling location data from GNSS and cellular sources using the - * Android LocationManager class. + * Uses Google's FusedLocationProviderClient for maximum accuracy by combining + * GPS, WiFi, cell towers, and device sensors automatically. + * Falls back to raw LocationManager if FusedLocation is unavailable. * * @author Virginia Cangelosi * @author Mate Stodulka */ public class GNSSDataProcessor { - // Application context for handling permissions and locationManager instances + private static final String TAG = "GNSSDataProcessor"; + private final Context context; - // Locations manager to enable access to GNSS and cellular location data via the android system private LocationManager locationManager; - // Location listener to receive the location data broadcast by the system private LocationListener locationListener; + // Google Fused Location (high accuracy) + private FusedLocationProviderClient fusedLocationClient; + private LocationCallback fusedLocationCallback; + private boolean usingFusedLocation = false; /** * Public default constructor of the GNSSDataProcessor class. * - * The constructor saves the context, checks for permissions to use the location services, - * creates an instance of the shared preferences to access settings using the context, - * initialises the location manager, and the location listener that will receive the data in the - * class the called the constructor. It checks if GPS and cellular networks are available and - * notifies the user via toasts if they need to be turned on. If permissions are granted it - * starts the location information gathering process. - * - * @param context Application Context to be used for permissions and device accesses. - * @param locationListener Location listener that will receive the location information from - * the device broadcasts. - * - * @see SensorFusion the intended parent class. + * Uses Google FusedLocationProviderClient as primary source for maximum accuracy. + * Falls back to raw GPS + Network providers if fused location is unavailable. */ public GNSSDataProcessor(Context context, LocationListener locationListener) { this.context = context; + this.locationListener = locationListener; - // Check for permissions boolean permissionsGranted = checkLocationPermissions(); - //Location manager and listener + // Initialize LocationManager as fallback this.locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); - this.locationListener = locationListener; - // Turn on gps if it is currently disabled + // Initialize Google Fused Location Client (primary - highest accuracy) + try { + this.fusedLocationClient = LocationServices.getFusedLocationProviderClient(context); + this.fusedLocationCallback = new LocationCallback() { + @Override + public void onLocationResult(LocationResult locationResult) { + if (locationResult == null) return; + // Use the most recent location + Location location = locationResult.getLastLocation(); + if (location != null) { + // Forward to existing LocationListener for compatibility with SensorFusion + locationListener.onLocationChanged(location); + } + } + }; + Log.d(TAG, "FusedLocationProviderClient initialized"); + } catch (Exception e) { + Log.w(TAG, "FusedLocationProvider unavailable, will use raw GPS: " + e.getMessage()); + this.fusedLocationClient = null; + } + if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { Toast.makeText(context, "Open GPS", Toast.LENGTH_SHORT).show(); } - if (!locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { - Toast.makeText(context, "Enable Cellular", Toast.LENGTH_SHORT).show(); - } - // Start location updates + if (permissionsGranted) { startLocationUpdates(); } } - /** - * Checks if the user authorised all permissions necessary for accessing location data. - * - * Explicit user permissions must be granted for android sdk version 23 and above. This - * function checks which permissions are granted, and returns their conjunction. - * - * @return boolean true if all permissions are granted for location access, false otherwise. - */ private boolean checkLocationPermissions() { int coarseLocationPermission = ActivityCompat.checkSelfPermission(this.context, Manifest.permission.ACCESS_COARSE_LOCATION); @@ -83,42 +95,68 @@ private boolean checkLocationPermissions() { int internetPermission = ActivityCompat.checkSelfPermission(this.context, Manifest.permission.INTERNET); - // Return missing permissions return coarseLocationPermission == PackageManager.PERMISSION_GRANTED && fineLocationPermission == PackageManager.PERMISSION_GRANTED && internetPermission == PackageManager.PERMISSION_GRANTED; } /** - * Request location updates via the GNSS and Cellular networks. - * - * The function checks for permissions again, and then requests updates via the location - * manager to the location listener. If permissions are granted but the GPS and cellular - * networks are disabled it reminds the user via toasts to turn them on. + * Start location updates using Google Fused Location (primary) + raw GPS (fallback). + * FusedLocation combines GPS, WiFi, cell, and sensors for best accuracy. */ @SuppressLint("MissingPermission") public void startLocationUpdates() { - //if (sharedPreferences.getBoolean("location", true)) { boolean permissionGranted = checkLocationPermissions(); - if (permissionGranted && locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) && - locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)){ + if (!permissionGranted) return; - locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListener); - locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, locationListener); + // PRIMARY: Google Fused Location Provider - highest accuracy + if (fusedLocationClient != null) { + try { + LocationRequest locationRequest = new LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, 300) // 300ms interval (balanced for smoothing) + .setMinUpdateIntervalMillis(200) // fastest 200ms (avoid too frequent updates) + .setMinUpdateDistanceMeters(0.5f) // small distance threshold + .setWaitForAccurateLocation(false) // don't wait, send immediately + .build(); + + fusedLocationClient.requestLocationUpdates(locationRequest, + fusedLocationCallback, Looper.getMainLooper()); + usingFusedLocation = true; + Log.d(TAG, "Started FusedLocation updates (HIGH_ACCURACY, 300ms, smooth mode)"); + } catch (Exception e) { + Log.w(TAG, "FusedLocation failed, falling back to raw GPS: " + e.getMessage()); + usingFusedLocation = false; + } } - else if(permissionGranted && !locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)){ + + // FALLBACK/SUPPLEMENT: Raw GPS provider (always start for redundancy) + if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, 500, 0, locationListener); + Log.d(TAG, "Started raw GPS updates"); + } else { Toast.makeText(context, "Open GPS", Toast.LENGTH_LONG).show(); } - else if(permissionGranted && !locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)){ - Toast.makeText(context, "Turn on WiFi", Toast.LENGTH_LONG).show(); + + // SUPPLEMENT: Network provider for faster initial fix + if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, 1000, 0, locationListener); + Log.d(TAG, "Started Network location updates"); } } /** - * Stops updates to the location listener via the location manager. + * Stops all location updates. */ public void stopUpdating() { + // Stop fused location + if (fusedLocationClient != null && fusedLocationCallback != null) { + fusedLocationClient.removeLocationUpdates(fusedLocationCallback); + Log.d(TAG, "Stopped FusedLocation updates"); + } + // Stop raw GPS/Network locationManager.removeUpdates(locationListener); + Log.d(TAG, "Stopped raw GPS/Network updates"); } - } 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 6eca847c..b12270f3 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -1,7 +1,14 @@ package com.openpositioning.PositionMe.sensors; +import android.Manifest; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanResult; import android.content.Context; import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; @@ -14,15 +21,14 @@ import android.util.Log; import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; import androidx.preference.PreferenceManager; import com.google.android.gms.maps.model.LatLng; -import com.openpositioning.PositionMe.presentation.activity.MainActivity; +import com.openpositioning.PositionMe.Traj; import com.openpositioning.PositionMe.utils.PathView; import com.openpositioning.PositionMe.utils.PdrProcessing; import com.openpositioning.PositionMe.data.remote.ServerCommunications; -import com.openpositioning.PositionMe.Traj; -import com.openpositioning.PositionMe.presentation.fragment.SettingsFragment; import org.json.JSONException; import org.json.JSONObject; @@ -36,57 +42,30 @@ import java.util.stream.Collectors; import java.util.stream.Stream; - /** - * The SensorFusion class is the main data gathering and processing class of the application. - * - * It follows the singleton design pattern to ensure that every fragment and process has access to - * the same date and sensor instances. Hence it has a private constructor, and must be initialised - * with the application context after creation. - *

- * The class implements {@link SensorEventListener} and has instances of {@link MovementSensor} for - * every device type necessary for data collection. As such, it implements the - * {@link SensorFusion#onSensorChanged(SensorEvent)} function, and process and records the data - * provided by the sensor hardware, which are stored in a {@link Traj} object. Data is read - * continuously but is only saved to the trajectory when recording is enabled. - *

- * The class provides a number of setters and getters so that other classes can have access to the - * sensor data and influence the behaviour of data collection. - * - * @author Michal Dvorak - * @author Mate Stodulka - * @author Virginia Cangelosi + * SensorFusion (Final Fix for Filename) + * Ensure startRecording uses the user-provided ID. */ public class SensorFusion implements SensorEventListener, Observer { - // Store the last event timestamps for each sensor type private HashMap lastEventTimestamps = new HashMap<>(); private HashMap eventCounts = new HashMap<>(); - long maxReportLatencyNs = 0; // Disable batching to deliver events immediately - - // Define a threshold for large time gaps (in milliseconds) - private static final long LARGE_GAP_THRESHOLD_MS = 500; // Adjust this if needed + long maxReportLatencyNs = 0; + private static final long LARGE_GAP_THRESHOLD_MS = 500; //region Static variables - // Singleton Class private static final SensorFusion sensorFusion = new SensorFusion(); - // Static constant for calculations with milliseconds + // IMU data sampling period: 10ms = 100Hz (high frequency for accurate trajectory recording) private static final long TIME_CONST = 10; - // Coefficient for fusing gyro-based and magnetometer-based orientation public static final float FILTER_COEFFICIENT = 0.96f; - //Tuning value for low pass filter private static final float ALPHA = 0.8f; - // String for creating WiFi fingerprint JSO N object private static final String WIFI_FINGERPRINT= "wf"; //endregion //region Instance variables - // Keep device awake while recording private PowerManager.WakeLock wakeLock; private Context appContext; - - // Settings private SharedPreferences settings; // Movement sensor instances @@ -100,127 +79,101 @@ public class SensorFusion implements SensorEventListener, Observer { private MovementSensor rotationSensor; private MovementSensor gravitySensor; private MovementSensor linearAccelerationSensor; - // Other data recording + + // Bluetooth components + private BluetoothAdapter bluetoothAdapter; + private BluetoothLeScanner bluetoothLeScanner; + private ScanCallback bleScanCallback; + private WifiDataProcessor wifiProcessor; private GNSSDataProcessor gnssProcessor; - // Data listener private final LocationListener locationListener; - // Server communication class for sending data private ServerCommunications serverCommunications; - // Trajectory object containing all data + + // Protobuf Builder private Traj.Trajectory.Builder trajectory; - // Settings private boolean saveRecording; private float filter_coefficient; - // Variables to help with timed events + private long absoluteStartTime; private long bootTime; long lastStepTime = 0; - // Timer object for scheduling data recording private Timer storeTrajectoryTimer; - // Counters for dividing timer to record data every 1 second/ every 5 seconds private int counter; private int secondCounter; + // Marker tracking for timestamp markers + private int markerCount = 0; + private float currentHeading = 0.0f; // Current bearing/heading in degrees + // Sensor values - private float[] acceleration; - private float[] filteredAcc; - private float[] gravity; - private float[] magneticField; - private float[] angularVelocity; - private float[] orientation; - private float[] rotation; + private float[] acceleration = new float[3]; + private float[] filteredAcc = new float[3]; + private float[] gravity = new float[3]; + private float[] magneticField = new float[3]; + private float[] angularVelocity = new float[3]; + private float[] orientation = new float[3]; + private float[] rotation = new float[4]; // x, y, z, w private float pressure; private float light; private float proximity; - private float[] R; - private int stepCounter ; + private int stepCounter; + + // Recording statistics + private int gnssRecordCount = 0; + private int pdrRecordCount = 0; + // Derived values private float elevation; private boolean elevator; - // Location values private float latitude; private float longitude; - private float[] startLocation; - // Wifi values - private List wifiList; + private float altitude_val; + private float gnssAccuracy = Float.MAX_VALUE; // GNSS accuracy in meters + private float[] startLocation = new float[2]; + private List wifiList = new ArrayList<>(); + private List bleList = new ArrayList<>(); + private List accelMagnitude = new ArrayList<>(); - // Over time accelerometer magnitude values since last step - private List accelMagnitude; - - // PDR calculation class private PdrProcessing pdrProcessing; - - // Trajectory displaying class private PathView pathView; - // WiFi positioning object private WiFiPositioning wiFiPositioning; + // Low-pass filter variables + private static final int SMOOTH_WINDOW = 10; + private float[] accWindow = new float[SMOOTH_WINDOW]; + private int accWindowIndex = 0; + + // Weinberg Step Length variables + private float currentMaxAcc = 0; + private float currentMinAcc = 0; + private static final long MIN_STEP_DELAY_MS = 350; // Reduced to 350ms for faster steps + private static final float STEP_THRESHOLD = 2.0f; // Reduced to 2.0f to detect smaller movements + //region Initialisation - /** - * Private constructor for implementing singleton design pattern for SensorFusion. - * Initialises empty arrays and new objects that do not depends on outside information. - */ private SensorFusion() { - // Location listener to be used by the GNSS class this.locationListener= new myLocationListener(); - // Timer to store sensor values in the trajectory object this.storeTrajectoryTimer = new Timer(); - // Counters to track elements with slower frequency this.counter = 0; this.secondCounter = 0; - // Step count initial value this.stepCounter = 0; - // PDR elevation initial values this.elevation = 0; this.elevator = false; - // PDR position array - this.startLocation = new float[2]; - // Empty array initialisation - this.acceleration = new float[3]; - this.filteredAcc = new float[3]; - this.gravity = new float[3]; - this.magneticField = new float[3]; - this.angularVelocity = new float[3]; - this.orientation = new float[3]; - this.rotation = new float[4]; this.rotation[3] = 1.0f; - this.R = new float[9]; - // GNSS initial Long-Lat array - this.startLocation = new float[2]; + this.wifiList = new ArrayList<>(); + this.bleList = new ArrayList<>(); } - - /** - * Static function to access singleton instance of SensorFusion. - * - * @return singleton instance of SensorFusion class. - */ public static SensorFusion getInstance() { return sensorFusion; } - /** - * Initialisation function for the SensorFusion instance. - * - * Initialise all Movement sensor instances from context and predetermined types. Creates a - * server communication instance for sending trajectories. Saves current absolute and relative - * time, and initialises saving the recording to false. - * - * @param context application context for permissions and device access. - * - * @see MovementSensor handling all SensorManager based data collection devices. - * @see ServerCommunications handling communication with the server. - * @see GNSSDataProcessor for location data processing. - * @see WifiDataProcessor for network data processing. - */ public void setContext(Context context) { - this.appContext = context.getApplicationContext(); // store app context for later use + this.appContext = context.getApplicationContext(); - // Initialise data collection devices (unchanged)... this.accelerometerSensor = new MovementSensor(context, Sensor.TYPE_ACCELEROMETER); this.barometerSensor = new MovementSensor(context, Sensor.TYPE_PRESSURE); this.gyroscopeSensor = new MovementSensor(context, Sensor.TYPE_GYROSCOPE); @@ -231,19 +184,21 @@ public void setContext(Context context) { this.rotationSensor = new MovementSensor(context, Sensor.TYPE_ROTATION_VECTOR); this.gravitySensor = new MovementSensor(context, Sensor.TYPE_GRAVITY); this.linearAccelerationSensor = new MovementSensor(context, Sensor.TYPE_LINEAR_ACCELERATION); - // Listener based devices + this.wifiProcessor = new WifiDataProcessor(context); wifiProcessor.registerObserver(this); this.gnssProcessor = new GNSSDataProcessor(context, locationListener); - // Create object handling HTTPS communication + + BluetoothManager bluetoothManager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE); + if (bluetoothManager != null) { + this.bluetoothAdapter = bluetoothManager.getAdapter(); + } + this.serverCommunications = new ServerCommunications(context); - // Save absolute and relative start time this.absoluteStartTime = System.currentTimeMillis(); this.bootTime = SystemClock.uptimeMillis(); - // Initialise saveRecording to false this.saveRecording = false; - // Other initialisations... this.accelMagnitude = new ArrayList<>(); this.pdrProcessing = new PdrProcessing(context); this.settings = PreferenceManager.getDefaultSharedPreferences(context); @@ -256,97 +211,56 @@ public void setContext(Context context) { this.filter_coefficient = FILTER_COEFFICIENT; } - // Keep app awake during the recording (using stored appContext) PowerManager powerManager = (PowerManager) this.appContext.getSystemService(Context.POWER_SERVICE); - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MyApp::MyWakelockTag"); - } + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PositionMe::WakeLock"); + // Start BLE scanning for UI display + startBleScan(); + } //endregion //region Sensor processing - /** - * {@inheritDoc} - * - * Called every time a Sensor value is updated. - * - * Checks originating sensor type, if the data is meaningful save it to a local variable. - * - * @param sensorEvent SensorEvent of sensor with values changed, includes types and values. - */ @Override public void onSensorChanged(SensorEvent sensorEvent) { - long currentTime = System.currentTimeMillis(); // Current time in milliseconds + long currentTime = System.currentTimeMillis(); int sensorType = sensorEvent.sensor.getType(); - // Get the previous timestamp for this sensor type - Long lastTimestamp = lastEventTimestamps.get(sensorType); - - if (lastTimestamp != null) { - long timeGap = currentTime - lastTimestamp; - -// // Log a warning if the time gap is larger than the threshold -// if (timeGap > LARGE_GAP_THRESHOLD_MS) { -// Log.e("SensorFusion", "Large time gap detected for sensor " + sensorType + -// " | Time gap: " + timeGap + " ms"); -// } - } - - // Update timestamp and frequency counter for this sensor lastEventTimestamps.put(sensorType, currentTime); eventCounts.put(sensorType, eventCounts.getOrDefault(sensorType, 0) + 1); - - switch (sensorType) { case Sensor.TYPE_ACCELEROMETER: - acceleration[0] = sensorEvent.values[0]; - acceleration[1] = sensorEvent.values[1]; - acceleration[2] = sensorEvent.values[2]; + System.arraycopy(sensorEvent.values, 0, acceleration, 0, 3); + + // Enhanced PDR Logic (Low-pass + Weinberg) + if (saveRecording) { + updateEnhancedPDR(acceleration[0], acceleration[1], acceleration[2]); + } break; case Sensor.TYPE_PRESSURE: pressure = (1 - ALPHA) * pressure + ALPHA * sensorEvent.values[0]; - if (saveRecording) { - this.elevation = pdrProcessing.updateElevation( - SensorManager.getAltitude(SensorManager.PRESSURE_STANDARD_ATMOSPHERE, pressure) - ); - } + // Update elevation regardless of recording state (needed for AutoFloor) + this.elevation = pdrProcessing.updateElevation( + SensorManager.getAltitude(SensorManager.PRESSURE_STANDARD_ATMOSPHERE, pressure) + ); break; case Sensor.TYPE_GYROSCOPE: - angularVelocity[0] = sensorEvent.values[0]; - angularVelocity[1] = sensorEvent.values[1]; - angularVelocity[2] = sensorEvent.values[2]; + System.arraycopy(sensorEvent.values, 0, angularVelocity, 0, 3); + break; case Sensor.TYPE_LINEAR_ACCELERATION: - filteredAcc[0] = sensorEvent.values[0]; - filteredAcc[1] = sensorEvent.values[1]; - filteredAcc[2] = sensorEvent.values[2]; - - // Compute magnitude & add to accelMagnitude + System.arraycopy(sensorEvent.values, 0, filteredAcc, 0, 3); double accelMagFiltered = Math.sqrt( - Math.pow(filteredAcc[0], 2) + - Math.pow(filteredAcc[1], 2) + - Math.pow(filteredAcc[2], 2) + filteredAcc[0]*filteredAcc[0] + filteredAcc[1]*filteredAcc[1] + filteredAcc[2]*filteredAcc[2] ); this.accelMagnitude.add(accelMagFiltered); - -// // Debug logging -// Log.v("SensorFusion", -// "Added new linear accel magnitude: " + accelMagFiltered -// + "; accelMagnitude size = " + accelMagnitude.size()); - elevator = pdrProcessing.estimateElevator(gravity, filteredAcc); break; case Sensor.TYPE_GRAVITY: - gravity[0] = sensorEvent.values[0]; - gravity[1] = sensorEvent.values[1]; - gravity[2] = sensorEvent.values[2]; - - // Possibly log gravity values if needed - //Log.v("SensorFusion", "Gravity: " + Arrays.toString(gravity)); - + System.arraycopy(sensorEvent.values, 0, gravity, 0, 3); elevator = pdrProcessing.estimateElevator(gravity, filteredAcc); break; @@ -359,468 +273,343 @@ public void onSensorChanged(SensorEvent sensorEvent) { break; case Sensor.TYPE_MAGNETIC_FIELD: - magneticField[0] = sensorEvent.values[0]; - magneticField[1] = sensorEvent.values[1]; - magneticField[2] = sensorEvent.values[2]; + System.arraycopy(sensorEvent.values, 0, magneticField, 0, 3); break; case Sensor.TYPE_ROTATION_VECTOR: - this.rotation = sensorEvent.values.clone(); + if (sensorEvent.values.length >= 4) { + System.arraycopy(sensorEvent.values, 0, rotation, 0, 4); + } else { + System.arraycopy(sensorEvent.values, 0, rotation, 0, 3); + rotation[3] = 1.0f; + } float[] rotationVectorDCM = new float[9]; SensorManager.getRotationMatrixFromVector(rotationVectorDCM, this.rotation); SensorManager.getOrientation(rotationVectorDCM, this.orientation); break; case Sensor.TYPE_STEP_DETECTOR: + // Keep original logic as backup or if manual step detector is preferred long stepTime = SystemClock.uptimeMillis() - bootTime; - if (currentTime - lastStepTime < 20) { - Log.e("SensorFusion", "Ignoring step event, too soon after last step event:" + (currentTime - lastStepTime) + " ms"); - // Ignore rapid successive step events break; - } - - else { - lastStepTime = currentTime; - // Log if accelMagnitude is empty - 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()); - } - + } else { + // Original logic kept but potentially overridden by manual PDR update in Accelerometer + // If you want to use ONLY the manual enhanced PDR, comment out this block or control it with a flag. + // For now, we keep it to support devices with good hardware step detectors. + /* lastStepTime = currentTime; float[] newCords = this.pdrProcessing.updatePdr( stepTime, this.accelMagnitude, this.orientation[0] ); - - // Clear the accelMagnitude after using it this.accelMagnitude.clear(); - - if (saveRecording) { + if (saveRecording && trajectory != null) { this.pathView.drawTrajectory(newCords); stepCounter++; - trajectory.addPdrData(Traj.Pdr_Sample.newBuilder() - .setRelativeTimestamp(SystemClock.uptimeMillis() - bootTime) + trajectory.addPdrData(Traj.RelativePosition.newBuilder() + .setRelativeTimestamp(stepTime) .setX(newCords[0]) - .setY(newCords[1])); + .setY(newCords[1]) + .build()); + + pdrRecordCount++; } + */ break; } - } } - /** - * Utility function to log the event frequency of each sensor. - * Call this periodically for debugging purposes. - */ - public void logSensorFrequencies() { - for (int sensorType : eventCounts.keySet()) { - Log.d("SensorFusion", "Sensor " + sensorType + " | Event Count: " + eventCounts.get(sensorType)); + // Low-pass filter for acceleration magnitude + private float getSmoothedMagnitude(float rawMagnitude) { + accWindow[accWindowIndex] = rawMagnitude; + accWindowIndex = (accWindowIndex + 1) % SMOOTH_WINDOW; + + float sum = 0; + for (float v : accWindow) sum += v; + return sum / SMOOTH_WINDOW; + } + + // Weinberg Stride Length Estimation + private float calculateWeinbergStride(float aMax, float aMin) { + float K = 0.45f; // Calibration constant + double amplitude = aMax - aMin; + if (amplitude < 0) amplitude = 0; + return (float) (K * Math.pow(amplitude, 0.25)); + } + + // Enhanced PDR Update Logic + private void updateEnhancedPDR(float x, float y, float z) { + // Calculate magnitude (minus gravity) + float rawMag = (float) Math.sqrt(x*x + y*y + z*z) - 9.81f; + + // Low-pass filter + float smoothMag = getSmoothedMagnitude(rawMag); + + // Track peaks for Weinberg + if (smoothMag > currentMaxAcc) currentMaxAcc = smoothMag; + if (smoothMag < currentMinAcc) currentMinAcc = smoothMag; + + // Step detection + long currentTime = System.currentTimeMillis(); + if (smoothMag > STEP_THRESHOLD && (currentTime - lastStepTime) > MIN_STEP_DELAY_MS) { + + // Step detected! + + // Calculate dynamic stride + float dynamicStride = calculateWeinbergStride(currentMaxAcc, currentMinAcc); + + // Override stride if manual setting is preferred/enforced in PdrProcessing + // But here we calculate a "better" dynamic one. + // We can feed this into pdrProcessing if we modify it, or apply it directly. + + // For integration with existing system, let's update PdrProcessing with this custom stride event + // Or calculate coordinates directly here. Let's do direct calc to ensure the fix works. + + float currentHeading = orientation[0]; // Radians + + // Manually update PDR state in PdrProcessing (requires PdrProcessing to allow external updates or we do it here) + // Since PdrProcessing is internal, we will simulate the update call but with our dynamic stride. + // Actually, PdrProcessing.updatePdr calculates stride internally. + // To force our Weinberg stride, we might need to modify PdrProcessing or set a temporary flag. + + // Simpler approach: Use the calculated stride to update position + float dx = (float) (dynamicStride * Math.sin(currentHeading)); + float dy = (float) (dynamicStride * Math.cos(currentHeading)); + + // We need to update the accumulated PDR movement in pdrProcessing so UI gets it + // Assuming we added a method `addManualMovement(dx, dy)` to PdrProcessing, or we rely on pdrProcessing's existing logic. + // If we cannot modify PdrProcessing, we must rely on its updatePdr which uses pre-defined stride estimation. + + // However, since the goal is to IMPROVE it, let's assume we use the existing updatePdr + // but we trigger it here based on our BETTER step detection. + + long stepTime = SystemClock.uptimeMillis() - bootTime; + lastStepTime = currentTime; + + // Pass empty mag list as we handled magnitude logic here, or pass current to let it do its thing? + // If we pass empty, it might use default stride. + // Let's rely on PdrProcessing's updatePdr for coordinate integration but trigger it with our timing. + float[] newCords = this.pdrProcessing.updatePdrWithStride( + dynamicStride, + currentHeading + ); + + // Reset peaks + currentMaxAcc = 0; + currentMinAcc = 0; + + if (saveRecording && trajectory != null) { + this.pathView.drawTrajectory(newCords); + stepCounter++; + trajectory.addPdrData(Traj.RelativePosition.newBuilder() + .setRelativeTimestamp(stepTime) + .setX(newCords[0]) + .setY(newCords[1]) + .build()); + + pdrRecordCount++; + Log.d("Recording", "Enhanced PDR recorded: count=" + pdrRecordCount + + ", steps=" + stepCounter); + } } } - /** - * {@inheritDoc} - * - * Location listener class to receive updates from the location manager. - * - * Passed to the {@link GNSSDataProcessor} to receive the location data in this class. Save the - * values in instance variables. - */ class myLocationListener implements LocationListener{ @Override public void onLocationChanged(@NonNull Location location) { - //Toast.makeText(context, "Location Changed", Toast.LENGTH_SHORT).show(); latitude = (float) location.getLatitude(); longitude = (float) location.getLongitude(); - float altitude = (float) location.getAltitude(); - float accuracy = (float) location.getAccuracy(); - float speed = (float) location.getSpeed(); - String provider = location.getProvider(); - if(saveRecording) { - trajectory.addGnssData(Traj.GNSS_Sample.newBuilder() - .setAccuracy(accuracy) - .setAltitude(altitude) - .setLatitude(latitude) - .setLongitude(longitude) - .setSpeed(speed) - .setProvider(provider) - .setRelativeTimestamp(System.currentTimeMillis()-absoluteStartTime)); + altitude_val = (float) location.getAltitude(); + gnssAccuracy = location.getAccuracy(); // Store accuracy for fusion + + if(saveRecording && trajectory != null) { + long relativeTime = SystemClock.uptimeMillis() - bootTime; + + Traj.GNSSPosition gnssPosition = Traj.GNSSPosition.newBuilder() + .setLatitude(location.getLatitude()) + .setLongitude(location.getLongitude()) + .setAltitude(location.getAltitude()) + .setRelativeTimestamp(relativeTime) + .build(); + + trajectory.addGnssData(Traj.GNSSReading.newBuilder() + .setPosition(gnssPosition) + .setAccuracy(location.getAccuracy()) + .setSpeed(location.getSpeed()) + .setBearing(location.getBearing()) + .setProvider(location.getProvider() != null ? location.getProvider() : "unknown") + .build()); + + gnssRecordCount++; + Log.d("Recording", "GNSS recorded: count=" + gnssRecordCount + + ", lat=" + location.getLatitude() + + ", lon=" + location.getLongitude() + + ", accuracy=" + location.getAccuracy() + "m"); } } } - /** - * {@inheritDoc} - * - * Receives updates from {@link WifiDataProcessor}. - * - * @see WifiDataProcessor object for wifi scanning. - */ @Override public void update(Object[] wifiList) { - // Save newest wifi values to local variable this.wifiList = Stream.of(wifiList).map(o -> (Wifi) o).collect(Collectors.toList()); - if(this.saveRecording) { - Traj.WiFi_Sample.Builder wifiData = Traj.WiFi_Sample.newBuilder() - .setRelativeTimestamp(SystemClock.uptimeMillis()-bootTime); + if(this.saveRecording && trajectory != null) { + long relativeTime = SystemClock.uptimeMillis() - bootTime; + + Traj.Fingerprint.Builder fingerprintBuilder = Traj.Fingerprint.newBuilder() + .setRelativeTimestamp(relativeTime); + for (Wifi data : this.wifiList) { - wifiData.addMacScans(Traj.Mac_Scan.newBuilder() - .setRelativeTimestamp(SystemClock.uptimeMillis() - bootTime) - .setMac(data.getBssid()).setRssi(data.getLevel())); + fingerprintBuilder.addRfScans(Traj.RFScan.newBuilder() + .setRelativeTimestamp(relativeTime) + .setMac(data.getBssid()) + .setRssi(data.getLevel()) + .build()); } - // Adding WiFi data to Trajectory - this.trajectory.addWifiData(wifiData); + this.trajectory.addWifiFingerprints(fingerprintBuilder.build()); } createWifiPositioningRequest(); } - /** - * Function to create a request to obtain a wifi location for the obtained wifi fingerprint - * - */ private void createWifiPositioningRequest(){ - // Try catch block to catch any errors and prevent app crashing try { - // Creating a JSON object to store the WiFi access points JSONObject wifiAccessPoints=new JSONObject(); for (Wifi data : this.wifiList){ wifiAccessPoints.put(String.valueOf(data.getBssid()), data.getLevel()); } - // Creating POST Request JSONObject wifiFingerPrint = new JSONObject(); wifiFingerPrint.put(WIFI_FINGERPRINT, wifiAccessPoints); this.wiFiPositioning.request(wifiFingerPrint); } catch (JSONException e) { - // Catching error while making JSON object, to prevent crashes - // Error log to keep record of errors (for secure programming and maintainability) - Log.e("jsonErrors","Error creating json object"+e.toString()); - } - } - // Callback Example Function - /** - * Function to create a request to obtain a wifi location for the obtained wifi fingerprint - * using Volley Callback - */ - private void createWifiPositionRequestCallback(){ - try { - // Creating a JSON object to store the WiFi access points - JSONObject wifiAccessPoints=new JSONObject(); - for (Wifi data : this.wifiList){ - wifiAccessPoints.put(String.valueOf(data.getBssid()), data.getLevel()); - } - // Creating POST Request - JSONObject wifiFingerPrint = new JSONObject(); - wifiFingerPrint.put(WIFI_FINGERPRINT, wifiAccessPoints); - this.wiFiPositioning.request(wifiFingerPrint, new WiFiPositioning.VolleyCallback() { - @Override - public void onSuccess(LatLng wifiLocation, int floor) { - // Handle the success response - } - - @Override - public void onError(String message) { - // Handle the error response - } - }); - } catch (JSONException e) { - // Catching error while making JSON object, to prevent crashes - // Error log to keep record of errors (for secure programming and maintainability) Log.e("jsonErrors","Error creating json object"+e.toString()); } - } - /** - * Method to get user position obtained using {@link WiFiPositioning}. - * - * @return {@link LatLng} corresponding to user's position. - */ public LatLng getLatLngWifiPositioning(){return this.wiFiPositioning.getWifiLocation();} - /** - * Method to get current floor the user is at, obtained using WiFiPositioning - * @see WiFiPositioning for WiFi positioning - * @return Current floor user is at using WiFiPositioning - */ public int getWifiFloor(){ return this.wiFiPositioning.getFloor(); } - /** - * Method used for converting an array of orientation angles into a rotation matrix. - * - * @param o An array containing orientation angles in radians - * @return resultMatrix representing the orientation angles - */ - private float[] getRotationMatrixFromOrientation(float[] o) { - float[] xM = new float[9]; - float[] yM = new float[9]; - float[] zM = new float[9]; - - float sinX = (float)Math.sin(o[1]); - float cosX = (float)Math.cos(o[1]); - float sinY = (float)Math.sin(o[2]); - float cosY = (float)Math.cos(o[2]); - float sinZ = (float)Math.sin(o[0]); - float cosZ = (float)Math.cos(o[0]); - - // rotation about x-axis (pitch) - xM[0] = 1.0f; xM[1] = 0.0f; xM[2] = 0.0f; - xM[3] = 0.0f; xM[4] = cosX; xM[5] = sinX; - xM[6] = 0.0f; xM[7] = -sinX; xM[8] = cosX; - - // rotation about y-axis (roll) - yM[0] = cosY; yM[1] = 0.0f; yM[2] = sinY; - yM[3] = 0.0f; yM[4] = 1.0f; yM[5] = 0.0f; - yM[6] = -sinY; yM[7] = 0.0f; yM[8] = cosY; - - // rotation about z-axis (azimuth) - zM[0] = cosZ; zM[1] = sinZ; zM[2] = 0.0f; - zM[3] = -sinZ; zM[4] = cosZ; zM[5] = 0.0f; - zM[6] = 0.0f; zM[7] = 0.0f; zM[8] = 1.0f; - - // rotation order is y, x, z (roll, pitch, azimuth) - float[] resultMatrix = matrixMultiplication(xM, yM); - resultMatrix = matrixMultiplication(zM, resultMatrix); - return resultMatrix; + //region Helper Methods + private Traj.Vector3 toVector3(float[] values) { + return Traj.Vector3.newBuilder() + .setX(values[0]) + .setY(values[1]) + .setZ(values[2]) + .build(); } - /** - * Performs and matrix multiplication of two 3x3 matrices and returns the product. - * - * @param A An array representing a 3x3 matrix - * @param B An array representing a 3x3 matrix - * @return result representing the product of A and B - */ - private float[] matrixMultiplication(float[] A, float[] B) { - float[] result = new float[9]; - - result[0] = A[0] * B[0] + A[1] * B[3] + A[2] * B[6]; - result[1] = A[0] * B[1] + A[1] * B[4] + A[2] * B[7]; - result[2] = A[0] * B[2] + A[1] * B[5] + A[2] * B[8]; - - result[3] = A[3] * B[0] + A[4] * B[3] + A[5] * B[6]; - result[4] = A[3] * B[1] + A[4] * B[4] + A[5] * B[7]; - result[5] = A[3] * B[2] + A[4] * B[5] + A[5] * B[8]; - - result[6] = A[6] * B[0] + A[7] * B[3] + A[8] * B[6]; - result[7] = A[6] * B[1] + A[7] * B[4] + A[8] * B[7]; - result[8] = A[6] * B[2] + A[7] * B[5] + A[8] * B[8]; - - return result; + private Traj.Quaternion toQuaternion(float[] values) { + return Traj.Quaternion.newBuilder() + .setX(values[0]) + .setY(values[1]) + .setZ(values[2]) + .setW(values.length > 3 ? values[3] : 1.0f) + .build(); } - /** - * {@inheritDoc} - */ - @Override - public void onAccuracyChanged(Sensor sensor, int i) {} + private Traj.SensorInfo createSensorInfo(MovementSensor sensor) { + if (sensor == null || sensor.sensorInfo == null) return Traj.SensorInfo.getDefaultInstance(); + return Traj.SensorInfo.newBuilder() + .setName(sensor.sensorInfo.getName()) + .setVendor(sensor.sensorInfo.getVendor()) + .setResolution(sensor.sensorInfo.getResolution()) + .setPower(sensor.sensorInfo.getPower()) + .setVersion(sensor.sensorInfo.getVersion()) + .setType(sensor.sensorInfo.getType()) + .build(); + } //endregion - //region Getters/Setters - /** - * Getter function for core location data. - * - * @param start set true to get the initial location - * @return longitude and latitude data in a float[2]. - */ - public float[] getGNSSLatitude(boolean start) { - float [] latLong = new float[2]; - if(!start) { - latLong[0] = latitude; - latLong[1] = longitude; - } - else{ - latLong = startLocation; + public void addMarker() { + if (saveRecording && trajectory != null) { + long relativeTime = SystemClock.uptimeMillis() - bootTime; + + // Create a timestamped marker with index + Traj.TimestampMarker marker = Traj.TimestampMarker.newBuilder() + .setRelativeTimestamp(relativeTime) + .setLatitude(latitude) + .setLongitude(longitude) + .setAltitude(altitude_val) + .setMarkerIndex(markerCount) + .setMarkerName("Marker_" + markerCount) // Auto-generated name + .build(); + + trajectory.addTestPoints(marker); + markerCount++; + Log.d("SensorFusion", "Marker #" + (markerCount - 1) + " added at: " + relativeTime + "ms"); } - return latLong; - } - - /** - * Setter function for core location data. - * - * @param startPosition contains the initial location set by the user - */ - public void setStartGNSSLatitude(float[] startPosition){ - startLocation = startPosition; - } - - - /** - * Function to redraw path in corrections fragment. - * - * @param scalingRatio new size of path due to updated step length - */ - public void redrawPath(float scalingRatio){ - pathView.redraw(scalingRatio); - } - - /** - * Getter function for average step count. - * Calls the average step count function in pdrProcessing class - * - * @return average step count of total PDR. - */ - public float passAverageStepLength(){ - return pdrProcessing.getAverageStepLength(); - } - - /** - * Getter function for device orientation. - * Passes the orientation variable - * - * @return orientation of device. - */ - public float passOrientation(){ - return orientation[0]; - } - - /** - * Return most recent sensor readings. - * - * Collects all most recent readings from movement and location sensors, packages them in a map - * that is indexed by {@link SensorTypes} and makes it accessible for other classes. - * - * @return Map of SensorTypes to float array of most recent values. - */ - public Map getSensorValueMap() { - Map sensorValueMap = new HashMap<>(); - sensorValueMap.put(SensorTypes.ACCELEROMETER, acceleration); - sensorValueMap.put(SensorTypes.GRAVITY, gravity); - sensorValueMap.put(SensorTypes.MAGNETICFIELD, magneticField); - sensorValueMap.put(SensorTypes.GYRO, angularVelocity); - sensorValueMap.put(SensorTypes.LIGHT, new float[]{light}); - sensorValueMap.put(SensorTypes.PRESSURE, new float[]{pressure}); - sensorValueMap.put(SensorTypes.PROXIMITY, new float[]{proximity}); - sensorValueMap.put(SensorTypes.GNSSLATLONG, getGNSSLatitude(false)); - sensorValueMap.put(SensorTypes.PDR, pdrProcessing.getPDRMovement()); - return sensorValueMap; } /** - * Return the most recent list of WiFi names and levels. - * Each Wifi object contains a BSSID and a level value. - * - * @return list of Wifi objects. + * Set the venue/building name for this trajectory */ - public List getWifiList() { - return this.wifiList; - } - - /** - * Get information about all the sensors registered in SensorFusion. - * - * @return List of SensorInfo objects containing name, resolution, power, etc. - */ - public List getSensorInfos() { - List sensorInfoList = new ArrayList<>(); - sensorInfoList.add(this.accelerometerSensor.sensorInfo); - sensorInfoList.add(this.barometerSensor.sensorInfo); - sensorInfoList.add(this.gyroscopeSensor.sensorInfo); - sensorInfoList.add(this.lightSensor.sensorInfo); - sensorInfoList.add(this.proximitySensor.sensorInfo); - sensorInfoList.add(this.magnetometerSensor.sensorInfo); - return sensorInfoList; + public void setVenueName(String venueName) { + if (trajectory != null && venueName != null && !venueName.isEmpty()) { + trajectory.setVenueName(venueName); + Log.d("SensorFusion", "Venue name set to: " + venueName); + } } /** - * Registers the caller observer to receive updates from the server instance. - * Necessary when classes want to act on a trajectory being successfully or unsuccessfully send - * to the server. This grants access to observing the {@link ServerCommunications} instance - * used by the SensorFusion class. - * - * @param observer Instance implementing {@link Observer} class who wants to be notified of - * events relating to sending and receiving trajectories. + * Set the building ID for linking to indoor map APIs */ - public void registerForServerUpdate(Observer observer) { - serverCommunications.registerObserver(observer); + public void setBuildingId(String buildingId) { + if (trajectory != null && buildingId != null && !buildingId.isEmpty()) { + trajectory.setBuildingId(buildingId); + Log.d("SensorFusion", "Building ID set to: " + buildingId); + } } /** - * Get the estimated elevation value in meters calculated by the PDR class. - * Elevation is relative to the starting position. - * - * @return float of the estimated elevation in meters. + * Update current heading/bearing (in degrees 0-360) */ - public float getElevation() { - return this.elevation; + public void updateHeading(float heading) { + this.currentHeading = heading % 360.0f; // Normalize to 0-360 + if (currentHeading < 0) { + currentHeading += 360.0f; + } } /** - * Get an estimate by the PDR class whether it estimates the user is currently taking an elevator. - * - * @return true if the PDR estimates the user is in an elevator, false otherwise. + * Get current heading */ - public boolean getElevator() { - return this.elevator; + public float getCurrentHeading() { + return currentHeading; } /** - * Estimates position of the phone based on proximity and light sensors. - * - * @return int 1 if the phone is by the ear, int 0 otherwise. + * Get marker count */ - public int getHoldMode(){ - int proximityThreshold = 1, lightThreshold = 100; //holdMode: by ear=1, not by ear =0 - if(proximitylightThreshold) { //unit cm - return 1; - } - else{ - return 0; - } + public int getMarkerCount() { + return markerCount; } - //endregion - //region Start/Stop - /** - * Registers all device listeners and enables updates with the specified sampling rate. - * - * Should be called from {@link MainActivity} when resuming the application. Sampling rate is in - * microseconds, IMU needs 100Hz, rest 1Hz - * - * @see MovementSensor handles SensorManager based devices. - * @see WifiDataProcessor handles wifi data. - * @see GNSSDataProcessor handles location data. - */ public void resumeListening() { - accelerometerSensor.sensorManager.registerListener(this, accelerometerSensor.sensor, 10000, (int) maxReportLatencyNs); - accelerometerSensor.sensorManager.registerListener(this, linearAccelerationSensor.sensor, 10000, (int) maxReportLatencyNs); - accelerometerSensor.sensorManager.registerListener(this, gravitySensor.sensor, 10000, (int) maxReportLatencyNs); - barometerSensor.sensorManager.registerListener(this, barometerSensor.sensor, (int) 1e6); - gyroscopeSensor.sensorManager.registerListener(this, gyroscopeSensor.sensor, 10000, (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); - stepDetectionSensor.sensorManager.registerListener(this, stepDetectionSensor.sensor, SensorManager.SENSOR_DELAY_NORMAL); - rotationSensor.sensorManager.registerListener(this, rotationSensor.sensor, (int) 1e6); + if (accelerometerSensor.sensor != null) accelerometerSensor.sensorManager.registerListener(this, accelerometerSensor.sensor, 10000); + if (linearAccelerationSensor.sensor != null) accelerometerSensor.sensorManager.registerListener(this, linearAccelerationSensor.sensor, 10000); + if (gravitySensor.sensor != null) accelerometerSensor.sensorManager.registerListener(this, gravitySensor.sensor, 10000); + if (barometerSensor.sensor != null) barometerSensor.sensorManager.registerListener(this, barometerSensor.sensor, (int) 1e6); + if (gyroscopeSensor.sensor != null) gyroscopeSensor.sensorManager.registerListener(this, gyroscopeSensor.sensor, 10000); + if (lightSensor.sensor != null) lightSensor.sensorManager.registerListener(this, lightSensor.sensor, (int) 1e6); + if (proximitySensor.sensor != null) proximitySensor.sensorManager.registerListener(this, proximitySensor.sensor, (int) 1e6); + if (magnetometerSensor.sensor != null) magnetometerSensor.sensorManager.registerListener(this, magnetometerSensor.sensor, 10000); + if (stepDetectionSensor.sensor != null) stepDetectionSensor.sensorManager.registerListener(this, stepDetectionSensor.sensor, SensorManager.SENSOR_DELAY_NORMAL); + if (rotationSensor.sensor != null) rotationSensor.sensorManager.registerListener(this, rotationSensor.sensor, (int) 1e6); + wifiProcessor.startListening(); gnssProcessor.startLocationUpdates(); } - /** - * Un-registers all device listeners and pauses data collection. - * - * Should be called from {@link MainActivity} when pausing the application. - * - * @see MovementSensor handles SensorManager based devices. - * @see WifiDataProcessor handles wifi data. - * @see GNSSDataProcessor handles location data. - */ public void stopListening() { if(!saveRecording) { - // Unregister sensor-manager based devices accelerometerSensor.sensorManager.unregisterListener(this); barometerSensor.sensorManager.unregisterListener(this); gyroscopeSensor.sensorManager.unregisterListener(this); @@ -831,53 +620,178 @@ public void stopListening() { rotationSensor.sensorManager.unregisterListener(this); linearAccelerationSensor.sensorManager.unregisterListener(this); gravitySensor.sensorManager.unregisterListener(this); - //The app often crashes here because the scan receiver stops after it has found the list. - // It will only unregister one if there is to unregister try { - this.wifiProcessor.stopListening(); //error here? + this.wifiProcessor.stopListening(); } catch (Exception e) { System.err.println("Wifi resumed before existing"); } - // Stop receiving location updates this.gnssProcessor.stopUpdating(); } } + private void startBleScan() { + // Prevent multiple scan starts + if (bluetoothLeScanner != null && bleScanCallback != null) { + android.util.Log.d("SensorFusion", "BLE scan already running"); + return; // Already scanning + } + + if (bluetoothAdapter == null) { + android.util.Log.w("SensorFusion", "Bluetooth adapter is null"); + return; + } + + if (!bluetoothAdapter.isEnabled()) { + android.util.Log.w("SensorFusion", "Bluetooth is not enabled"); + return; + } + + bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner(); + if (bluetoothLeScanner == null) { + android.util.Log.w("SensorFusion", "Bluetooth LE scanner is null"); + return; + } + + bleScanCallback = new ScanCallback() { + @Override + public void onScanResult(int callbackType, ScanResult result) { + super.onScanResult(callbackType, result); + + // Add to display list for UI + String macAddress = result.getDevice().getAddress(); + String name = null; + try { + if (ActivityCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) { + name = result.getDevice().getName(); + } + } catch (SecurityException ignored) { } + + int rssi = result.getRssi(); + + // Update existing device or add new one + synchronized (bleList) { + boolean found = false; + for (BleDevice device : bleList) { + if (device.getMacAddress().equals(macAddress)) { + device.setRssi(rssi); + if (name != null) { + device.setName(name); + } + found = true; + break; + } + } + if (!found) { + bleList.add(new BleDevice(macAddress, name, rssi)); + android.util.Log.d("SensorFusion", "New BLE device added: " + macAddress); + } + } + + // Save to trajectory if recording + if (saveRecording && trajectory != null) { + Traj.BleData.Builder bleBuilder = Traj.BleData.newBuilder() + .setMacAddress(macAddress) + .setTxPowerLevel(result.getTxPower()); + + if (name != null) { + bleBuilder.setName(name); + } + + trajectory.addBleData(bleBuilder.build()); + } + } + + @Override + public void onScanFailed(int errorCode) { + super.onScanFailed(errorCode); + android.util.Log.e("SensorFusion", "BLE scan failed with error code: " + errorCode); + } + }; + + try { + if (ActivityCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED) { + bluetoothLeScanner.startScan(bleScanCallback); + android.util.Log.d("SensorFusion", "BLE scan started successfully"); + } else { + android.util.Log.w("SensorFusion", "BLUETOOTH_SCAN permission not granted"); + } + } catch (SecurityException e) { + android.util.Log.e("SensorFusion", "SecurityException starting BLE scan: " + e.getMessage()); + } + } + + private void stopBleScan() { + if (bluetoothLeScanner != null && bleScanCallback != null) { + try { + if (ActivityCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED) { + bluetoothLeScanner.stopScan(bleScanCallback); + android.util.Log.d("SensorFusion", "BLE scan stopped"); + } + } catch (SecurityException e) { + android.util.Log.e("SensorFusion", "Error stopping BLE scan: " + e.getMessage()); + } + } + } + /** - * Enables saving sensor values to the trajectory object. - * - * Sets save recording to true, resets the absolute start time and create new timer object for - * periodically writing data to trajectory. - * - * @see Traj object for storing data. + * Starts recording with the specified trajectory ID. */ - public void startRecording() { - // If wakeLock is null (e.g. not initialized or was cleared), reinitialize it. + public void startRecording(String trajectoryId) { if (wakeLock == null) { PowerManager powerManager = (PowerManager) this.appContext.getSystemService(Context.POWER_SERVICE); - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MyApp::MyWakelockTag"); + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PositionMe::WakeLock"); } - wakeLock.acquire(31 * 60 * 1000L /*31 minutes*/); + wakeLock.acquire(31 * 60 * 1000L); + + // Safety check: provide default if ID is empty + if (trajectoryId == null || trajectoryId.isEmpty()) { + trajectoryId = "UnknownTraj_" + System.currentTimeMillis(); + } + + // Log to confirm received ID is correct + Log.d("SensorFusion", "Start recording with ID: " + trajectoryId); this.saveRecording = true; this.stepCounter = 0; + this.gnssRecordCount = 0; + this.pdrRecordCount = 0; + this.markerCount = 0; // Reset marker counter + this.currentHeading = orientation[2]; // Set initial heading from magnetometer this.absoluteStartTime = System.currentTimeMillis(); this.bootTime = SystemClock.uptimeMillis(); - // Protobuf trajectory class for sending sensor data to restful API + + // Build trajectory with provided ID this.trajectory = Traj.Trajectory.newBuilder() .setAndroidVersion(Build.VERSION.RELEASE) .setStartTimestamp(absoluteStartTime) - .setAccelerometerInfo(createInfoBuilder(accelerometerSensor)) - .setGyroscopeInfo(createInfoBuilder(gyroscopeSensor)) - .setMagnetometerInfo(createInfoBuilder(magnetometerSensor)) - .setBarometerInfo(createInfoBuilder(barometerSensor)) - .setLightSensorInfo(createInfoBuilder(lightSensor)); - - + .setTrajectoryId(trajectoryId) // Use parameter variable + .setInitialHeading(currentHeading) // Set initial bearing + .setAccelerometerInfo(createSensorInfo(accelerometerSensor)) + .setGyroscopeInfo(createSensorInfo(gyroscopeSensor)) + .setMagnetometerInfo(createSensorInfo(magnetometerSensor)) + .setBarometerInfo(createSensorInfo(barometerSensor)) + .setLightSensorInfo(createSensorInfo(lightSensor)); + + Traj.GNSSPosition initialPos = Traj.GNSSPosition.newBuilder() + .setLatitude(latitude) + .setLongitude(longitude) + .setAltitude(altitude_val) + .setRelativeTimestamp(0) + .build(); + this.trajectory.setInitialPosition(initialPos); + + // BLE scan is already running from setContext(), no need to start again this.storeTrajectoryTimer = new Timer(); this.storeTrajectoryTimer.schedule(new storeDataInTrajectory(), 0, TIME_CONST); this.pdrProcessing.resetPDR(); + + // Reset Low-pass filter and peaks for new recording + accWindowIndex = 0; + for(int i=0; i 0) { + Traj.GNSSReading firstGnss = sentTrajectory.getGnssData(0); + Traj.GNSSReading lastGnss = sentTrajectory.getGnssData(sentTrajectory.getGnssDataCount() - 1); + Log.d("SensorFusion", "First GNSS: lat=" + firstGnss.getPosition().getLatitude() + + ", lon=" + firstGnss.getPosition().getLongitude()); + Log.d("SensorFusion", "Last GNSS: lat=" + lastGnss.getPosition().getLatitude() + + ", lon=" + lastGnss.getPosition().getLongitude()); + } else { + Log.e("SensorFusion", "WARNING: No GNSS data in trajectory!"); + } - /** - * Creates a {@link Traj.Sensor_Info} objects from the specified sensor's data. - * - * @param sensor MovementSensor objects with populated sensorInfo fields - * @return Traj.SensorInfo object to be used in building the trajectory - * - * @see Traj Trajectory object used for communication with the server - * @see MovementSensor class abstracting SensorManager based sensors - */ - private Traj.Sensor_Info.Builder createInfoBuilder(MovementSensor sensor) { - return Traj.Sensor_Info.newBuilder() - .setName(sensor.sensorInfo.getName()) - .setVendor(sensor.sensorInfo.getVendor()) - .setResolution(sensor.sensorInfo.getResolution()) - .setPower(sensor.sensorInfo.getPower()) - .setVersion(sensor.sensorInfo.getVersion()) - .setType(sensor.sensorInfo.getType()); + if (sentTrajectory.getPdrDataCount() > 0) { + Traj.RelativePosition firstPdr = sentTrajectory.getPdrData(0); + Traj.RelativePosition lastPdr = sentTrajectory.getPdrData(sentTrajectory.getPdrDataCount() - 1); + Log.d("SensorFusion", "First PDR: x=" + firstPdr.getX() + ", y=" + firstPdr.getY()); + Log.d("SensorFusion", "Last PDR: x=" + lastPdr.getX() + ", y=" + lastPdr.getY()); + } else { + Log.e("SensorFusion", "WARNING: No PDR data in trajectory!"); + } + + Log.d("SensorFusion", "Campaign: " + (campaign.isEmpty() ? "(none)" : campaign)); + Log.d("SensorFusion", "=========================================="); + + this.serverCommunications.sendTrajectory(sentTrajectory, campaign); + } else { + Log.e("SensorFusion", "ERROR: trajectory is null, cannot send!"); + } } - /** - * Timer task to record data with the desired frequency in the trajectory class. - * - * Inherently threaded, runnables are created in {@link SensorFusion#startRecording()} and - * destroyed in {@link SensorFusion#stopRecording()}. - */ private class storeDataInTrajectory extends TimerTask { public void run() { - // Store IMU and magnetometer data in Trajectory class - trajectory.addImuData(Traj.Motion_Sample.newBuilder() - .setRelativeTimestamp(SystemClock.uptimeMillis()-bootTime) - .setAccX(acceleration[0]) - .setAccY(acceleration[1]) - .setAccZ(acceleration[2]) - .setGyrX(angularVelocity[0]) - .setGyrY(angularVelocity[1]) - .setGyrZ(angularVelocity[2]) - .setGyrZ(angularVelocity[2]) - .setRotationVectorX(rotation[0]) - .setRotationVectorY(rotation[1]) - .setRotationVectorZ(rotation[2]) - .setRotationVectorW(rotation[3]) - .setStepCount(stepCounter)) - .addPositionData(Traj.Position_Sample.newBuilder() - .setMagX(magneticField[0]) - .setMagY(magneticField[1]) - .setMagZ(magneticField[2]) - .setRelativeTimestamp(SystemClock.uptimeMillis()-bootTime)) -// .addGnssData(Traj.GNSS_Sample.newBuilder() -// .setLatitude(latitude) -// .setLongitude(longitude) -// .setRelativeTimestamp(SystemClock.uptimeMillis()-bootTime)) - ; - - // Divide timer with a counter for storing data every 1 second + if (trajectory == null) return; + long relTime = SystemClock.uptimeMillis() - bootTime; + + trajectory.addImuData(Traj.IMUReading.newBuilder() + .setRelativeTimestamp(relTime) + .setAcc(toVector3(acceleration)) + .setGyr(toVector3(angularVelocity)) + .setRotationVector(toQuaternion(rotation)) + .setStepCount(stepCounter) + .build()); + + trajectory.addMagnetometerData(Traj.MagnetometerReading.newBuilder() + .setRelativeTimestamp(relTime) + .setMag(toVector3(magneticField)) + .build()); + if (counter == 99) { counter = 0; - // Store pressure and light data + if (barometerSensor.sensor != null) { - trajectory.addPressureData(Traj.Pressure_Sample.newBuilder() - .setPressure(pressure) - .setRelativeTimestamp(SystemClock.uptimeMillis() - bootTime)) - .addLightData(Traj.Light_Sample.newBuilder() - .setLight(light) - .setRelativeTimestamp(SystemClock.uptimeMillis() - bootTime) - .build()); + trajectory.addPressureData(Traj.BarometerReading.newBuilder() + .setPressure(pressure) + .setRelativeTimestamp(relTime) + .build()); + } + + if (lightSensor.sensor != null) { + trajectory.addLightData(Traj.LightReading.newBuilder() + .setLight(light) + .setRelativeTimestamp(relTime) + .build()); + } + + if(proximitySensor.sensor != null) { + trajectory.addProximityData(Traj.ProximityReading.newBuilder() + .setDistance(proximity) + .setRelativeTimestamp(relTime) + .build()); } - // Divide the timer for storing AP data every 5 seconds if (secondCounter == 4) { secondCounter = 0; - //Current Wifi Object Wifi currentWifi = wifiProcessor.getCurrentWifiData(); - trajectory.addApsData(Traj.AP_Data.newBuilder() - .setMac(currentWifi.getBssid()) - .setSsid(currentWifi.getSsid()) - .setFrequency(currentWifi.getFrequency())); + if (currentWifi != null) { + trajectory.addApsData(Traj.WiFiAPData.newBuilder() + .setMac(currentWifi.getBssid()) + .setSsid(currentWifi.getSsid() != null ? currentWifi.getSsid() : "") + .setFrequency(currentWifi.getFrequency()) + .build()); + } } else { secondCounter++; @@ -1005,10 +939,133 @@ public void run() { else { counter++; } + } + } + + //endregion + + //region Getters/Setters + + public Map getSensorValueMap() { + Map sensorValueMap = new HashMap<>(); + + sensorValueMap.put(SensorTypes.ACCELEROMETER, acceleration); + sensorValueMap.put(SensorTypes.GRAVITY, gravity); + sensorValueMap.put(SensorTypes.MAGNETICFIELD, magneticField); + sensorValueMap.put(SensorTypes.GYRO, angularVelocity); + sensorValueMap.put(SensorTypes.LIGHT, new float[]{light}); + sensorValueMap.put(SensorTypes.PRESSURE, new float[]{pressure}); + sensorValueMap.put(SensorTypes.PROXIMITY, new float[]{proximity}); + sensorValueMap.put(SensorTypes.GNSSLATLONG, new float[]{latitude, longitude, gnssAccuracy}); // Added accuracy + sensorValueMap.put(SensorTypes.PDR, pdrProcessing.getPDRMovement()); + sensorValueMap.put(SensorTypes.WIFI, new float[]{0f}); + sensorValueMap.put(SensorTypes.BLE, new float[]{0f}); + + return sensorValueMap; + } + + public List getWifiList() { + return wifiList; + } + + public List getBleList() { + synchronized (bleList) { + return new ArrayList<>(bleList); + } + } + + public float[] getGNSSLatitude(boolean start) { + float [] latLong = new float[2]; + if(!start) { + latLong[0] = latitude; + latLong[1] = longitude; + } + else{ + latLong = startLocation; + } + return latLong; + } + + public void setStartGNSSLatitude(float[] startPosition){ + startLocation = startPosition; + } + + public void redrawPath(float scalingRatio){ + pathView.redraw(scalingRatio); + } + + public float passAverageStepLength(){ + return pdrProcessing.getAverageStepLength(); + } + + public float passOrientation(){ + return orientation[0]; + } + + public List getSensorInfos() { + List infoList = new ArrayList<>(); + if (accelerometerSensor != null && accelerometerSensor.sensorInfo != null) infoList.add(accelerometerSensor.sensorInfo); + if (gyroscopeSensor != null && gyroscopeSensor.sensorInfo != null) infoList.add(gyroscopeSensor.sensorInfo); + if (magnetometerSensor != null && magnetometerSensor.sensorInfo != null) infoList.add(magnetometerSensor.sensorInfo); + if (barometerSensor != null && barometerSensor.sensorInfo != null) infoList.add(barometerSensor.sensorInfo); + if (lightSensor != null && lightSensor.sensorInfo != null) infoList.add(lightSensor.sensorInfo); + if (proximitySensor != null && proximitySensor.sensorInfo != null) infoList.add(proximitySensor.sensorInfo); + if (stepDetectionSensor != null && stepDetectionSensor.sensorInfo != null) infoList.add(stepDetectionSensor.sensorInfo); + if (rotationSensor != null && rotationSensor.sensorInfo != null) infoList.add(rotationSensor.sensorInfo); + if (gravitySensor != null && gravitySensor.sensorInfo != null) infoList.add(gravitySensor.sensorInfo); + if (linearAccelerationSensor != null && linearAccelerationSensor.sensorInfo != null) infoList.add(linearAccelerationSensor.sensorInfo); + + // Add Bluetooth adapter info + if (bluetoothAdapter != null) { + String adapterName = "System"; + try { + if (ActivityCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) { + String name = bluetoothAdapter.getName(); + if (name != null && !name.isEmpty()) { + adapterName = name; + } + } + } catch (SecurityException e) { + android.util.Log.d("SensorFusion", "Cannot get Bluetooth adapter name: " + e.getMessage()); + } + SensorInfo bleInfo = new SensorInfo( + "Bluetooth LE Scanner", + adapterName, + -1.0f, + 0.0f, + 0, + -1 + ); + infoList.add(bleInfo); } + + return infoList; + } + + public void registerForServerUpdate(Observer observer) { + serverCommunications.registerObserver(observer); } + public float getElevation() { return this.elevation; } + public boolean getElevator() { return this.elevator; } + + public int getHoldMode(){ + int proximityThreshold = 1, lightThreshold = 100; + if(proximitylightThreshold) return 1; + else return 0; + } //endregion -} + @Override + public void onAccuracyChanged(Sensor sensor, int i) {} + + public Traj.Trajectory.Builder getTrajectory() { + return this.trajectory; + } + public void resetPDR() { + if (pdrProcessing != null) { + pdrProcessing.refreshSettings(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorTypes.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorTypes.java index ee3bbcc1..64bcbba4 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorTypes.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorTypes.java @@ -20,5 +20,7 @@ public enum SensorTypes { PRESSURE, PROXIMITY, GNSSLATLONG, - PDR; -} + PDR, // Changed semicolon to comma to continue the list + WIFI, // Added + BLE; // Semicolon now correctly ends the constant list +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiDataProcessor.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiDataProcessor.java index fa8a17dd..400c4792 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiDataProcessor.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiDataProcessor.java @@ -11,41 +11,34 @@ import android.net.wifi.ScanResult; import android.net.wifi.WifiManager; import android.provider.Settings; +import android.util.Log; import android.widget.Toast; import androidx.core.app.ActivityCompat; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.Timer; import java.util.TimerTask; + /** - * The WifiDataProcessor class is the Wi-Fi data gathering and processing class of the application. - * It implements the wifi scanning and broadcasting design to identify a list of nearby Wi-Fis as - * well as collecting information about the current Wi-Fi connection. - *

- * The class implements {@link Observable} for informing {@link Observer} classes of updated - * variables. As such, it implements the {@link WifiDataProcessor#notifyObservers(int idx)} function and - * the {@link WifiDataProcessor#registerObserver(Observer o)} function to add new users which will - * be notified of new changes. - *

- * The class ensures all required permissions are granted before enabling the Wi-Fi. The class will - * periodically start a wifi scan as determined by {@link SensorFusion}. When a broadcast is - * received it will collect a list of users and notify users. The - * {@link WifiDataProcessor#getCurrentWifiData()} function will return information about the current - * Wi-Fi when called by {@link SensorFusion}. - * - * @author Mate Stodulka - * @author Virginia Cangelosi + * WifiDataProcessor (Updated for Assignment 1) + * * Updates: + * 1. Fixed crash caused by unregistering receiver twice. + * 2. Populates SSID and Frequency in scan results (for Task B). + * 3. Improves MAC address conversion stability. + * 4. Ensures no duplicate BSSIDs in a single scan. */ public class WifiDataProcessor implements Observable { - //Time over which a new scan will be initiated + //Time over which a new scan will be initiated (5 seconds) private static final long scanInterval = 5000; // Application context for handling permissions and WifiManager instances private final Context context; - // Locations manager to enable access to Wifi data via the android system + // Wifi manager to enable access to Wifi data via the android system private final WifiManager wifiManager; //List of nearby networks @@ -57,20 +50,11 @@ public class WifiDataProcessor implements Observable { // Timer object private Timer scanWifiDataTimer; + // Fix: Track registration to prevent crash + private boolean isReceiverRegistered = false; + /** * Public default constructor of the WifiDataProcessor class. - * The constructor saves the context, checks for permissions to use the location services, - * creates an instance of the shared preferences to access settings using the context, - * initialises the wifi manager, and creates a timer object and list of observers. It checks if - * wifi is enabled and enables wifi scans every 5seconds. It also informs the user to disable - * wifi throttling if the device implements it. - * - * @param context Application Context to be used for permissions and device accesses. - * - * @see SensorFusion the intended parent class. - * - * @author Virginia Cangelosi - * @author Mate Stodulka */ public WifiDataProcessor(Context context) { this.context = context; @@ -80,15 +64,9 @@ public WifiDataProcessor(Context context) { this.scanWifiDataTimer = new Timer(); this.observers = new ArrayList<>(); - // Decreapted method after API 29 - // Turn on wifi if it is currently disabled - // TODO - turn it to a notification toward user -// // if(permissionsGranted && wifiManager.getWifiState()== WifiManager.WIFI_STATE_DISABLED) { -// // wifiManager.setWifiEnabled(true); -// // } - // Start wifi scan and return results via broadcast if(permissionsGranted) { + // Schedule the first scan immediately (0 delay) this.scanWifiDataTimer.schedule(new scheduledWifiScan(), 0, scanInterval); } @@ -98,93 +76,103 @@ public WifiDataProcessor(Context context) { /** * Broadcast receiver to receive updates from the wifi manager. - * Receives updates when a wifi scan is complete. Observers are notified when the broadcast is - * received to update the list of wifis */ BroadcastReceiver wifiScanReceiver = new BroadcastReceiver() { - /** - * Updates the list of nearby wifis when the broadcast is received. - * Ensures wifi scans are not enabled if permissions are not granted. The list of wifis is - * then passed to store the Mac Address and strength and observers of the WifiDataProcessor - * class are notified of the updated wifi list. - * - * - * @param context Application Context to be used for permissions and device accesses. - * @param intent ???. - */ @Override public void onReceive(Context context, Intent intent) { - + // Safety check for permissions if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { - // Unregister this listener - stopListening(); return; } - //Collect the list of nearby wifis - List wifiScanList = wifiManager.getScanResults(); - //Stop receiver as scan is complete - context.unregisterReceiver(this); - - //Loop though each item in wifi list - wifiData = new Wifi[wifiScanList.size()]; - for(int i = 0; i < wifiScanList.size(); i++) { - wifiData[i] = new Wifi(); - //Convert String mac address to an integer - String wifiMacAddress = wifiScanList.get(i).BSSID; - long intMacAddress = convertBssidToLong(wifiMacAddress); - //store mac address and rssi of wifi - wifiData[i].setBssid(intMacAddress); - wifiData[i].setLevel(wifiScanList.get(i).level); + // Unregister receiver immediately to prevent leaks, but check flag first + // Note: In this design, we register for EACH scan and unregister immediately after. + try { + if (isReceiverRegistered) { + context.unregisterReceiver(this); + isReceiverRegistered = false; + } + } catch (IllegalArgumentException e) { + // Ignore if already unregistered } - //Notify observers of change in wifiData variable - notifyObservers(0); + // Check for success + boolean success = intent.getBooleanExtra(WifiManager.EXTRA_RESULTS_UPDATED, false); + if (success) { + processScanResults(); + } else { + // Scan failed (maybe throttled), but we still notify observers with old or empty data if needed + // For now, we just skip update + Log.w("WifiDataProcessor", "Scan failure received. Throttling might be active."); + } } }; /** - * Converts mac address from string to integer. - * Removes semicolons from mac address and converts each hex byte to a hex integer. - * - * - * @param wifiMacAddress String Mac Address received from WifiManager containing colons - * - * @return Long variable with decimal conversion of the mac address + * Process the successful scan results. */ - private long convertBssidToLong(String wifiMacAddress){ - long intMacAddress =0; - int colonCount =5; - //Loop through each character - for(int j =0; j<17; j++){ - //Identify character - char macByte = wifiMacAddress.charAt(j); - //convert string hex mac address with colons to decimal long integer - if(macByte != ':'){ - //For characters 0-9 subtract 48 from ASCII code and multiply by 16^position - if((int) macByte >= 48 && (int) macByte <= 57){ - intMacAddress = intMacAddress + (((int)macByte-48)*((long)Math.pow(16,16-j-colonCount))); - } + private void processScanResults() { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + return; + } - //For characters a-f subtract 87 (=97-10) from ASCII code and multiply by 16^index - else if ((int) macByte >= 97 && (int) macByte <= 102){ - intMacAddress = intMacAddress + (((int)macByte-87)*((long)Math.pow(16,16-j-colonCount))); - } + List wifiScanList = wifiManager.getScanResults(); + + // Use a set to filter duplicates based on BSSID + List uniqueWifiList = new ArrayList<>(); + Set seenBssids = new HashSet<>(); + + for (ScanResult result : wifiScanList) { + // Skip if we've already seen this MAC in this batch + if (seenBssids.contains(result.BSSID)) { + continue; } - else - //coloncount is used to obtain the index of each character - colonCount --; + seenBssids.add(result.BSSID); + + Wifi wifi = new Wifi(); + + // 1. MAC / BSSID + long intMacAddress = convertBssidToLong(result.BSSID); + wifi.setBssid(intMacAddress); + + // 2. RSSI / Level + wifi.setLevel(result.level); + + // 3. 【Task B Upgrade】: SSID + wifi.setSsid(result.SSID != null ? result.SSID : ""); + + // 4. 【Task B Upgrade】: Frequency + wifi.setFrequency(result.frequency); + + uniqueWifiList.add(wifi); } - return intMacAddress; + // Convert List to Array for compatibility with existing Observer interface + wifiData = uniqueWifiList.toArray(new Wifi[0]); + + // Notify observers of change + notifyObservers(0); + } + + /** + * Converts mac address from string to integer (Robust version). + */ + private long convertBssidToLong(String wifiMacAddress){ + if (wifiMacAddress == null || wifiMacAddress.isEmpty()) return 0; + try { + // Remove colons and parse as hex + String hex = wifiMacAddress.replace(":", "").replace("-", "").trim(); + // Use Long.parseUnsignedLong to handle large MAC values correctly + return Long.parseUnsignedLong(hex, 16); + } catch (NumberFormatException e) { + // Fallback or log error + Log.e("WifiDataProcessor", "Error parsing MAC: " + wifiMacAddress); + return 0; + } } /** * Checks if the user authorised all permissions necessary for accessing wifi data. - * Explicit user permissions must be granted for android sdk version 23 and above. This - * function checks which permissions are granted, and returns their conjunction. - * - * @return boolean true if all permissions are granted for wifi access, false otherwise. */ private boolean checkWifiPermissions() { int wifiAccessPermission = ActivityCompat.checkSelfPermission(this.context, @@ -196,7 +184,6 @@ private boolean checkWifiPermissions() { int fineLocationPermission = ActivityCompat.checkSelfPermission(this.context, Manifest.permission.ACCESS_FINE_LOCATION); - // Return missing permissions return wifiAccessPermission == PackageManager.PERMISSION_GRANTED && wifiChangePermission == PackageManager.PERMISSION_GRANTED && coarseLocationPermission == PackageManager.PERMISSION_GRANTED && @@ -205,89 +192,93 @@ private boolean checkWifiPermissions() { /** * Scan for nearby networks. - * The method checks for permissions again, and then requests a scan of nearby wifis. A - * broadcast receiver is registered to be called when the scan is complete. */ private void startWifiScan() { - //Check settings for wifi permissions if(checkWifiPermissions()) { - //if(sharedPreferences.getBoolean("wifi", false)) { - //Register broadcast receiver for wifi scans - context.registerReceiver(wifiScanReceiver, new IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)); - wifiManager.startScan(); - - //} + try { + // Register receiver ONLY if not already registered + if (!isReceiverRegistered) { + context.registerReceiver(wifiScanReceiver, new IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)); + isReceiverRegistered = true; + } + boolean started = wifiManager.startScan(); + if (!started) { + Log.d("WifiDataProcessor", "Wifi Scan start failed (likely throttled)"); + } + } catch (Exception e) { + Log.e("WifiDataProcessor", "Error starting scan: " + e.getMessage()); + isReceiverRegistered = false; // Reset flag on error + } } } /** * Initiate scans for nearby networks every 5 seconds. - * The method declares a new timer instance to schedule a scan for nearby wifis every 5 seconds. */ public void startListening() { + // Cancel existing timer if any to avoid duplicates + if (this.scanWifiDataTimer != null) { + this.scanWifiDataTimer.cancel(); + } this.scanWifiDataTimer = new Timer(); this.scanWifiDataTimer.scheduleAtFixedRate(new scheduledWifiScan(), 0, scanInterval); } /** * Cancel wifi scans. - * The method unregisters the broadcast receiver associated with the wifi scans and cancels the - * timer so that new scans are not initiated. */ public void stopListening() { - context.unregisterReceiver(wifiScanReceiver); - this.scanWifiDataTimer.cancel(); + // Safe unregister + try { + if (isReceiverRegistered) { + context.unregisterReceiver(wifiScanReceiver); + isReceiverRegistered = false; + } + } catch (IllegalArgumentException e) { + // Ignore if not registered + } + + if (this.scanWifiDataTimer != null) { + this.scanWifiDataTimer.cancel(); + this.scanWifiDataTimer = null; // Prevent reuse + } } /** - * Inform user if throttling is resent on their device. - * If the device supports wifi throttling check if it is enabled and instruct the user to - * disable it. + * Inform user if throttling is present. */ public void checkWifiThrottling(){ if(checkWifiPermissions()) { - //If the device does not support wifi throttling an exception is thrown try { - if(Settings.Global.getInt(context.getContentResolver(), "wifi_scan_throttle_enabled")==1) { - //Inform user to disable wifi throttling - Toast.makeText(context, "Disable Wi-Fi Throttling", Toast.LENGTH_SHORT).show(); + // Check if throttling is enabled (API 28+) + // Note: This setting might not be readable on all devices/versions without special permissions, + // but we keep the try-catch block as in original. + if(Settings.Global.getInt(context.getContentResolver(), "wifi_scan_throttle_enabled") == 1) { + Toast.makeText(context, "Disable Wi-Fi Throttling in Dev Options", Toast.LENGTH_LONG).show(); } } catch (Settings.SettingNotFoundException e) { - e.printStackTrace(); + // Setting not found, ignore } } } - /** - * Implement default method from Observable Interface to add new observers to the class. - * - * @param o Classes which implement the Observer interface to receive updates from the class. - */ @Override public void registerObserver(Observer o) { observers.add(o); } - /** - * Implement default method from Observable Interface to add notify observers to the class. - * Changes to the wifiData variable are passed to observers of the class. - * @param idx Unused. - */ @Override public void notifyObservers(int idx) { for(Observer o : observers) { - o.update(wifiData); + // Make a copy or pass the array. + // wifiData might be null if no scan has finished yet. + if (wifiData != null) { + o.update(wifiData); + } } } - /** - * Class to schedule wifi scans. - * - * Implements default method in {@link TimerTask} class which it implements. It begins to start - * calling wifi scans every 5 seconds. - */ private class scheduledWifiScan extends TimerTask { - @Override public void run() { startWifiScan(); @@ -296,37 +287,35 @@ public void run() { /** * Obtains required information about wifi in which the device is currently connected. - * - * A connectivity manager is used to obtain information about the current network. If the device - * is connected to a network its ssid, mac address and frequency is stored to a Wifi object so - * that it can be accessed by the caller of the method - * - * @return wifi object containing the currently connected wifi's ssid, mac address and frequency */ public Wifi getCurrentWifiData(){ - //Set up a connectivity manager to get information about the wifi ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService (Context.CONNECTIVITY_SERVICE); - //Set up a network info object to store information about the current network NetworkInfo networkInfo = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI); - //Only obtain wifi data if the device is connected - //Wifi in which the device is currently connected to Wifi currentWifi = new Wifi(); - if(networkInfo.isConnected()) { - //Store the ssid, mac address and frequency of the current wifi - currentWifi.setSsid(wifiManager.getConnectionInfo().getSSID()); - String wifiMacAddress = wifiManager.getConnectionInfo().getBSSID(); - long intMacAddress = convertBssidToLong(wifiMacAddress); - currentWifi.setBssid(intMacAddress); - currentWifi.setFrequency(wifiManager.getConnectionInfo().getFrequency()); + + if(networkInfo != null && networkInfo.isConnected()) { + // Store the ssid, mac address and frequency of the current wifi + // Use standard API safely + try { + android.net.wifi.WifiInfo info = wifiManager.getConnectionInfo(); + if (info != null) { + // SSID usually comes with quotes, keep them or strip them as needed. + // Original code kept them, so we keep them. + currentWifi.setSsid(info.getSSID()); + currentWifi.setBssid(convertBssidToLong(info.getBSSID())); + currentWifi.setFrequency(info.getFrequency()); + } + } catch (Exception e) { + Log.e("WifiDataProcessor", "Error getting connection info"); + } } - else{ - //Store standard information if not connected + else { currentWifi.setSsid("Not connected"); currentWifi.setBssid(0); currentWifi.setFrequency(0); } return currentWifi; } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/GeometryUtils.java b/app/src/main/java/com/openpositioning/PositionMe/utils/GeometryUtils.java new file mode 100644 index 00000000..85385c02 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/GeometryUtils.java @@ -0,0 +1,207 @@ +package com.openpositioning.PositionMe.utils; + +import com.google.android.gms.maps.model.LatLng; +import java.util.List; + +/** + * GeometryUtils - Utility class for geometric calculations + * Used for indoor navigation constraint checking (wall collision, boundary detection, etc.) + */ +public class GeometryUtils { + + /** + * Check if a point is inside a polygon using ray casting algorithm + */ + public static boolean isPointInPolygon(LatLng point, List polygon) { + if (polygon == null || polygon.size() < 3) return false; + + boolean inside = false; + int j = polygon.size() - 1; + + for (int i = 0; i < polygon.size(); i++) { + LatLng pi = polygon.get(i); + LatLng pj = polygon.get(j); + + if ((pi.longitude > point.longitude) != (pj.longitude > point.longitude) && + (point.latitude < (pj.latitude - pi.latitude) * (point.longitude - pi.longitude) / + (pj.longitude - pi.longitude) + pi.latitude)) { + inside = !inside; + } + j = i; + } + return inside; + } + + /** + * Calculate distance between two LatLng points in meters (Haversine formula) + */ + public static double distanceBetween(LatLng p1, LatLng p2) { + final double R = 6371000; // Earth radius in meters + double lat1 = Math.toRadians(p1.latitude); + double lat2 = Math.toRadians(p2.latitude); + double dLat = Math.toRadians(p2.latitude - p1.latitude); + double dLon = Math.toRadians(p2.longitude - p1.longitude); + + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1) * Math.cos(lat2) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; + } + + /** + * Check if a line segment (from -> to) crosses any wall line segment + * Returns true if movement would cross a wall + */ + public static boolean crossesWall(LatLng from, LatLng to, List> walls) { + if (walls == null || walls.isEmpty()) return false; + + for (List wall : walls) { + if (wall.size() < 2) continue; + + // Check each segment of the wall polyline + for (int i = 0; i < wall.size() - 1; i++) { + if (lineSegmentsIntersect(from, to, wall.get(i), wall.get(i + 1))) { + return true; + } + } + } + return false; + } + + /** + * Check if two line segments intersect + */ + private static boolean lineSegmentsIntersect(LatLng p1, LatLng p2, LatLng p3, LatLng p4) { + double d1 = direction(p3, p4, p1); + double d2 = direction(p3, p4, p2); + double d3 = direction(p1, p2, p3); + double d4 = direction(p1, p2, p4); + + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && + ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { + return true; + } + + // Check for collinear cases + if (d1 == 0 && onSegment(p3, p1, p4)) return true; + if (d2 == 0 && onSegment(p3, p2, p4)) return true; + if (d3 == 0 && onSegment(p1, p3, p2)) return true; + if (d4 == 0 && onSegment(p1, p4, p2)) return true; + + return false; + } + + /** + * Calculate direction (cross product) + */ + private static double direction(LatLng p1, LatLng p2, LatLng p3) { + return (p3.latitude - p1.latitude) * (p2.longitude - p1.longitude) - + (p2.latitude - p1.latitude) * (p3.longitude - p1.longitude); + } + + /** + * Check if point q lies on segment pr + */ + private static boolean onSegment(LatLng p, LatLng q, LatLng r) { + return q.latitude <= Math.max(p.latitude, r.latitude) && + q.latitude >= Math.min(p.latitude, r.latitude) && + q.longitude <= Math.max(p.longitude, r.longitude) && + q.longitude >= Math.min(p.longitude, r.longitude); + } + + /** + * Find the closest valid point inside a polygon boundary + * Used when detected position is outside building + */ + public static LatLng constrainToPolygon(LatLng point, List polygon) { + if (isPointInPolygon(point, polygon)) { + return point; // Already inside + } + + // Find closest point on polygon perimeter + LatLng closest = null; + double minDistance = Double.MAX_VALUE; + + for (int i = 0; i < polygon.size(); i++) { + LatLng p1 = polygon.get(i); + LatLng p2 = polygon.get((i + 1) % polygon.size()); + LatLng nearestOnSegment = closestPointOnSegment(point, p1, p2); + + double dist = distanceBetween(point, nearestOnSegment); + if (dist < minDistance) { + minDistance = dist; + closest = nearestOnSegment; + } + } + + // Move slightly inward from boundary (0.5 meters) + if (closest != null) { + LatLng center = getPolygonCenter(polygon); + double dx = (center.latitude - closest.latitude) * 0.00001; // ~1 meter + double dy = (center.longitude - closest.longitude) * 0.00001; + return new LatLng(closest.latitude + dx, closest.longitude + dy); + } + + return point; // Fallback + } + + /** + * Find closest point on a line segment to a given point + */ + private static LatLng closestPointOnSegment(LatLng point, LatLng segStart, LatLng segEnd) { + double dx = segEnd.latitude - segStart.latitude; + double dy = segEnd.longitude - segStart.longitude; + + if (dx == 0 && dy == 0) return segStart; + + double t = ((point.latitude - segStart.latitude) * dx + + (point.longitude - segStart.longitude) * dy) / (dx * dx + dy * dy); + + t = Math.max(0, Math.min(1, t)); // Clamp to [0,1] + + return new LatLng( + segStart.latitude + t * dx, + segStart.longitude + t * dy + ); + } + + /** + * Calculate polygon center (centroid) + */ + private static LatLng getPolygonCenter(List polygon) { + double sumLat = 0, sumLon = 0; + for (LatLng p : polygon) { + sumLat += p.latitude; + sumLon += p.longitude; + } + return new LatLng(sumLat / polygon.size(), sumLon / polygon.size()); + } + + /** + * Smooth trajectory using exponential moving average + * alpha = smoothing factor (0-1), higher = less smoothing + */ + public static LatLng smoothPosition(LatLng newPos, LatLng prevPos, double alpha) { + if (prevPos == null) return newPos; + + double smoothLat = alpha * newPos.latitude + (1 - alpha) * prevPos.latitude; + double smoothLon = alpha * newPos.longitude + (1 - alpha) * prevPos.longitude; + + return new LatLng(smoothLat, smoothLon); + } + + /** + * Detect if position jump is unrealistic (teleportation) + * maxSpeed in meters/second + */ + public static boolean isUnrealisticJump(LatLng from, LatLng to, long deltaTimeMs, double maxSpeed) { + if (from == null || to == null || deltaTimeMs <= 0) return false; + + double distance = distanceBetween(from, to); + double speed = distance / (deltaTimeMs / 1000.0); // m/s + + return speed > maxSpeed; // Typically 2-3 m/s for walking + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorBuilding.java b/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorBuilding.java new file mode 100644 index 00000000..748eca33 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorBuilding.java @@ -0,0 +1,27 @@ +package com.openpositioning.PositionMe.utils; + +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +import java.util.List; +import java.util.Map; + +/** + * Stores building info from API. + */ +public class IndoorBuilding { + public String id; + public String name; + public List polygonPoints; // Building outline + public LatLngBounds bounds; // Image coverage bounds + public Map floorUrls; // Floor to image URL map (e.g. 0 -> "http://.../g_floor.png") + public float floorHeight; // Floor height + + public IndoorBuilding(String id, String name, List polygonPoints, LatLngBounds bounds, Map floorUrls, float floorHeight) { + this.id = id; + this.name = name; + this.polygonPoints = polygonPoints; + this.bounds = bounds; + this.floorUrls = floorUrls; + this.floorHeight = floorHeight; + } +} \ No newline at end of file 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 9d7167df..f316fdfe 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java @@ -1,193 +1,1030 @@ package com.openpositioning.PositionMe.utils; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.graphics.Color; +import android.os.Handler; +import android.os.Looper; import android.util.Log; +import androidx.preference.PreferenceManager; + import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.BitmapDescriptorFactory; import com.google.android.gms.maps.model.GroundOverlay; import com.google.android.gms.maps.model.GroundOverlayOptions; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.Polygon; +import com.google.android.gms.maps.model.PolygonOptions; import com.google.android.gms.maps.model.PolylineOptions; -import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.BuildConfig; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; -import java.util.Arrays; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** - * Class used to manage indoor floor map overlays - * Currently used by RecordingFragment - * @see BuildingPolygon Describes the bounds of buildings and the methods to check if point is - * in the building - * @author Arun Gopalakrishnan + * IndoorMapManager - API-based indoor map handler. + * Supports GeoJSON data parsing and floor plan management. */ public class IndoorMapManager { - // To store the map instance + + private static final String TAG = "IndoorMapManager"; private GoogleMap gMap; - //Stores the overlay of the indoor maps + private Context context; private GroundOverlay groundOverlay; - // Stores the current Location of user + private SharedPreferences settings; + + // Current location private LatLng currentLocation; - // Stores if indoor map overlay is currently set - private boolean isIndoorMapSet=false; - //Stores the current floor in building - private int currentFloor; - // Floor height of current building - private float floorHeight; - //Images of the Nucleus Building and Library indoor floor maps - private final List NUCLEUS_MAPS =Arrays.asList( - R.drawable.nucleuslg, R.drawable.nucleusg, R.drawable.nucleus1, - R.drawable.nucleus2,R.drawable.nucleus3); - private final List LIBRARY_MAPS =Arrays.asList( - R.drawable.libraryg, R.drawable.library1, R.drawable.library2, - R.drawable.library3); - // South-west and north east Bounds of Nucleus building and library to set the Overlay - LatLngBounds NUCLEUS=new LatLngBounds( - BuildingPolygon.NUCLEUS_SW, - BuildingPolygon.NUCLEUS_NE - ); - LatLngBounds LIBRARY=new LatLngBounds( - BuildingPolygon.LIBRARY_SW, - BuildingPolygon.LIBRARY_NE - ); - //Average Floor Heights of the Buildings - public static final float NUCLEUS_FLOOR_HEIGHT=4.2F; - public static final float LIBRARY_FLOOR_HEIGHT=3.6F; + // Stores drawn shapes on map for easy removal + private List drawnShapes = new ArrayList<>(); + + private IndoorBuilding selectedBuilding; + private int currentFloor = 0; + private boolean isIndoorMapSet = false; + private boolean isIndoorMapVisible = true; // Indoor map visibility toggle + + // Store floor data from API response + private String currentVenueName = null; + private String currentOutlineGeoJson = null; + private Map floorShapesMap = new HashMap<>(); // floor name -> GeoJSON string + private List floorNamesList = new ArrayList<>(); // Ordered list of floor names + + // Wall collision detection data + private Map>> floorWallsMap = new HashMap<>(); // floor name -> wall polylines + private List buildingBoundary = null; // Current building boundary polygon + + private Map polygonMap = new HashMap<>(); + + // ✅ Request tracking to handle race conditions + private int currentRequestId = 0; + private String pendingBuildingName = null; // Building name for current pending request + + private static final String BASE_URL = "https://openpositioning.org/api/live/floorplan/request/"; + private static final String RAW_API_KEY = BuildConfig.OPENPOSITIONING_API_KEY; + private static final String RAW_MASTER_KEY = BuildConfig.OPENPOSITIONING_MASTER_KEY; + + private ExecutorService executor = Executors.newSingleThreadExecutor(); + private Handler mainHandler = new Handler(Looper.getMainLooper()); + + // Callback for when floor data is loaded + public interface OnFloorDataLoadedListener { + void onFloorDataLoaded(boolean hasData); + } + private OnFloorDataLoadedListener floorDataLoadedListener; + + public IndoorMapManager(GoogleMap map, Context context) { + this.gMap = map; + this.context = context; + this.settings = PreferenceManager.getDefaultSharedPreferences(context); + } + + // ============================================================ + // 1. Core API Methods + // ============================================================ + + public void fetchFloorPlan(LatLng loc, List macs) { + // Increment request ID and save expected building name + final int thisRequestId = ++currentRequestId; + final String expectedBuildingName = (selectedBuilding != null) ? selectedBuilding.name : "unknown"; + pendingBuildingName = expectedBuildingName; + + Log.d(TAG, ">>> [REQUEST #" + thisRequestId + "] Starting for: " + expectedBuildingName); + + executor.execute(() -> { + try { + String cleanApiKey = RAW_API_KEY.replace("<", "").replace(">", "").trim(); + String cleanMasterKey = RAW_MASTER_KEY.replace("<", "").replace(">", "").trim(); + String urlString = BASE_URL + cleanApiKey + "?key=" + cleanMasterKey; + + Log.d(TAG, ">>> [API] Requesting: " + urlString); + Log.d(TAG, ">>> [API] Request body: lat=" + String.format("%.6f", loc.latitude) + ", lon=" + String.format("%.6f", loc.longitude)); + + URL url = new URL(urlString); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + conn.setConnectTimeout(10000); + + JSONObject jsonBody = new JSONObject(); + jsonBody.put("lat", loc.latitude); + jsonBody.put("lon", loc.longitude); + JSONArray macArray = new JSONArray(); + if (macs != null) for (String mac : macs) macArray.put(mac); + jsonBody.put("macs", macArray); + + try (OutputStream os = conn.getOutputStream()) { + byte[] input = jsonBody.toString().getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = conn.getResponseCode(); + Log.d(TAG, ">>> [API] Response code: " + responseCode); + + if (responseCode == 200) { + BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)); + StringBuilder responseSb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) responseSb.append(line); + String jsonResponse = responseSb.toString().trim(); + + Log.d(TAG, ">>> [API] Response received (" + jsonResponse.length() + " chars)"); + Log.d(TAG, ">>> [API] Full response: " + jsonResponse); + + // Clear previous map + mainHandler.post(this::hideMap); + + // ✅ Check if this response is still relevant (not superseded by newer request) + if (thisRequestId != currentRequestId) { + Log.w(TAG, ">>> [REQUEST #" + thisRequestId + "] IGNORED - superseded by request #" + currentRequestId); + return; + } + + // Parse response + if (jsonResponse.startsWith("[")) { + JSONArray arr = new JSONArray(jsonResponse); + Log.d(TAG, ">>> [API] Response is an array with " + arr.length() + " elements"); + if (arr.length() > 0) { + parseResponseObject(arr.getJSONObject(0), expectedBuildingName); + } else { + Log.w(TAG, ">>> [API] Empty list [] returned. No map data for this location."); + Log.w(TAG, ">>> Server may not have indoor map data for this building."); + Log.w(TAG, ">>> Building: " + (selectedBuilding != null ? selectedBuilding.name : "unknown")); + // Notify listener that no data is available so UI can update + mainHandler.post(() -> { + if (floorDataLoadedListener != null) { + floorDataLoadedListener.onFloorDataLoaded(false); + } + }); + } + } else if (jsonResponse.startsWith("{")) { + Log.d(TAG, ">>> [API] Response is an object"); + parseResponseObject(new JSONObject(jsonResponse), expectedBuildingName); + } else { + Log.e(TAG, ">>> [API] Invalid response format: " + jsonResponse.substring(0, Math.min(100, jsonResponse.length()))); + } + + } else { + Log.e(TAG, ">>> [API] Error Code: " + responseCode); + if (responseCode == 404) { + Log.w(TAG, ">>> Building not found in server database."); + Log.w(TAG, ">>> Requested coordinates: (" + String.format("%.6f", loc.latitude) + ", " + String.format("%.6f", loc.longitude) + ")"); + } + // Try to read error response body + try { + BufferedReader br = new BufferedReader(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8)); + StringBuilder errorSb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) errorSb.append(line); + Log.e(TAG, ">>> [API] Error response: " + errorSb.toString()); + } catch (Exception e) { + // Ignore + } + } + conn.disconnect(); + + } catch (Exception e) { + Log.e(TAG, ">>> [API] Request Failed: " + e.getMessage()); + e.printStackTrace(); + } + }); + } + + private void parseResponseObject(JSONObject obj, String expectedBuildingName) { + try { + Log.d(TAG, ">>> [PARSE] Starting to parse response object"); + Log.d(TAG, ">>> [PARSE] Expected building: " + expectedBuildingName); + + // Extract the three required fields: name, outline, map_shapes + currentVenueName = obj.optString("name", null); + currentOutlineGeoJson = obj.optString("outline", null); + String mapShapesJsonString = obj.optString("map_shapes", null); + + Log.d(TAG, ">>> Parsing API Response:"); + Log.d(TAG, " - API Returned Venue Name: " + currentVenueName); + Log.d(TAG, " - Expected Building Name: " + (selectedBuilding != null ? selectedBuilding.name : "null")); + + // Check if API returned wrong building + if (selectedBuilding != null && currentVenueName != null) { + if (!currentVenueName.equalsIgnoreCase(selectedBuilding.name) && + !currentVenueName.contains("Nucleus") && selectedBuilding.name.contains("Library")) { + Log.w(TAG, " - ⚠️ WARNING: Building mismatch!"); + Log.w(TAG, " - Clicked: " + selectedBuilding.name); + Log.w(TAG, " - API returned: " + currentVenueName); + } + } + + Log.d(TAG, " - Has Outline: " + (currentOutlineGeoJson != null && !currentOutlineGeoJson.isEmpty())); + Log.d(TAG, " - Outline length: " + (currentOutlineGeoJson != null ? currentOutlineGeoJson.length() : 0) + " chars"); + Log.d(TAG, " - Has map_shapes: " + (mapShapesJsonString != null && !mapShapesJsonString.isEmpty())); + Log.d(TAG, " - map_shapes length: " + (mapShapesJsonString != null ? mapShapesJsonString.length() : 0) + " chars"); + + // Parse map_shapes: it's a JSON string containing a dictionary + // where keys are floor names and values are GeoJSON strings + floorShapesMap.clear(); + floorNamesList.clear(); + if (mapShapesJsonString != null && !mapShapesJsonString.isEmpty()) { + try { + Log.d(TAG, ">>> [PARSE] Parsing map_shapes string..."); + JSONObject shapesDict = new JSONObject(mapShapesJsonString); + Log.d(TAG, ">>> [PARSE] map_shapes has " + shapesDict.length() + " floors"); + java.util.Iterator keys = shapesDict.keys(); + int floorIndex = 0; + while (keys.hasNext()) { + String floorName = keys.next(); + String floorGeoJson = shapesDict.getString(floorName); + floorShapesMap.put(floorName, floorGeoJson); + floorNamesList.add(floorName); + Log.d(TAG, " - Found floor " + (++floorIndex) + ": " + floorName + " (" + floorGeoJson.length() + " chars)"); + } + + // Sort floors in logical order (ground, 0, 1, 2...) + floorNamesList.sort((f1, f2) -> { + int priority1 = getFloorPriority(f1); + int priority2 = getFloorPriority(f2); + return Integer.compare(priority1, priority2); + }); + + Log.d(TAG, " - Floor order after sorting: " + floorNamesList); + } catch ( + JSONException e) { + Log.e(TAG, ">>> Failed to parse map_shapes dictionary: " + e.getMessage()); + e.printStackTrace(); + } + } else { + Log.w(TAG, ">>> [PARSE] No map_shapes data in response!"); + } + + // Update selected building name if we have it + if (currentVenueName != null && selectedBuilding != null) { + Log.d(TAG, ">>> [PARSE] Updating selected building info:"); + Log.d(TAG, " - Requested: " + expectedBuildingName); + Log.d(TAG, " - API returned: " + currentVenueName); + + // ✅ NO STRICT VALIDATION - Accept any data from API + // The API returns the nearest building with indoor map data. + // We display whatever data is returned to ensure the map is always shown. + if (!currentVenueName.equalsIgnoreCase(expectedBuildingName)) { + Log.w(TAG, " - ⚠️ Note: API returned different building's data."); + Log.w(TAG, " - This is normal if the requested building has no data in the server."); + } + + // Always accept: Update building info with API response + selectedBuilding.name = currentVenueName; + selectedBuilding.id = "venue_" + currentVenueName.toLowerCase().replace(" ", "_"); + Log.d(TAG, " - ✅ Accepted API data"); + Log.d(TAG, " - New ID: " + selectedBuilding.id); + } + + // Render the data on the map + mainHandler.post(() -> { + hideMap(); // Clear previous drawings + + // Draw outline first (if available) + if (currentOutlineGeoJson != null && !currentOutlineGeoJson.isEmpty()) { + Log.d(TAG, ">>> Drawing building outline"); + renderGeoJson(currentOutlineGeoJson, Color.argb(80, 0, 0, 255), 6); + + // Parse and store building boundary for collision detection + try { + parseBuildingBoundary(currentOutlineGeoJson); + } catch (Exception e) { + Log.e(TAG, "Failed to parse building boundary: " + e.getMessage()); + } + } else { + Log.w(TAG, ">>> No outline data to draw"); + } + + // Draw ground floor shapes by default (if indoor map is visible) + if (isIndoorMapVisible) { + Log.d(TAG, ">>> Rendering current floor (index " + currentFloor + ")"); + renderCurrentFloor(); + } else { + Log.d(TAG, ">>> Indoor map hidden by user, skipping floor render"); + } + + // Notify listener that floor data is ready + if (floorDataLoadedListener != null) { + boolean hasFloors = !floorNamesList.isEmpty(); + Log.d(TAG, ">>> Notifying floor data loaded listener (hasData=" + hasFloors + ")"); + floorDataLoadedListener.onFloorDataLoaded(hasFloors); + } + }); + + } catch (Exception e) { + Log.e(TAG, ">>> JSON Parse Error: " + e.getMessage()); + e.printStackTrace(); + } + } + + private void renderGeoJson(String jsonString, int color, float width) { + renderGeoJsonWithWallData(jsonString, color, width, null); + } + /** - * Constructor to set the map instance - * @param map The map on which the indoor floor map overlays are set + * Render GeoJSON and optionally store wall data for collision detection + * @param jsonString GeoJSON string to render + * @param color Line color + * @param width Line width + * @param floorName Floor name (if not null, stores wall data for this floor) */ - public IndoorMapManager(GoogleMap map){ - this.gMap=map; + private void renderGeoJsonWithWallData(String jsonString, int color, float width, String floorName) { + try { + JSONObject geoJson; + if (jsonString.trim().startsWith("{") && !jsonString.contains("FeatureCollection")) { + JSONObject wrapper = new JSONObject(jsonString); + String key = wrapper.keys().next(); + geoJson = wrapper.getJSONObject(key); + } else { + geoJson = new JSONObject(jsonString); + } + + JSONArray features = geoJson.optJSONArray("features"); + if (features == null) return; + + // Store wall data if floorName is provided + List> wallsForThisFloor = new ArrayList<>(); + + for (int i = 0; i < features.length(); i++) { + JSONObject feature = features.getJSONObject(i); + JSONObject geometry = feature.getJSONObject("geometry"); + String type = geometry.getString("type"); + JSONArray coordinates = geometry.getJSONArray("coordinates"); + + if (type.equals("MultiPolygon")) { + for (int j = 0; j < coordinates.length(); j++) { + JSONArray polygon = coordinates.getJSONArray(j); + JSONArray ring = polygon.getJSONArray(0); + List points = parseCoordinates(ring); + + // Draw polygon + Polygon p = gMap.addPolygon(new PolygonOptions() + .addAll(points) + .strokeColor(color) + .strokeWidth(width) + .fillColor(Color.TRANSPARENT)); + drawnShapes.add(p); + + // Store as wall data (polygon perimeter is a wall) + if (floorName != null) { + wallsForThisFloor.add(points); + } + } + } else if (type.equals("MultiLineString")) { + for (int j = 0; j < coordinates.length(); j++) { + JSONArray line = coordinates.getJSONArray(j); + List points = parseCoordinates(line); + + // Draw polyline + com.google.android.gms.maps.model.Polyline p = gMap.addPolyline(new PolylineOptions() + .addAll(points) + .color(color) + .width(width)); + drawnShapes.add(p); + + // Store as wall data + if (floorName != null) { + wallsForThisFloor.add(points); + } + } + } else if (type.equals("Polygon")) { + JSONArray ring = coordinates.getJSONArray(0); + List points = parseCoordinates(ring); + + // Draw polygon + Polygon p = gMap.addPolygon(new PolygonOptions() + .addAll(points) + .strokeColor(color) + .strokeWidth(width) + .fillColor(Color.TRANSPARENT)); + drawnShapes.add(p); + + // Store as wall data + if (floorName != null) { + wallsForThisFloor.add(points); + } + } else if (type.equals("LineString")) { + List points = parseCoordinates(coordinates); + + // Draw polyline + com.google.android.gms.maps.model.Polyline p = gMap.addPolyline(new PolylineOptions() + .addAll(points) + .color(color) + .width(width)); + drawnShapes.add(p); + + // Store as wall data + if (floorName != null) { + wallsForThisFloor.add(points); + } + } + } + + // Store wall data for this floor + if (floorName != null && !wallsForThisFloor.isEmpty()) { + floorWallsMap.put(floorName, wallsForThisFloor); + Log.d(TAG, ">>> Stored " + wallsForThisFloor.size() + " walls for floor: " + floorName); + } + + isIndoorMapSet = true; + Log.d(TAG, ">>> Vector shapes rendered successfully."); + + } catch (Exception e) { + Log.e(TAG, ">>> Vector Render Failed: " + e.getMessage()); + e.printStackTrace(); + } } + private List parseCoordinates(JSONArray ring) throws Exception { + List list = new ArrayList<>(); + for (int k = 0; k < ring.length(); k++) { + JSONArray coord = ring.getJSONArray(k); + double lon = coord.getDouble(0); + double lat = coord.getDouble(1); + list.add(new LatLng(lat, lon)); + } + return list; + } + /** - * Function to update the current location of user and display the indoor map - * if user in building with indoor map available - * @param currentLocation new location of user + * Parse building boundary from outline GeoJSON for collision detection */ - public void setCurrentLocation(LatLng currentLocation){ - this.currentLocation=currentLocation; - setBuildingOverlay(); + private void parseBuildingBoundary(String outlineGeoJson) throws Exception { + buildingBoundary = null; // Clear previous boundary + + JSONObject geoJson; + if (outlineGeoJson.trim().startsWith("{") && !outlineGeoJson.contains("FeatureCollection")) { + JSONObject wrapper = new JSONObject(outlineGeoJson); + String key = wrapper.keys().next(); + geoJson = wrapper.getJSONObject(key); + } else { + geoJson = new JSONObject(outlineGeoJson); + } + + JSONArray features = geoJson.optJSONArray("features"); + if (features == null || features.length() == 0) return; + + // Get the first polygon as building boundary + JSONObject feature = features.getJSONObject(0); + JSONObject geometry = feature.getJSONObject("geometry"); + String type = geometry.getString("type"); + JSONArray coordinates = geometry.getJSONArray("coordinates"); + + if (type.equals("MultiPolygon")) { + // Take first polygon + JSONArray polygon = coordinates.getJSONArray(0); + JSONArray ring = polygon.getJSONArray(0); + buildingBoundary = parseCoordinates(ring); + } else if (type.equals("Polygon")) { + JSONArray ring = coordinates.getJSONArray(0); + buildingBoundary = parseCoordinates(ring); + } + + if (buildingBoundary != null && !buildingBoundary.isEmpty()) { + Log.d(TAG, ">>> Building boundary parsed: " + buildingBoundary.size() + " points"); + } + } + + private void downloadAndShowImage(String imageUrl) { + try { + URL url = new URL(imageUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(10000); + conn.connect(); + InputStream in = conn.getInputStream(); + Bitmap bitmap = BitmapFactory.decodeStream(in); + if (bitmap != null) { + mainHandler.post(() -> updateOverlay(bitmap)); + } + conn.disconnect(); + } catch (Exception e) { + Log.e(TAG, ">>> Image Download Error: " + e.getMessage()); + } + } + + // ============================================================ + // 2. Helper Methods + // ============================================================ + + // Track selected polygon for visual feedback + private Polygon selectedPolygon = null; + private int selectedPolygonOriginalStroke = Color.DKGRAY; + + public void addFallbackBuildings() { + List fallbackList = new ArrayList<>(); + + // ========== Nucleus Building ========== + // Precise polygon based on BuildingPolygon.java reference + List nucleusPoints = new ArrayList<>(); + nucleusPoints.add(new LatLng(55.92332, -3.17388)); // NE corner + nucleusPoints.add(new LatLng(55.92282, -3.17388)); // SE corner + nucleusPoints.add(new LatLng(55.92282, -3.17460)); // SW corner + nucleusPoints.add(new LatLng(55.92332, -3.17460)); // NW corner + fallbackList.add(new IndoorBuilding("venue_nucleus", "The Nucleus Building", nucleusPoints, calculateBounds(nucleusPoints), new HashMap<>(), 4.2f)); + + // ========== Murray Library ========== + // Moved west to eliminate overlap with Nucleus (east edge at -3.17477) + List libraryPoints = new ArrayList<>(); + libraryPoints.add(new LatLng(55.92307, -3.17477)); // NE corner + libraryPoints.add(new LatLng(55.92281, -3.17477)); // SE corner + libraryPoints.add(new LatLng(55.92281, -3.17518)); // SW corner + libraryPoints.add(new LatLng(55.92307, -3.17518)); // NW corner + fallbackList.add(new IndoorBuilding("venue_library", "Murray Library", libraryPoints, calculateBounds(libraryPoints), new HashMap<>(), 4.0f)); + + // ========== Murchison House ========== + List murchisonPoints = new ArrayList<>(); + murchisonPoints.add(new LatLng(55.92447, -3.17868)); // NE + murchisonPoints.add(new LatLng(55.92379, -3.17868)); // SE + murchisonPoints.add(new LatLng(55.92379, -3.17964)); // SW + murchisonPoints.add(new LatLng(55.92447, -3.17964)); // NW + fallbackList.add(new IndoorBuilding("venue_murchison", "Murchison House", murchisonPoints, calculateBounds(murchisonPoints), new HashMap<>(), 4.0f)); + + // ========== Fleeming Jenkin Building ========== + List fjbPoints = new ArrayList<>(); + fjbPoints.add(new LatLng(55.92282, -3.17259)); // NE + fjbPoints.add(new LatLng(55.92221, -3.17192)); // SE + fjbPoints.add(new LatLng(55.92211, -3.17228)); // SW + fjbPoints.add(new LatLng(55.92269, -3.17296)); // NW + fallbackList.add(new IndoorBuilding("venue_fjb", "Fleeming Jenkin Building", fjbPoints, calculateBounds(fjbPoints), new HashMap<>(), 3.5f)); + + Log.d(TAG, ">>> Loaded " + fallbackList.size() + " fallback buildings:"); + for (IndoorBuilding b : fallbackList) { + LatLng center = b.bounds.getCenter(); + Log.d(TAG, " - " + b.name + " at (" + String.format("%.6f", center.latitude) + ", " + String.format("%.6f", center.longitude) + ")"); + } + + mainHandler.post(() -> drawBuildingOutlines(fallbackList)); + } + + private void drawBuildingOutlines(List buildings) { + // Distinct colors for each building + int[][] buildingColors = { + {Color.rgb(255, 191, 0), 0x30FFD700}, // Nucleus: gold + {Color.rgb(41, 128, 185), 0x252980B9}, // Library: blue + {Color.rgb(39, 174, 96), 0x2527AE60}, // Murchison: green + {Color.rgb(192, 57, 43), 0x25C0392B}, // FJB: red + }; + + for (int i = 0; i < buildings.size(); i++) { + IndoorBuilding b = buildings.get(i); + int strokeColor = (i < buildingColors.length) ? buildingColors[i][0] : Color.DKGRAY; + int fillColor = (i < buildingColors.length) ? buildingColors[i][1] : 0x20444444; + + Polygon poly = gMap.addPolygon(new PolygonOptions() + .addAll(b.polygonPoints) + .strokeColor(strokeColor) + .strokeWidth(4f) + .fillColor(fillColor) + .clickable(true) + .zIndex(10)); + poly.setTag(b.name); // Store building name as tag + polygonMap.put(poly, b); + } + } + + public boolean onPolygonClick(Polygon polygon) { + IndoorBuilding b = polygonMap.get(polygon); + if (b != null) { + // Reset previously selected polygon + if (selectedPolygon != null && selectedPolygon != polygon) { + selectedPolygon.setStrokeColor(selectedPolygonOriginalStroke); + selectedPolygon.setStrokeWidth(4f); + } + + selectedBuilding = b; + currentFloor = 0; + + // ✅ Clear old floor data immediately when selecting a new building + // This prevents stale floor data from a previous building being used + floorShapesMap.clear(); + floorNamesList.clear(); + floorWallsMap.clear(); + buildingBoundary = null; + currentVenueName = null; + currentOutlineGeoJson = null; + hideMap(); // Clear previously drawn indoor shapes + + LatLng center = b.bounds.getCenter(); + Log.d(TAG, "===================================="); + Log.d(TAG, ">>> Building Clicked: " + b.name); + Log.d(TAG, ">>> Building ID: " + b.id); + Log.d(TAG, ">>> Center: (" + String.format("%.6f", center.latitude) + ", " + String.format("%.6f", center.longitude) + ")"); + Log.d(TAG, ">>> Requesting API for THIS building's floor data..."); + + // Highlight selected building + selectedPolygonOriginalStroke = polygon.getStrokeColor(); + selectedPolygon = polygon; + polygon.setStrokeColor(Color.rgb(0, 230, 118)); // Material green accent + polygon.setStrokeWidth(6f); + + // ✅ Convert building name to API campaign ID and save to SharedPreferences + String apiCampaignId = convertNameToApiId(b.name); + settings.edit().putString("current_campaign", apiCampaignId).apply(); + Log.d(TAG, "💾 Saved Campaign to Prefs: " + apiCampaignId); + + fetchFloorPlan(center, new ArrayList<>()); + return true; + } else { + Log.w(TAG, ">>> Polygon clicked but not found in polygonMap!"); + } + return false; + } + + public void hideMap() { + if (groundOverlay != null) { + groundOverlay.remove(); + groundOverlay = null; + } + // Clear drawn vector shapes + for (Object shape : drawnShapes) { + if (shape instanceof Polygon) ((Polygon) shape).remove(); + if (shape instanceof com.google.android.gms.maps.model.Polyline) ((com.google.android.gms.maps.model.Polyline) shape).remove(); + } + drawnShapes.clear(); + isIndoorMapSet = false; } + private void updateOverlay(Bitmap bitmap) { + if (bitmap == null || selectedBuilding == null) return; + hideMap(); + + try { + groundOverlay = gMap.addGroundOverlay(new GroundOverlayOptions() + .image(BitmapDescriptorFactory.fromBitmap(bitmap)) + .positionFromBounds(selectedBuilding.bounds) + .zIndex(100) + .transparency(0.1f)); + isIndoorMapSet = true; + } catch (Exception e) { + Log.e(TAG, "Overlay Error: " + e.getMessage()); + } + } + + // Setter methods + public void setCurrentLocation(LatLng loc) { this.currentLocation = loc; } + public void fetchBuildingsFromApi(LatLng loc) { fetchFloorPlan(loc, new ArrayList<>()); } + public void setOnFloorDataLoadedListener(OnFloorDataLoadedListener listener) { + this.floorDataLoadedListener = listener; + } + /** - * Function to obtain the current building's floor height - * @return the floor height of the current building the user is in + * Set selected building before API call (for validation) + * This is critical for building name verification in API responses */ - public float getFloorHeight() { - return floorHeight; + public void setSelectedBuilding(String name, LatLng center) { + // Create a temporary building object for validation purposes + List tempPolygon = new ArrayList<>(); + tempPolygon.add(center); + + // Clear old floor data when switching buildings + floorShapesMap.clear(); + floorNamesList.clear(); + floorWallsMap.clear(); + buildingBoundary = null; + currentVenueName = null; + currentOutlineGeoJson = null; + hideMap(); + + selectedBuilding = new IndoorBuilding( + "venue_" + name.toLowerCase().replace(" ", "_"), + name, + tempPolygon, + new LatLngBounds(center, center), + new HashMap<>(), + 4.0f + ); + + Log.d(TAG, "===================================="); + Log.d(TAG, ">>> Selected building set: " + name); + Log.d(TAG, ">>> Building ID: " + selectedBuilding.id); + Log.d(TAG, ">>> Center: (" + String.format("%.6f", center.latitude) + ", " + String.format("%.6f", center.longitude) + ")"); + Log.d(TAG, ">>> This will be used to validate API response"); } - + + /** + * Toggle indoor map visibility (toggle floor plan display) + */ + public void setIndoorMapVisible(boolean visible) { + this.isIndoorMapVisible = visible; + if (!visible) { + // Hide indoor shapes but keep building outlines + List shapesToRemove = new ArrayList<>(); + for (Object shape : drawnShapes) { + if (shape instanceof com.google.android.gms.maps.model.Polyline) { + ((com.google.android.gms.maps.model.Polyline) shape).setVisible(false); + } else if (shape instanceof Polygon) { + ((Polygon) shape).setVisible(false); + } + } + Log.d(TAG, ">>> Indoor map hidden"); + } else { + // Show indoor shapes + for (Object shape : drawnShapes) { + if (shape instanceof com.google.android.gms.maps.model.Polyline) { + ((com.google.android.gms.maps.model.Polyline) shape).setVisible(true); + } else if (shape instanceof Polygon) { + ((Polygon) shape).setVisible(true); + } + } + Log.d(TAG, ">>> Indoor map shown"); + } + } + + public boolean isIndoorMapVisible() { return isIndoorMapVisible; } + public String getSelectedVenueId() { return currentVenueName != null ? currentVenueName : (selectedBuilding != null ? selectedBuilding.name : "None"); } + public String getSelectedBuildingId() { return selectedBuilding != null ? selectedBuilding.id : null; } + public String getSelectedBuildingName() { return currentVenueName != null ? currentVenueName : (selectedBuilding != null ? selectedBuilding.name : null); } + public float getFloorHeight() { return selectedBuilding != null ? selectedBuilding.floorHeight : 4.0f; } + public int getCurrentFloor() { return currentFloor; } + public int getAvailableFloorsCount() { return floorNamesList.size(); } + /** - * Getter to obtain if currently an indoor floor map is being displayed - * @return true if an indoor map is visible to the user, false otherwise + * Get the current floor name as it appears in the map data */ - public boolean getIsIndoorMapSet(){ - return isIndoorMapSet; + public String getCurrentFloorName() { + if (floorNamesList.isEmpty()) return null; + + // Get floor name by index from ordered list + if (currentFloor >= 0 && currentFloor < floorNamesList.size()) { + return floorNamesList.get(currentFloor); + } + + return null; } - + public void setCurrentFloor(int floor, boolean auto) { + this.currentFloor = floor; + if (selectedBuilding != null && !floorShapesMap.isEmpty()) { + // Don't re-fetch from API, just re-render from cached data + mainHandler.post(() -> { + // Clear indoor shapes (both Polygons and Polylines), keep the outline + List shapesToRemove = new ArrayList<>(); + for (Object shape : drawnShapes) { + if (shape instanceof com.google.android.gms.maps.model.Polyline) { + ((com.google.android.gms.maps.model.Polyline) shape).remove(); + shapesToRemove.add(shape); + } else if (shape instanceof Polygon) { + // Only remove if it's not the building outline + Polygon polygon = (Polygon) shape; + // Building outlines have specific color (blue), indoor shapes are black + if (polygon.getStrokeColor() != Color.argb(80, 0, 0, 255) && + polygon.getStrokeColor() != Color.GREEN) { + polygon.remove(); + shapesToRemove.add(shape); + } + } + } + drawnShapes.removeAll(shapesToRemove); + Log.d(TAG, ">>> Cleared " + shapesToRemove.size() + " indoor shapes for floor switch"); + if (isIndoorMapVisible) { + renderCurrentFloor(); + } + }); + } + } + /** - * Setting the new floor of a user and displaying the indoor floor map accordingly - * (if floor exists in building) - * @param newFloor the floor the user is at - * @param autoFloor flag if function called by auto-floor feature + * Helper method to determine floor priority for sorting */ - public void setCurrentFloor(int newFloor, boolean autoFloor) { - if (BuildingPolygon.inNucleus(currentLocation)){ - //Special case for nucleus when auto-floor is being used - if (autoFloor) { - // If nucleus add bias floor as lower-ground floor referred to as floor 0 - newFloor += 1; + private int getFloorPriority(String floorName) { + String lower = floorName.toLowerCase(); + // Ground floor variants get priority 0 + if (lower.contains("ground") || lower.equals("0")) { + return 0; + } + // Try to extract floor number + if (lower.startsWith("floor_")) { + try { + return Integer.parseInt(lower.substring(6)) + 1; + } catch (NumberFormatException e) { + return 999; } - // If within bounds and different from floor map currently being shown - if (newFloor>=0 && newFloor=0 && newFloor>> No floor data available to render"); + return; + } + + // Get floor name by index from ordered list + if (currentFloor >= 0 && currentFloor < floorNamesList.size()) { + String floorName = floorNamesList.get(currentFloor); + String floorGeoJson = floorShapesMap.get(floorName); + + if (floorGeoJson != null) { + Log.d(TAG, ">>> Rendering floor: " + floorName + " (index=" + currentFloor + " of " + floorNamesList.size() + ")"); + + // Clear existing wall data for this floor before re-rendering + floorWallsMap.remove(floorName); + + // Render and store wall data for collision detection + renderGeoJsonWithWallData(floorGeoJson, Color.BLACK, 3, floorName); + isIndoorMapSet = true; + } else { + Log.w(TAG, ">>> Floor data missing for: " + floorName); } + } else { + Log.w(TAG, ">>> Invalid floor index: " + currentFloor + " (available: 0-" + (floorNamesList.size() - 1) + ")"); } - } - + + public void increaseFloor() { + if (!floorNamesList.isEmpty()) { + int maxFloor = floorNamesList.size() - 1; + if (currentFloor < maxFloor) { + setCurrentFloor(currentFloor + 1, false); + String floorName = floorNamesList.get(currentFloor); + Log.d(TAG, "Increased to floor: " + floorName + " (index=" + currentFloor + ")"); + } else { + Log.d(TAG, "Already at top floor: " + currentFloor + "/" + maxFloor); + } + } + } + + public void decreaseFloor() { + if (!floorNamesList.isEmpty()) { + if (currentFloor > 0) { + setCurrentFloor(currentFloor - 1, false); + String floorName = floorNamesList.get(currentFloor); + Log.d(TAG, "Decreased to floor: " + floorName + " (index=" + currentFloor + ")"); + } else { + Log.d(TAG, "Already at ground floor: 0"); + } + } + } + + private LatLngBounds calculateBounds(List points) { + LatLngBounds.Builder b = new LatLngBounds.Builder(); + for (LatLng p : points) b.include(p); + return b.build(); + } + /** - * Increments the Current Floor and changes to higher floor's map (if a higher floor exists) + * Check if indoor constraints (walls, boundaries) are available for current floor */ - public void increaseFloor(){ - this.setCurrentFloor(currentFloor+1,false); + public boolean hasIndoorConstraints() { + // Check if we have wall data for the current floor and indoor map is visible + if (!isIndoorMapVisible) { + return false; // Don't apply constraints when indoor map is hidden + } + + String currentFloorName = getCurrentFloorName(); + if (currentFloorName != null && floorWallsMap.containsKey(currentFloorName)) { + List> walls = floorWallsMap.get(currentFloorName); + return walls != null && !walls.isEmpty(); + } + + // Also check if we have building boundary + return buildingBoundary != null && !buildingBoundary.isEmpty(); } /** - * Decrements the Current Floor and changes to the lower floor's map (if a lower floor exists) + * Validate a position against indoor constraints (wall collision, boundary check) + * Prevents the position marker from going through walls or outside building + * Enhanced with wall sliding - allows movement parallel to walls */ - public void decreaseFloor(){ - this.setCurrentFloor(currentFloor-1,false); + public LatLng validatePosition(LatLng newLoc, LatLng oldLoc) { + if (newLoc == null) return oldLoc; + if (oldLoc == null) return newLoc; // First position, no validation needed + if (!isIndoorMapVisible) return newLoc; // No constraints when indoor map is off + + // 1. Check building boundary + if (buildingBoundary != null && !buildingBoundary.isEmpty()) { + if (!GeometryUtils.isPointInPolygon(newLoc, buildingBoundary)) { + // New position is outside building - constrain to boundary + Log.d(TAG, "Position outside building boundary - constraining"); + return GeometryUtils.constrainToPolygon(newLoc, buildingBoundary); + } + } + + // 2. Check wall collision for current floor + String currentFloorName = getCurrentFloorName(); + if (currentFloorName != null && floorWallsMap.containsKey(currentFloorName)) { + List> walls = floorWallsMap.get(currentFloorName); + if (walls != null && !walls.isEmpty()) { + // Check if movement from oldLoc to newLoc crosses any wall + if (GeometryUtils.crossesWall(oldLoc, newLoc, walls)) { + Log.d(TAG, "Wall collision detected - attempting wall slide"); + + // Try to slide along the wall instead of stopping completely + // Decompose movement into x and y components + LatLng slidPos = tryWallSlide(oldLoc, newLoc, walls); + if (slidPos != null && !slidPos.equals(oldLoc)) { + Log.d(TAG, "Wall slide successful - allowing partial movement"); + return slidPos; + } + + // If slide failed, stay at old position + return oldLoc; + } + } + } + + // No collision - allow movement + return newLoc; } - + /** - * Sets the map overlay for the building if user's current - * location is in building and is not already set - * Removes the overlay if user no longer in building + * Try to slide along a wall when direct movement is blocked + * Attempts horizontal and vertical components separately */ - private void setBuildingOverlay() { - // Try catch block to prevent fatal crashes - try { - // Setting overlay if in Nucleus and not already set - if (BuildingPolygon.inNucleus(currentLocation) && !isIndoorMapSet) { - groundOverlay = gMap.addGroundOverlay(new GroundOverlayOptions() - .image(BitmapDescriptorFactory.fromResource(R.drawable.nucleusg)) - .positionFromBounds(NUCLEUS)); - isIndoorMapSet = true; - // Nucleus has an LG floor so G floor is at index 1 - currentFloor=1; - floorHeight=NUCLEUS_FLOOR_HEIGHT; - } - // Setting overlay if in Library and not already set - else if (BuildingPolygon.inLibrary(currentLocation) && !isIndoorMapSet) { - groundOverlay = gMap.addGroundOverlay(new GroundOverlayOptions() - .image(BitmapDescriptorFactory.fromResource(R.drawable.libraryg)) - .positionFromBounds(LIBRARY)); - isIndoorMapSet = true; - currentFloor=0; - floorHeight=LIBRARY_FLOOR_HEIGHT; - } - // Removing overlay if user no longer in area with indoor maps available - else if (!BuildingPolygon.inLibrary(currentLocation) && - !BuildingPolygon.inNucleus(currentLocation)&& isIndoorMapSet){ - groundOverlay.remove(); - isIndoorMapSet = false; - currentFloor=0; - } - } catch (Exception ex) { - Log.e("Error with overlay, Exception:", ex.toString()); + private LatLng tryWallSlide(LatLng from, LatLng to, List> walls) { + // Try horizontal movement only (keep latitude) + LatLng horizontalMove = new LatLng(from.latitude, to.longitude); + if (!GeometryUtils.crossesWall(from, horizontalMove, walls)) { + return horizontalMove; + } + + // Try vertical movement only (keep longitude) + LatLng verticalMove = new LatLng(to.latitude, from.longitude); + if (!GeometryUtils.crossesWall(from, verticalMove, walls)) { + return verticalMove; } + + // Try partial movement (50% distance) + double midLat = (from.latitude + to.latitude) / 2.0; + double midLon = (from.longitude + to.longitude) / 2.0; + LatLng halfMove = new LatLng(midLat, midLon); + if (!GeometryUtils.crossesWall(from, halfMove, walls)) { + return halfMove; + } + + return null; // No valid slide found } + + /** + * ✅ Convert display name to API campaign ID + * Maps UI-friendly building names to API-compatible IDs for trajectory uploads + * Only uses verified campaign IDs that exist on the server + */ + private String convertNameToApiId(String displayName) { + if (displayName == null) return ""; // Default: empty (no campaign) + + String lowerName = displayName.toLowerCase().trim(); + // Only map campaigns that are CONFIRMED to exist on the server + if (lowerName.contains("murchison")) { + return "murchison_house"; + } + else if (lowerName.contains("nucleus")) { + return "nucleus_building"; + } + + // ⚠️ Library, FJB and other buildings: use empty campaign + // These campaigns may not exist on the server, so return empty string + // Once confirmed these campaigns exist, update this mapping + return ""; + } + /** - * Function used to set the indication of available floor maps for building using green Polylines - * along the building's boundaries. + * Check if a location is inside the currently selected building's boundary + * @param location The location to check + * @return true if location is inside the selected building, false otherwise */ - public void setIndicationOfIndoorMap(){ - //Indicator for Nucleus Building - List points=BuildingPolygon.NUCLEUS_POLYGON; - // Closing Boundary - points.add(BuildingPolygon.NUCLEUS_POLYGON.get(0)); - gMap.addPolyline(new PolylineOptions().color(Color.GREEN) - .addAll(points)); - - // Indicator for the Library Building - points=BuildingPolygon.LIBRARY_POLYGON; - // Closing Boundary - points.add(BuildingPolygon.LIBRARY_POLYGON.get(0)); - gMap.addPolyline(new PolylineOptions().color(Color.GREEN) - .addAll(points)); - } -} + public boolean isLocationInsideSelectedBuilding(LatLng location) { + if (location == null) return false; + + // Check against building boundary if available + if (buildingBoundary != null && !buildingBoundary.isEmpty()) { + return GeometryUtils.isPointInPolygon(location, buildingBoundary); + } + + // Fallback: check against selected building's polygon bounds + if (selectedBuilding != null && selectedBuilding.polygonPoints != null) { + return GeometryUtils.isPointInPolygon(location, selectedBuilding.polygonPoints); + } + + return false; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/PathView.java b/app/src/main/java/com/openpositioning/PositionMe/utils/PathView.java index 5a5efa8d..5d2c3c5a 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/PathView.java +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/PathView.java @@ -8,7 +8,6 @@ import android.util.AttributeSet; import android.view.View; -import com.openpositioning.PositionMe.presentation.fragment.CorrectionFragment; import com.openpositioning.PositionMe.sensors.SensorFusion; import java.util.ArrayList; @@ -19,8 +18,10 @@ * A path of straight lines is drawn based on PDR coordinates. The coordinates are passed to * PathView by calling method {@link PathView#drawTrajectory(float[])} in {@link SensorFusion}. * The coordinates are scaled and centered in {@link PathView#scaleTrajectory()} to fill the - * device's screen. The scaling ratio is passed to the {@link CorrectionFragment} for calculating - * the Google Maps zoom ratio. + * device's screen. + * + * Updates: + * - Removed dependency on CorrectionFragment to fix compilation error. * * @author Michal Dvorak * @author Virginia Cangelosi @@ -37,8 +38,10 @@ public class PathView extends View { private static ArrayList yCoords = new ArrayList(); // Scaling ratio for multiplying PDR coordinates to fill the screen size private static float scalingRatio; - // Instantiate correction fragment for passing it the scaling ratio - private CorrectionFragment correctionFragment = new CorrectionFragment(); + + // Fix: Removed CorrectionFragment instance + // private CorrectionFragment correctionFragment = new CorrectionFragment(); + // Boolean flag to avoid rescaling trajectory when view is redrawn private static boolean firstTimeOnDraw = true; //Variable to only draw when the variable is true @@ -204,8 +207,8 @@ private void scaleTrajectory() { } System.out.println("Adjusted scaling ratio: " + scalingRatio); - // Set the scaling ratio for the correction fragment for setting Google Maps zoom - correctionFragment.setScalingRatio(scalingRatio); + // Fix: Removed CorrectionFragment call (unused) + // correctionFragment.setScalingRatio(scalingRatio); // Iterate over all coordinates, shifting to the center and scaling for (int i = 0; i < xCoords.size(); i++) { @@ -243,5 +246,4 @@ public void redraw(float newScale){ //Enable redrawing of path reDraw = true; } - -} +} \ No newline at end of file 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..857ed060 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java @@ -130,6 +130,23 @@ public PdrProcessing(Context context) { this.currentFloor = 0; } + /** + * Re-read settings from SharedPreferences (e.g. step length). + * Call this when starting recording to ensure latest settings are used. + */ + public void refreshSettings() { + this.useManualStep = this.settings.getBoolean("manual_step_values", false); + if(useManualStep) { + try { + this.stepLength = this.settings.getInt("user_step_length", 75) / 100f; + } catch (Exception e) { + this.stepLength = 0.75f; + } + } else { + this.stepLength = 0; + } + } + /** * Function to calculate PDR coordinates from sensor values. * Should be called from the step detector sensor's event with the sensor values since the last @@ -174,7 +191,32 @@ public float[] updatePdr(long currentStepEnd, List accelMagnitudeOvertim this.positionX += x; this.positionY += y; - // return current position + // return current position + return new float[]{this.positionX, this.positionY}; + } + + /** + * Update PDR with a specific step length (e.g. from Weinberg algorithm). + * @param stepLengthMeters The calculated stride length in meters. + * @param headingRad Current heading in radians. + * @return New [x, y] coordinates. + */ + public float[] updatePdrWithStride(float stepLengthMeters, float headingRad) { + // Change angle so zero rad is east + float adaptedHeading = (float) (Math.PI/2 - headingRad); + + // Increment aggregate variables + this.sumStepLength += stepLengthMeters; + this.stepCount++; + + // Translate to cartesian coordinate system + float x = (float) (stepLengthMeters * Math.cos(adaptedHeading)); + float y = (float) (stepLengthMeters * Math.sin(adaptedHeading)); + + // Update position values + this.positionX += x; + this.positionY += y; + return new float[]{this.positionX, this.positionY}; } diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/TrajectoryVerifier.java b/app/src/main/java/com/openpositioning/PositionMe/utils/TrajectoryVerifier.java new file mode 100644 index 00000000..a97a1776 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/TrajectoryVerifier.java @@ -0,0 +1,161 @@ +package com.openpositioning.PositionMe.utils; + +import android.util.Log; + +import com.openpositioning.PositionMe.Traj; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +/** + * Utility class to verify trajectory file contents + * Use this to debug trajectory recording/playback issues + */ +public class TrajectoryVerifier { + private static final String TAG = "TrajectoryVerifier"; + + /** + * Verify a trajectory file and log its contents + * @param filePath Path to the trajectory .txt file + * @return true if file contains valid data, false otherwise + */ + public static boolean verifyTrajectoryFile(String filePath) { + File file = new File(filePath); + + Log.d(TAG, "========== TRAJECTORY FILE VERIFICATION =========="); + Log.d(TAG, "File: " + filePath); + + if (!file.exists()) { + Log.e(TAG, "ERROR: File does not exist!"); + return false; + } + + if (!file.canRead()) { + Log.e(TAG, "ERROR: File exists but cannot be read!"); + return false; + } + + Log.d(TAG, "File size: " + file.length() + " bytes"); + + if (file.length() == 0) { + Log.e(TAG, "ERROR: File is empty (0 bytes)!"); + return false; + } + + try (FileInputStream fis = new FileInputStream(file)) { + Traj.Trajectory traj = Traj.Trajectory.parseFrom(fis); + + Log.d(TAG, "---------- Parsed Data Summary ----------"); + Log.d(TAG, "Trajectory ID: " + traj.getTrajectoryId()); + Log.d(TAG, "Start Timestamp: " + traj.getStartTimestamp()); + Log.d(TAG, "Android Version: " + traj.getAndroidVersion()); + + if (traj.hasInitialPosition()) { + Traj.GNSSPosition initPos = traj.getInitialPosition(); + Log.d(TAG, "Initial Position: lat=" + initPos.getLatitude() + + ", lon=" + initPos.getLongitude()); + } + + Log.d(TAG, "---------- Data Counts ----------"); + Log.d(TAG, "IMU Data: " + traj.getImuDataCount()); + Log.d(TAG, "GNSS Data: " + traj.getGnssDataCount()); + Log.d(TAG, "PDR Data: " + traj.getPdrDataCount()); + Log.d(TAG, "Magnetometer Data: " + traj.getMagnetometerDataCount()); + Log.d(TAG, "Pressure Data: " + traj.getPressureDataCount()); + Log.d(TAG, "WiFi Fingerprints: " + traj.getWifiFingerprintsCount()); + Log.d(TAG, "BLE Data: " + traj.getBleDataCount()); + Log.d(TAG, "Test Points: " + traj.getTestPointsCount()); + + boolean hasGnss = traj.getGnssDataCount() > 0; + boolean hasPdr = traj.getPdrDataCount() > 0; + + Log.d(TAG, "---------- GNSS Data Details ----------"); + if (hasGnss) { + Log.d(TAG, "✓ GNSS data present - " + traj.getGnssDataCount() + " points"); + + // Show first and last GNSS points + Traj.GNSSReading first = traj.getGnssData(0); + Traj.GNSSReading last = traj.getGnssData(traj.getGnssDataCount() - 1); + + Log.d(TAG, "First GNSS point:"); + Log.d(TAG, " Lat: " + first.getPosition().getLatitude()); + Log.d(TAG, " Lon: " + first.getPosition().getLongitude()); + Log.d(TAG, " Accuracy: " + first.getAccuracy() + "m"); + Log.d(TAG, " Speed: " + first.getSpeed() + " m/s"); + Log.d(TAG, " Bearing: " + first.getBearing() + "°"); + + Log.d(TAG, "Last GNSS point:"); + Log.d(TAG, " Lat: " + last.getPosition().getLatitude()); + Log.d(TAG, " Lon: " + last.getPosition().getLongitude()); + Log.d(TAG, " Accuracy: " + last.getAccuracy() + "m"); + + // Calculate distance between first and last + double distance = calculateDistance( + first.getPosition().getLatitude(), + first.getPosition().getLongitude(), + last.getPosition().getLatitude(), + last.getPosition().getLongitude() + ); + Log.d(TAG, "Distance first->last: " + String.format("%.1f", distance) + "m"); + } else { + Log.e(TAG, "✗ NO GNSS DATA - trajectory will not display!"); + } + + Log.d(TAG, "---------- PDR Data Details ----------"); + if (hasPdr) { + Log.d(TAG, "✓ PDR data present - " + traj.getPdrDataCount() + " points"); + + Traj.RelativePosition firstPdr = traj.getPdrData(0); + Traj.RelativePosition lastPdr = traj.getPdrData(traj.getPdrDataCount() - 1); + + Log.d(TAG, "First PDR: x=" + firstPdr.getX() + ", y=" + firstPdr.getY()); + Log.d(TAG, "Last PDR: x=" + lastPdr.getX() + ", y=" + lastPdr.getY()); + + double pdrDistance = Math.sqrt( + Math.pow(lastPdr.getX() - firstPdr.getX(), 2) + + Math.pow(lastPdr.getY() - firstPdr.getY(), 2) + ); + Log.d(TAG, "PDR distance: " + String.format("%.1f", pdrDistance) + "m"); + } else { + Log.e(TAG, "✗ NO PDR DATA"); + } + + Log.d(TAG, "---------- Verdict ----------"); + if (hasGnss && hasPdr) { + Log.d(TAG, "✓✓ FILE IS VALID - Contains both GNSS and PDR data"); + Log.d(TAG, " Trajectory should replay correctly"); + } else if (hasGnss) { + Log.w(TAG, "⚠ FILE HAS GNSS ONLY - Trajectory will show (GNSS used)"); + } else if (hasPdr) { + Log.w(TAG, "⚠ FILE HAS PDR ONLY - Trajectory will show (PDR used)"); + } else { + Log.e(TAG, "✗✗ FILE IS INVALID - No trajectory data!"); + Log.e(TAG, " Recording failed or data was not saved"); + } + + Log.d(TAG, "=================================================="); + + return hasGnss || hasPdr; + + } catch (IOException e) { + Log.e(TAG, "ERROR reading/parsing trajectory file", e); + Log.d(TAG, "=================================================="); + return false; + } + } + + /** + * Calculate distance between two GPS coordinates using Haversine formula + */ + private static double calculateDistance(double lat1, double lon1, double lat2, double lon2) { + final double R = 6371000; // Earth radius in meters + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/WifiApObservation.java b/app/src/main/java/com/openpositioning/PositionMe/utils/WifiApObservation.java new file mode 100644 index 00000000..abe34697 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/WifiApObservation.java @@ -0,0 +1,13 @@ +package com.openpositioning.PositionMe.utils; + +public class WifiApObservation { + public final String bssid; // MAC + public final int rssi; // dBm + public final String ssid; // optional + + public WifiApObservation(String bssid, int rssi, String ssid) { + this.bssid = bssid; + this.rssi = rssi; + this.ssid = ssid; + } +} From 6a5a8e4cc07de8fa8ebe99d4d26511efd75a1fee Mon Sep 17 00:00:00 2001 From: Lidong Guo Date: Tue, 10 Feb 2026 16:35:39 +0000 Subject: [PATCH 03/19] Add indoor map display features and update user interface --- .../presentation/activity/MainActivity.java | 199 ++---- .../activity/RecordingActivity.java | 49 +- .../fragment/CorrectionFragment.java | 204 +++--- .../presentation/fragment/HomeFragment.java | 8 + .../fragment/IndoorMapFragment.java | 279 +++++++- .../fragment/IndoorPositioningFragment.java | 375 ++++++++++ .../presentation/fragment/InfoFragment.java | 18 +- .../fragment/MeasurementsFragment.java | 99 ++- .../fragment/RecordingFragment.java | 468 ++++++------ .../presentation/fragment/ReplayFragment.java | 118 +++- .../fragment/StartLocationFragment.java | 184 +++-- .../fragment/TrajectoryMapFragment.java | 668 ++++++++++-------- .../presentation/fragment/UploadFragment.java | 66 +- .../viewitems/BleListAdapter.java | 65 ++ .../presentation/viewitems/BleViewHolder.java | 29 + .../viewitems/UploadListAdapter.java | 102 +-- .../viewitems/WifiListAdapter.java | 16 +- .../main/res/drawable/status_background.xml | 6 + .../main/res/drawable/status_recording.xml | 6 + .../main/res/layout/fragment_correction.xml | 140 +--- .../layout/fragment_indoor_positioning.xml | 305 ++++++++ .../main/res/layout/fragment_measurements.xml | 64 +- .../main/res/layout/fragment_recording.xml | 209 +++--- app/src/main/res/layout/fragment_replay.xml | 26 + .../res/layout/fragment_startlocation.xml | 108 ++- .../res/layout/fragment_trajectory_map.xml | 158 +++-- .../main/res/layout/item_ble_card_view.xml | 75 ++ app/src/main/res/navigation/main_nav.xml | 13 + app/src/main/res/values/strings.xml | 1 + 29 files changed, 2670 insertions(+), 1388 deletions(-) create mode 100644 app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorPositioningFragment.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/BleListAdapter.java create mode 100644 app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/BleViewHolder.java create mode 100644 app/src/main/res/drawable/status_background.xml create mode 100644 app/src/main/res/drawable/status_recording.xml create mode 100644 app/src/main/res/layout/fragment_indoor_positioning.xml create mode 100644 app/src/main/res/layout/item_ble_card_view.xml 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 995f010d..450abc37 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 @@ -1,9 +1,8 @@ package com.openpositioning.PositionMe.presentation.activity; + import android.Manifest; import android.content.SharedPreferences; - import android.content.pm.PackageManager; -import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.view.Menu; @@ -17,7 +16,6 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatDelegate; import androidx.appcompat.widget.Toolbar; - import androidx.core.content.ContextCompat; import androidx.navigation.NavController; import androidx.navigation.NavOptions; @@ -27,42 +25,20 @@ import androidx.preference.PreferenceManager; import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.data.remote.ServerCommunications; import com.openpositioning.PositionMe.presentation.fragment.HomeFragment; import com.openpositioning.PositionMe.presentation.fragment.SettingsFragment; import com.openpositioning.PositionMe.sensors.Observer; import com.openpositioning.PositionMe.sensors.SensorFusion; import com.openpositioning.PositionMe.utils.PermissionManager; - +// [New] Import required for file saving +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; import java.util.Objects; -/** - * The Main Activity of the application, handling setup, permissions and starting all other fragments - * and processes. - * The Main Activity takes care of most essential tasks before the app can run. Such as setting up - * the views, and enforcing light mode so the colour scheme is consistent. It initialises the - * various fragments and the navigation between them, getting the Navigation controller. It also - * loads the custom action bar with the set theme and icons, and enables back-navigation. The shared - * preferences are also loaded. - *

- * The most important task of the main activity is check and asking for the necessary permissions to - * enable the application to use the required hardware devices. This is done through a number of - * functions that call the OS, as well as pop-up messages warning the user if permissions are denied. - *

- * Once all permissions are granted, the Main Activity obtains the Sensor Fusion instance and sets - * the context, enabling the Fragments to interact with the class without setting it up again. - * - * @see HomeFragment the initial fragment displayed. - * @see com.openpositioning.PositionMe.R.navigation the navigation graph. - * @see SensorFusion the singletion data processing class. - * - * @author Mate Stodulka - * @author Virginia Cangelosi - */ public class MainActivity extends AppCompatActivity implements Observer { - //region Instance variables private NavController navController; private ActivityResultLauncher locationPermissionLauncher; @@ -75,29 +51,19 @@ public class MainActivity extends AppCompatActivity implements Observer { private PermissionManager permissionManager; private static final int PERMISSION_REQUEST_CODE = 100; - //endregion //region Activity Lifecycle - - /** - * {@inheritDoc} - * Forces light mode, sets up the navigation graph, initialises the toolbar with back action on - * the nav controller, loads the shared preferences and checks for all permissions necessary. - * Sets up a Handler for displaying messages from other classes. - */ @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); setContentView(R.layout.activity_main); - // Set up navigation and fragments NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager() .findFragmentById(R.id.nav_host_fragment); navController = Objects.requireNonNull(navHostFragment).getNavController(); - // Set action bar Toolbar toolbar = findViewById(R.id.main_toolbar); setSupportActionBar(toolbar); toolbar.showOverflowMenu(); @@ -105,19 +71,15 @@ protected void onCreate(Bundle savedInstanceState) { toolbar.setTitleTextColor(ContextCompat.getColor(getApplicationContext(), R.color.black)); toolbar.setNavigationIcon(R.drawable.ic_baseline_back_arrow); - // Set up back action with NavigationUI AppBarConfiguration appBarConfiguration = new AppBarConfiguration.Builder(navController.getGraph()).build(); NavigationUI.setupWithNavController(toolbar, navController, appBarConfiguration); - // Get handle for settings this.settings = PreferenceManager.getDefaultSharedPreferences(this); settings.edit().putBoolean("permanentDeny", false).apply(); - // Initialize SensorFusion early so that its context is set this.sensorFusion = SensorFusion.getInstance(); this.sensorFusion.setContext(getApplicationContext()); - // Register multiple permissions launcher multiplePermissionsLauncher = registerForActivityResult( new ActivityResultContracts.RequestMultiplePermissions(), result -> { @@ -125,10 +87,8 @@ protected void onCreate(Bundle savedInstanceState) { boolean activityGranted = result.getOrDefault(Manifest.permission.ACTIVITY_RECOGNITION, false); if (locationGranted && activityGranted) { - // Both permissions granted allPermissionsObtained(); } else { - // Permission denied Toast.makeText(this, "Location or Physical Activity permission denied. Some features may not work.", Toast.LENGTH_LONG).show(); @@ -136,46 +96,26 @@ protected void onCreate(Bundle savedInstanceState) { } ); - // Handler for global toasts and popups from other classes this.httpResponseHandler = new Handler(); } - - - - /** - * {@inheritDoc} - */ @Override public void onPause() { super.onPause(); - - //Ensure sensorFusion has been initialised before unregistering listeners if(sensorFusion != null) { -// sensorFusion.stopListening(); + // sensorFusion.stopListening(); } } - /** - * {@inheritDoc} - * Checks for activities in case the app was closed without granting them, or if they were - * granted through the settings page. Repeats the startup checks done in - * {@link MainActivity#onCreate(Bundle)}. Starts listening in the SensorFusion class. - * - * @see SensorFusion the main data processing class. - */ @Override public void onResume() { super.onResume(); - if (getSupportActionBar() != null) { getSupportActionBar().show(); } - // Delay permission check slightly to ensure the Activity is in the foreground new Handler().postDelayed(() -> { if (isActivityVisible()) { - // Check if both permissions are granted boolean locationGranted = ContextCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED; @@ -185,7 +125,6 @@ public void onResume() { ) == PackageManager.PERMISSION_GRANTED; if (!locationGranted || !activityGranted) { - // Request both permissions using ActivityResultLauncher multiplePermissionsLauncher.launch(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }); @@ -193,11 +132,10 @@ public void onResume() { Manifest.permission.ACTIVITY_RECOGNITION }); } else { - // Both permissions are already granted allPermissionsObtained(); } } - }, 300); // Delay ensures activity is fully visible before requesting permissions + }, 300); if (sensorFusion != null) { sensorFusion.resumeListening(); @@ -208,61 +146,27 @@ private boolean isActivityVisible() { return !isFinishing() && !isDestroyed(); } - - - /** - * Unregisters sensor listeners when the app closes. Not in {@link MainActivity#onPause()} to - * enable recording data with a locked screen. - * - * @see SensorFusion the main data processing class. - */ @Override protected void onDestroy() { if (sensorFusion != null) { -// sensorFusion.stopListening(); // suspended due to the need to record data with -// a locked screen or cross activity + // sensorFusion.stopListening(); } super.onDestroy(); } - - //endregion //region Permissions - - /** - * Prepares global resources when all permissions are granted. - * Resets the permissions tracking boolean in shared preferences, and initialises the - * {@link SensorFusion} class with the application context, and registers the main activity to - * listen for server responses that SensorFusion receives. - * - * @see SensorFusion the main data processing class. - * @see ServerCommunications the communication class sending and recieving data from the server. - */ private void allPermissionsObtained() { - // Reset any permission denial flag in SharedPreferences if needed. settings.edit().putBoolean("permanentDeny", false).apply(); - - // Ensure SensorFusion is initialized with a valid context. if (this.sensorFusion == null) { this.sensorFusion = SensorFusion.getInstance(); this.sensorFusion.setContext(getApplicationContext()); } sensorFusion.registerForServerUpdate(this); } - - - - //endregion //region Navigation - - /** - * {@inheritDoc} - * Sets desired animations and navigates to {@link SettingsFragment} - * when the settings wheel in the action bar is clicked. - */ @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { if(Objects.requireNonNull(navController.getCurrentDestination()).getId() == item.getItemId()) @@ -279,35 +183,19 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { } } - /** - * {@inheritDoc} - * Enables navigating back between fragments. - */ @Override public boolean onSupportNavigateUp() { return navController.navigateUp() || super.onSupportNavigateUp(); } - /** - * {@inheritDoc} - * Inflate the designed menu view. - * - * @see com.openpositioning.PositionMe.R.menu for the xml file. - */ @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_items, menu); return true; } - /** - * {@inheritDoc} - * Handles the back button press. If the current fragment is the HomeFragment, a dialog is - * displayed to confirm the exit. If not, the default back navigation is performed. - */ @Override public void onBackPressed() { - // Check if the current destination is HomeFragment (assumed to be the root) if (navController.getCurrentDestination() != null && navController.getCurrentDestination().getId() == R.id.homeFragment) { new AlertDialog.Builder(this) @@ -315,27 +203,18 @@ public void onBackPressed() { .setMessage("Are you sure you want to exit the app?") .setPositiveButton("Yes", (dialog, which) -> { dialog.dismiss(); - finish(); // Close the activity (exit the app) + finish(); }) .setNegativeButton("No", (dialog, which) -> dialog.dismiss()) .create() .show(); } else { - // If not on the root destination, perform the default back navigation. super.onBackPressed(); } } - - - //endregion //region Global toasts - - /** - * {@inheritDoc} - * Calls the corresponding handler that runs a toast on the Main UI thread. - */ @Override public void update(Object[] objList) { assert objList[0] instanceof Boolean; @@ -347,20 +226,60 @@ public void update(Object[] objList) { } } - /** - * Task that displays positive toast on the main UI thread. - * Called when {@link ServerCommunications} successfully uploads a trajectory. - */ private final Runnable displayToastTaskSuccess = () -> Toast.makeText(MainActivity.this, "Trajectory uploaded", Toast.LENGTH_SHORT).show(); - /** - * Task that displays negative toast on the main UI thread. - * Called when {@link ServerCommunications} fails to upload a trajectory. - */ private final Runnable displayToastTaskFailure = () -> { -// Toast.makeText(MainActivity.this, "Failed to complete trajectory upload", Toast.LENGTH_SHORT).show(); + // Toast.makeText(MainActivity.this, "Failed to complete trajectory upload", Toast.LENGTH_SHORT).show(); }; - //endregion + + // ==================================================================================== + // ⬇️ Only Modified Part: File Saving Logic ⬇️ + // ==================================================================================== + + /** + * This method fixes the file saving issue. + * 1. Force use of .protobuf suffix + * 2. Use writeTo() to write binary data, preventing garbled txt generation + * Call this method in the Stop button of HomeFragment: ((MainActivity)getActivity()).stopRecordingAndSave(); + */ + public void stopRecordingAndSave() { + // 1. Stop sensor collection + if (sensorFusion != null) { + sensorFusion.stopRecording(); + } + + // 2. Prepare to save + try { + // [Critical Modification] File name suffix must be .protobuf, otherwise History interface won't recognize it + String filename = "Traj_" + System.currentTimeMillis() + ".protobuf"; + + // Get App private storage path + File file = new File(getExternalFilesDir(null), filename); + FileOutputStream fos = new FileOutputStream(file); + + // [Critical Modification] Use writeTo to write binary stream directly + // Note: You need to add a public Traj.Trajectory.Builder getTrajectory() method in SensorFusion.java + if (sensorFusion != null && sensorFusion.getTrajectory() != null) { + sensorFusion.getTrajectory().build().writeTo(fos); + Toast.makeText(this, "File Saved: " + filename, Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, "Error: No data to save", Toast.LENGTH_SHORT).show(); + } + + fos.close(); + + } catch (IOException e) { + e.printStackTrace(); + Toast.makeText(this, "Save Failed: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + // 如果您需要从 Fragment 调用开始录制并设置名字,可以使用这个辅助方法 + public void startRecording(String filename) { + if (sensorFusion != null) { + sensorFusion.startRecording(filename); + } + } } \ No newline at end of file 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 c0d82ae2..40782c86 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 @@ -8,36 +8,15 @@ import androidx.fragment.app.FragmentTransaction; import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.presentation.fragment.StartLocationFragment; -import com.openpositioning.PositionMe.presentation.fragment.RecordingFragment; import com.openpositioning.PositionMe.presentation.fragment.CorrectionFragment; - +import com.openpositioning.PositionMe.presentation.fragment.RecordingFragment; +import com.openpositioning.PositionMe.presentation.fragment.StartLocationFragment; /** - * The RecordingActivity manages the recording flow of the application, guiding the user through a sequence - * of screens for location selection, recording, and correction before finalizing the process. - *

- * This activity follows a structured workflow: - *

    - *
  1. StartLocationFragment - Allows users to select their starting location.
  2. - *
  3. RecordingFragment - Handles the recording process and contains a TrajectoryMapFragment.
  4. - *
  5. CorrectionFragment - Enables users to review and correct recorded data before completion.
  6. - *
- *

- * The activity ensures that the screen remains on during the recording process to prevent interruptions. - * It also provides fragment transactions for seamless navigation between different stages of the workflow. - *

- * This class is referenced in various fragments such as HomeFragment, StartLocationFragment, - * RecordingFragment, and CorrectionFragment to control navigation through the recording flow. - * - * @see StartLocationFragment The first step in the recording process where users select their starting location. - * @see RecordingFragment Handles data recording and map visualization. - * @see CorrectionFragment Allows users to review and make corrections before finalizing the process. - * @see com.openpositioning.PositionMe.R.layout#activity_recording The associated layout for this activity. - * - * @author ShuGu + * RecordingActivity (Updated) + * Manages the navigation flow: StartLocation -> Recording -> Correction. + * Keeps the screen on during the process. */ - public class RecordingActivity extends AppCompatActivity { @Override @@ -46,15 +25,16 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { setContentView(R.layout.activity_recording); if (savedInstanceState == null) { - showStartLocationScreen(); // Start with the user selecting the start location + // Step 1: Start with Location Selection + showStartLocationScreen(); } - // Keep screen on + // Keep screen on to prevent interruption during sensor recording getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); } /** - * Show the StartLocationFragment (beginning of flow). + * Show the StartLocationFragment. */ public void showStartLocationScreen() { FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); @@ -63,17 +43,20 @@ public void showStartLocationScreen() { } /** - * Show the RecordingFragment, which contains the TrajectoryMapFragment internally. + * Show the RecordingFragment. + * Called by StartLocationFragment after location is confirmed. */ public void showRecordingScreen() { FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); ft.replace(R.id.mainFragmentContainer, new RecordingFragment()); + // Add to back stack so user can go back to change start location if needed ft.addToBackStack(null); ft.commit(); } /** - * Show the CorrectionFragment after the user stops recording. + * Show the CorrectionFragment. + * Should be called by RecordingFragment after recording stops. */ public void showCorrectionScreen() { FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); @@ -83,10 +66,10 @@ public void showCorrectionScreen() { } /** - * Finish the Activity (or do any final steps) once corrections are done. + * Finish the Activity flow. */ public void finishFlow() { getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); finish(); } -} +} \ No newline at end of file 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 8f94cb27..684a150f 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,53 +1,50 @@ package com.openpositioning.PositionMe.presentation.fragment; import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.KeyEvent; +import android.os.Handler; +import android.os.Looper; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Button; -import android.widget.EditText; +import android.widget.SeekBar; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; 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.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.LatLng; import com.google.android.gms.maps.model.MarkerOptions; +import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.presentation.activity.RecordingActivity; +import com.openpositioning.PositionMe.sensors.SensorFusion; +import com.openpositioning.PositionMe.utils.IndoorMapManager; +// Fix: Removed non-existent TrajMapPoints reference +// import com.openpositioning.PositionMe.utils.TrajMapPoints; + +import java.util.List; -/** - * 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. - */ public class CorrectionFragment extends Fragment { - //Map variable - public GoogleMap mMap; - //Button to go to next - private Button button; - //Singleton SensorFusion class + // UI Components + private Button uploadButton; + private SeekBar rotationBar; + private TextView correctionTitle; + + // Map & Logic + private GoogleMap gMap; private SensorFusion sensorFusion = SensorFusion.getInstance(); - private TextView averageStepLengthText; - private EditText stepLengthInput; - private float averageStepLength; - private float newStepLength; - private int secondPass = 0; - private CharSequence changedText; - private static float scalingRatio = 0f; - private static LatLng start; - private PathView pathView; + private IndoorMapManager indoorMapManager; + + // Correction State + private float currentRotation = 0f; public CorrectionFragment() { // Required empty public constructor @@ -60,102 +57,79 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, if (activity != null && activity.getSupportActionBar() != null) { activity.getSupportActionBar().hide(); } - View rootView = inflater.inflate(R.layout.fragment_correction, container, false); - - // Send trajectory data to the cloud - sensorFusion.sendTrajectoryToCloud(); - - //Obtain start position - float[] startPosition = sensorFusion.getGNSSLatitude(true); - - // Initialize map fragment - SupportMapFragment supportMapFragment=(SupportMapFragment) - getChildFragmentManager().findFragmentById(R.id.map); - - supportMapFragment.getMapAsync(new OnMapReadyCallback() { - @Override - public void onMapReady(GoogleMap map) { - mMap = map; - mMap.setMapType(GoogleMap.MAP_TYPE_HYBRID); - mMap.getUiSettings().setCompassEnabled(true); - mMap.getUiSettings().setTiltGesturesEnabled(true); - 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")); - - // 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)); - } - }); - - return rootView; + return inflater.inflate(R.layout.fragment_correction, container, false); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - this.averageStepLengthText = view.findViewById(R.id.averageStepView); - this.stepLengthInput = view.findViewById(R.id.inputStepLength); - this.pathView = view.findViewById(R.id.pathView1); - - averageStepLength = sensorFusion.passAverageStepLength(); - 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)); - pathView.invalidate(); - - secondPass++; - if (secondPass == 2) { - averageStepLength = newStepLength; - secondPass = 0; + // Bind UI (matching XML IDs) + uploadButton = view.findViewById(R.id.uploadButton); + rotationBar = view.findViewById(R.id.rotationSeekBar); + correctionTitle = view.findViewById(R.id.correctionTitle); + + // Configure SeekBar + if (rotationBar != null) { + rotationBar.setMax(360); + rotationBar.setProgress(180); + rotationBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + currentRotation = (progress - 180); + // Actual rotation logic requires data support, omitted here } - } - return false; - }); - - 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; - } - }); - - // 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(); - } - }); + @Override + public void onStartTrackingTouch(SeekBar seekBar) {} + @Override + public void onStopTrackingTouch(SeekBar seekBar) {} + }); + } + + // Initialize Map + SupportMapFragment mapFragment = (SupportMapFragment) getChildFragmentManager() + .findFragmentById(R.id.map); // Note: Map ID must be 'map' in XML + + if (mapFragment != null) { + mapFragment.getMapAsync(new OnMapReadyCallback() { + @Override + public void onMapReady(GoogleMap googleMap) { + gMap = googleMap; + gMap.setMapType(GoogleMap.MAP_TYPE_HYBRID); + gMap.getUiSettings().setCompassEnabled(true); + gMap.getUiSettings().setZoomControlsEnabled(true); + + indoorMapManager = new IndoorMapManager(gMap, requireContext()); + drawTrajectory(); + } + }); + } + + // Upload Button Listener + if (uploadButton != null) { + uploadButton.setOnClickListener(v -> { + sensorFusion.sendTrajectoryToCloud(); + Toast.makeText(getContext(), "Trajectory Uploaded!", Toast.LENGTH_SHORT).show(); + new Handler(Looper.getMainLooper()).postDelayed(() -> { + if (getActivity() instanceof RecordingActivity) { + ((RecordingActivity) getActivity()).finishFlow(); + } + }, 1000); + }); + } } - public void setScalingRatio(float scalingRatio) { - this.scalingRatio = scalingRatio; + private void drawTrajectory() { + if (gMap == null) return; + + float[] startGen = sensorFusion.getGNSSLatitude(true); + LatLng startPos = new LatLng(startGen[0], startGen[1]); + + gMap.addMarker(new MarkerOptions().position(startPos).title("Start")); + gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(startPos, 19f)); + + if (indoorMapManager != null) { + indoorMapManager.setCurrentLocation(startPos); + } } -} +} \ No newline at end of file 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 8371b04e..789bea71 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 @@ -50,6 +50,7 @@ public class HomeFragment extends Fragment implements OnMapReadyCallback { private Button start; private Button measurements; private Button files; + private MaterialButton indoorButton; private TextView gnssStatusTextView; // For the map @@ -116,6 +117,13 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat Navigation.findNavController(v).navigate(action); }); + // Indoor Positioning button + indoorButton = view.findViewById(R.id.indoorButton); + indoorButton.setOnClickListener(v -> { + // Navigate to IndoorPositioningFragment + Navigation.findNavController(v).navigate(R.id.action_homeFragment_to_indoorPositioningFragment); + }); + // TextView to display GNSS disabled message gnssStatusTextView = view.findViewById(R.id.gnssStatusTextView); diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorMapFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorMapFragment.java index 48c40474..e172d695 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorMapFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorMapFragment.java @@ -1,24 +1,243 @@ package com.openpositioning.PositionMe.presentation.fragment; +import android.util.Log; + import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.BitmapDescriptorFactory; import com.google.android.gms.maps.model.BitmapDescriptor; - import com.google.android.gms.maps.model.GroundOverlay; import com.google.android.gms.maps.model.GroundOverlayOptions; +import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.Polygon; +import com.google.android.gms.maps.model.PolygonOptions; +import com.openpositioning.PositionMe.data.remote.IndoorMapAPI; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +/** + * IndoorMapFragment - Enhanced version with API support + * Handles: + * 1. Fetching building and floor data from API + * 2. Drawing building outlines on map + * 3. Displaying indoor floor plans + * 4. Managing venue/building selection + */ public class IndoorMapFragment { + private static final String TAG = "IndoorMapFragment"; + private GoogleMap mMap; private GroundOverlay[] groundOverlays; // GroundOverlay used to store each layer private int currentFloor = 0; // Floor by default + private int floorCount = 0; + + // Indoor map API + private IndoorMapAPI indoorMapAPI; + + // Building data + private Map buildingMap = new HashMap<>(); + private Map floorMap = new HashMap<>(); + + // Map overlays + private List buildingPolygons = new ArrayList<>(); + private String currentBuildingId = null; + private String currentVenueName = null; + + // Callback for venue selection + private VenueSelectionCallback venueCallback; + + public interface VenueSelectionCallback { + void onVenueSelected(String buildingId, String venueName); + } + + public IndoorMapFragment() { + this.indoorMapAPI = new IndoorMapAPI(); + } public IndoorMapFragment(GoogleMap map, int floorNumber) { this.mMap = map; // Pass in Google Maps + this.floorCount = floorNumber; this.groundOverlays = new GroundOverlay[floorNumber]; // Set the number of floors + this.indoorMapAPI = new IndoorMapAPI(); + } + + /** + * Set the venue selection callback + */ + public void setVenueSelectionCallback(VenueSelectionCallback callback) { + this.venueCallback = callback; + } + + /** + * Fetch buildings near user location + */ + public void fetchNearbyBuildings(double latitude, double longitude, double radiusMeters) { + if (indoorMapAPI == null) return; + + indoorMapAPI.fetchNearbyBuildings(latitude, longitude, radiusMeters, + new IndoorMapAPI.BuildingsCallback() { + @Override + public void onSuccess(List buildings) { + Log.d(TAG, "Received " + buildings.size() + " buildings"); + displayBuildings(buildings); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Error fetching buildings: " + error); + } + }); + } + + /** + * Display building outlines and enable selection + */ + private void displayBuildings(List buildings) { + if (mMap == null) return; + + // Clear previous polygons + for (Polygon polygon : buildingPolygons) { + polygon.remove(); + } + buildingPolygons.clear(); + buildingMap.clear(); + + // Add each building as a marker and polygon + for (final IndoorMapAPI.BuildingInfo building : buildings) { + buildingMap.put(building.buildingId, building); + + // Add building name as marker + com.google.android.gms.maps.model.MarkerOptions markerOptions = + new com.google.android.gms.maps.model.MarkerOptions() + .position(new LatLng(building.latitude, building.longitude)) + .title(building.buildingName) + .snippet("Taps to select"); + + final com.google.android.gms.maps.model.Marker marker = mMap.addMarker(markerOptions); + + // Fetch and display building outline + indoorMapAPI.fetchBuildingOutline(building.buildingId, + new IndoorMapAPI.OutlineCallback() { + @Override + public void onSuccess(double[][] coordinates) { + if (coordinates.length > 0) { + drawBuildingOutline(building.buildingId, building.buildingName, coordinates); + } + } + + @Override + public void onError(String error) { + Log.w(TAG, "Could not fetch outline for building " + building.buildingId); + } + }); + } + + // Set up marker click listener to select venue + mMap.setOnMarkerClickListener(marker -> { + String buildingId = null; + for (IndoorMapAPI.BuildingInfo b : buildingMap.values()) { + if (new LatLng(b.latitude, b.longitude).equals(marker.getPosition())) { + buildingId = b.buildingId; + break; + } + } + + if (buildingId != null) { + selectVenue(buildingId, marker.getTitle()); + // Fetch and display floors for this building + fetchBuildingFloors(buildingId); + } + return true; + }); + } + + /** + * Draw building outline as polygon + */ + private void drawBuildingOutline(String buildingId, String buildingName, double[][] coordinates) { + if (mMap == null || coordinates.length == 0) return; + + PolygonOptions polygonOptions = new PolygonOptions() + .strokeColor(0xFF0000FF) // Blue + .fillColor(0x220000FF) // Semi-transparent blue + .strokeWidth(3.0f); + + for (double[] coord : coordinates) { + polygonOptions.add(new LatLng(coord[0], coord[1])); + } + + Polygon polygon = mMap.addPolygon(polygonOptions); + buildingPolygons.add(polygon); + + // Store building ID in polygon tag for later reference + polygon.setTag(buildingId); + } + + /** + * Fetch and display floor plans for selected building + */ + public void fetchBuildingFloors(String buildingId) { + indoorMapAPI.fetchBuildingFloors(buildingId, + new IndoorMapAPI.FloorsCallback() { + @Override + public void onSuccess(List floors) { + Log.d(TAG, "Received " + floors.size() + " floors"); + displayFloors(floors); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Error fetching floors: " + error); + } + }); + } + + /** + * Display floor plans as ground overlays + */ + private void displayFloors(List floors) { + if (mMap == null || floors.isEmpty()) return; + + // Clear existing overlays + if (groundOverlays != null) { + for (GroundOverlay overlay : groundOverlays) { + if (overlay != null) { + overlay.remove(); + } + } + } + + // Create new array for floors + groundOverlays = new GroundOverlay[floors.size()]; + floorCount = floors.size(); + + for (int i = 0; i < floors.size(); i++) { + IndoorMapAPI.FloorPlan floor = floors.get(i); + LatLngBounds bounds = new LatLngBounds( + new LatLng(floor.minLat, floor.minLon), + new LatLng(floor.maxLat, floor.maxLon) + ); + + // Add floor from URL if available + if (!floor.imageUrl.isEmpty()) { + addFloorFromUrl(i, floor.imageUrl, bounds); + } + + floorMap.put(floor.floorId, floor); + Log.d(TAG, "Added floor: " + floor.floorName); + } + + // Show first floor by default + currentFloor = 0; + if (groundOverlays.length > 0 && groundOverlays[0] != null) { + groundOverlays[0].setVisible(true); + } } - // Used to add floors + // Used to add floors from drawable resource public void addFloor(int floorIndex, int drawableResId, LatLngBounds bounds) { BitmapDescriptor image = BitmapDescriptorFactory.fromResource(drawableResId); GroundOverlayOptions groundOverlayOptions = new GroundOverlayOptions() @@ -27,12 +246,34 @@ public void addFloor(int floorIndex, int drawableResId, LatLngBounds bounds) { .visible(floorIndex == currentFloor) .transparency(0.2f); - groundOverlays[floorIndex] = mMap.addGroundOverlay(groundOverlayOptions); + if (groundOverlays != null && floorIndex < groundOverlays.length) { + groundOverlays[floorIndex] = mMap.addGroundOverlay(groundOverlayOptions); + } + } + + /** + * Add floor plan from URL (as bitmap) + */ + public void addFloorFromUrl(int floorIndex, String imageUrl, LatLngBounds bounds) { + // For now, we'll use a placeholder + // In production, you'd need to download the image from URL + // and cache it locally + BitmapDescriptor image = BitmapDescriptorFactory.defaultMarker(); + + GroundOverlayOptions groundOverlayOptions = new GroundOverlayOptions() + .image(image) + .positionFromBounds(bounds) + .visible(false) // Hidden initially + .transparency(0.2f); + + if (groundOverlays != null && floorIndex < groundOverlays.length) { + groundOverlays[floorIndex] = mMap.addGroundOverlay(groundOverlayOptions); + } } // Switch floors and make sure only one floor is displayed public void switchFloor(int floorIndex) { - if (floorIndex < 0 || floorIndex >= groundOverlays.length) { + if (groundOverlays == null || floorIndex < 0 || floorIndex >= groundOverlays.length) { return; // Prevent index out of bounds } // Hide all floors @@ -47,10 +288,12 @@ public void switchFloor(int floorIndex) { selectedOverlay.setVisible(true); } currentFloor = floorIndex; + Log.d(TAG, "Switched to floor " + floorIndex); } // Hide all floors public void hideMap() { + if (groundOverlays == null) return; //Hide all floors for (GroundOverlay overlay : groundOverlays) { if (overlay != null) { @@ -58,4 +301,32 @@ public void hideMap() { } } } + + /** + * Select a venue/building and notify callback + */ + private void selectVenue(String buildingId, String venueName) { + this.currentBuildingId = buildingId; + this.currentVenueName = venueName; + + Log.d(TAG, "Venue selected: " + venueName + " (" + buildingId + ")"); + + if (venueCallback != null) { + venueCallback.onVenueSelected(buildingId, venueName); + } + } + + /** + * Get selected venue name + */ + public String getSelectedVenueName() { + return currentVenueName; + } + + /** + * Get selected building ID + */ + public String getSelectedBuildingId() { + return currentBuildingId; + } } diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorPositioningFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorPositioningFragment.java new file mode 100644 index 00000000..47594d43 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorPositioningFragment.java @@ -0,0 +1,375 @@ +package com.openpositioning.PositionMe.presentation.fragment; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.location.Location; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.fragment.app.Fragment; + +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationCallback; +import com.google.android.gms.location.LocationRequest; +import com.google.android.gms.location.LocationResult; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.location.Priority; +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.LatLng; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.android.gms.maps.model.Polygon; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.sensors.SensorFusion; +import com.openpositioning.PositionMe.utils.IndoorMapManager; + +import java.util.Locale; + +/** + * IndoorPositioningFragment - Real-time indoor positioning debug panel + * Displays current GPS coordinates, altitude, building selection, and indoor maps + */ +public class IndoorPositioningFragment extends Fragment implements OnMapReadyCallback { + + private static final String TAG = "IndoorPositioning"; + + // UI Elements + private TextView latitudeText, longitudeText, altitudeText, accuracyText, floorText, currentFloorText; + private MaterialButton nucleusButton, libraryButton, murchisonButton, fjbButton; + private FloatingActionButton floorUpBtn, floorDownBtn; + private View floorControlsLayout; + + // Map and location + private GoogleMap googleMap; + private IndoorMapManager indoorMapManager; + private FusedLocationProviderClient fusedLocationClient; + private LocationCallback locationCallback; + private Marker currentLocationMarker; + + // Sensor fusion for altitude + private SensorFusion sensorFusion; + + // State + private MaterialButton selectedBuildingButton = null; + private Handler updateHandler = new Handler(Looper.getMainLooper()); + private Runnable updateRunnable; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + AppCompatActivity activity = (AppCompatActivity) getActivity(); + if (activity != null && activity.getSupportActionBar() != null) { + activity.getSupportActionBar().setTitle("Indoor Positioning Debug"); + } + return inflater.inflate(R.layout.fragment_indoor_positioning, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + // Initialize UI elements + latitudeText = view.findViewById(R.id.latitudeText); + longitudeText = view.findViewById(R.id.longitudeText); + altitudeText = view.findViewById(R.id.altitudeText); + accuracyText = view.findViewById(R.id.accuracyText); + floorText = view.findViewById(R.id.floorText); + currentFloorText = view.findViewById(R.id.currentFloorText); + floorControlsLayout = view.findViewById(R.id.floorControlsLayout); + + nucleusButton = view.findViewById(R.id.nucleusButton); + libraryButton = view.findViewById(R.id.libraryButton); + murchisonButton = view.findViewById(R.id.murchisonButton); + fjbButton = view.findViewById(R.id.fjbButton); + + floorUpBtn = view.findViewById(R.id.floorUpBtn); + floorDownBtn = view.findViewById(R.id.floorDownBtn); + + // Initialize services + fusedLocationClient = LocationServices.getFusedLocationProviderClient(requireContext()); + sensorFusion = SensorFusion.getInstance(); + + // Set up building buttons + nucleusButton.setOnClickListener(v -> selectBuilding("Nucleus", nucleusButton)); + libraryButton.setOnClickListener(v -> selectBuilding("Library", libraryButton)); + murchisonButton.setOnClickListener(v -> selectBuilding("Murchison", murchisonButton)); + fjbButton.setOnClickListener(v -> selectBuilding("FJB", fjbButton)); + + // Floor control buttons + floorUpBtn.setOnClickListener(v -> { + if (indoorMapManager != null) { + indoorMapManager.increaseFloor(); + updateFloorDisplay(); + } + }); + + floorDownBtn.setOnClickListener(v -> { + if (indoorMapManager != null) { + indoorMapManager.decreaseFloor(); + updateFloorDisplay(); + } + }); + + // Initialize map + SupportMapFragment mapFragment = (SupportMapFragment) + getChildFragmentManager().findFragmentById(R.id.indoorMapFragment); + if (mapFragment != null) { + mapFragment.getMapAsync(this); + } + + // Start periodic UI updates + startPeriodicUpdates(); + } + + @Override + public void onMapReady(@NonNull GoogleMap map) { + googleMap = map; + googleMap.setMapType(GoogleMap.MAP_TYPE_HYBRID); + googleMap.getUiSettings().setCompassEnabled(true); + googleMap.getUiSettings().setZoomControlsEnabled(true); + + // Initialize IndoorMapManager + indoorMapManager = new IndoorMapManager(googleMap, requireContext()); + indoorMapManager.addFallbackBuildings(); + + // Set floor data listener + indoorMapManager.setOnFloorDataLoadedListener(hasData -> { + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + if (hasData) { + floorControlsLayout.setVisibility(View.VISIBLE); + updateFloorDisplay(); + } else { + floorControlsLayout.setVisibility(View.GONE); + } + }); + } + }); + + // Set up polygon click listener + googleMap.setOnPolygonClickListener(polygon -> { + if (indoorMapManager != null) { + indoorMapManager.onPolygonClick(polygon); + updateFloorDisplay(); + } + }); + + // Move camera to Edinburgh KB campus + LatLng kbCampus = new LatLng(55.9230, -3.1750); + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(kbCampus, 17f)); + + // Start location updates + startLocationUpdates(); + } + + /** + * Select a building and load its indoor map + */ + private void selectBuilding(String buildingName, MaterialButton button) { + // Update button styles + resetBuildingButtons(); + button.setBackgroundColor(getResources().getColor(R.color.md_theme_primary, null)); + selectedBuildingButton = button; + + // Get building center coordinates + LatLng buildingCenter = getBuildingCenter(buildingName); + if (buildingCenter != null && googleMap != null) { + googleMap.animateCamera(CameraUpdateFactory.newLatLngZoom(buildingCenter, 19f)); + + // Trigger indoor map load + if (indoorMapManager != null) { + // ✅ CRITICAL: Set selected building BEFORE API call + // This enables building name verification in API response + indoorMapManager.setSelectedBuilding(buildingName, buildingCenter); + indoorMapManager.fetchFloorPlan(buildingCenter, new java.util.ArrayList<>()); + } + } + + Toast.makeText(getContext(), "Loading " + buildingName + " indoor map...", Toast.LENGTH_SHORT).show(); + } + + /** + * Reset all building buttons to outlined style + */ + private void resetBuildingButtons() { + int outlinedColor = getResources().getColor(android.R.color.transparent, null); + nucleusButton.setBackgroundColor(outlinedColor); + libraryButton.setBackgroundColor(outlinedColor); + murchisonButton.setBackgroundColor(outlinedColor); + fjbButton.setBackgroundColor(outlinedColor); + } + + /** + * Get building center coordinates + */ + private LatLng getBuildingCenter(String name) { + switch (name) { + case "Nucleus": + return new LatLng(55.92307, -3.17424); + case "Library": + return new LatLng(55.92294, -3.17497); + case "Murchison": + return new LatLng(55.92413, -3.17916); + case "FJB": + return new LatLng(55.92246, -3.17243); + default: + return null; + } + } + + /** + * Start real-time location updates + */ + private void startLocationUpdates() { + if (ActivityCompat.checkSelfPermission(requireContext(), + Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + return; + } + + LocationRequest locationRequest = new LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, 1000) // 1 second interval + .setMinUpdateIntervalMillis(500) + .build(); + + locationCallback = new LocationCallback() { + @Override + public void onLocationResult(@NonNull LocationResult locationResult) { + Location location = locationResult.getLastLocation(); + if (location != null) { + updateLocationDisplay(location); + } + } + }; + + fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, + Looper.getMainLooper()); + } + + /** + * Update location display with new GPS data + */ + private void updateLocationDisplay(Location location) { + LatLng latLng = new LatLng(location.getLatitude(), location.getLongitude()); + + // Update marker on map + if (currentLocationMarker == null) { + currentLocationMarker = googleMap.addMarker(new MarkerOptions() + .position(latLng) + .title("Current Location")); + } else { + currentLocationMarker.setPosition(latLng); + } + + // Update text displays + latitudeText.setText(String.format(Locale.US, "%.6f", location.getLatitude())); + longitudeText.setText(String.format(Locale.US, "%.6f", location.getLongitude())); + + if (location.hasAltitude()) { + altitudeText.setText(String.format(Locale.US, "%.1f m", location.getAltitude())); + } + + if (location.hasAccuracy()) { + accuracyText.setText(String.format(Locale.US, "± %.1f m", location.getAccuracy())); + } + } + + /** + * Start periodic UI updates for sensor data + */ + private void startPeriodicUpdates() { + updateRunnable = new Runnable() { + @Override + public void run() { + updateSensorData(); + updateHandler.postDelayed(this, 1000); // Update every second + } + }; + updateHandler.post(updateRunnable); + } + + /** + * Update sensor data displays + */ + private void updateSensorData() { + if (sensorFusion != null) { + // Update altitude from barometer sensor + float elevation = sensorFusion.getElevation(); + if (elevation != 0) { + altitudeText.setText(String.format(Locale.US, "%.1f m", elevation)); + } + + // Update floor estimation + if (indoorMapManager != null && indoorMapManager.getAvailableFloorsCount() > 0) { + String floorName = indoorMapManager.getCurrentFloorName(); + if (floorName != null) { + floorText.setText(floorName); + } + } + } + } + + /** + * Update floor display text + */ + private void updateFloorDisplay() { + if (indoorMapManager != null) { + int currentFloor = indoorMapManager.getCurrentFloor(); + int totalFloors = indoorMapManager.getAvailableFloorsCount(); + String floorName = indoorMapManager.getCurrentFloorName(); + + if (floorName != null) { + currentFloorText.setText(floorName + " (" + (currentFloor + 1) + "/" + totalFloors + ")"); + } else { + currentFloorText.setText("Floor " + currentFloor); + } + } + } + + @Override + public void onPause() { + super.onPause(); + // Stop location updates + if (fusedLocationClient != null && locationCallback != null) { + fusedLocationClient.removeLocationUpdates(locationCallback); + } + // Stop periodic updates + if (updateHandler != null && updateRunnable != null) { + updateHandler.removeCallbacks(updateRunnable); + } + } + + @Override + public void onResume() { + super.onResume(); + // Restart location updates + startLocationUpdates(); + // Restart periodic updates + if (updateHandler != null && updateRunnable != null) { + updateHandler.post(updateRunnable); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (updateHandler != null && updateRunnable != null) { + updateHandler.removeCallbacks(updateRunnable); + } + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/InfoFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/InfoFragment.java index f0cc78de..9ccf4818 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/InfoFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/InfoFragment.java @@ -17,6 +17,7 @@ import com.openpositioning.PositionMe.sensors.SensorInfo; import com.openpositioning.PositionMe.presentation.viewitems.SensorInfoListAdapter; +import java.util.ArrayList; import java.util.List; /** @@ -81,9 +82,22 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat sensorInfoView = (RecyclerView) getView().findViewById(R.id.sensorInfoList); // Register layout manager sensorInfoView.setLayoutManager(new LinearLayoutManager(getActivity())); - // Get singleton sensor fusion instance, load sensor info data + + // Get singleton sensor fusion instance sensorFusion = SensorFusion.getInstance(); - List sensorInfoList = sensorFusion.getSensorInfos(); + + // Fix: Explicitly cast the generic objects back to SensorInfo + List rawList = sensorFusion.getSensorInfos(); + List sensorInfoList = new ArrayList<>(); + + if (rawList != null) { + for (Object obj : rawList) { + if (obj instanceof SensorInfo) { + sensorInfoList.add((SensorInfo) obj); + } + } + } + // Set adapter for the recycler view. sensorInfoView.setAdapter(new SensorInfoListAdapter(getActivity(), sensorInfoList)); } diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/MeasurementsFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/MeasurementsFragment.java index 20c43987..a52027f1 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/MeasurementsFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/MeasurementsFragment.java @@ -19,8 +19,11 @@ import com.openpositioning.PositionMe.sensors.SensorFusion; import com.openpositioning.PositionMe.sensors.SensorTypes; import com.openpositioning.PositionMe.sensors.Wifi; +import com.openpositioning.PositionMe.sensors.BleDevice; import com.openpositioning.PositionMe.presentation.viewitems.WifiListAdapter; +import com.openpositioning.PositionMe.presentation.viewitems.BleListAdapter; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -47,6 +50,7 @@ public class MeasurementsFragment extends Fragment { // UI elements private ConstraintLayout sensorMeasurementList; private RecyclerView wifiListView; + private RecyclerView bleListView; // List of string resource IDs private int[] prefaces; private int[] gnssPrefaces; @@ -110,8 +114,10 @@ public void onPause() { */ @Override public void onResume() { - refreshDataHandler.postDelayed(refreshTableTask, REFRESH_TIME); super.onResume(); + // Ensure sensors and WiFi scanner are running + sensorFusion.resumeListening(); + refreshDataHandler.postDelayed(refreshTableTask, REFRESH_TIME); } /** @@ -125,6 +131,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat sensorMeasurementList = (ConstraintLayout) getView().findViewById(R.id.sensorMeasurementList); wifiListView = (RecyclerView) getView().findViewById(R.id.wifiList); wifiListView.setLayoutManager(new LinearLayoutManager(getActivity())); + bleListView = (RecyclerView) getView().findViewById(R.id.bleList); + bleListView.setLayoutManager(new LinearLayoutManager(getActivity())); } /** @@ -141,35 +149,80 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat public void run() { // Get all the values from SensorFusion Map sensorValueMap = sensorFusion.getSensorValueMap(); + // Loop through UI elements and update the values for(SensorTypes st : SensorTypes.values()) { - CardView cardView = (CardView) sensorMeasurementList.getChildAt(st.ordinal()); - ConstraintLayout currentRow = (ConstraintLayout) cardView.getChildAt(0); - float[] values = sensorValueMap.get(st); - for (int i = 0; i < values.length; i++) { - String valueString; - // Set string wrapper based on data type. - if(values.length == 1) { - valueString = getString(R.string.level, String.format("%.2f", values[0])); - } - else if(values.length == 2){ - if(st == SensorTypes.GNSSLATLONG) - valueString = getString(gnssPrefaces[i], String.format("%.2f", values[i])); - else - valueString = getString(prefaces[i], String.format("%.2f", values[i])); + // Safety check: prevent index out of bounds for new enum items (WIFI, BLE) + if (st.ordinal() < sensorMeasurementList.getChildCount()) { + View view = sensorMeasurementList.getChildAt(st.ordinal()); + if (view instanceof CardView) { + CardView cardView = (CardView) view; + ConstraintLayout currentRow = (ConstraintLayout) cardView.getChildAt(0); + float[] values = sensorValueMap.get(st); + + if (values != null && currentRow != null) { + for (int i = 0; i < values.length; i++) { + if (i + 1 < currentRow.getChildCount()) { + String valueString; + if(values.length == 1) { + valueString = getString(R.string.level, String.format("%.2f", values[0])); + } + else if(values.length == 2){ + if(st == SensorTypes.GNSSLATLONG) + valueString = getString(gnssPrefaces[i], String.format("%.2f", values[i])); + else + valueString = getString(prefaces[i], String.format("%.2f", values[i])); + } + else{ + valueString = getString(prefaces[i], String.format("%.2f", values[i])); + } + ((TextView) currentRow.getChildAt(i + 1)).setText(valueString); + } + } + } } - else{ - valueString = getString(prefaces[i], String.format("%.2f", values[i])); - } - ((TextView) currentRow.getChildAt(i + 1)).setText(valueString); } } - // Get all WiFi values - convert to list of strings + + // Update WiFi list List wifiObjects = sensorFusion.getWifiList(); - // If there are WiFi networks visible, update the recycler view with the data. - if(wifiObjects != null) { - wifiListView.setAdapter(new WifiListAdapter(getActivity(), wifiObjects)); + if (wifiObjects != null && !wifiObjects.isEmpty()) { + android.util.Log.d("WiFiDebug", "Detected networks: " + wifiObjects.size()); + if (getContext() != null) { + wifiListView.setAdapter(new WifiListAdapter(getContext(), wifiObjects)); + } + } else { + android.util.Log.d("WiFiDebug", "No WiFi networks detected"); + // Create placeholder item for empty state + if (getContext() != null) { + List placeholderList = new ArrayList<>(); + Wifi placeholder = new Wifi(); + placeholder.setBssid(0); + placeholder.setSsid("Scanning..."); + placeholder.setLevel(-100); + placeholderList.add(placeholder); + wifiListView.setAdapter(new WifiListAdapter(getContext(), placeholderList)); + } + } + + // Update BLE list + List bleDevices = sensorFusion.getBleList(); + if (bleDevices != null && !bleDevices.isEmpty()) { + android.util.Log.d("BLEDebug", "Detected BLE devices: " + bleDevices.size()); + if (getContext() != null) { + bleListView.setAdapter(new BleListAdapter(getContext(), bleDevices)); + } + } else { + android.util.Log.d("BLEDebug", "No BLE devices detected"); + // Create placeholder item for empty state + if (getContext() != null) { + List placeholderList = new ArrayList<>(); + BleDevice placeholder = new BleDevice("00:00:00:00:00:00", "Scanning...", -100); + placeholderList.add(placeholder); + bleListView.setAdapter(new BleListAdapter(getContext(), placeholderList)); + } } + // Restart the data updater task in REFRESH_TIME milliseconds. refreshDataHandler.postDelayed(refreshTableTask, REFRESH_TIME); } 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 6362a971..802500a3 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,298 +1,282 @@ package com.openpositioning.PositionMe.presentation.fragment; -import android.app.AlertDialog; -import android.content.Context; -import android.content.SharedPreferences; -import android.graphics.Color; import android.os.Bundle; -import android.os.CountDownTimer; import android.os.Handler; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; - -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.LinearInterpolator; import android.widget.Button; -import android.widget.ImageView; -import android.widget.ProgressBar; import android.widget.TextView; -import com.google.android.material.button.MaterialButton; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.material.textfield.TextInputEditText; import com.openpositioning.PositionMe.R; import com.openpositioning.PositionMe.presentation.activity.RecordingActivity; +import com.openpositioning.PositionMe.sensors.Observer; import com.openpositioning.PositionMe.sensors.SensorFusion; import com.openpositioning.PositionMe.sensors.SensorTypes; -import com.openpositioning.PositionMe.utils.UtilFunctions; -import com.google.android.gms.maps.model.LatLng; +import java.util.Map; -/** - * Fragment responsible for managing the recording process of trajectory data. - *

- * The RecordingFragment serves as the interface for users to initiate, monitor, and - * complete trajectory recording. It integrates sensor fusion data to track user movement - * and updates a map view in real time. Additionally, it provides UI controls to cancel, - * stop, and monitor recording progress. - *

- * Features: - * - Starts and stops trajectory recording. - * - Displays real-time sensor data such as elevation and distance traveled. - * - Provides UI controls to cancel or complete recording. - * - Uses {@link TrajectoryMapFragment} to visualize recorded paths. - * - Manages GNSS tracking and error display. - * - * @see TrajectoryMapFragment The map fragment displaying the recorded trajectory. - * @see RecordingActivity The activity managing the recording workflow. - * @see SensorFusion Handles sensor data collection. - * @see SensorTypes Enumeration of available sensor types. - * - * @author Shu Gu - */ - -public class RecordingFragment extends Fragment { - - // UI elements - private MaterialButton completeButton, cancelButton; - private ImageView recIcon; - private ProgressBar timeRemaining; - private TextView elevation, distanceTravelled, gnssError; - - // App settings - private SharedPreferences settings; - - // Sensor & data logic - private SensorFusion sensorFusion; - private Handler refreshDataHandler; - private CountDownTimer autoStop; - // Distance tracking - private float distance = 0f; - private float previousPosX = 0f; - private float previousPosY = 0f; +public class RecordingFragment extends Fragment implements Observer, IndoorMapFragment.VenueSelectionCallback { + + + private static final int AXIS_MODE = 1; + + + private static final float DISTANCE_MULTIPLIER = 1.2f; + + private static final double ROTATION_FINE_TUNE = 0.0; + + + + private Button startStopButton, markerButton; + private TextInputEditText trajectoryIdInput; + private TextView statusTextView; - // References to the child map fragment private TrajectoryMapFragment trajectoryMapFragment; + private SensorFusion sensorFusion; + private boolean isRecording = false; - private final Runnable refreshDataTask = new Runnable() { - @Override - public void run() { - updateUIandPosition(); - // Loop again - refreshDataHandler.postDelayed(refreshDataTask, 200); - } - }; + private Handler uiHandler = new Handler(); + private Runnable updateMapTask; - public RecordingFragment() { - // Required empty public constructor - } + private String selectedBuildingId = null; + private String selectedVenueName = null; - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - this.sensorFusion = SensorFusion.getInstance(); - Context context = requireActivity(); - this.settings = PreferenceManager.getDefaultSharedPreferences(context); - this.refreshDataHandler = new Handler(); - } + // PDR Variables + private LatLng pdrOrigin = null; + private float[] pdrStartOffset = null; + private LatLng currentPdrLocation = null; + private static final double EARTH_RADIUS = 6378137.0; + private double totalDistanceMeters = 0.0; - @Nullable @Override - public View onCreateView(@NonNull LayoutInflater inflater, - @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - // Inflate only the "recording" UI parts (no map) + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_recording, container, false); } @Override - public void onViewCreated(@NonNull View view, - @Nullable Bundle savedInstanceState) { + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - // Child Fragment: the container in fragment_recording.xml - // where TrajectoryMapFragment is placed - trajectoryMapFragment = (TrajectoryMapFragment) - getChildFragmentManager().findFragmentById(R.id.trajectoryMapFragmentContainer); - - // If not present, create it - if (trajectoryMapFragment == null) { - trajectoryMapFragment = new TrajectoryMapFragment(); - getChildFragmentManager() - .beginTransaction() - .replace(R.id.trajectoryMapFragmentContainer, trajectoryMapFragment) - .commit(); - } + sensorFusion = SensorFusion.getInstance(); - // Initialize UI references - elevation = view.findViewById(R.id.currentElevation); - distanceTravelled = view.findViewById(R.id.currentDistanceTraveled); - gnssError = view.findViewById(R.id.gnssError); - - completeButton = view.findViewById(R.id.stopButton); - cancelButton = view.findViewById(R.id.cancelButton); - recIcon = view.findViewById(R.id.redDot); - timeRemaining = view.findViewById(R.id.timeRemainingBar); - - // Hide or initialize default values - gnssError.setVisibility(View.GONE); - elevation.setText(getString(R.string.elevation, "0")); - distanceTravelled.setText(getString(R.string.meter, "0")); - - // Buttons - completeButton.setOnClickListener(v -> { - // Stop recording & go to correction - if (autoStop != null) autoStop.cancel(); - sensorFusion.stopRecording(); - // Show Correction screen - ((RecordingActivity) requireActivity()).showCorrectionScreen(); + trajectoryMapFragment = new TrajectoryMapFragment(); + trajectoryMapFragment.setOnVenueSelectedListener((buildingId, venueName) -> { + selectedBuildingId = buildingId; + selectedVenueName = venueName; + if (isRecording && statusTextView != null) { + statusTextView.append(" [" + venueName + "]"); + } }); + getChildFragmentManager().beginTransaction() + .replace(R.id.mapFragmentContainer, trajectoryMapFragment) + .commit(); + + startStopButton = view.findViewById(R.id.bButton); + markerButton = view.findViewById(R.id.markerButton); + trajectoryIdInput = view.findViewById(R.id.trajectoryIdInput); + statusTextView = view.findViewById(R.id.statusText); + + + float[] startCoords = sensorFusion.getGNSSLatitude(true); + if (startCoords[0] != 0) { + new Handler().postDelayed(() -> { + if (trajectoryMapFragment != null && trajectoryMapFragment.isAdded()) { + trajectoryMapFragment.setInitialCameraPosition(new LatLng(startCoords[0], startCoords[1])); + } + }, 600); + } - - // Cancel button with confirmation dialog - cancelButton.setOnClickListener(v -> { - AlertDialog dialog = new AlertDialog.Builder(requireActivity()) - .setTitle("Confirm Cancel") - .setMessage("Are you sure you want to cancel the recording? Your progress will be lost permanently!") - .setNegativeButton("Yes", (dialogInterface, which) -> { - // User confirmed cancellation - sensorFusion.stopRecording(); - if (autoStop != null) autoStop.cancel(); - requireActivity().onBackPressed(); - }) - .setPositiveButton("No", (dialogInterface, which) -> { - // User cancelled the dialog. Do nothing. - dialogInterface.dismiss(); - }) - .create(); // Create the dialog but do not show it yet - - // Show the dialog and change the button color - dialog.setOnShowListener(dialogInterface -> { - Button negativeButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); - negativeButton.setTextColor(Color.RED); // Set "Yes" button color to red - }); - - dialog.show(); // Finally, show the dialog + startStopButton.setOnClickListener(v -> { + if (!isRecording) startRecording(); + else stopRecording(); }); - // The blinking effect for recIcon - blinkingRecordingIcon(); - - // Start the timed or indefinite UI refresh - if (this.settings.getBoolean("split_trajectory", false)) { - // A maximum recording time is set - long limit = this.settings.getInt("split_duration", 30) * 60000L; - timeRemaining.setMax((int) (limit / 1000)); - timeRemaining.setProgress(0); - timeRemaining.setScaleY(3f); - - autoStop = new CountDownTimer(limit, 1000) { - @Override - public void onTick(long millisUntilFinished) { - timeRemaining.incrementProgressBy(1); - updateUIandPosition(); + markerButton.setOnClickListener(v -> { + if (isRecording) { + sensorFusion.addMarker(); + if (currentPdrLocation != null && trajectoryMapFragment != null) { + trajectoryMapFragment.addMarkerToMap(currentPdrLocation); } - - @Override - public void onFinish() { - sensorFusion.stopRecording(); - ((RecordingActivity) requireActivity()).showCorrectionScreen(); - } - }.start(); - } else { - // No set time limit, just keep refreshing - refreshDataHandler.post(refreshDataTask); - } + Toast.makeText(getContext(), "Marker Added", Toast.LENGTH_SHORT).show(); + } + }); } - /** - * Update the UI with sensor data and pass map updates to TrajectoryMapFragment. - */ - private void updateUIandPosition() { - float[] pdrValues = sensorFusion.getSensorValueMap().get(SensorTypes.PDR); - if (pdrValues == null) return; - - // Distance - distance += Math.sqrt(Math.pow(pdrValues[0] - previousPosX, 2) - + Math.pow(pdrValues[1] - previousPosY, 2)); - distanceTravelled.setText(getString(R.string.meter, String.format("%.2f", distance))); - - // Elevation - 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 } - ); - - // Pass the location + orientation to the map - if (trajectoryMapFragment != null) { - trajectoryMapFragment.updateUserLocation(newLocation, - (float) Math.toDegrees(sensorFusion.passOrientation())); - } + private void startRecording() { + String id = ""; + if (trajectoryIdInput.getText() != null) { + id = trajectoryIdInput.getText().toString().trim(); + } + if (id.isEmpty()) { + id = "Traj_" + System.currentTimeMillis(); } - // GNSS logic if you want to show GNSS error, etc. - 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)); + Log.e("RecordingFragment", "Starting Manual PDR recording: " + id); + sensorFusion.startRecording(id); + + if (selectedVenueName != null) sensorFusion.setVenueName(selectedVenueName); + if (selectedBuildingId != null) sensorFusion.setBuildingId(selectedBuildingId); + + isRecording = true; + totalDistanceMeters = 0.0; + + // 1. Reset underlying PDR + sensorFusion.resetPDR(); + + // 2. Lock start point (Manual Origin) + if (trajectoryMapFragment != null) { + pdrOrigin = trajectoryMapFragment.getCameraTarget(); + currentPdrLocation = pdrOrigin; + + // 3. Record initial offset + Map sensorData = sensorFusion.getSensorValueMap(); + if (sensorData != null) { + float[] currentPDR = sensorData.get(SensorTypes.PDR); + if (currentPDR != null) { + pdrStartOffset = new float[]{currentPDR[0], currentPDR[1]}; + } else { + pdrStartOffset = new float[]{0f, 0f}; } - trajectoryMapFragment.updateGNSS(gnssLocation); - } else { - gnssError.setVisibility(View.GONE); - trajectoryMapFragment.clearGNSS(); } } - // Update previous - previousPosX = pdrValues[0]; - previousPosY = pdrValues[1]; + statusTextView.setText("Recording (Mode " + AXIS_MODE + ")"); + statusTextView.setBackgroundResource(R.drawable.status_recording); + + startStopButton.setText("Stop"); + startStopButton.setBackgroundColor(getResources().getColor(android.R.color.holo_red_dark)); + trajectoryIdInput.setEnabled(false); + markerButton.setEnabled(true); + + startUiUpdates(); } - /** - * Start the blinking effect for the recording icon. - */ - private void blinkingRecordingIcon() { - Animation blinking = new AlphaAnimation(1, 0); - blinking.setDuration(800); - blinking.setInterpolator(new LinearInterpolator()); - blinking.setRepeatCount(Animation.INFINITE); - blinking.setRepeatMode(Animation.REVERSE); - recIcon.startAnimation(blinking); + private void startUiUpdates() { + updateMapTask = new Runnable() { + @Override + public void run() { + if (isRecording && isAdded()) { + Map sensorData = sensorFusion.getSensorValueMap(); + + if (sensorData != null) { + float orientation = sensorFusion.passOrientation(); + float[] pdrMovement = sensorData.get(SensorTypes.PDR); + float[] gnssPos = sensorData.get(SensorTypes.GNSSLATLONG); + + // 1. Update Blue Dot (Reference only) + if (gnssPos != null && gnssPos[0] != 0) { + if (trajectoryMapFragment != null) { + trajectoryMapFragment.updateGNSS(new LatLng(gnssPos[0], gnssPos[1])); + } + } + + // 2. PDR Trajectory Logic + if (pdrOrigin != null && pdrMovement != null) { + + // A. Calculate raw relative displacement + float startX = (pdrStartOffset != null) ? pdrStartOffset[0] : 0; + float startY = (pdrStartOffset != null) ? pdrStartOffset[1] : 0; + float rawX = pdrMovement[0] - startX; + float rawY = pdrMovement[1] - startY; + + // B. [Key] Axis Mapping Correction + float mapX = rawX; + float mapY = rawY; + + switch (AXIS_MODE) { + case 1: // Standard + mapX = rawX; mapY = rawY; + break; + case 2: // Swap XY - Solves common "Sin/Cos swapped" issue + mapX = rawY; mapY = rawX; + break; + case 3: // Flip Y - Solves North/South inversion + mapX = rawX; mapY = -rawY; + break; + case 4: // Flip X - Solves East/West inversion + mapX = -rawX; mapY = rawY; + break; + } + + // C. Apply Distance Multiplier + mapX *= DISTANCE_MULTIPLIER; + mapY *= DISTANCE_MULTIPLIER; + + // D. Apply Fine Tune Rotation + double theta = Math.toRadians(ROTATION_FINE_TUNE); + double rotatedX = mapX * Math.cos(theta) - mapY * Math.sin(theta); + double rotatedY = mapX * Math.sin(theta) + mapY * Math.cos(theta); + + // E. Convert to LatLng and Update + LatLng newLocation = calculateLatLngFromMeters(pdrOrigin, (float)rotatedX, (float)rotatedY); + + if (trajectoryMapFragment != null) { + // Also correct arrow orientation + float correctedOri = orientation; + // Simple handling, if XY swapped, orientation might need -90deg, keep original for now to observe red line + trajectoryMapFragment.updateUserLocation(newLocation, correctedOri); + } + + currentPdrLocation = newLocation; + totalDistanceMeters = Math.sqrt(rotatedX*rotatedX + rotatedY*rotatedY); + + if (statusTextView != null && isAdded()) { + String distStr = totalDistanceMeters < 1000 ? + String.format("%.1f m", totalDistanceMeters) : + String.format("%.2f km", totalDistanceMeters / 1000.0); + statusTextView.setText("PDR(M" + AXIS_MODE + ")\nDist: " + distStr); + } + } + } + uiHandler.postDelayed(this, 100); + } + } + }; + uiHandler.post(updateMapTask); } - @Override - public void onPause() { - super.onPause(); - refreshDataHandler.removeCallbacks(refreshDataTask); + private LatLng calculateLatLngFromMeters(LatLng origin, float xMeters, float yMeters) { + double lat = origin.latitude; + double dLat = (yMeters / EARTH_RADIUS) * (180 / Math.PI); + double cosLat = Math.cos(Math.toRadians(lat)); + if (Math.abs(cosLat) < 0.000001) cosLat = 0.000001; + double dLon = (xMeters / (EARTH_RADIUS * cosLat)) * (180 / Math.PI); + return new LatLng(lat + dLat, origin.longitude + dLon); } - @Override - public void onResume() { - super.onResume(); - if(!this.settings.getBoolean("split_trajectory", false)) { - refreshDataHandler.postDelayed(refreshDataTask, 500); + private void stopRecording() { + isRecording = false; + sensorFusion.stopRecording(); + uiHandler.removeCallbacks(updateMapTask); + pdrOrigin = null; + pdrStartOffset = null; + currentPdrLocation = null; + totalDistanceMeters = 0.0; + startStopButton.setText("Start"); + startStopButton.setBackgroundColor(getResources().getColor(R.color.purple_500)); + markerButton.setEnabled(false); + trajectoryIdInput.setEnabled(true); + statusTextView.setText("Recording stopped"); + statusTextView.setBackgroundResource(R.drawable.status_background); + if (getActivity() instanceof RecordingActivity) { + ((RecordingActivity) getActivity()).showCorrectionScreen(); } } -} + + @Override public void update(Object[] data) { } + @Override public void onVenueSelected(String buildingId, String venueName) { + this.selectedBuildingId = buildingId; + this.selectedVenueName = venueName; + if (isRecording && statusTextView != null) statusTextView.append(" [" + venueName + "]"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/ReplayFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/ReplayFragment.java index d15a4a83..7bd01a77 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/ReplayFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/ReplayFragment.java @@ -18,6 +18,7 @@ import com.openpositioning.PositionMe.R; import com.openpositioning.PositionMe.presentation.activity.ReplayActivity; import com.openpositioning.PositionMe.data.local.TrajParser; +import com.openpositioning.PositionMe.utils.TrajectoryVerifier; import java.io.File; import java.util.ArrayList; @@ -95,12 +96,28 @@ public void onCreate(@Nullable Bundle savedInstanceState) { Log.i(TAG, "Trajectory file confirmed to exist and is readable."); - // Parse the JSON file and prepare replayData using TrajParser + // VERIFY FILE CONTENTS FIRST - this will show what data is in the file + boolean isValid = TrajectoryVerifier.verifyTrajectoryFile(filePath); + if (!isValid) { + Log.e(TAG, "Trajectory file verification FAILED - file may be corrupt or empty"); + } + + // Parse the trajectory file and prepare replayData using TrajParser replayData = TrajParser.parseTrajectoryData(filePath, requireContext(), initialLat, initialLon); - // Log the number of parsed points + // Log the number of parsed points with detailed info if (replayData != null && !replayData.isEmpty()) { Log.i(TAG, "Trajectory data loaded successfully. Total points: " + replayData.size()); + + // Count points with GNSS data + int gnssCount = 0; + for (TrajParser.ReplayPoint point : replayData) { + if (point.gnssLocation != null) gnssCount++; + } + Log.i(TAG, "Points with GNSS data: " + gnssCount); + Log.i(TAG, "First point - PDR: " + replayData.get(0).pdrLocation + + ", GNSS: " + replayData.get(0).gnssLocation + + ", Orientation: " + replayData.get(0).orientation); } else { Log.e(TAG, "Failed to load trajectory data! replayData is empty or null."); } @@ -115,6 +132,11 @@ public View onCreateView(@NonNull LayoutInflater inflater, return inflater.inflate(R.layout.fragment_replay, container, false); } + // Rotation controls + private SeekBar rotationSeekBar; + private android.widget.TextView rotationLabel; + private int currentRotationDegrees = 0; + @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { @@ -131,8 +153,6 @@ public void onViewCreated(@NonNull View view, .commit(); } - - // 1) Check if the file contains any GNSS data boolean gnssExists = hasAnyGnssData(replayData); @@ -154,6 +174,27 @@ public void onViewCreated(@NonNull View view, goEndButton = view.findViewById(R.id.goEndButton); playbackSeekBar = view.findViewById(R.id.playbackSeekBar); + // Rotation UI + rotationSeekBar = view.findViewById(R.id.rotationSeekBar); + rotationLabel = view.findViewById(R.id.rotationLabel); + + // Rotation Listener + if (rotationSeekBar != null) { + rotationSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + currentRotationDegrees = progress; + if (rotationLabel != null) { + rotationLabel.setText("Rotate: " + progress + "°"); + } + // Redraw map with new rotation (refresh current state) + updateMapForIndex(currentIndex); + } + @Override public void onStartTrackingTouch(SeekBar seekBar) {} + @Override public void onStopTrackingTouch(SeekBar seekBar) {} + }); + } + // Set SeekBar max value based on replay data if (!replayData.isEmpty()) { playbackSeekBar.setMax(replayData.size() - 1); @@ -229,8 +270,6 @@ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { } } - - /** * Checks if any ReplayPoint contains a non-null gnssLocation. */ @@ -324,24 +363,32 @@ public void run() { private void updateMapForIndex(int newIndex) { if (newIndex < 0 || newIndex >= replayData.size()) return; + // Apply rotation to the PDR location + // Base Rotation Center: The first point of the PDR path + LatLng rotationCenter = replayData.get(0).pdrLocation; + // Detect if user is playing sequentially (lastIndex + 1) // or is skipping around (backwards, or jump forward) - boolean isSequentialForward = (newIndex == lastIndex + 1); - - if (!isSequentialForward) { - // Clear everything and redraw up to newIndex - trajectoryMapFragment.clearMapAndReset(); - for (int i = 0; i <= newIndex; i++) { - TrajParser.ReplayPoint p = replayData.get(i); - trajectoryMapFragment.updateUserLocation(p.pdrLocation, p.orientation); - if (p.gnssLocation != null) { - trajectoryMapFragment.updateGNSS(p.gnssLocation); - } - } - } else { - // Normal sequential forward step: add just the new point - TrajParser.ReplayPoint p = replayData.get(newIndex); - trajectoryMapFragment.updateUserLocation(p.pdrLocation, p.orientation); + // Note: When rotating, we must redraw everything to update positions + boolean isSequentialForward = (newIndex == lastIndex + 1) && (lastIndex != -1); + + // However, if rotation changed, we MUST invalid equality and redraw all + // For simplicity with rotation, let's redraw full path if dragging slider (not efficient but safe) + // If playing, we can just add point? No, because previous points need rotation too. + // Actually, if orientation changes, the Whole Path rotates. + // So we should probably redraw everything in the current view. + + trajectoryMapFragment.clearMapAndReset(); + for (int i = 0; i <= newIndex; i++) { + TrajParser.ReplayPoint p = replayData.get(i); + + // Calculate rotated location + LatLng rotatedLoc = getRotatedLocation(p.pdrLocation, rotationCenter, currentRotationDegrees); + + // Also rotate orientation + float rotatedOri = p.orientation + (float) Math.toRadians(currentRotationDegrees); + + trajectoryMapFragment.updateUserLocation(rotatedLoc, rotatedOri); if (p.gnssLocation != null) { trajectoryMapFragment.updateGNSS(p.gnssLocation); } @@ -350,6 +397,33 @@ private void updateMapForIndex(int newIndex) { lastIndex = newIndex; } + // Helper to rotate LatLng around a center point + private LatLng getRotatedLocation(LatLng point, LatLng center, int angleDegrees) { + if (angleDegrees == 0) return point; + + double lat1 = Math.toRadians(center.latitude); + double lon1 = Math.toRadians(center.longitude); + double lat2 = Math.toRadians(point.latitude); + double lon2 = Math.toRadians(point.longitude); + + // Convert to meters approximation relative to center + // Radius of Earth ~ 6371km + double R = 6371000; + double x = (lon2 - lon1) * Math.cos((lat1 + lat2) / 2) * R; + double y = (lat2 - lat1) * R; + + // Rotate + double theta = Math.toRadians(angleDegrees); + double xNew = x * Math.cos(theta) - y * Math.sin(theta); + double yNew = x * Math.sin(theta) + y * Math.cos(theta); + + // Convert back to LatLng + double latNew = lat1 + yNew / R; + double lonNew = lon1 + xNew / (R * Math.cos((lat1 + latNew) / 2)); + + return new LatLng(Math.toDegrees(latNew), Math.toDegrees(lonNew)); + } + @Override public void onPause() { super.onPause(); 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 ee14f69f..ed8db7b8 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 @@ -22,12 +22,18 @@ import com.openpositioning.PositionMe.presentation.activity.RecordingActivity; import com.openpositioning.PositionMe.presentation.activity.ReplayActivity; import com.openpositioning.PositionMe.sensors.SensorFusion; -import com.openpositioning.PositionMe.utils.NucleusBuildingManager; +import com.openpositioning.PositionMe.utils.IndoorMapManager; +import com.google.android.gms.maps.model.Polygon; +import android.widget.Toast; /** * A simple {@link Fragment} subclass. The startLocation fragment is displayed before the trajectory * recording starts. This fragment displays a map in which the user can adjust their location to - * correct the PDR when it is complete + * correct the PDR when it is complete. + * + * Updated for Assignment 1: + * - Removed immediate startRecording() call (moved to RecordingFragment). + * - Ensures start position is passed to SensorFusion before navigation. * * @author Virginia Cangelosi * @see HomeFragment the previous fragment in the nav graph. @@ -46,10 +52,10 @@ public class StartLocationFragment extends Fragment { private float[] startPosition = new float[2]; // Zoom level for the Google map private float zoom = 19f; - // Instance for managing indoor building overlays (if any) - private NucleusBuildingManager nucleusBuildingManager; - // Dummy variable for floor index - private int FloorNK; + // Instance for managing indoor building overlays using new API-based system + private IndoorMapManager indoorMapManager; + // Google map instance + private GoogleMap googleMap; /** * Public Constructor for the class. @@ -85,71 +91,86 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, SupportMapFragment supportMapFragment = (SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.startMap); - supportMapFragment.getMapAsync(new OnMapReadyCallback() { - /** - * {@inheritDoc} - * Controls to allow scrolling, tilting, rotating and a compass view of the - * map are enabled. A marker is added to the map with the start position and a marker - * drag listener is generated to detect when the marker has moved to obtain the new - * location. - */ - @Override - public void onMapReady(GoogleMap mMap) { - // Set map type and UI settings - mMap.setMapType(GoogleMap.MAP_TYPE_HYBRID); - mMap.getUiSettings().setCompassEnabled(true); - mMap.getUiSettings().setTiltGesturesEnabled(true); - mMap.getUiSettings().setRotateGesturesEnabled(true); - mMap.getUiSettings().setScrollGesturesEnabled(true); - - // *** FIX: Clear any existing markers so the start marker isn’t duplicated *** - mMap.clear(); - - // Create NucleusBuildingManager instance (if needed) - nucleusBuildingManager = new NucleusBuildingManager(mMap); - nucleusBuildingManager.getIndoorMapManager().hideMap(); - - // Add a marker at the current GPS location and move the camera - position = new LatLng(startPosition[0], startPosition[1]); - Marker startMarker = mMap.addMarker(new MarkerOptions() - .position(position) - .title("Start Position") - .draggable(true)); - mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(position, zoom)); - - // Drag listener for the marker to update the start position when dragged - mMap.setOnMarkerDragListener(new GoogleMap.OnMarkerDragListener() { - /** - * {@inheritDoc} - */ - @Override - public void onMarkerDragStart(Marker marker) {} - - /** - * {@inheritDoc} - * Updates the start position of the user. - */ - @Override - public void onMarkerDragEnd(Marker marker) { - startPosition[0] = (float) marker.getPosition().latitude; - startPosition[1] = (float) marker.getPosition().longitude; - } - - /** - * {@inheritDoc} - */ - @Override - public void onMarkerDrag(Marker marker) {} - }); - } - }); + if (supportMapFragment != null) { + supportMapFragment.getMapAsync(new OnMapReadyCallback() { + /** + * {@inheritDoc} + * Controls to allow scrolling, tilting, rotating and a compass view of the + * map are enabled. A marker is added to the map with the start position and a marker + * drag listener is generated to detect when the marker has moved to obtain the new + * location. + */ + @Override + public void onMapReady(GoogleMap mMap) { + googleMap = mMap; + + // Set map type and UI settings + mMap.setMapType(GoogleMap.MAP_TYPE_HYBRID); + mMap.getUiSettings().setCompassEnabled(true); + mMap.getUiSettings().setTiltGesturesEnabled(true); + mMap.getUiSettings().setRotateGesturesEnabled(true); + mMap.getUiSettings().setScrollGesturesEnabled(true); + + // Clear any existing markers + mMap.clear(); + + // ✅ Initialize new IndoorMapManager (supports all buildings via API) + indoorMapManager = new IndoorMapManager(mMap, requireContext()); + + // Load all building outlines (Nucleus, Library, FJB, Murchison) + indoorMapManager.addFallbackBuildings(); + + // Try to fetch building data from API + LatLng kbCampus = new LatLng(55.9230, -3.1750); + indoorMapManager.fetchBuildingsFromApi(kbCampus); + + // Set up building polygon click listener + mMap.setOnPolygonClickListener(new GoogleMap.OnPolygonClickListener() { + @Override + public void onPolygonClick(@NonNull Polygon polygon) { + if (indoorMapManager != null) { + boolean handled = indoorMapManager.onPolygonClick(polygon); + if (handled) { + String buildingName = indoorMapManager.getSelectedBuildingName(); + Toast.makeText(getContext(), "Selected: " + buildingName, Toast.LENGTH_SHORT).show(); + } + } + } + }); + + // Add a marker at the current GPS location and move the camera + position = new LatLng(startPosition[0], startPosition[1]); + Marker startMarker = mMap.addMarker(new MarkerOptions() + .position(position) + .title("Start Position") + .draggable(true)); + mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(position, zoom)); + + // Drag listener for the marker to update the start position when dragged + mMap.setOnMarkerDragListener(new GoogleMap.OnMarkerDragListener() { + @Override + public void onMarkerDragStart(Marker marker) {} + + @Override + public void onMarkerDragEnd(Marker marker) { + startPosition[0] = (float) marker.getPosition().latitude; + startPosition[1] = (float) marker.getPosition().longitude; + } + + @Override + public void onMarkerDrag(Marker marker) {} + }); + } + }); + } return rootView; } /** * {@inheritDoc} - * Button onClick listener enabled to detect when to go to next fragment and start PDR recording. + * Button onClick listener enabled to detect when to go to next fragment. + * NOTE: Actual recording start is now deferred to RecordingFragment. */ @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { @@ -157,11 +178,6 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat this.button = view.findViewById(R.id.startLocationDone); this.button.setOnClickListener(new View.OnClickListener() { - /** - * {@inheritDoc} - * When button clicked the PDR recording can start and the start position is stored for - * the {@link CorrectionFragment} to display. The {@link RecordingFragment} is loaded. - */ @Override public void onClick(View view) { float chosenLat = startPosition[0]; @@ -169,38 +185,18 @@ public void onClick(View view) { // If the Activity is RecordingActivity if (requireActivity() instanceof RecordingActivity) { - // Start sensor recording + set the start location - sensorFusion.startRecording(); + // 【Task B Change】: Do NOT start recording here. + // Just set the corrected start location in SensorFusion. sensorFusion.setStartGNSSLatitude(startPosition); - // Now switch to the recording screen + // Navigate to the Recording Screen where user will enter ID and click Start ((RecordingActivity) requireActivity()).showRecordingScreen(); - // If the Activity is ReplayActivity } else if (requireActivity() instanceof ReplayActivity) { - // *Do not* cast to RecordingActivity here // Just call the Replay method ((ReplayActivity) requireActivity()).onStartLocationChosen(chosenLat, chosenLon); - - // Otherwise (unexpected host) - } else { - // Optional: log or handle error - // Log.e("StartLocationFragment", "Unknown host Activity: " + requireActivity()); } } }); } - - /** - * Switches the indoor map to the specified floor. - * - * @param floorIndex the index of the floor to switch to - */ - private void switchFloorNU(int floorIndex) { - FloorNK = floorIndex; // Set the current floor index - if (nucleusBuildingManager != null) { - // Call the switchFloor method of the IndoorMapManager to switch to the specified floor - nucleusBuildingManager.getIndoorMapManager().switchFloor(floorIndex); - } - } -} +} \ No newline at end of file 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 eb0bad65..68651bf7 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 @@ -10,6 +10,9 @@ import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + import com.google.android.material.switchmaterial.SwitchMaterial; import androidx.annotation.NonNull; @@ -19,6 +22,7 @@ import com.google.android.gms.maps.OnMapReadyCallback; import com.openpositioning.PositionMe.R; import com.openpositioning.PositionMe.sensors.SensorFusion; +import com.openpositioning.PositionMe.utils.BuildingPolygon; import com.openpositioning.PositionMe.utils.IndoorMapManager; import com.openpositioning.PositionMe.utils.UtilFunctions; import com.google.android.gms.maps.CameraUpdateFactory; @@ -29,58 +33,55 @@ import java.util.ArrayList; import java.util.List; - /** - * A fragment responsible for displaying a trajectory map using Google Maps. - *

- * The TrajectoryMapFragment provides a map interface for visualizing movement trajectories, - * GNSS tracking, and indoor mapping. It manages map settings, user interactions, and real-time - * updates to user location and GNSS markers. - *

- * Key Features: - * - Displays a Google Map with support for different map types (Hybrid, Normal, Satellite). - * - Tracks and visualizes user movement using polylines. - * - Supports GNSS position updates and visual representation. - * - Includes indoor mapping with floor selection and auto-floor adjustments. - * - Allows user interaction through map controls and UI elements. - * - * @see com.openpositioning.PositionMe.presentation.activity.RecordingActivity The activity hosting this fragment. - * @see com.openpositioning.PositionMe.utils.IndoorMapManager Utility for managing indoor map overlays. - * @see com.openpositioning.PositionMe.utils.UtilFunctions Utility functions for UI and graphics handling. - * - * @author Mate Stodulka + * TrajectoryMapFragment + * Adapted for hybrid indoor map manager. + * Fixed: Auto Floor Offset Logic. */ - public class TrajectoryMapFragment extends Fragment { - private GoogleMap gMap; // Google Maps instance - private LatLng currentLocation; // Stores the user's current location - private Marker orientationMarker; // Marker representing user's heading - private Marker gnssMarker; // GNSS position marker - 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 + public interface OnVenueSelectedListener { + void onVenueSelected(String buildingId, String venueName); + } + + private GoogleMap gMap; + private LatLng currentLocation; + private Marker orientationMarker; + private Marker gnssMarker; + private Polyline polyline; + private boolean isRed = true; + private boolean isGnssOn = false; - private Polyline gnssPolyline; // Polyline for GNSS path - private LatLng lastGnssLocation = null; // Stores the last GNSS location + private Polyline gnssPolyline; + private LatLng lastGnssLocation = null; - private LatLng pendingCameraPosition = null; // Stores pending camera movement - private boolean hasPendingCameraMove = false; // Tracks if camera needs to move + private LatLng pendingCameraPosition = null; + private boolean hasPendingCameraMove = false; - private IndoorMapManager indoorMapManager; // Manages indoor mapping + private IndoorMapManager indoorMapManager; private SensorFusion sensorFusion; + private OnVenueSelectedListener venueSelectedListener; + private List manualMarkers = new ArrayList<>(); - // UI - private Spinner switchMapSpinner; + // Track if arrow is inside a building for auto-enable indoor map feature + private boolean isArrowInsideBuilding = false; + // [FIX START]: New variable to store Auto Floor calibration offset + private int autoFloorOffset = 0; + // [FIX END] + + // UI Controls + private Spinner switchMapSpinner; private SwitchMaterial gnssSwitch; + private SwitchMaterial indoorMapSwitch; private SwitchMaterial autoFloorSwitch; - private com.google.android.material.floatingactionbutton.FloatingActionButton floorUpButton, floorDownButton; private Button switchColorButton; - private Polygon buildingPolygon; - + private TextView floorTextView; + private View floorControlsContainer; + private View buildingInfoCard; + private TextView buildingNameText; public TrajectoryMapFragment() { // Required empty public constructor @@ -91,7 +92,6 @@ public TrajectoryMapFragment() { public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - // Inflate the separate layout containing map + map-related UI return inflater.inflate(R.layout.fragment_trajectory_map, container, false); } @@ -100,58 +100,144 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - // Grab references to UI controls + sensorFusion = SensorFusion.getInstance(); + switchMapSpinner = view.findViewById(R.id.mapSwitchSpinner); gnssSwitch = view.findViewById(R.id.gnssSwitch); + indoorMapSwitch = view.findViewById(R.id.indoorMapSwitch); autoFloorSwitch = view.findViewById(R.id.autoFloor); floorUpButton = view.findViewById(R.id.floorUpButton); floorDownButton = view.findViewById(R.id.floorDownButton); switchColorButton = view.findViewById(R.id.lineColorButton); + floorTextView = view.findViewById(R.id.floorTextView); + floorControlsContainer = view.findViewById(R.id.floorControlsContainer); + buildingInfoCard = view.findViewById(R.id.buildingInfoCard); + buildingNameText = view.findViewById(R.id.buildingNameText); - // Setup floor up/down UI hidden initially until we know there's an indoor map setFloorControlsVisibility(View.GONE); - // Initialize the map asynchronously + // Initialize IndoorMapManager (will be set properly in onMapReady) + indoorMapManager = new IndoorMapManager(null, getContext()); + indoorMapManager.setOnFloorDataLoadedListener((hasData) -> { + // Update floor display when floor data is loaded from API + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + if (hasData) { + setFloorControlsVisibility(View.VISIBLE); + updateFloorDisplay(); + + // Now send venue selection notification + String buildingId = indoorMapManager.getSelectedBuildingId(); + String venueName = indoorMapManager.getSelectedBuildingName(); + if (venueSelectedListener != null && buildingId != null && venueName != null) { + venueSelectedListener.onVenueSelected(buildingId, venueName); + } + } else { + // No floor data for this building - hide floor controls + setFloorControlsVisibility(View.GONE); + } + }); + } + }); + SupportMapFragment mapFragment = (SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.trajectoryMap); if (mapFragment != null) { mapFragment.getMapAsync(new OnMapReadyCallback() { @Override public void onMapReady(@NonNull GoogleMap googleMap) { - // Assign the provided googleMap to your field variable gMap = googleMap; - // Initialize map settings with the now non-null gMap initMapSettings(gMap); - // If we had a pending camera move, apply it now + // ========================================== + // Hybrid strategy: local fallback + API load + // ========================================== + + // 1. Local fallback: immediately load Murchison/Nucleus/Library + if (indoorMapManager != null) { + indoorMapManager.addFallbackBuildings(); + } + + // 2. Camera movement + LatLng kbCampus = new LatLng(55.9230, -3.1750); + if (!hasPendingCameraMove) { + gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(kbCampus, 17.5f)); + } + + // 4. Click interaction + gMap.setOnPolygonClickListener(new GoogleMap.OnPolygonClickListener() { + @Override + public void onPolygonClick(@NonNull Polygon polygon) { + if (indoorMapManager != null) { + boolean handled = indoorMapManager.onPolygonClick(polygon); + if (handled) { + // Don't show floor controls yet - wait for API callback + // Just show building name immediately + String venueName = indoorMapManager.getSelectedBuildingName(); + if (buildingInfoCard != null && buildingNameText != null && venueName != null) { + buildingNameText.setText(venueName); + buildingInfoCard.setVisibility(View.VISIBLE); + } + + // Venue selection notification will be sent in API callback + } + } + } + }); + if (hasPendingCameraMove && pendingCameraPosition != null) { gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(pendingCameraPosition, 19f)); + // Add a marker at the pending initial position + if (orientationMarker == null) { + orientationMarker = gMap.addMarker(new MarkerOptions() + .position(pendingCameraPosition) + .flat(true) + .title("Start Position") + .icon(BitmapDescriptorFactory.fromBitmap( + UtilFunctions.getBitmapFromVector(requireContext(), + R.drawable.ic_baseline_navigation_24)))); + } + currentLocation = pendingCameraPosition; hasPendingCameraMove = false; pendingCameraPosition = null; } - drawBuildingPolygon(); - - Log.d("TrajectoryMapFragment", "onMapReady: Map is ready!"); - - + Log.d("TrajectoryMapFragment", "Map Ready: Hybrid Mode."); } }); } - // Map type spinner setup initMapTypeSpinner(); - // GNSS Switch gnssSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { isGnssOn = isChecked; if (!isChecked && gnssMarker != null) { gnssMarker.remove(); gnssMarker = null; + } else if (isChecked) { + // When GNSS is turned on, immediately show current GNSS position + if (sensorFusion != null && gMap != null) { + float[] gnssCoords = sensorFusion.getGNSSLatitude(false); + if (gnssCoords[0] != 0 || gnssCoords[1] != 0) { + LatLng gnssLocation = new LatLng(gnssCoords[0], gnssCoords[1]); + updateGNSS(gnssLocation); + } + } + } + }); + + indoorMapSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (indoorMapManager != null) { + indoorMapManager.setIndoorMapVisible(isChecked); + // Show/hide floor controls based on visibility and whether we have floor data + if (isChecked && indoorMapManager.getAvailableFloorsCount() > 0) { + setFloorControlsVisibility(View.VISIBLE); + } else { + setFloorControlsVisibility(View.GONE); + } } }); - // Color switch switchColorButton.setOnClickListener(v -> { if (polyline != null) { if (isRed) { @@ -166,20 +252,40 @@ public void onMapReady(@NonNull GoogleMap googleMap) { } }); - // Floor up/down logic + // [FIX START]: Fix Auto Floor Logic + // When Auto Floor is enabled, record the difference (Offset) between current floor and sensor floor autoFloorSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> { + if (isChecked && indoorMapManager != null && sensorFusion != null) { + float elevationVal = sensorFusion.getElevation(); + float floorHeight = indoorMapManager.getFloorHeight(); + + if (floorHeight > 0) { + // 1. Get current manually set floor (Real Floor) + int currentManualFloor = indoorMapManager.getCurrentFloor(); + + // 2. Calculate sensor floor (usually starts from 0) + int sensorCalculatedFloor = (int) Math.round(elevationVal / floorHeight); + + // 3. Calculate calibration offset: Real Floor - Sensor Floor + // Example: User is at 2nd floor, sensor shows 0. Offset = 2 - 0 = 2. + // Later updates: Sensor 1st floor + Offset 2 = 3rd floor. + autoFloorOffset = currentManualFloor - sensorCalculatedFloor; - //TODO - fix the sensor fusion method to get the elevation (cannot get it from the current method) -// float elevationVal = sensorFusion.getElevation(); -// indoorMapManager.setCurrentFloor((int)(elevationVal/indoorMapManager.getFloorHeight()) -// ,true); + Log.d("AutoFloor", "Enabled! Calibration Offset: " + autoFloorOffset); + Toast.makeText(getContext(), "Auto Floor Calibrated", Toast.LENGTH_SHORT).show(); + } + } else { + // Reset offset when disabled (optional) + autoFloorOffset = 0; + } }); + // [FIX END] floorUpButton.setOnClickListener(v -> { - // If user manually changes floor, turn off auto floor autoFloorSwitch.setChecked(false); if (indoorMapManager != null) { indoorMapManager.increaseFloor(); + updateFloorDisplay(); } }); @@ -187,63 +293,55 @@ public void onMapReady(@NonNull GoogleMap googleMap) { autoFloorSwitch.setChecked(false); if (indoorMapManager != null) { indoorMapManager.decreaseFloor(); + updateFloorDisplay(); } }); } - /** - * 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. - * - * @param map - */ + public void addMarkerToMap(LatLng location) { + if (gMap != null && location != null) { + Marker marker = gMap.addMarker(new MarkerOptions() + .position(location) + .title("Manual Marker") + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_YELLOW))); + if (marker != null) { + manualMarkers.add(marker); + } + } + } private void initMapSettings(GoogleMap map) { - // Basic map settings map.getUiSettings().setCompassEnabled(true); - map.getUiSettings().setTiltGesturesEnabled(true); - map.getUiSettings().setRotateGesturesEnabled(true); - map.getUiSettings().setScrollGesturesEnabled(true); map.setMapType(GoogleMap.MAP_TYPE_HYBRID); - // Initialize indoor manager - indoorMapManager = new IndoorMapManager(map); - - // Initialize an empty polyline - polyline = map.addPolyline(new PolylineOptions() - .color(Color.RED) - .width(5f) - .add() // start empty - ); + // Initialize Manager with Context + indoorMapManager = new IndoorMapManager(map, requireContext()); + indoorMapManager.setOnFloorDataLoadedListener((hasData) -> { + // Update floor display when floor data is loaded from API + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + if (hasData) { + setFloorControlsVisibility(View.VISIBLE); + updateFloorDisplay(); + + // Now send venue selection notification + String buildingId = indoorMapManager.getSelectedBuildingId(); + String venueName = indoorMapManager.getSelectedBuildingName(); + if (venueSelectedListener != null && buildingId != null && venueName != null) { + venueSelectedListener.onVenueSelected(buildingId, venueName); + } + } else { + // No floor data for this building - hide floor controls + setFloorControlsVisibility(View.GONE); + } + }); + } + }); - // GNSS path in blue - gnssPolyline = map.addPolyline(new PolylineOptions() - .color(Color.BLUE) - .width(5f) - .add() // start empty - ); + polyline = map.addPolyline(new PolylineOptions().color(Color.RED).width(5f)); + gnssPolyline = map.addPolyline(new PolylineOptions().color(Color.BLUE).width(5f)); } - - /** - * 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. - */ private void initMapTypeSpinner() { if (switchMapSpinner == null) return; String[] maps = new String[]{ @@ -260,41 +358,33 @@ private void initMapTypeSpinner() { switchMapSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override - public void onItemSelected(AdapterView parent, View view, - int position, long id) { + 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; + 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; } } - @Override - public void onNothingSelected(AdapterView parent) {} + @Override public void onNothingSelected(AdapterView parent) {} }); } - /** - * Update the user's current location on the map, create or move orientation marker, - * and append to polyline if the user actually moved. - * - * @param newLocation The new location to plot. - * @param orientation The user’s heading (e.g. from sensor fusion). - */ public void updateUserLocation(@NonNull LatLng newLocation, float orientation) { if (gMap == null) return; - // Keep track of current location LatLng oldLocation = this.currentLocation; + + // Wall collision detection disabled for smoother trajectory + this.currentLocation = newLocation; - // If no marker, create it + // Initialize polyline if not exists (important for replay) + if (polyline == null) { + polyline = gMap.addPolyline(new PolylineOptions().color(Color.RED).width(5f)); + Log.d("TrajectoryMapFragment", "Polyline initialized in updateUserLocation"); + } + if (orientationMarker == null) { orientationMarker = gMap.addMarker(new MarkerOptions() .position(newLocation) @@ -306,79 +396,152 @@ public void updateUserLocation(@NonNull LatLng newLocation, float orientation) { ); gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(newLocation, 19f)); } else { - // Update marker position + orientation orientationMarker.setPosition(newLocation); - orientationMarker.setRotation(orientation); - // Move camera a bit + // Convert orientation from radians to degrees for marker rotation + float orientationDegrees = (float) Math.toDegrees(orientation); + orientationMarker.setRotation(orientationDegrees); gMap.moveCamera(CameraUpdateFactory.newLatLng(newLocation)); } - // Extend polyline if movement occurred - if (oldLocation != null && !oldLocation.equals(newLocation) && polyline != null) { + // Add point to trajectory polyline + if (oldLocation != null && !oldLocation.equals(newLocation)) { List points = new ArrayList<>(polyline.getPoints()); points.add(newLocation); polyline.setPoints(points); + } else if (oldLocation == null) { + // First point - add it to start the trajectory + List points = new ArrayList<>(); + points.add(newLocation); + polyline.setPoints(points); } - // Update indoor map overlay if (indoorMapManager != null) { indoorMapManager.setCurrentLocation(newLocation); - setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE); + + // ===== Auto-enable Indoor Map when Arrow Enters Building ===== + // Check if the arrow is inside a building boundary + boolean isCurrentlyInsideBuilding = checkIfInsideBuilding(newLocation); + + // If arrow just entered a building, auto-enable indoor map + if (isCurrentlyInsideBuilding && !isArrowInsideBuilding) { + Log.d("TrajectoryMapFragment", "Arrow entered building - auto-enabling indoor map"); + if (!indoorMapSwitch.isChecked()) { + indoorMapSwitch.setChecked(true); + } + isArrowInsideBuilding = true; + } + // If arrow just exited a building + else if (!isCurrentlyInsideBuilding && isArrowInsideBuilding) { + Log.d("TrajectoryMapFragment", "Arrow exited building"); + isArrowInsideBuilding = false; + } + + if (autoFloorSwitch.isChecked() && indoorMapManager != null) { + try { + float elevationVal = sensorFusion.getElevation(); + float floorHeight = indoorMapManager.getFloorHeight(); + + Log.d("TrajectoryMapFragment", String.format( + "AutoFloor: elevation=%.2fm, floorHeight=%.2fm, NaN=%b, Infinite=%b", + elevationVal, floorHeight, Float.isNaN(elevationVal), Float.isInfinite(elevationVal))); + + if (floorHeight > 0 && !Float.isNaN(elevationVal) && !Float.isInfinite(elevationVal)) { + // [FIX START]: Add Offset + // Calculate raw sensor floor + int sensorCalculatedFloor = Math.round(elevationVal / floorHeight); + + // Apply Offset (Sensor Floor + Offset = Target Real Floor) + int targetFloor = sensorCalculatedFloor + autoFloorOffset; + // [FIX END] + + // Get current floor to avoid unnecessary updates + int currentFloor = indoorMapManager.getCurrentFloor(); + + if (targetFloor != currentFloor) { + Log.d("TrajectoryMapFragment", String.format( + "AutoFloor: Switching floor %d -> %d (elevation: %.2fm / floorHeight: %.2fm, offset: %d)", + currentFloor, targetFloor, elevationVal, floorHeight, autoFloorOffset)); + indoorMapManager.setCurrentFloor(targetFloor, true); + } + } else { + Log.w("TrajectoryMapFragment", "AutoFloor: Invalid data - " + + "floorHeight=" + floorHeight + ", elevation=" + elevationVal); + } + } catch (Exception e) { + Log.e("TrajectoryMapFragment", "AutoFloor error: " + e.getMessage(), e); + } + } } } + /** + * Check if a location is inside any known building + * Uses building boundary polygons from BuildingPolygon class and dynamic boundaries from IndoorMapManager + */ + private boolean checkIfInsideBuilding(LatLng location) { + // Check against pre-defined buildings (Nucleus, Library) + if (BuildingPolygon.inNucleus(location) || BuildingPolygon.inLibrary(location)) { + return true; + } + + // Check against dynamically loaded building boundaries from API + if (indoorMapManager != null && indoorMapManager.isLocationInsideSelectedBuilding(location)) { + return true; + } + return false; + } /** - * 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. + * Public method to check if current location is inside a building + * Used by RecordingFragment for adaptive filtering */ + public boolean isCurrentlyInsideBuilding() { + return currentLocation != null && checkIfInsideBuilding(currentLocation); + } + public void setInitialCameraPosition(@NonNull LatLng startLocation) { - // If the map is already ready, move camera immediately if (gMap != null) { gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(startLocation, 19f)); + // Add a marker at the initial position + if (orientationMarker == null) { + orientationMarker = gMap.addMarker(new MarkerOptions() + .position(startLocation) + .flat(true) + .title("Start Position") + .icon(BitmapDescriptorFactory.fromBitmap( + UtilFunctions.getBitmapFromVector(requireContext(), + R.drawable.ic_baseline_navigation_24)))); + } + currentLocation = startLocation; } else { - // Otherwise, store it until onMapReady pendingCameraPosition = startLocation; hasPendingCameraMove = true; } } - - /** - * Get the current user location on the map. - * @return The current user location as a LatLng object. - */ - public LatLng getCurrentLocation() { - return currentLocation; + public LatLng getCurrentLocation() { return currentLocation; } + public void setOnVenueSelectedListener(OnVenueSelectedListener listener) { + this.venueSelectedListener = listener; } - /** - * Called when we want to set or update the GNSS marker position - */ public void updateGNSS(@NonNull LatLng gnssLocation) { - if (gMap == null) return; - if (!isGnssOn) return; + if (gMap == null || !isGnssOn) return; + + // Initialize GNSS polyline if not exists + if (gnssPolyline == null) { + gnssPolyline = gMap.addPolyline(new PolylineOptions().color(Color.BLUE).width(5f)); + Log.d("TrajectoryMapFragment", "GNSS Polyline initialized in updateGNSS"); + } if (gnssMarker == null) { - // Create the GNSS marker for the first time gnssMarker = gMap.addMarker(new MarkerOptions() .position(gnssLocation) .title("GNSS Position") - .icon(BitmapDescriptorFactory - .defaultMarker(BitmapDescriptorFactory.HUE_AZURE))); + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE))); lastGnssLocation = gnssLocation; } else { - // Move existing GNSS marker gnssMarker.setPosition(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); @@ -388,10 +551,6 @@ public void updateGNSS(@NonNull LatLng gnssLocation) { } } - - /** - * Remove GNSS marker if user toggles it off - */ public void clearGNSS() { if (gnssMarker != null) { gnssMarker.remove(); @@ -399,143 +558,74 @@ public void clearGNSS() { } } - /** - * Whether user is currently showing GNSS or not - */ - public boolean isGnssEnabled() { - return isGnssOn; - } + public boolean isGnssEnabled() { return isGnssOn; } private void setFloorControlsVisibility(int visibility) { - floorUpButton.setVisibility(visibility); - floorDownButton.setVisibility(visibility); - autoFloorSwitch.setVisibility(visibility); - } - - public void clearMapAndReset() { - if (polyline != null) { - polyline.remove(); - polyline = null; + if (floorControlsContainer != null) { + floorControlsContainer.setVisibility(visibility); } - if (gnssPolyline != null) { - gnssPolyline.remove(); - gnssPolyline = null; - } - if (orientationMarker != null) { - orientationMarker.remove(); - orientationMarker = null; - } - if (gnssMarker != null) { - gnssMarker.remove(); - gnssMarker = null; - } - lastGnssLocation = null; - currentLocation = null; - - // 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) - .add()); + if (autoFloorSwitch != null) { + autoFloorSwitch.setVisibility(visibility); } } /** - * 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. + * Update floor display text based on current floor */ - private void drawBuildingPolygon() { - if (gMap == null) { - Log.e("TrajectoryMapFragment", "GoogleMap is not ready"); - return; + private void updateFloorDisplay() { + if (indoorMapManager == null || floorTextView == null) return; + + int currentFloor = indoorMapManager.getCurrentFloor(); + String currentFloorName = indoorMapManager.getCurrentFloorName(); + int totalFloors = indoorMapManager.getAvailableFloorsCount(); + + String displayText; + if (currentFloorName != null && !currentFloorName.isEmpty()) { + displayText = currentFloorName; + } else if (currentFloor == 0) { + displayText = "G"; + } else if (currentFloor > 0) { + displayText = "F" + currentFloor; + } else { + displayText = "B" + Math.abs(currentFloor); } - // 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 - - 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 - - - // Remove the old polygon if it exists - if (buildingPolygon != null) { - buildingPolygon.remove(); + if (totalFloors > 1) { + displayText += "\n" + (currentFloor + 1) + "/" + totalFloors; } - // Add the polygon to the map - buildingPolygon = gMap.addPolygon(buildingPolygonOptions); - gMap.addPolygon(buildingPolygonOptions2); - gMap.addPolygon(buildingPolygonOptions3); - gMap.addPolygon(buildingPolygonOptions4); - Log.d("TrajectoryMapFragment", "Building polygon added, vertex count: " + buildingPolygon.getPoints().size()); + floorTextView.setText(displayText); } + public void clearMapAndReset() { + if (polyline != null) { polyline.remove(); polyline = null; } + if (gnssPolyline != null) { gnssPolyline.remove(); gnssPolyline = null; } + if (orientationMarker != null) { orientationMarker.remove(); orientationMarker = null; } + if (gnssMarker != null) { gnssMarker.remove(); gnssMarker = null; } + + for (Marker m : manualMarkers) m.remove(); + manualMarkers.clear(); + + lastGnssLocation = null; + currentLocation = null; + + if (gMap != null) { + polyline = gMap.addPolyline(new PolylineOptions().color(Color.RED).width(5f)); + gnssPolyline = gMap.addPolyline(new PolylineOptions().color(Color.BLUE).width(5f)); + } -} + if (indoorMapManager != null) { + indoorMapManager.hideMap(); + setFloorControlsVisibility(View.GONE); + if (buildingInfoCard != null) { + buildingInfoCard.setVisibility(View.GONE); + } + } + } + public LatLng getCameraTarget() { + if (gMap != null) { + return gMap.getCameraPosition().target; + } + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/UploadFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/UploadFragment.java index 9d435812..ccd35afb 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/UploadFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/UploadFragment.java @@ -1,5 +1,6 @@ package com.openpositioning.PositionMe.presentation.fragment; +import android.content.SharedPreferences; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -11,6 +12,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -21,15 +23,15 @@ import com.openpositioning.PositionMe.presentation.viewitems.UploadListAdapter; import java.io.File; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; /** - * A simple {@link Fragment} subclass. Displays trajectories that were saved locally because no - * acceptable network was available to upload it when the recording finished. Trajectories can be - * uploaded manually. + * A simple {@link Fragment} subclass. Displays trajectories that were saved locally. + * FIXED: Now correctly detects files starting with "traj_" * * @author Mate Stodulka */ @@ -42,29 +44,21 @@ public class UploadFragment extends Fragment { // Server communication class private ServerCommunications serverCommunications; + private SharedPreferences settings; // List of files saved locally private List localTrajectories; - /** - * Public default constructor, empty. - */ public UploadFragment() { // Required empty public constructor } - - /** - * {@inheritDoc} - * Initialises new Server Communication instance with the context, and finds all the files that - * match the trajectory naming scheme in local storage. - */ - @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Get communication class serverCommunications = new ServerCommunications(getActivity()); + settings = PreferenceManager.getDefaultSharedPreferences(getActivity()); // Determine the directory to load trajectory files from. File trajectoriesDir = null; @@ -79,43 +73,33 @@ public void onCreate(Bundle savedInstanceState) { trajectoriesDir = getActivity().getFilesDir(); } - localTrajectories = Stream.of(trajectoriesDir.listFiles((file, name) -> - name.contains("trajectory_") && name.endsWith(".txt"))) - .filter(file -> !file.isDirectory()) - .collect(Collectors.toList()); + // Filter trajectory files (both old and new formats) + File[] files = trajectoriesDir.listFiles((file, name) -> + name.startsWith("traj_") && name.endsWith(".txt")); + + if (files != null) { + localTrajectories = Stream.of(files) + .filter(file -> !file.isDirectory()) + .collect(Collectors.toList()); + } else { + localTrajectories = new ArrayList<>(); + } } - /** - * {@inheritDoc} - * Sets the title in the action bar to "Upload" - */ @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { getActivity().setTitle("Upload"); - // Inflate the layout for this fragment return inflater.inflate(R.layout.fragment_upload, container, false); } - /** - * {@inheritDoc} - * Checks if there are locally saved trajectories. If there are none, it displays a text message - * notifying the user. If there are local files, the text is hidden, and instead a Recycler View - * is displayed showing all the trajectories. - *

- * A Layout Manager is registered, and the adapter and list of files passed. An onClick listener - * is set up to upload the file when clicked and remove it from local storage. - * - * @see UploadListAdapter list adapter for the recycler view. - * @see UploadViewHolder view holder for the recycler view. - * @see com.openpositioning.PositionMe.R.layout#item_upload_card_view xml view for list elements. - */ @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); this.emptyNotice = view.findViewById(R.id.emptyUpload); this.uploadList = view.findViewById(R.id.uploadTrajectories); + // Check if there are locally saved trajectories if(localTrajectories.isEmpty()) { uploadList.setVisibility(View.GONE); @@ -129,17 +113,13 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat LinearLayoutManager manager = new LinearLayoutManager(getActivity()); uploadList.setLayoutManager(manager); uploadList.setHasFixedSize(true); + listAdapter = new UploadListAdapter(getActivity(), localTrajectories, new DownloadClickListener() { - /** - * {@inheritDoc} - * Upload the trajectory at the clicked position, remove it from the recycler view - * and the local list. - */ @Override public void onPositionClicked(int position) { - serverCommunications.uploadLocalTrajectory(localTrajectories.get(position)); -// localTrajectories.remove(position); -// listAdapter.notifyItemRemoved(position); + // Read campaign from SharedPreferences, default to empty string + String campaign = settings.getString("current_campaign", ""); + serverCommunications.uploadLocalTrajectory(localTrajectories.get(position), campaign); } }); uploadList.setAdapter(listAdapter); diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/BleListAdapter.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/BleListAdapter.java new file mode 100644 index 00000000..1018c9c2 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/BleListAdapter.java @@ -0,0 +1,65 @@ +package com.openpositioning.PositionMe.presentation.viewitems; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.sensors.BleDevice; + +import java.util.List; + +/** + * Adapter for displaying BLE device data in a RecyclerView. + * + * @see BleViewHolder + * @see com.openpositioning.PositionMe.sensors.BleDevice + */ +public class BleListAdapter extends RecyclerView.Adapter { + + Context context; + List items; + + public BleListAdapter(Context context, List items) { + this.context = context; + this.items = items; + } + + @NonNull + @Override + public BleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new BleViewHolder(LayoutInflater.from(context).inflate(R.layout.item_ble_card_view, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull BleViewHolder holder, int position) { + BleDevice device = items.get(position); + + // Check if this is a placeholder item + if ("00:00:00:00:00:00".equals(device.getMacAddress())) { + holder.deviceName.setText(device.getName() != null ? device.getName() : "Scanning..."); + holder.macAddress.setText("No devices found"); + holder.rssi.setText(""); + } else { + holder.macAddress.setText(device.getMacAddress()); + + String name = device.getName(); + if (name == null || name.isEmpty()) { + holder.deviceName.setText("Unknown Device"); + } else { + holder.deviceName.setText(name); + } + + String rssiString = device.getRssi() + " dBm"; + holder.rssi.setText(rssiString); + } + } + + @Override + public int getItemCount() { + return items.size(); + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/BleViewHolder.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/BleViewHolder.java new file mode 100644 index 00000000..d742dbcf --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/BleViewHolder.java @@ -0,0 +1,29 @@ +package com.openpositioning.PositionMe.presentation.viewitems; + +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.openpositioning.PositionMe.R; + +/** + * View holder for BLE device list items. + * + * @see BleListAdapter + * @see com.openpositioning.PositionMe.sensors.BleDevice + */ +public class BleViewHolder extends RecyclerView.ViewHolder { + + TextView macAddress; + TextView deviceName; + TextView rssi; + + public BleViewHolder(@NonNull View itemView) { + super(itemView); + macAddress = itemView.findViewById(R.id.bleMacItem); + deviceName = itemView.findViewById(R.id.bleNameItem); + rssi = itemView.findViewById(R.id.bleRssiItem); + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadListAdapter.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadListAdapter.java index b564e231..51e936a6 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadListAdapter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadListAdapter.java @@ -13,16 +13,10 @@ import java.io.File; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Adapter used for displaying local Trajectory file data - * - * @see UploadViewHolder corresponding View Holder class - * @see com.openpositioning.PositionMe.R.layout#item_upload_card_view xml layout file - * - * @author Mate Stodulka + * FINAL VERSION: Correctly parses names by finding the last underscore. */ public class UploadListAdapter extends RecyclerView.Adapter { @@ -30,26 +24,12 @@ public class UploadListAdapter extends RecyclerView.Adapter { private final List uploadItems; private final DownloadClickListener listener; - /** - * Default public constructor with context for inflating views and list to be displayed. - * - * @param context application context to enable inflating views used in the list. - * @param uploadItems List of trajectory Files found locally on the device. - * @param listener clickListener to download trajectories when clicked. - * - * @see com.openpositioning.PositionMe.Traj protobuf objects exchanged with the server. - */ public UploadListAdapter(Context context, List uploadItems, DownloadClickListener listener) { this.context = context; this.uploadItems = uploadItems; this.listener = listener; } - /** - * {@inheritDoc} - * - * @see com.openpositioning.PositionMe.R.layout#item_upload_card_view xml layout file - */ @NonNull @Override public UploadViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @@ -57,52 +37,74 @@ public UploadViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewTy } /** - * {@inheritDoc} - * Formats and assigns the data fields from the local Trajectory Files object to the TextView fields. - * - * @see UploadFragment finding the data from on local storage. - * @see com.openpositioning.PositionMe.R.layout#item_upload_card_view xml layout file. + * Parse filename format: traj_NAME_DATE.txt + * Extract name (remove "traj_" prefix) and date. */ @Override public void onBindViewHolder(@NonNull UploadViewHolder holder, int position) { - holder.trajId.setText(String.valueOf(position)); - Pattern datePattern = Pattern.compile("_(.*?)\\.txt"); - Matcher dateMatcher = datePattern.matcher(uploadItems.get(position).getName()); - String dateString = dateMatcher.find() ? dateMatcher.group(1) : "N/A"; - System.err.println("UPLOAD - Date string: " + dateString); - holder.trajDate.setText(dateString); - - // Set click listener for the delete button - holder.deletebutton.setOnClickListener(v -> deleteFileAtPosition(position)); + // 1. Get filename (e.g. "traj_MyWalk_2026-02-05.txt") + File currentFile = uploadItems.get(position); + String fileName = currentFile.getName(); + + // 2. Remove ".txt" suffix + if (fileName.endsWith(".txt")) { + fileName = fileName.substring(0, fileName.length() - 4); + } + + // 3. Find last "_" to split name and date + int lastUnderscoreIndex = fileName.lastIndexOf("_"); + + if (lastUnderscoreIndex != -1) { + // Extract date + String datePart = fileName.substring(lastUnderscoreIndex + 1); + holder.trajDate.setText(datePart); + + // Extract name + String namePart = fileName.substring(0, lastUnderscoreIndex); + // Remove "traj_" prefix if exists + if (namePart.startsWith("traj_")) { + namePart = namePart.substring(5); + } + + // Display name or "Unnamed" if empty + if (namePart.isEmpty()) { + holder.trajId.setText("Unnamed"); + } else { + // Display actual name (e.g. "MyWalk") + // For legacy files like "traj_trajectory_DATE" + holder.trajId.setText(namePart); + } + holder.trajId.setTextSize(20); + + } else { + // Edge case: filename without underscore + holder.trajDate.setText("N/A"); + holder.trajId.setText(fileName); + holder.trajId.setTextSize(14); + } + + // Set up delete button + holder.deletebutton.setOnClickListener(v -> deleteFileAtPosition(position)); } - /** - * {@inheritDoc} - * Number of local files. - */ @Override public int getItemCount() { return uploadItems.size(); } - private void deleteFileAtPosition(int position) - { - if (position >= 0 && position < uploadItems.size()) - { + private void deleteFileAtPosition(int position) { + if (position >= 0 && position < uploadItems.size()) { File fileToDelete = uploadItems.get(position); - if (fileToDelete.exists() && fileToDelete.delete()) - { + if (fileToDelete.exists() && fileToDelete.delete()) { uploadItems.remove(position); notifyItemRemoved(position); - notifyItemRangeChanged(position, uploadItems.size()); // Update subsequent items + notifyItemRangeChanged(position, uploadItems.size()); Toast.makeText(context, "File deleted successfully", Toast.LENGTH_SHORT).show(); - } - else - { + } else { Toast.makeText(context, "Failed to delete file", Toast.LENGTH_SHORT).show(); } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiListAdapter.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiListAdapter.java index 887e7689..618909a4 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiListAdapter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiListAdapter.java @@ -58,10 +58,18 @@ public WifiViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType */ @Override public void onBindViewHolder(@NonNull WifiViewHolder holder, int position) { - String macString = context.getString(R.string.mac, Long.toString(items.get(position).getBssid())); - holder.bssid.setText(macString); - String levelString = context.getString(R.string.db, Long.toString(items.get(position).getLevel())); - holder.level.setText(levelString); + Wifi wifi = items.get(position); + + // Check if this is a placeholder item (BSSID = 0) + if (wifi.getBssid() == 0) { + holder.bssid.setText(wifi.getSsid()); // Display "Scanning..." or similar message + holder.level.setText(""); + } else { + String macString = context.getString(R.string.mac, Long.toString(wifi.getBssid())); + holder.bssid.setText(macString); + String levelString = context.getString(R.string.db, Long.toString(wifi.getLevel())); + holder.level.setText(levelString); + } } /** diff --git a/app/src/main/res/drawable/status_background.xml b/app/src/main/res/drawable/status_background.xml new file mode 100644 index 00000000..09c5a126 --- /dev/null +++ b/app/src/main/res/drawable/status_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/status_recording.xml b/app/src/main/res/drawable/status_recording.xml new file mode 100644 index 00000000..c610d780 --- /dev/null +++ b/app/src/main/res/drawable/status_recording.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_correction.xml b/app/src/main/res/layout/fragment_correction.xml index ce536570..ea6c8471 100644 --- a/app/src/main/res/layout/fragment_correction.xml +++ b/app/src/main/res/layout/fragment_correction.xml @@ -4,127 +4,65 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + tools:context=".presentation.fragment.CorrectionFragment"> - - - - - - + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> - - - - + android:orientation="vertical" + android:padding="16dp"> - + android:text="Adjust Path Rotation" + android:textAlignment="center" + android:textSize="18sp" + android:textStyle="bold" /> - - - - - + android:layout_marginTop="8dp" + android:max="360" + android:progress="180" /> + - - - - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_indoor_positioning.xml b/app/src/main/res/layout/fragment_indoor_positioning.xml new file mode 100644 index 00000000..973709bb --- /dev/null +++ b/app/src/main/res/layout/fragment_indoor_positioning.xml @@ -0,0 +1,305 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_measurements.xml b/app/src/main/res/layout/fragment_measurements.xml index 640af10d..95c0d393 100644 --- a/app/src/main/res/layout/fragment_measurements.xml +++ b/app/src/main/res/layout/fragment_measurements.xml @@ -603,10 +603,72 @@ android:layout_marginStart="8dp" android:layout_marginEnd="8dp" android:layout_marginBottom="8dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/dividerLine" + app:layout_constraintBottom_toTopOf="@id/bleDividerLine" /> + + + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/bleDividerLine" /> diff --git a/app/src/main/res/layout/fragment_recording.xml b/app/src/main/res/layout/fragment_recording.xml index c04381a5..4cd57d09 100644 --- a/app/src/main/res/layout/fragment_recording.xml +++ b/app/src/main/res/layout/fragment_recording.xml @@ -1,145 +1,106 @@ - + android:layout_height="match_parent" + android:background="#F5F5F5" + tools:context=".presentation.fragment.RecordingFragment"> - + + + + + app:cardElevation="8dp" + app:cardBackgroundColor="@android:color/white" + android:layout_marginHorizontal="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"> - - - + android:orientation="vertical" + android:paddingHorizontal="16dp" + android:paddingTop="12dp" + android:paddingBottom="8dp"> - + + android:background="@drawable/status_background" + android:padding="8dp" + android:text="Ready to record" + android:textColor="@android:color/white" + android:textAlignment="center" + android:textSize="13sp" /> - - + - + android:layout_marginTop="8dp" + android:hint="Trajectory Name"> + + - - + - - + android:layout_marginTop="8dp" + android:layout_marginBottom="4dp" + android:orientation="horizontal" + android:gravity="center"> - - + - - - - - - - - - - - - - - + + - - + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_replay.xml b/app/src/main/res/layout/fragment_replay.xml index b6e88834..84cbf299 100644 --- a/app/src/main/res/layout/fragment_replay.xml +++ b/app/src/main/res/layout/fragment_replay.xml @@ -21,6 +21,32 @@ android:layout_height="wrap_content" android:layout_margin="14dp"/> + + + + + + + + - - + android:layout_height="match_parent" + tools:context=".presentation.fragment.StartLocationFragment"> - - - - - - - + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - - + + -