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));
- }
+ updateWifiList(wifiObjects);
// Restart the data updater task in REFRESH_TIME milliseconds.
refreshDataHandler.postDelayed(refreshTableTask, REFRESH_TIME);
}
};
-}
\ No newline at end of file
+}
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..ddb9a5d0 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
@@ -7,59 +7,61 @@
import android.os.Bundle;
import android.os.CountDownTimer;
import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+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.EditText;
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.button.MaterialButton;
import com.openpositioning.PositionMe.R;
import com.openpositioning.PositionMe.presentation.activity.RecordingActivity;
+import com.openpositioning.PositionMe.presentation.display.DataDisplayController;
import com.openpositioning.PositionMe.sensors.SensorFusion;
-import com.openpositioning.PositionMe.sensors.SensorTypes;
+import com.openpositioning.PositionMe.utils.PathView;
import com.openpositioning.PositionMe.utils.UtilFunctions;
-import com.google.android.gms.maps.model.LatLng;
+import java.util.Locale;
+import java.util.Date;
/**
- * 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
+ * New code guide:
+ * 1. Live refresh loop for fused map display.
+ * 2. Recording-name flow and controlled stop handling.
+ * 3. Test-point restore and tagging on the live map.
+ * 4. Lightweight UI updates for distance, elevation, and GNSS error.
*/
-
public class RecordingFragment extends Fragment {
+ private static final double DEGREE_IN_METERS = 111111.0;
+ private static final long LIVE_REFRESH_INTERVAL_MS = 130L;
+ private static final long LIVE_REFRESH_INTERVAL_SLOW_MS = 180L;
+ private static final long LIVE_REFRESH_INTERVAL_MAX_MS = 240L;
+ private static final long FLOOR_UI_SYNC_INTERVAL_MS = 450L;
+ private static final long GNSS_ERROR_REFRESH_INTERVAL_MS = 320L;
+ private static final float DISTANCE_UI_EPSILON_METERS = 0.02f;
+ private static final float ELEVATION_UI_EPSILON_METERS = 0.05f;
+ private static final double GNSS_ERROR_UI_EPSILON_METERS = 0.05;
+
// UI elements
- private MaterialButton completeButton, cancelButton;
+ private MaterialButton completeButton, cancelButton, addTagButton;
+ private PathView pathView;
private ImageView recIcon;
private ProgressBar timeRemaining;
private TextView elevation, distanceTravelled, gnssError;
@@ -71,21 +73,50 @@ public class RecordingFragment extends Fragment {
private SensorFusion sensorFusion;
private Handler refreshDataHandler;
private CountDownTimer autoStop;
+ private DataDisplayController dataDisplayController;
// Distance tracking
private float distance = 0f;
private float previousPosX = 0f;
private float previousPosY = 0f;
+ private float lastDisplayedDistance = Float.NaN;
+ private float lastDisplayedElevation = Float.NaN;
+ private double lastDisplayedGnssError = Double.NaN;
+ private final float[] latestPdrPositionBuffer = new float[2];
+ private final float[] latestFusedPositionBuffer = new float[2];
+ private final double[] displayOriginLatLonBuffer = new double[2];
// References to the child map fragment
private TrajectoryMapFragment trajectoryMapFragment;
+ // Add Tag counter
+ private int tagCount = 0;
+ private long nextRefreshIntervalMs = LIVE_REFRESH_INTERVAL_MS;
+ private long lastFloorUiSyncTimeMs = 0L;
+ private long lastGnssErrorRefreshTimeMs = 0L;
+
+ // Save the test point of the user pressing "Add Tag"
+ private final java.util.ArrayList testPoints =
+ new java.util.ArrayList<>();
+ // Timestamp
+ private long startTimestampMs = 0L;
+
+ // Runs the live display loop with adaptive pacing based on the last frame cost.
private final Runnable refreshDataTask = new Runnable() {
@Override
public void run() {
- updateUIandPosition();
- // Loop again
- refreshDataHandler.postDelayed(refreshDataTask, 200);
+ long frameStartMs = SystemClock.elapsedRealtime();
+ try {
+ updateUIandPosition();
+ } catch (Exception e) {
+ Log.e("RecordingFragment", "Live refresh failed", e);
+ }
+ if (refreshDataHandler != null && isAdded()) {
+ long frameDurationMs = SystemClock.elapsedRealtime() - frameStartMs;
+ nextRefreshIntervalMs = computeNextRefreshDelayMs(frameDurationMs);
+ refreshDataHandler.removeCallbacks(refreshDataTask);
+ refreshDataHandler.postDelayed(refreshDataTask, nextRefreshIntervalMs);
+ }
}
};
@@ -99,7 +130,7 @@ public void onCreate(Bundle savedInstanceState) {
this.sensorFusion = SensorFusion.getInstance();
Context context = requireActivity();
this.settings = PreferenceManager.getDefaultSharedPreferences(context);
- this.refreshDataHandler = new Handler();
+ this.refreshDataHandler = new Handler(Looper.getMainLooper());
}
@Nullable
@@ -107,7 +138,6 @@ public void onCreate(Bundle savedInstanceState) {
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
- // Inflate only the "recording" UI parts (no map)
return inflater.inflate(R.layout.fragment_recording, container, false);
}
@@ -116,163 +146,258 @@ 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
+ java.util.List cachedPoints =
+ sensorFusion.getTestPoints();
+ testPoints.clear();
+ if (cachedPoints != null) {
+ testPoints.addAll(cachedPoints);
+ }
+ tagCount = testPoints.size();
+ long storedStartTimestamp = sensorFusion.getStartTimestampMs();
+ startTimestampMs = storedStartTimestamp > 0L ? storedStartTimestamp : System.currentTimeMillis();
+ distance = 0f;
+ previousPosX = 0f;
+ previousPosY = 0f;
+ lastDisplayedDistance = Float.NaN;
+ lastDisplayedElevation = Float.NaN;
+ lastDisplayedGnssError = Double.NaN;
+ nextRefreshIntervalMs = LIVE_REFRESH_INTERVAL_MS;
+ lastFloorUiSyncTimeMs = 0L;
+ lastGnssErrorRefreshTimeMs = 0L;
+
+ sensorFusion.setStartTimestampMs(startTimestampMs);
+ sensorFusion.setTestPoints(testPoints);
+
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();
+ .commitNow();
}
- // Initialize UI references
+ dataDisplayController = new DataDisplayController(sensorFusion, trajectoryMapFragment);
+ dataDisplayController.reset();
+ trajectoryMapFragment.clearMapAndReset();
+ restorePersistedTagMarkers();
+
elevation = view.findViewById(R.id.currentElevation);
distanceTravelled = view.findViewById(R.id.currentDistanceTraveled);
gnssError = view.findViewById(R.id.gnssError);
-
+ pathView = view.findViewById(R.id.pathView);
completeButton = view.findViewById(R.id.stopButton);
cancelButton = view.findViewById(R.id.cancelButton);
+ addTagButton = view.findViewById(R.id.addTagButton);
+ addTagButton.bringToFront();
+ addTagButton.setElevation(20f);
recIcon = view.findViewById(R.id.redDot);
timeRemaining = view.findViewById(R.id.timeRemainingBar);
- // Hide or initialize default values
+ // Assignment 2 uses the map-based live display, so hide the old PathView overlay.
+ if (pathView != null) {
+ pathView.setVisibility(View.GONE);
+ }
+ sensorFusion.setPathView(null);
+
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();
+ showRecordingNameDialog();
});
-
- // 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
+ .setPositiveButton("No", (dialogInterface, which) -> dialogInterface.dismiss())
+ .create();
- // 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
+ negativeButton.setTextColor(Color.RED);
});
- dialog.show(); // Finally, show the dialog
+ dialog.show();
+ });
+
+ addTagButton.setOnClickListener(v -> {
+ LatLng current = resolveCurrentTagLocation();
+ if (current == null) {
+ Toast.makeText(requireContext(), "Position not ready yet, please try again.", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ tagCount++;
+ if (trajectoryMapFragment != null) {
+ trajectoryMapFragment.addTagPoint(current, tagCount);
+ }
+
+ long relativeTs = Math.max(1L, System.currentTimeMillis() - startTimestampMs);
+ com.openpositioning.PositionMe.Traj.GNSSPosition p =
+ com.openpositioning.PositionMe.Traj.GNSSPosition.newBuilder()
+ .setRelativeTimestamp(relativeTs)
+ .setLatitude(current.latitude)
+ .setLongitude(current.longitude)
+ .setAltitude((double) sensorFusion.getElevation())
+ .build();
+
+ testPoints.add(p);
+ sensorFusion.appendTestPoint(p);
});
- // 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;
+ int splitDurationMinutes = Math.max(5, Math.min(30, this.settings.getInt("split_duration", 10)));
+ long limit = splitDurationMinutes * 60000L;
+ timeRemaining.setVisibility(View.VISIBLE);
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();
}
@Override
public void onFinish() {
sensorFusion.stopRecording();
- ((RecordingActivity) requireActivity()).showCorrectionScreen();
+ if (!isAdded()) return;
+ if (getActivity() instanceof RecordingActivity) {
+ ((RecordingActivity) getActivity()).showCorrectionScreen();
+ }
}
}.start();
- } else {
- // No set time limit, just keep refreshing
- refreshDataHandler.post(refreshDataTask);
}
+ startLiveRefreshLoop();
+ }
+
+ private void startLiveRefreshLoop() {
+ if (refreshDataHandler == null || !isAdded()) {
+ return;
+ }
+ nextRefreshIntervalMs = LIVE_REFRESH_INTERVAL_MS;
+ refreshDataHandler.removeCallbacks(refreshDataTask);
+ refreshDataHandler.post(refreshDataTask);
+ }
+
+ private long computeNextRefreshDelayMs(long frameDurationMs) {
+ if (frameDurationMs >= 120L) {
+ return LIVE_REFRESH_INTERVAL_MAX_MS;
+ }
+ if (frameDurationMs >= 70L) {
+ return LIVE_REFRESH_INTERVAL_SLOW_MS;
+ }
+ return LIVE_REFRESH_INTERVAL_MS;
+ }
+
+ // Lets the user confirm a readable recording name before the upload/save step.
+ private void showRecordingNameDialog() {
+ if (!isAdded()) {
+ return;
+ }
+
+ EditText input = new EditText(requireContext());
+ input.setHint(getString(R.string.recording_name_hint));
+ input.setSingleLine(true);
+ input.setText(buildDefaultRecordingName());
+ input.setSelection(input.getText().length());
+
+ new AlertDialog.Builder(requireContext())
+ .setTitle(R.string.recording_name_title)
+ .setView(input)
+ .setPositiveButton(R.string.save_recording_name, (dialog, which) -> {
+ String chosenName = input.getText() == null ? "" : input.getText().toString().trim();
+ finalizeRecordingWithName(chosenName);
+ })
+ .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss())
+ .show();
+ }
+
+ private String buildDefaultRecordingName() {
+ String timestampLabel = new java.text.SimpleDateFormat(
+ "yyyy-MM-dd HH:mm",
+ Locale.getDefault()
+ ).format(new Date(Math.max(startTimestampMs, System.currentTimeMillis())));
+ return getString(R.string.recording_name_default) + " " + timestampLabel;
+ }
+
+ // Freezes the chosen metadata and hands control back to the correction screen.
+ private void finalizeRecordingWithName(String chosenName) {
+ sensorFusion.setPendingRecordingName(chosenName);
+ sensorFusion.setStartTimestampMs(startTimestampMs);
+ sensorFusion.setTestPoints(testPoints);
+
+ if (autoStop != null) autoStop.cancel();
+ sensorFusion.stopRecording();
+ ((RecordingActivity) requireActivity()).showCorrectionScreen();
}
/**
- * Update the UI with sensor data and pass map updates to TrajectoryMapFragment.
+ * Update the UI with sensor data and hand off live map rendering to DataDisplayController.
*/
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()));
+ try {
+ long nowMs = SystemClock.elapsedRealtime();
+ if (!sensorFusion.copyLatestPdrPositionXY(latestPdrPositionBuffer)) return;
+
+ float dx = latestPdrPositionBuffer[0] - previousPosX;
+ float dy = latestPdrPositionBuffer[1] - previousPosY;
+ float segmentDistance = (float) Math.hypot(dx, dy);
+ if (Float.isFinite(segmentDistance) && segmentDistance > 1e-4f) {
+ distance += segmentDistance;
+ }
+ updateDistanceText(distance);
+
+ float elevationVal = sensorFusion.getElevation();
+ updateElevationText(elevationVal);
+
+ if (trajectoryMapFragment != null
+ && (nowMs - lastFloorUiSyncTimeMs) >= FLOOR_UI_SYNC_INTERVAL_MS) {
+ trajectoryMapFragment.updateElevation();
+ lastFloorUiSyncTimeMs = nowMs;
+ }
+
+ if (dataDisplayController != null) {
+ dataDisplayController.renderFrame();
}
- }
- // 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));
+ if (trajectoryMapFragment != null && trajectoryMapFragment.isGnssEnabled()) {
+ if ((nowMs - lastGnssErrorRefreshTimeMs) >= GNSS_ERROR_REFRESH_INTERVAL_MS) {
+ LatLng currentLoc = trajectoryMapFragment.getCurrentLocation();
+ LatLng gnssLocation = sensorFusion.getLatestGnssLatLng();
+ if (currentLoc != null && gnssLocation != null) {
+ double errorDist = UtilFunctions.distanceBetweenPoints(currentLoc, gnssLocation);
+ updateGnssError(errorDist);
+ } else {
+ lastDisplayedGnssError = Double.NaN;
+ gnssError.setVisibility(View.GONE);
+ }
+ lastGnssErrorRefreshTimeMs = nowMs;
}
- trajectoryMapFragment.updateGNSS(gnssLocation);
} else {
+ lastDisplayedGnssError = Double.NaN;
gnssError.setVisibility(View.GONE);
- trajectoryMapFragment.clearGNSS();
}
- }
- // Update previous
- previousPosX = pdrValues[0];
- previousPosY = pdrValues[1];
+ previousPosX = latestPdrPositionBuffer[0];
+ previousPosY = latestPdrPositionBuffer[1];
+ } catch (Exception e) {
+ Log.e("RecordingFragment", "updateUIandPosition failed", e);
+ }
}
- /**
- * Start the blinking effect for the recording icon.
- */
private void blinkingRecordingIcon() {
Animation blinking = new AlphaAnimation(1, 0);
blinking.setDuration(800);
@@ -282,17 +407,120 @@ private void blinkingRecordingIcon() {
recIcon.startAnimation(blinking);
}
+ private void updateDistanceText(float distanceMeters) {
+ if (!Float.isNaN(lastDisplayedDistance)
+ && Math.abs(distanceMeters - lastDisplayedDistance) < DISTANCE_UI_EPSILON_METERS) {
+ return;
+ }
+ lastDisplayedDistance = distanceMeters;
+ distanceTravelled.setText(getString(
+ R.string.meter,
+ String.format(Locale.US, "%.2f", distanceMeters)
+ ));
+ }
+
+ private void updateElevationText(float elevationMeters) {
+ if (!Float.isNaN(lastDisplayedElevation)
+ && Math.abs(elevationMeters - lastDisplayedElevation) < ELEVATION_UI_EPSILON_METERS) {
+ return;
+ }
+ lastDisplayedElevation = elevationMeters;
+ elevation.setText(getString(
+ R.string.elevation,
+ String.format(Locale.US, "%.1f", elevationMeters)
+ ));
+ }
+
+ private void updateGnssError(double errorMeters) {
+ gnssError.setVisibility(View.VISIBLE);
+ if (!Double.isNaN(lastDisplayedGnssError)
+ && Math.abs(errorMeters - lastDisplayedGnssError) < GNSS_ERROR_UI_EPSILON_METERS) {
+ return;
+ }
+ lastDisplayedGnssError = errorMeters;
+ gnssError.setText(String.format(Locale.US, "%s%.2fm", getString(R.string.gnss_error), errorMeters));
+ }
+
+ // Restores previously saved test points when the fragment is recreated mid-session.
+ private void restorePersistedTagMarkers() {
+ if (trajectoryMapFragment == null || testPoints.isEmpty()) {
+ return;
+ }
+ int markerIndex = 1;
+ for (com.openpositioning.PositionMe.Traj.GNSSPosition point : testPoints) {
+ if (point == null) {
+ continue;
+ }
+ double lat = point.getLatitude();
+ double lon = point.getLongitude();
+ if (Double.isNaN(lat) || Double.isNaN(lon)) {
+ continue;
+ }
+ trajectoryMapFragment.addTagPoint(new LatLng(lat, lon), markerIndex++);
+ }
+ }
+
+ // Resolves the best available map position for a new manual test marker.
+ @Nullable
+ private LatLng resolveCurrentTagLocation() {
+ if (trajectoryMapFragment != null) {
+ LatLng current = trajectoryMapFragment.getCurrentLocation();
+ if (current != null) {
+ return current;
+ }
+ }
+
+ if (sensorFusion.copyLatestFusedPositionXY(latestFusedPositionBuffer)
+ && sensorFusion.copyDisplayOriginLatLon(displayOriginLatLonBuffer)) {
+ double lat = displayOriginLatLonBuffer[0] + (latestFusedPositionBuffer[1] / DEGREE_IN_METERS);
+ double lon = displayOriginLatLonBuffer[1] + (latestFusedPositionBuffer[0]
+ / (DEGREE_IN_METERS * Math.cos(Math.toRadians(displayOriginLatLonBuffer[0]))));
+ return new LatLng(lat, lon);
+ }
+
+ LatLng wifiLocation = sensorFusion.getLatestWifiLatLng();
+ if (wifiLocation != null) {
+ return wifiLocation;
+ }
+ return sensorFusion.getLatestGnssLatLng();
+ }
+
@Override
public void onPause() {
super.onPause();
- refreshDataHandler.removeCallbacks(refreshDataTask);
+ if (refreshDataHandler != null) {
+ refreshDataHandler.removeCallbacks(refreshDataTask);
+ }
+ if (recIcon != null) {
+ recIcon.clearAnimation();
+ }
}
@Override
public void onResume() {
super.onResume();
- if(!this.settings.getBoolean("split_trajectory", false)) {
- refreshDataHandler.postDelayed(refreshDataTask, 500);
+ sensorFusion.setPathView(null);
+ sensorFusion.resumeListening();
+ if (recIcon != null) {
+ blinkingRecordingIcon();
+ }
+ startLiveRefreshLoop();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ if (refreshDataHandler != null) {
+ refreshDataHandler.removeCallbacks(refreshDataTask);
+ }
+ if (autoStop != null) {
+ autoStop.cancel();
+ }
+ if (recIcon != null) {
+ recIcon.clearAnimation();
}
+ dataDisplayController = null;
+ trajectoryMapFragment = null;
+ pathView = null;
}
}
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..287c2790 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
@@ -272,7 +272,7 @@ private void showGnssChoiceDialog() {
}
private void setupInitialMapPosition(float latitude, float longitude) {
- LatLng startPoint = new LatLng(initialLat, initialLon);
+ LatLng startPoint = new LatLng(latitude, longitude);
Log.i(TAG, "Setting initial map position: " + startPoint.toString());
trajectoryMapFragment.setInitialCameraPosition(startPoint);
}
@@ -283,7 +283,7 @@ private void setupInitialMapPosition(float latitude, float longitude) {
private LatLng getFirstGnssLocation(List data) {
for (TrajParser.ReplayPoint point : data) {
if (point.gnssLocation != null) {
- return new LatLng(replayData.get(0).gnssLocation.latitude, replayData.get(0).gnssLocation.longitude);
+ return point.gnssLocation;
}
}
return null; // None found
@@ -302,7 +302,7 @@ public void run() {
Log.i(TAG, "Playing index: " + currentIndex);
updateMapForIndex(currentIndex);
currentIndex++;
- playbackSeekBar.setProgress(currentIndex);
+ playbackSeekBar.setProgress(Math.min(currentIndex, replayData.size() - 1));
if (currentIndex < replayData.size()) {
playbackHandler.postDelayed(this, PLAYBACK_INTERVAL_MS);
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/SettingsFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/SettingsFragment.java
index c1f6501c..f776564e 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/SettingsFragment.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/SettingsFragment.java
@@ -2,7 +2,9 @@
import android.os.Bundle;
import android.text.InputType;
+import android.widget.Toast;
+import androidx.annotation.StringRes;
import androidx.preference.EditTextPreference;
import androidx.preference.PreferenceFragmentCompat;
@@ -35,23 +37,85 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setPreferencesFromResource(R.xml.root_preferences, rootKey);
getActivity().setTitle("Settings");
weibergK = findPreference("weiberg_k");
- weibergK.setOnBindEditTextListener(editText -> editText.setInputType(
- InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL));
+ bindPositiveDecimalPreference(weibergK, R.string.settings_value_positive_number);
elevationSeconds = findPreference("elevation_seconds");
- elevationSeconds.setOnBindEditTextListener(editText -> editText.setInputType(
- InputType.TYPE_CLASS_NUMBER));
+ bindPositiveIntegerPreference(elevationSeconds, R.string.settings_value_positive_integer);
accelSamples = findPreference("accel_samples");
- accelSamples.setOnBindEditTextListener(editText -> editText.setInputType(
- InputType.TYPE_CLASS_NUMBER));
+ bindPositiveIntegerPreference(accelSamples, R.string.settings_value_positive_integer);
epsilon = findPreference("epsilon");
- epsilon.setOnBindEditTextListener(editText -> editText.setInputType(
- InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL));
+ bindNonNegativeDecimalPreference(epsilon, R.string.settings_value_non_negative_number);
accelFilter = findPreference("accel_filter");
- accelFilter.setOnBindEditTextListener(editText -> editText.setInputType(
- InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL));
+ bindUnitIntervalPreference(accelFilter, R.string.settings_value_unit_interval);
wifiInterval = findPreference("wifi_interval");
- wifiInterval.setOnBindEditTextListener(editText -> editText.setInputType(
- InputType.TYPE_CLASS_NUMBER));
+ bindPositiveIntegerPreference(wifiInterval, R.string.settings_value_positive_integer);
+ }
+
+ private void bindPositiveIntegerPreference(EditTextPreference preference, @StringRes int errorResId) {
+ if (preference == null) return;
+ preference.setOnBindEditTextListener(editText -> editText.setInputType(InputType.TYPE_CLASS_NUMBER));
+ preference.setOnPreferenceChangeListener((pref, newValue) ->
+ validatePositiveInteger(newValue, errorResId));
+ }
+
+ private void bindPositiveDecimalPreference(EditTextPreference preference, @StringRes int errorResId) {
+ if (preference == null) return;
+ preference.setOnBindEditTextListener(editText -> editText.setInputType(
+ InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL));
+ preference.setOnPreferenceChangeListener((pref, newValue) ->
+ validatePositiveDecimal(newValue, errorResId));
+ }
+
+ private void bindNonNegativeDecimalPreference(EditTextPreference preference, @StringRes int errorResId) {
+ if (preference == null) return;
+ preference.setOnBindEditTextListener(editText -> editText.setInputType(
+ InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL));
+ preference.setOnPreferenceChangeListener((pref, newValue) ->
+ validateNonNegativeDecimal(newValue, errorResId));
+ }
+
+ private void bindUnitIntervalPreference(EditTextPreference preference, @StringRes int errorResId) {
+ if (preference == null) return;
+ preference.setOnBindEditTextListener(editText -> editText.setInputType(
+ InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL));
+ preference.setOnPreferenceChangeListener((pref, newValue) ->
+ validateUnitInterval(newValue, errorResId));
+ }
+
+ private boolean validatePositiveInteger(Object newValue, @StringRes int errorResId) {
+ try {
+ return Integer.parseInt(String.valueOf(newValue).trim()) > 0 || rejectValue(errorResId);
+ } catch (RuntimeException e) {
+ return rejectValue(errorResId);
+ }
+ }
+
+ private boolean validatePositiveDecimal(Object newValue, @StringRes int errorResId) {
+ try {
+ return Float.parseFloat(String.valueOf(newValue).trim()) > 0f || rejectValue(errorResId);
+ } catch (RuntimeException e) {
+ return rejectValue(errorResId);
+ }
+ }
+
+ private boolean validateNonNegativeDecimal(Object newValue, @StringRes int errorResId) {
+ try {
+ return Float.parseFloat(String.valueOf(newValue).trim()) >= 0f || rejectValue(errorResId);
+ } catch (RuntimeException e) {
+ return rejectValue(errorResId);
+ }
+ }
+
+ private boolean validateUnitInterval(Object newValue, @StringRes int errorResId) {
+ try {
+ float value = Float.parseFloat(String.valueOf(newValue).trim());
+ return (value >= 0f && value <= 1f) || rejectValue(errorResId);
+ } catch (RuntimeException e) {
+ return rejectValue(errorResId);
+ }
+ }
+ private boolean rejectValue(@StringRes int errorResId) {
+ Toast.makeText(requireContext(), getString(errorResId), Toast.LENGTH_SHORT).show();
+ return false;
}
-}
\ No newline at end of file
+}
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..14fd8e0d 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,7 +22,6 @@
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;
/**
* A simple {@link Fragment} subclass. The startLocation fragment is displayed before the trajectory
@@ -46,10 +45,6 @@ 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;
/**
* Public Constructor for the class.
@@ -72,8 +67,24 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container,
}
View rootView = inflater.inflate(R.layout.fragment_startlocation, container, false);
- // Obtain the start position from the GPS data from the SensorFusion class
- startPosition = sensorFusion.getGNSSLatitude(false);
+ // Prefer fresh Wi-Fi anchor first; fallback to GNSS when Wi-Fi is unavailable.
+ LatLng latestWifi = sensorFusion.getLatestWifiLatLng();
+ LatLng latestGnss = sensorFusion.getLatestGnssLatLng();
+ boolean wifiFresh = latestWifi != null && sensorFusion.isLatestWifiFresh();
+ boolean gnssFresh = latestGnss != null && sensorFusion.isLatestGnssFresh();
+ if (wifiFresh) {
+ startPosition[0] = (float) latestWifi.latitude;
+ startPosition[1] = (float) latestWifi.longitude;
+ } else if (gnssFresh) {
+ startPosition[0] = (float) latestGnss.latitude;
+ startPosition[1] = (float) latestGnss.longitude;
+ } else {
+ startPosition = sensorFusion.getGNSSLatitude(false);
+ if ((startPosition[0] == 0f && startPosition[1] == 0f) && latestWifi != null) {
+ startPosition[0] = (float) latestWifi.latitude;
+ startPosition[1] = (float) latestWifi.longitude;
+ }
+ }
// If no location found, zoom the map out
if (startPosition[0] == 0 && startPosition[1] == 0) {
zoom = 1f;
@@ -102,12 +113,9 @@ public void onMapReady(GoogleMap mMap) {
mMap.getUiSettings().setRotateGesturesEnabled(true);
mMap.getUiSettings().setScrollGesturesEnabled(true);
- // *** FIX: Clear any existing markers so the start marker isnโt duplicated ***
+ // *** 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();
+ // Assignment 2: no local indoor bitmap overlay, only API/Google map.
// Add a marker at the current GPS location and move the camera
position = new LatLng(startPosition[0], startPosition[1]);
@@ -169,9 +177,9 @@ public void onClick(View view) {
// If the Activity is RecordingActivity
if (requireActivity() instanceof RecordingActivity) {
- // Start sensor recording + set the start location
- sensorFusion.startRecording();
+ // Set the start location first, then start recording with this origin.
sensorFusion.setStartGNSSLatitude(startPosition);
+ sensorFusion.startRecording();
// Now switch to the recording screen
((RecordingActivity) requireActivity()).showRecordingScreen();
@@ -191,16 +199,4 @@ public void onClick(View view) {
});
}
- /**
- * 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);
- }
- }
}
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..608516fb 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java
@@ -1,97 +1,193 @@
package com.openpositioning.PositionMe.presentation.fragment;
+import android.app.AlertDialog;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Rect;
import android.os.Bundle;
-import android.util.Log;
+import android.text.InputType;
+import android.util.SparseArray;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageButton;
import android.widget.Spinner;
-import com.google.android.material.switchmaterial.SwitchMaterial;
+import android.widget.TextView;
+import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
-import com.google.android.gms.maps.OnMapReadyCallback;
-import com.openpositioning.PositionMe.R;
-import com.openpositioning.PositionMe.sensors.SensorFusion;
-import com.openpositioning.PositionMe.utils.IndoorMapManager;
-import com.openpositioning.PositionMe.utils.UtilFunctions;
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.*;
+import com.google.android.gms.maps.model.BitmapDescriptor;
+import com.google.android.gms.maps.model.BitmapDescriptorFactory;
+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.Polyline;
+import com.google.android.gms.maps.model.PolylineOptions;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import com.google.android.material.switchmaterial.SwitchMaterial;
+import com.openpositioning.PositionMe.R;
+import com.openpositioning.PositionMe.presentation.display.DisplayObservationType;
+import com.openpositioning.PositionMe.presentation.display.ExponentialLatLngSmoother;
+import com.openpositioning.PositionMe.presentation.display.MapControlPanelController;
+import com.openpositioning.PositionMe.presentation.display.ObservationMarkerFactory;
+import com.openpositioning.PositionMe.utils.BuildingMapController;
+import com.openpositioning.PositionMe.utils.UtilFunctions;
+import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
-
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
/**
- * 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
+ * New code guide:
+ * 1. Observation marker queues and smoothing controls.
+ * 2. Indoor map floor sync with SensorFusion.
+ * 3. Fused trajectory rendering and compaction.
+ * 4. Numbered test-point markers and reset helpers.
*/
+public class TrajectoryMapFragment extends Fragment implements OnMapReadyCallback {
+ private static final int DEFAULT_MAX_OBSERVATION_MARKERS = 3;
+ private static final long FLOOR_SYNC_MIN_INTERVAL_MS = 250L;
+ private static final double TRAJECTORY_RENDER_MIN_DISTANCE_METERS = 0.08;
+ private static final int MAX_TRAJECTORY_RENDER_POINTS = 2400;
+ private static final int TARGET_TRAJECTORY_RENDER_POINTS = 1600;
+ private static final int TRAJECTORY_RECENT_TAIL_POINTS = 160;
+ private static final int MAX_PENDING_OBSERVATION_UPDATES = 48;
+ private static final int MAX_OBSERVATION_UPDATES_PER_FLUSH = 8;
+ private static final float OBSERVATION_HISTORY_MIN_ALPHA = 0.28f;
+
+ private GoogleMap gMap;
+ private LatLng currentLocation;
+ private Marker orientationMarker;
+ private Marker bestEstimateDotMarker;
+ private Marker gnssMarker;
+ private Polyline fusedPolyline;
+ private boolean isRed = true;
+ private boolean isGnssOn = true;
+
+ private LatLng pendingCameraPosition = null;
+ private boolean hasPendingCameraMove = false;
+
+ private BuildingMapController mapController;
-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
-
- private Polyline gnssPolyline; // Polyline for GNSS path
- private LatLng lastGnssLocation = null; // Stores the last GNSS location
-
- private LatLng pendingCameraPosition = null; // Stores pending camera movement
- private boolean hasPendingCameraMove = false; // Tracks if camera needs to move
-
- private IndoorMapManager indoorMapManager; // Manages indoor mapping
- private SensorFusion sensorFusion;
-
-
- // UI
private Spinner switchMapSpinner;
-
+ private Spinner observationCountSpinner;
+ private View mapControlList;
+ private ImageButton mapControlToggleButton;
private SwitchMaterial gnssSwitch;
+ private SwitchMaterial wifiObservationSwitch;
+ private SwitchMaterial pdrObservationSwitch;
private SwitchMaterial autoFloorSwitch;
+ private SwitchMaterial smoothDisplaySwitch;
- private com.google.android.material.floatingactionbutton.FloatingActionButton floorUpButton, floorDownButton;
+ private FloatingActionButton floorUpButton, floorDownButton;
private Button switchColorButton;
- private Polygon buildingPolygon;
-
+ private TextView currentFloorIndicator;
+ private View floorControlCard;
+
+ // --- Auto Floor Logic ---
+ private static final boolean DEFAULT_SMOOTH_DISPLAY_ENABLED = true;
+ private boolean isAutoFloorEnabled = true;
+ private boolean smoothDisplayEnabled = DEFAULT_SMOOTH_DISPLAY_ENABLED;
+ private int currentFloorValue = 0;
+ private int manualFloorOffset = 0;
+ private static final double FLOOR_HEIGHT_STEP = 4.0;
+
+ private static final int MIN_FLOOR_VAL = -1; // BF
+ private static final int MAX_FLOOR_VAL = 3; // 3F
+ private int maxObservationMarkers = DEFAULT_MAX_OBSERVATION_MARKERS;
+ private boolean isWifiObservationOn = true;
+ private boolean isPdrObservationOn = true;
+ // Keeps recent observation markers bounded so the map stays readable.
+ private final ArrayDeque gnssObservationMarkers = new ArrayDeque<>();
+ private final ArrayDeque wifiObservationMarkers = new ArrayDeque<>();
+ private final ArrayDeque pdrObservationMarkers = new ArrayDeque<>();
+ private final ArrayDeque pendingObservationUpdates = new ArrayDeque<>();
+ private final List fusedPathPoints = new ArrayList<>();
+ private final List pendingTagLocations = new ArrayList<>();
+ private final List pendingTagIndices = new ArrayList<>();
+ private final SparseArray numberedTagIconCache = new SparseArray<>();
+ private ObservationMarkerFactory observationMarkerFactory;
+ private MapControlPanelController mapControlPanelController;
+ private final ExponentialLatLngSmoother replaySmoother = new ExponentialLatLngSmoother(0.65); // was 0.25 โ matched to DataDisplayController smoother (0.78) for consistent smooth behaviour
+ private boolean floorSelectionSyncArmed = false;
+ private boolean sensorFloorSyncInProgress = false;
+ private long lastFloorSyncAttemptTimeMs = 0L;
+ private int lastRequestedSensorFloor = Integer.MIN_VALUE;
+
+ private static final class PendingObservationMarker {
+ private final DisplayObservationType type;
+ private final LatLng point;
+
+ private PendingObservationMarker(@NonNull DisplayObservationType type, @NonNull LatLng point) {
+ this.type = type;
+ this.point = point;
+ }
+ }
- public TrajectoryMapFragment() {
- // Required empty public constructor
+ // Creates cached numbered icons for assignment test-point markers.
+ private BitmapDescriptor createNumberedMarkerIcon(int number) {
+ BitmapDescriptor cached = numberedTagIconCache.get(number);
+ if (cached != null) {
+ return cached;
+ }
+ final int size = 56;
+ Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+
+ Paint circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ circlePaint.setStyle(Paint.Style.FILL);
+ circlePaint.setColor(0xFF2196F3);
+
+ float cx = size / 2f;
+ float cy = size / 2f;
+ float r = size / 2f;
+ canvas.drawCircle(cx, cy, r, circlePaint);
+
+ Paint strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ strokePaint.setStyle(Paint.Style.STROKE);
+ strokePaint.setStrokeWidth(3f);
+ strokePaint.setColor(0xFFFFFFFF);
+ canvas.drawCircle(cx, cy, r - 3f, strokePaint);
+
+ Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ textPaint.setColor(0xFFFFFFFF);
+ textPaint.setTextAlign(Paint.Align.CENTER);
+ textPaint.setFakeBoldText(true);
+ textPaint.setTextSize(25f);
+
+ String text = String.valueOf(number);
+ Rect textBounds = new Rect();
+ textPaint.getTextBounds(text, 0, text.length(), textBounds);
+ float textY = cy + textBounds.height() / 2f;
+ canvas.drawText(text, cx, textY, textPaint);
+
+ BitmapDescriptor descriptor = BitmapDescriptorFactory.fromBitmap(bitmap);
+ numberedTagIconCache.put(number, descriptor);
+ return descriptor;
}
+ public TrajectoryMapFragment() {}
+
@Nullable
@Override
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,150 +196,250 @@ public void onViewCreated(@NonNull View view,
@Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
- // Grab references to UI controls
switchMapSpinner = view.findViewById(R.id.mapSwitchSpinner);
+ observationCountSpinner = view.findViewById(R.id.observationCountSpinner);
+ mapControlList = view.findViewById(R.id.mapControlList);
+ mapControlToggleButton = view.findViewById(R.id.mapControlToggleButton);
gnssSwitch = view.findViewById(R.id.gnssSwitch);
+ wifiObservationSwitch = view.findViewById(R.id.wifiObservationSwitch);
+ pdrObservationSwitch = view.findViewById(R.id.pdrObservationSwitch);
autoFloorSwitch = view.findViewById(R.id.autoFloor);
+ smoothDisplaySwitch = view.findViewById(R.id.smoothDisplaySwitch);
floorUpButton = view.findViewById(R.id.floorUpButton);
floorDownButton = view.findViewById(R.id.floorDownButton);
switchColorButton = view.findViewById(R.id.lineColorButton);
+ floorControlCard = view.findViewById(R.id.floorControlCard);
+ currentFloorIndicator = view.findViewById(R.id.currentFloorIndicator);
+ observationMarkerFactory = new ObservationMarkerFactory(requireContext());
+ if (mapControlList != null && mapControlToggleButton != null) {
+ mapControlPanelController = new MapControlPanelController(mapControlList, mapControlToggleButton);
+ mapControlPanelController.setCollapsed(true);
+ }
- // Setup floor up/down UI hidden initially until we know there's an indoor map
- setFloorControlsVisibility(View.GONE);
+ currentFloorValue = 0;
+ updateFloorIndicatorUI(0);
+ updateManualFloorControlsVisibility();
- // Initialize the map asynchronously
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
- if (hasPendingCameraMove && pendingCameraPosition != null) {
- gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(pendingCameraPosition, 19f));
- hasPendingCameraMove = false;
- pendingCameraPosition = null;
- }
+ mapFragment.getMapAsync(this);
+ }
+
+ initMapTypeSpinner();
+ initObservationCountSpinner();
+
+ if (gnssSwitch != null) {
+ gnssSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ isGnssOn = isChecked;
+ if (gnssMarker != null) {
+ gnssMarker.setVisible(isChecked);
+ }
+ setMarkerCollectionVisible(gnssObservationMarkers, isChecked);
+ });
+ }
- drawBuildingPolygon();
+ if (wifiObservationSwitch != null) {
+ wifiObservationSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ isWifiObservationOn = isChecked;
+ setMarkerCollectionVisible(wifiObservationMarkers, isChecked);
+ });
+ }
- Log.d("TrajectoryMapFragment", "onMapReady: Map is ready!");
+ if (pdrObservationSwitch != null) {
+ pdrObservationSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ isPdrObservationOn = isChecked;
+ setMarkerCollectionVisible(pdrObservationMarkers, isChecked);
+ });
+ }
+ if (smoothDisplaySwitch != null) {
+ smoothDisplaySwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ smoothDisplayEnabled = isChecked;
+ if (!isChecked) {
+ replaySmoother.reset();
+ }
+ });
+ smoothDisplaySwitch.setChecked(smoothDisplayEnabled);
+ }
+ if (switchColorButton != null) {
+ switchColorButton.setOnClickListener(v -> {
+ if (fusedPolyline != null) {
+ if (isRed) {
+ switchColorButton.setBackgroundColor(Color.BLACK);
+ fusedPolyline.setColor(Color.BLACK);
+ isRed = false;
+ } else {
+ switchColorButton.setBackgroundColor(Color.RED);
+ fusedPolyline.setColor(Color.RED);
+ isRed = true;
+ }
}
});
}
- // Map type spinner setup
- initMapTypeSpinner();
+ if (autoFloorSwitch != null) {
+ autoFloorSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> {
+ isAutoFloorEnabled = isChecked;
+ if (isChecked) {
+ Toast.makeText(requireContext(), "Auto Floor: ON", Toast.LENGTH_SHORT).show();
+ manualFloorOffset = 0;
+ } else {
+ com.openpositioning.PositionMe.sensors.SensorFusion
+ .getInstance()
+ .consumePendingFloorDelta();
+ }
+ updateManualFloorControlsVisibility();
+ });
+ autoFloorSwitch.setChecked(true);
+ }
+
+ if (floorUpButton != null) {
+ floorUpButton.setOnClickListener(v -> {
+ if (mapController != null) {
+ floorSelectionSyncArmed = true;
+ manualFloorOffset++;
+ mapController.changeFloor(1);
+ }
+ });
+ }
+
+ if (floorDownButton != null) {
+ floorDownButton.setOnClickListener(v -> {
+ if (mapController != null) {
+ floorSelectionSyncArmed = true;
+ manualFloorOffset--;
+ mapController.changeFloor(-1);
+ }
+ });
+ }
+ }
+
+ public void updateElevation() {
+ if (!isAutoFloorEnabled || mapController == null) {
+ return;
+ }
+ syncMapFloorToSensorFusion(false);
+ }
- // GNSS Switch
- gnssSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
- isGnssOn = isChecked;
- if (!isChecked && gnssMarker != null) {
- gnssMarker.remove();
- gnssMarker = null;
+ private String getFloorLabel(int val) {
+ if (val == -1) return "BF";
+ if (val == 0) return "GF";
+ return val + "F";
+ }
+
+ private void updateFloorIndicatorUI(int floorVal) {
+ if (currentFloorIndicator != null) {
+ String label = getFloorLabel(floorVal);
+ currentFloorIndicator.setText("Floor: " + label);
+ }
+ }
+
+ @Override
+ public void onMapReady(@NonNull GoogleMap googleMap) {
+ gMap = googleMap;
+ initMapSettings(gMap);
+
+ mapController = new BuildingMapController(requireContext(), gMap);
+
+ mapController.setSelectionListener((buildingName, floorCode) -> {
+ updateManualFloorControlsVisibility();
+
+ int val = parseFloorCode(floorCode);
+ currentFloorValue = val;
+ updateFloorIndicatorUI(val);
+
+ if (!isAutoFloorEnabled) {
+ manualFloorOffset = val;
}
- });
- // Color switch
- switchColorButton.setOnClickListener(v -> {
- if (polyline != null) {
- if (isRed) {
- switchColorButton.setBackgroundColor(Color.BLACK);
- polyline.setColor(Color.BLACK);
- isRed = false;
- } else {
- switchColorButton.setBackgroundColor(Color.RED);
- polyline.setColor(Color.RED);
- isRed = true;
+ // Keep SensorFusion floor state aligned with the selected map floor.
+ if (mapController != null) {
+ com.openpositioning.PositionMe.sensors.SensorFusion sensorFusion =
+ com.openpositioning.PositionMe.sensors.SensorFusion.getInstance();
+ sensorFusion
+ .setAvailableFloorPlans(mapController.getSelectedFloorPlanMap());
+ sensorFusion
+ .setCurrentFloorPlan(mapController.getCurrentFloorPlan());
+ if (floorSelectionSyncArmed) {
+ sensorFusion
+ .setCurrentFloorByMapMatching(val);
+ }
+ floorSelectionSyncArmed = false;
+ if (!sensorFloorSyncInProgress && isAutoFloorEnabled) {
+ syncMapFloorToSensorFusion(true);
}
}
});
- // Floor up/down logic
- autoFloorSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> {
+ gMap.setOnPolygonClickListener(polygon -> mapController.onPolygonClick(polygon));
- //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);
- });
+ if (hasPendingCameraMove && pendingCameraPosition != null) {
+ gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(pendingCameraPosition, 19f));
+ hasPendingCameraMove = false;
+ if (mapController != null) {
+ mapController.downloadNearbyBuildings(pendingCameraPosition);
+ }
+ pendingCameraPosition = null;
+ } else {
+ LatLng defaultLoc = new LatLng(55.92330, -3.17450);
+ gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(defaultLoc, 18f));
+ if (mapController != null) {
+ mapController.downloadNearbyBuildings(defaultLoc);
+ }
+ }
+ flushPendingTags();
+ flushPendingObservationMarkers();
+ }
- floorUpButton.setOnClickListener(v -> {
- // If user manually changes floor, turn off auto floor
- autoFloorSwitch.setChecked(false);
- if (indoorMapManager != null) {
- indoorMapManager.increaseFloor();
+ // Normalizes API floor labels into the local integer floor model.
+ private int parseFloorCode(String code) {
+ if (code == null) return 0;
+ String raw = code.toUpperCase().trim();
+
+ if (raw.equals("BF") || raw.equals("B")) return -1;
+ if (raw.equals("GF") || raw.equals("G") || raw.equals("0") || raw.contains("GROUND")) return 0;
+ if (raw.contains("BASEMENT") || raw.contains("BF")) return -1;
+
+ if (raw.startsWith("B")) {
+ String digits = raw.replaceAll("[^0-9]", "");
+ if (!digits.isEmpty()) {
+ try {
+ return -Integer.parseInt(digits);
+ } catch (Exception ignored) {
+ }
}
- });
+ return -1;
+ }
- floorDownButton.setOnClickListener(v -> {
- autoFloorSwitch.setChecked(false);
- if (indoorMapManager != null) {
- indoorMapManager.decreaseFloor();
+ try {
+ Matcher matcher = Pattern.compile("-?\\d+").matcher(raw);
+ if (matcher.find()) {
+ return Integer.parseInt(matcher.group());
}
- });
- }
+ } catch (Exception ignored) {
+ }
- /**
- * 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
- */
+ return 0;
+ }
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()
+ fusedPolyline = map.addPolyline(new PolylineOptions()
.color(Color.RED)
.width(5f)
- .add() // start empty
- );
-
- // GNSS path in blue
- gnssPolyline = map.addPolyline(new PolylineOptions()
- .color(Color.BLUE)
- .width(5f)
- .add() // start empty
+ .zIndex(200f)
+ .add()
);
+ fusedPathPoints.clear();
}
-
- /**
- * 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,10 +456,9 @@ 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){
+ switch (position) {
case 0:
gMap.setMapType(GoogleMap.MAP_TYPE_HYBRID);
break;
@@ -275,267 +470,498 @@ public void onItemSelected(AdapterView> parent, View view,
break;
}
}
+
@Override
- public void onNothingSelected(AdapterView> parent) {}
+ public void onNothingSelected(AdapterView> parent) {
+ }
});
}
+ private void initObservationCountSpinner() {
+ if (observationCountSpinner == null) return;
+ String[] countOptions = new String[]{"1", "2", "3", getString(R.string.custom_observation_count)};
+ ArrayAdapter adapter = new ArrayAdapter<>(
+ requireContext(),
+ android.R.layout.simple_spinner_dropdown_item,
+ countOptions
+ );
+ observationCountSpinner.setAdapter(adapter);
+ observationCountSpinner.setSelection(2, false);
- /**
- * 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;
+ observationCountSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ if (position >= 0 && position <= 2) {
+ setMaxObservationMarkers(position + 1);
+ } else {
+ showCustomObservationCountDialog();
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {
+ }
+ });
+ }
+ private void showCustomObservationCountDialog() {
+ EditText input = new EditText(requireContext());
+ input.setInputType(InputType.TYPE_CLASS_NUMBER);
+ input.setHint(getString(R.string.custom_observation_count_hint));
+ input.setText(String.valueOf(maxObservationMarkers));
+
+ new AlertDialog.Builder(requireContext())
+ .setTitle(getString(R.string.custom_observation_count_title))
+ .setView(input)
+ .setPositiveButton(getString(R.string.ok), (dialog, which) -> {
+ String value = input.getText() == null ? "" : input.getText().toString().trim();
+ if (value.isEmpty()) return;
+ try {
+ int parsed = Integer.parseInt(value);
+ if (parsed <= 0) {
+ Toast.makeText(requireContext(), getString(R.string.custom_observation_count_invalid), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ setMaxObservationMarkers(parsed);
+ } catch (NumberFormatException e) {
+ Toast.makeText(requireContext(), getString(R.string.custom_observation_count_invalid), Toast.LENGTH_SHORT).show();
+ }
+ })
+ .setNegativeButton(getString(R.string.cancel), (dialog, which) -> dialog.dismiss())
+ .show();
+ }
+ private void setMaxObservationMarkers(int count) {
+ maxObservationMarkers = count;
+ trimObservationQueue(gnssObservationMarkers, getMaxObservationMarkersForType(DisplayObservationType.GNSS));
+ trimObservationQueue(wifiObservationMarkers, getMaxObservationMarkersForType(DisplayObservationType.WIFI));
+ trimObservationQueue(pdrObservationMarkers, getMaxObservationMarkersForType(DisplayObservationType.PDR));
+ refreshObservationQueueStyle(DisplayObservationType.GNSS, gnssObservationMarkers);
+ refreshObservationQueueStyle(DisplayObservationType.WIFI, wifiObservationMarkers);
+ refreshObservationQueueStyle(DisplayObservationType.PDR, pdrObservationMarkers);
+ }
+ private void trimObservationQueue(ArrayDeque queue, int limit) {
+ while (queue.size() > limit) {
+ Marker oldest = queue.removeFirst();
+ if (oldest != null) oldest.remove();
+ }
+ }
+
+ private int getMaxObservationMarkersForType(@NonNull DisplayObservationType type) {
+ return maxObservationMarkers;
+ }
+
+ // Draws the current fused position and the heading marker used in live display.
+ public void renderFusedPosition(@NonNull LatLng newLocation, float orientation, boolean moveCamera) {
+ if (gMap == null || observationMarkerFactory == null) return;
- // Keep track of current location
- LatLng oldLocation = this.currentLocation;
this.currentLocation = newLocation;
+ float normalizedOrientation = normalizeMarkerRotationDegrees(orientation);
- // If no marker, create it
if (orientationMarker == null) {
orientationMarker = gMap.addMarker(new MarkerOptions()
.position(newLocation)
.flat(true)
- .title("Current Position")
+ .anchor(0.5f, 0.5f)
+ .title("Best Estimate")
+ .zIndex(300f)
.icon(BitmapDescriptorFactory.fromBitmap(
UtilFunctions.getBitmapFromVector(requireContext(),
R.drawable.ic_baseline_navigation_24)))
);
+ if (orientationMarker != null) {
+ orientationMarker.setRotation(normalizedOrientation);
+ }
gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(newLocation, 19f));
} else {
- // Update marker position + orientation
orientationMarker.setPosition(newLocation);
- orientationMarker.setRotation(orientation);
- // Move camera a bit
- gMap.moveCamera(CameraUpdateFactory.newLatLng(newLocation));
+ orientationMarker.setRotation(normalizedOrientation);
+ if (moveCamera) {
+ gMap.moveCamera(CameraUpdateFactory.newLatLng(newLocation));
+ }
+ }
+ if (observationMarkerFactory != null) {
+ if (bestEstimateDotMarker == null) {
+ bestEstimateDotMarker = gMap.addMarker(new MarkerOptions()
+ .position(newLocation)
+ .anchor(0.5f, 0.5f)
+ .zIndex(320f)
+ .icon(observationMarkerFactory.getBestEstimateDotIcon()));
+ } else {
+ bestEstimateDotMarker.setPosition(newLocation);
+ }
+ }
+ // Assignment 2 requires API map rendering only; local indoor overlays are disabled.
+ }
+
+ // Backward-compatible wrapper for existing callers
+ public void updateUserLocation(@NonNull LatLng newLocation, float orientation) {
+ LatLng displayPoint = newLocation;
+ if (smoothDisplayEnabled) {
+ displayPoint = replaySmoother.filter(newLocation);
+ } else {
+ replaySmoother.reset(newLocation);
+ }
+ renderFusedPosition(displayPoint, orientation, true);
+ appendFusedTrajectoryPoint(displayPoint);
+ }
+ public void appendFusedTrajectoryPoint(@NonNull LatLng point) {
+ if (gMap == null || fusedPolyline == null) return;
+ if (fusedPathPoints.isEmpty()) {
+ fusedPathPoints.add(point);
+ fusedPolyline.setPoints(fusedPathPoints);
+ return;
+ }
+ LatLng lastPoint = fusedPathPoints.get(fusedPathPoints.size() - 1);
+ if (UtilFunctions.distanceBetweenPoints(lastPoint, point) < TRAJECTORY_RENDER_MIN_DISTANCE_METERS) {
+ fusedPathPoints.set(fusedPathPoints.size() - 1, point);
+ } else {
+ fusedPathPoints.add(point);
+ compactTrajectoryIfNeeded();
+ }
+ fusedPolyline.setPoints(fusedPathPoints);
+ }
+
+ private void compactTrajectoryIfNeeded() {
+ int currentSize = fusedPathPoints.size();
+ if (currentSize <= MAX_TRAJECTORY_RENDER_POINTS) {
+ return;
+ }
+ int overflow = currentSize - TARGET_TRAJECTORY_RENDER_POINTS;
+ if (overflow <= 0) {
+ return;
+ }
+ int removable = Math.max(0, fusedPathPoints.size() - TRAJECTORY_RECENT_TAIL_POINTS - 1);
+ int removeCount = Math.min(overflow, removable);
+ if (removeCount <= 0) {
+ return;
+ }
+ fusedPathPoints.subList(0, removeCount).clear();
+ }
+
+ public void addTagPoint(@NonNull LatLng latLng, int index) {
+ if (gMap == null) {
+ pendingTagLocations.add(latLng);
+ pendingTagIndices.add(index);
+ return;
+ }
+ gMap.addMarker(new MarkerOptions()
+ .position(latLng)
+ .anchor(0.5f, 0.5f)
+ .zIndex(300f)
+ .icon(createNumberedMarkerIcon(index)));
+ }
+
+ // Buffers marker updates so frequent sensor callbacks do not overload the UI thread.
+ public void enqueueObservationMarker(@NonNull DisplayObservationType type, @NonNull LatLng point) {
+ if (pendingObservationUpdates.size() >= MAX_PENDING_OBSERVATION_UPDATES) {
+ pendingObservationUpdates.removeFirst();
+ }
+ pendingObservationUpdates.addLast(new PendingObservationMarker(type, point));
+ }
+
+ public void addObservationMarker(@NonNull DisplayObservationType type, @NonNull LatLng point) {
+ enqueueObservationMarker(type, point);
+ flushPendingObservationMarkers();
+ }
+
+ public void flushPendingObservationMarkers() {
+ if (gMap == null || observationMarkerFactory == null || pendingObservationUpdates.isEmpty()) {
+ return;
+ }
+ int processed = 0;
+ while (!pendingObservationUpdates.isEmpty() && processed < MAX_OBSERVATION_UPDATES_PER_FLUSH) {
+ PendingObservationMarker pendingMarker = pendingObservationUpdates.removeFirst();
+ applyObservationMarker(pendingMarker.type, pendingMarker.point);
+ processed++;
+ }
+ }
+
+ private void applyObservationMarker(@NonNull DisplayObservationType type, @NonNull LatLng point) {
+ ArrayDeque targetQueue = getObservationQueue(type);
+ Marker marker = obtainReusableObservationMarker(type, point, targetQueue);
+ if (marker != null) {
+ targetQueue.addLast(marker);
+ refreshObservationQueueStyle(type, targetQueue);
+ }
+
+ if (type == DisplayObservationType.GNSS) {
+ if (gnssMarker == null) {
+ gnssMarker = gMap.addMarker(new MarkerOptions()
+ .position(point)
+ .title("Latest GNSS")
+ .zIndex(260f)
+ .visible(isGnssOn)
+ .icon(observationMarkerFactory.getObservationIcon(DisplayObservationType.GNSS)));
+ } else {
+ gnssMarker.setPosition(point);
+ gnssMarker.setVisible(isGnssOn);
+ }
+ }
+ }
+
+ private void refreshObservationQueueStyle(@NonNull DisplayObservationType type,
+ @NonNull ArrayDeque targetQueue) {
+ int size = targetQueue.size();
+ if (size <= 0) {
+ return;
+ }
+ int index = 0;
+ for (Marker queuedMarker : targetQueue) {
+ if (queuedMarker == null) {
+ index++;
+ continue;
+ }
+ float progress = size <= 1 ? 1f : (index + 1f) / size;
+ float alpha = OBSERVATION_HISTORY_MIN_ALPHA
+ + (1f - OBSERVATION_HISTORY_MIN_ALPHA) * progress;
+ queuedMarker.setAlpha(alpha);
+ queuedMarker.setZIndex(236f + index);
+ queuedMarker.setVisible(isObservationVisible(type));
+ index++;
}
+ }
+
+ private ArrayDeque getObservationQueue(@NonNull DisplayObservationType type) {
+ switch (type) {
+ case GNSS:
+ return gnssObservationMarkers;
+ case WIFI:
+ return wifiObservationMarkers;
+ default:
+ return pdrObservationMarkers;
+ }
+ }
- // Extend polyline if movement occurred
- if (oldLocation != null && !oldLocation.equals(newLocation) && polyline != null) {
- List points = new ArrayList<>(polyline.getPoints());
- points.add(newLocation);
- polyline.setPoints(points);
+ private Marker obtainReusableObservationMarker(@NonNull DisplayObservationType type,
+ @NonNull LatLng point,
+ @NonNull ArrayDeque targetQueue) {
+ Marker marker = null;
+ int limit = getMaxObservationMarkersForType(type);
+ if (targetQueue.size() >= limit) {
+ marker = targetQueue.removeFirst();
+ }
+ if (marker == null) {
+ marker = gMap.addMarker(new MarkerOptions()
+ .position(point)
+ .anchor(0.5f, 0.5f)
+ .zIndex(240f)
+ .alpha(1f)
+ .visible(isObservationVisible(type))
+ .icon(observationMarkerFactory.getObservationIcon(type)));
+ } else {
+ marker.setPosition(point);
+ marker.setVisible(isObservationVisible(type));
+ marker.setAlpha(1f);
+ marker.setZIndex(240f);
+ }
+ return marker;
+ }
+ private boolean isObservationVisible(@NonNull DisplayObservationType type) {
+ switch (type) {
+ case GNSS:
+ return isGnssOn;
+ case WIFI:
+ return isWifiObservationOn;
+ default:
+ return isPdrObservationOn;
}
+ }
- // Update indoor map overlay
- if (indoorMapManager != null) {
- indoorMapManager.setCurrentLocation(newLocation);
- setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE);
+ // Keeps the displayed indoor floor aligned with the floor chosen by fusion logic.
+ private void syncMapFloorToSensorFusion(boolean force) {
+ if (mapController == null) {
+ return;
+ }
+ com.openpositioning.PositionMe.sensors.SensorFusion sensorFusion =
+ com.openpositioning.PositionMe.sensors.SensorFusion.getInstance();
+ int sensorFloor = sensorFusion.getCurrentFloorByMapMatching();
+ int mapFloor = mapController.getCurrentFloorValue();
+ long nowMs = System.currentTimeMillis();
+ if (!force
+ && sensorFloor == lastRequestedSensorFloor
+ && sensorFloor == mapFloor
+ && (nowMs - lastFloorSyncAttemptTimeMs) < FLOOR_SYNC_MIN_INTERVAL_MS) {
+ return;
+ }
+ lastRequestedSensorFloor = sensorFloor;
+ lastFloorSyncAttemptTimeMs = nowMs;
+ if (sensorFloor == mapFloor) {
+ return;
+ }
+ sensorFloorSyncInProgress = true;
+ try {
+ mapController.setFloorByValue(sensorFloor);
+ } finally {
+ sensorFloorSyncInProgress = false;
}
}
+ private void setMarkerCollectionVisible(ArrayDeque markers, boolean visible) {
+ for (Marker marker : markers) {
+ if (marker != null) {
+ marker.setVisible(visible);
+ }
+ }
+ }
+ private void flushPendingTags() {
+ if (gMap == null || pendingTagLocations.isEmpty()) {
+ return;
+ }
+ int count = Math.min(pendingTagLocations.size(), pendingTagIndices.size());
+ for (int i = 0; i < count; i++) {
+ LatLng latLng = pendingTagLocations.get(i);
+ Integer index = pendingTagIndices.get(i);
+ if (latLng != null && index != null) {
+ gMap.addMarker(new MarkerOptions()
+ .position(latLng)
+ .anchor(0.5f, 0.5f)
+ .zIndex(300f)
+ .icon(createNumberedMarkerIcon(index)));
+ }
+ }
+ pendingTagLocations.clear();
+ pendingTagIndices.clear();
+ }
- /**
- * 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 void setInitialCameraPosition(@NonNull LatLng startLocation) {
- // If the map is already ready, move camera immediately
if (gMap != null) {
gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(startLocation, 19f));
} 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;
}
- /**
- * Called when we want to set or update the GNSS marker position
- */
+ public boolean isGnssEnabled() {
+ return isGnssOn;
+ }
+
+ public boolean isSmoothDisplayEnabled() {
+ return smoothDisplayEnabled;
+ }
public void updateGNSS(@NonNull LatLng gnssLocation) {
if (gMap == null) return;
- if (!isGnssOn) return;
-
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)));
- lastGnssLocation = gnssLocation;
+ .title("Latest GNSS")
+ .zIndex(260f)
+ .visible(isGnssOn)
+ .icon(observationMarkerFactory != null
+ ? observationMarkerFactory.getObservationIcon(DisplayObservationType.GNSS)
+ : BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE)));
} 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);
- gnssPolyline.setPoints(gnssPoints);
- }
- lastGnssLocation = gnssLocation;
+ gnssMarker.setVisible(isGnssOn);
}
}
-
- /**
- * Remove GNSS marker if user toggles it off
- */
public void clearGNSS() {
if (gnssMarker != null) {
gnssMarker.remove();
gnssMarker = null;
}
+ for (Marker marker : gnssObservationMarkers) {
+ if (marker != null) marker.remove();
+ }
+ gnssObservationMarkers.clear();
}
- /**
- * Whether user is currently showing GNSS or not
- */
- public boolean isGnssEnabled() {
- return isGnssOn;
+ private void setFloorControlsVisibility(int visibility) {
+ if (floorControlCard != null) {
+ floorControlCard.setVisibility(visibility);
+ }
}
- private void setFloorControlsVisibility(int visibility) {
- floorUpButton.setVisibility(visibility);
- floorDownButton.setVisibility(visibility);
- autoFloorSwitch.setVisibility(visibility);
+ private void updateManualFloorControlsVisibility() {
+ setFloorControlsVisibility(isAutoFloorEnabled ? View.GONE : View.VISIBLE);
}
+ // Clears all temporary map state so the next recording starts from a clean display.
public void clearMapAndReset() {
- if (polyline != null) {
- polyline.remove();
- polyline = null;
- }
- if (gnssPolyline != null) {
- gnssPolyline.remove();
- gnssPolyline = null;
+ if (fusedPolyline != null) {
+ fusedPolyline.remove();
+ fusedPolyline = null;
}
if (orientationMarker != null) {
orientationMarker.remove();
orientationMarker = null;
}
+ if (bestEstimateDotMarker != null) {
+ bestEstimateDotMarker.remove();
+ bestEstimateDotMarker = null;
+ }
if (gnssMarker != null) {
gnssMarker.remove();
gnssMarker = null;
}
- lastGnssLocation = null;
- currentLocation = null;
- // Re-create empty polylines with your chosen colors
+ clearObservationQueue(gnssObservationMarkers);
+ clearObservationQueue(wifiObservationMarkers);
+ clearObservationQueue(pdrObservationMarkers);
+ pendingObservationUpdates.clear();
+ fusedPathPoints.clear();
+ pendingTagLocations.clear();
+ pendingTagIndices.clear();
+ pendingCameraPosition = null;
+ hasPendingCameraMove = false;
+
+ currentLocation = null;
+
+ isAutoFloorEnabled = true;
+ smoothDisplayEnabled = DEFAULT_SMOOTH_DISPLAY_ENABLED;
+ isRed = true;
+ replaySmoother.reset();
+ floorSelectionSyncArmed = false;
+ sensorFloorSyncInProgress = false;
+ lastFloorSyncAttemptTimeMs = 0L;
+ lastRequestedSensorFloor = Integer.MIN_VALUE;
+ isWifiObservationOn = true;
+ isPdrObservationOn = true;
+ com.openpositioning.PositionMe.sensors.SensorFusion.getInstance().consumePendingFloorDelta();
+ maxObservationMarkers = DEFAULT_MAX_OBSERVATION_MARKERS;
+ currentFloorValue = 0;
+ manualFloorOffset = 0;
+
+ if (currentFloorIndicator != null) updateFloorIndicatorUI(0);
+ if (gnssSwitch != null) gnssSwitch.setChecked(true);
+ if (wifiObservationSwitch != null) wifiObservationSwitch.setChecked(true);
+ if (pdrObservationSwitch != null) pdrObservationSwitch.setChecked(true);
+ if (autoFloorSwitch != null) autoFloorSwitch.setChecked(true);
+ if (smoothDisplaySwitch != null) smoothDisplaySwitch.setChecked(DEFAULT_SMOOTH_DISPLAY_ENABLED);
+ if (observationCountSpinner != null) observationCountSpinner.setSelection(2, false);
+ if (mapControlPanelController != null) mapControlPanelController.setCollapsed(true);
+ if (switchColorButton != null) switchColorButton.setBackgroundColor(Color.RED);
+ updateManualFloorControlsVisibility();
+
if (gMap != null) {
- polyline = gMap.addPolyline(new PolylineOptions()
+ fusedPolyline = gMap.addPolyline(new PolylineOptions()
.color(Color.RED)
.width(5f)
+ .zIndex(200f)
.add());
- gnssPolyline = gMap.addPolyline(new PolylineOptions()
- .color(Color.BLUE)
- .width(5f)
- .add());
+ fusedPathPoints.clear();
}
}
- /**
- * 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.
- */
- private void drawBuildingPolygon() {
- if (gMap == null) {
- Log.e("TrajectoryMapFragment", "GoogleMap is not ready");
- return;
+ private void clearObservationQueue(ArrayDeque queue) {
+ while (!queue.isEmpty()) {
+ Marker marker = queue.removeFirst();
+ if (marker != null) marker.remove();
}
+ }
- // 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();
+ private float normalizeMarkerRotationDegrees(float degrees) {
+ if (!Float.isFinite(degrees)) {
+ return 0f;
}
-
- // 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());
+ float normalized = degrees % 360f;
+ if (normalized < 0f) {
+ normalized += 360f;
+ }
+ return normalized;
}
-
-
}
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..e7585abb 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
@@ -5,8 +5,6 @@
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
-import android.os.Environment;
-import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -63,26 +61,33 @@ public UploadFragment() {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- // Get communication class
serverCommunications = new ServerCommunications(getActivity());
+ localTrajectories = new java.util.ArrayList<>();
+ }
- // Determine the directory to load trajectory files from.
- File trajectoriesDir = null;
-
- // for android 13 or higher use dedicated external storage
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- trajectoriesDir = getActivity().getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS);
- if (trajectoriesDir == null) {
- trajectoriesDir = getActivity().getFilesDir();
- }
- } else { // for android 12 or lower use internal storage
- trajectoriesDir = getActivity().getFilesDir();
+ /**
+ * Rescans local storage each time the fragment becomes visible, so newly saved recordings
+ * are found without requiring the user to restart the app.
+ */
+ @Override
+ public void onResume() {
+ super.onResume();
+ // Use the same directory and extension as SensorFusion.stopRecording():
+ // getExternalFilesDir(null) with "term_project_trajectory_*.proto"
+ File trajectoriesDir = requireActivity().getExternalFilesDir(null);
+ if (trajectoriesDir == null) {
+ trajectoriesDir = requireActivity().getFilesDir();
+ }
+ File[] files = trajectoriesDir.listFiles((file, name) ->
+ name.contains("trajectory_") && name.endsWith(".proto"));
+ localTrajectories = files != null
+ ? Stream.of(files).filter(file -> !file.isDirectory()).collect(Collectors.toList())
+ : new java.util.ArrayList<>();
+
+ // Refresh UI if the view is already created
+ if (emptyNotice != null && uploadList != null) {
+ refreshListUI();
}
-
- localTrajectories = Stream.of(trajectoriesDir.listFiles((file, name) ->
- name.contains("trajectory_") && name.endsWith(".txt")))
- .filter(file -> !file.isDirectory())
- .collect(Collectors.toList());
}
/**
@@ -113,34 +118,31 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container,
@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()) {
+ LinearLayoutManager manager = new LinearLayoutManager(getActivity());
+ uploadList.setLayoutManager(manager);
+ uploadList.setHasFixedSize(true);
+ refreshListUI();
+ }
+
+ private void refreshListUI() {
+ if (localTrajectories.isEmpty()) {
uploadList.setVisibility(View.GONE);
emptyNotice.setVisibility(View.VISIBLE);
- }
- else {
+ } else {
uploadList.setVisibility(View.VISIBLE);
emptyNotice.setVisibility(View.GONE);
-
- // Set up RecyclerView
- 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);
- }
+ listAdapter = new UploadListAdapter(getActivity(), localTrajectories, position -> {
+ File file = localTrajectories.get(position);
+ serverCommunications.uploadLocalTrajectory(file, () -> {
+ // Delete the local file and remove from list after successful upload
+ file.delete();
+ if (localTrajectories.remove(file)) {
+ listAdapter.notifyDataSetChanged();
+ if (localTrajectories.isEmpty()) refreshListUI();
+ }
+ });
});
uploadList.setAdapter(listAdapter);
}
diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/TrajDownloadListAdapter.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/TrajDownloadListAdapter.java
index 7de29c8a..971ad042 100644
--- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/TrajDownloadListAdapter.java
+++ b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/TrajDownloadListAdapter.java
@@ -85,7 +85,14 @@ public TrajDownloadListAdapter(Context context, List