diff --git a/app/build.gradle b/app/build.gradle index 3e29b13f..9da6a47a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -67,6 +67,10 @@ dependencies { implementation 'com.android.volley:volley:1.2.1' implementation 'androidx.gridlayout:gridlayout:1.0.0' + // Google Play Services + implementation 'com.google.android.gms:play-services-location:21.2.0' + implementation 'com.google.android.gms:play-services-maps:19.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' @@ -77,7 +81,6 @@ dependencies { 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' // Navigation components def nav_version = "2.8.6" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 678711fd..2c5ecedd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,67 +2,26 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + android:theme="@style/Theme.Cloud"> + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/ekf/EKF.java b/app/src/main/java/com/example/ekf/EKF.java new file mode 100644 index 00000000..3020d3e8 --- /dev/null +++ b/app/src/main/java/com/example/ekf/EKF.java @@ -0,0 +1,475 @@ +package com.example.ekf; + +import com.google.android.gms.maps.model.LatLng; +import android.util.Log; + +/** + * 扩展卡尔曼滤波器(EKF)类用于融合GNSS、PDR和WiFi定位数据 + * 实现高精度的室内外无缝定位 + */ +public class EKF { + // 状态向量 [x, y, heading] + private double[] state = new double[3]; + + // 状态协方差矩阵 + private double[][] P = new double[3][3]; + + // 过程噪声协方差矩阵 + private double[][] Q = new double[3][3]; + + // 观测噪声协方差矩阵 - 根据实际观测质量调整 + private double[][] RGNSS = new double[2][2]; + private double[][] RWiFi = new double[2][2]; + + // 当前融合后的位置 + private LatLng fusedPosition; + private LatLng initialPosition; // 保存初始位置用于坐标转换 + + // 位置和方向的初始值是否已设置 + private boolean isInitialized = false; + + /** + * 构造函数,初始化EKF参数 + */ + public EKF() { + // 初始化状态向量 + state[0] = 0; // x + state[1] = 0; // y + state[2] = 0; // heading (弧度) + + // 初始化状态协方差矩阵 (初始不确定性较高) + for (int i = 0; i < 3; i++) { + P[i][i] = 100.0; + } + + // 调整过程噪声协方差矩阵 - 降低PDR的过程噪声 + Q[0][0] = 0.005; // x位置过程噪声 (降低) + Q[1][1] = 0.005; // y位置过程噪声 (降低) + Q[2][2] = 0.005; // 航向过程噪声 (降低,使方向更稳定) + + // 调整GNSS测量噪声协方差 - 增加,因为GNSS在室内或城市峡谷误差较大 + RGNSS[0][0] = 10.0; // x位置测量噪声 (增加) + RGNSS[1][1] = 10.0; // y位置测量噪声 (增加) + + // 调整WiFi测量噪声协方差 - 根据WiFi定位精度调整 + RWiFi[0][0] = 15.0; // x位置测量噪声 (稍微增加) + RWiFi[1][1] = 15.0; // y位置测量噪声 (稍微增加) + } + + /** + * 初始化滤波器状态 + * @param initialLatLng 初始位置 + * @param initialHeading 初始航向角(弧度) + */ + public void initialize(LatLng initialLatLng, double initialHeading) { + if (initialLatLng != null) { + state[0] = 0; // 初始x相对坐标设为0 + state[1] = 0; // 初始y相对坐标设为0 + + // 对初始航向角进行标准化 + while (initialHeading > Math.PI) initialHeading -= 2 * Math.PI; + while (initialHeading < -Math.PI) initialHeading += 2 * Math.PI; + + state[2] = initialHeading; + + // 降低航向角的初始不确定性 + P[2][2] = 0.1; // 航向角初始协方差设为较小值 + + this.initialPosition = initialLatLng; + this.fusedPosition = initialLatLng; + isInitialized = true; + + Log.d("EKF", String.format("EKF初始化完成 - 初始位置: [%.6f, %.6f], 初始航向: %.2f°", + initialLatLng.latitude, initialLatLng.longitude, Math.toDegrees(initialHeading))); + } + } + + /** + * 使用PDR步进数据进行预测更新 + * @param stepLength 步长(米) + * @param headingChange 航向变化(弧度) + */ + public void predict(double stepLength, double headingChange) { + if (!isInitialized) return; + + // 限制航向变化幅度 + while (headingChange > Math.PI) headingChange -= 2 * Math.PI; + while (headingChange < -Math.PI) headingChange += 2 * Math.PI; + + // 限制单次航向变化不超过90度 + if (Math.abs(headingChange) > Math.PI/2) { + headingChange = Math.signum(headingChange) * Math.PI/2; + } + + // 更新状态 + state[0] += stepLength * Math.cos(state[2]); + state[1] += stepLength * Math.sin(state[2]); + state[2] += headingChange; + + // 标准化航向角 + while (state[2] > Math.PI) state[2] -= 2 * Math.PI; + while (state[2] < -Math.PI) state[2] += 2 * Math.PI; + + // 更新状态协方差 + double cosHeading = Math.cos(state[2]); + double sinHeading = Math.sin(state[2]); + + // 计算雅可比矩阵 + double[][] F = new double[3][3]; + F[0][0] = 1; + F[0][1] = 0; + F[0][2] = -stepLength * sinHeading; + F[1][0] = 0; + F[1][1] = 1; + F[1][2] = stepLength * cosHeading; + F[2][0] = 0; + F[2][1] = 0; + F[2][2] = 1; + + // 更新状态协方差 + P = multiplyMatrices(F, P); + P = addMatrices(P, Q); + + // 更新融合位置 + updateFusedPosition(); + + Log.d("EKF", String.format("EKF预测更新 - 步长: %.2fm, 航向变化: %.2f°, 当前位置: [%.6f, %.6f]", + stepLength, Math.toDegrees(headingChange), fusedPosition.latitude, fusedPosition.longitude)); + } + + /** + * 使用GNSS位置进行测量更新 + * @param gnssLatLng GNSS测量位置 + */ + public void updateWithGNSS(LatLng gnssLatLng) { + if (!isInitialized || gnssLatLng == null) return; + + // 将GNSS位置转换为相对于初始位置的局部坐标 + double[] z = latLngToLocalXY(gnssLatLng); + + // 观测矩阵H (2x3),只观测位置,不观测航向 + double[][] H = new double[2][3]; + H[0][0] = 1.0; // x位置 + H[1][1] = 1.0; // y位置 + + // 计算卡尔曼增益: K = P*H^T * (H*P*H^T + R)^-1 + double[][] PHt = multiplyMatrices(P, transpose(H)); + double[][] HPHt_R = addMatrices(multiplyMatrices(multiplyMatrices(H, P), transpose(H)), RGNSS); + double[][] inv_HPHt_R = inverse(HPHt_R); + + // 确认矩阵求逆成功 + if (inv_HPHt_R == null) { + return; // 矩阵奇异,跳过本次更新 + } + + double[][] K = multiplyMatrices(PHt, inv_HPHt_R); + + // 计算测量残差: y = z - H*x + double[] y = new double[2]; + y[0] = z[0] - state[0]; + y[1] = z[1] - state[1]; + + // 限制残差大小,防止突变 - 重要优化点 + double maxResidual = 5.0; // 最大允许残差(米) + if (Math.abs(y[0]) > maxResidual) { + y[0] = y[0] > 0 ? maxResidual : -maxResidual; + } + if (Math.abs(y[1]) > maxResidual) { + y[1] = y[1] > 0 ? maxResidual : -maxResidual; + } + + // 更新状态: x = x + K*y + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 2; j++) { + state[i] += K[i][j] * y[j]; + } + } + + // 更新协方差: P = (I - K*H)*P + double[][] I = new double[3][3]; + I[0][0] = 1.0; + I[1][1] = 1.0; + I[2][2] = 1.0; + + P = multiplyMatrices(subtractMatrices(I, multiplyMatrices(K, H)), P); + + // 更新融合位置 + updateFusedPosition(); + } + + /** + * 使用WiFi位置进行测量更新 + * @param wifiLatLng WiFi测量位置 + */ + public void updateWithWiFi(LatLng wifiLatLng) { + if (!isInitialized || wifiLatLng == null) return; + + // 将WiFi位置转换为相对于初始位置的局部坐标 + double[] z = latLngToLocalXY(wifiLatLng); + + // 观测矩阵H (2x3),只观测位置,不观测航向 + double[][] H = new double[2][3]; + H[0][0] = 1.0; // x位置 + H[1][1] = 1.0; // y位置 + + // 动态调整WiFi测量噪声协方差 + double[][] currentRWiFi = new double[2][2]; + currentRWiFi[0][0] = RWiFi[0][0]; + currentRWiFi[1][1] = RWiFi[1][1]; + + // 计算与当前状态的差异 + double dx = z[0] - state[0]; + double dy = z[1] - state[1]; + double distance = Math.sqrt(dx*dx + dy*dy); + + // 如果WiFi位置与当前状态差异较大,增加测量噪声 + if (distance > 5.0) { + double factor = Math.min(distance / 5.0, 3.0); // 最多增加3倍 + currentRWiFi[0][0] *= factor; + currentRWiFi[1][1] *= factor; + } + + // 计算卡尔曼增益 + double[][] PHt = multiplyMatrices(P, transpose(H)); + double[][] HPHt_R = addMatrices(multiplyMatrices(multiplyMatrices(H, P), transpose(H)), currentRWiFi); + double[][] inv_HPHt_R = inverse(HPHt_R); + + // 确认矩阵求逆成功 + if (inv_HPHt_R == null) { + return; // 矩阵奇异,跳过本次更新 + } + + double[][] K = multiplyMatrices(PHt, inv_HPHt_R); + + // 计算测量残差 + double[] y = new double[2]; + y[0] = z[0] - state[0]; + y[1] = z[1] - state[1]; + + // 使用自适应残差限制 + double maxResidual = Math.min(5.0 + Math.sqrt(P[0][0] + P[1][1]), 10.0); + if (Math.abs(y[0]) > maxResidual) { + y[0] = y[0] > 0 ? maxResidual : -maxResidual; + } + if (Math.abs(y[1]) > maxResidual) { + y[1] = y[1] > 0 ? maxResidual : -maxResidual; + } + + // 更新状态 + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 2; j++) { + state[i] += K[i][j] * y[j]; + } + } + + // 更新协方差 + double[][] I = new double[3][3]; + I[0][0] = 1.0; + I[1][1] = 1.0; + I[2][2] = 1.0; + + P = multiplyMatrices(subtractMatrices(I, multiplyMatrices(K, H)), P); + + // 更新融合位置 + updateFusedPosition(); + } + + /** + * 使用GNSS经纬度位置进行测量更新 + * @param latitude GNSS纬度 + * @param longitude GNSS经度 + */ + public void update_gnss(double latitude, double longitude) { + // 使用经纬度创建LatLng对象 + LatLng gnssLatLng = new LatLng(latitude, longitude); + + // 调用已有的updateWithGNSS方法处理 + updateWithGNSS(gnssLatLng); + } + + /** + * 获取当前融合后的位置 + * @return 融合后的LatLng位置 + */ + public LatLng getFusedPosition() { + return fusedPosition; + } + + /** + * 更新融合位置 + */ + private void updateFusedPosition() { + // 确保初始位置已设置 + if (initialPosition != null) { + // 将局部坐标转回LatLng + fusedPosition = localXYToLatLng(state[0], state[1]); + } + } + + /** + * 将LatLng坐标转换为相对于初始位置的局部XY坐标 + * @param latLng 需要转换的LatLng坐标 + * @return 相对局部XY坐标 [x, y] + */ + private double[] latLngToLocalXY(LatLng latLng) { + // 实际实现应使用精确的地球坐标转换公式 + // 这里使用简化计算,假设在小范围内地球是平的 + double[] xy = new double[2]; + + if (initialPosition != null) { + // 使用更精确的转换公式,考虑经度随纬度变化的比例 + double latDiff = latLng.latitude - initialPosition.latitude; + double lngDiff = latLng.longitude - initialPosition.longitude; + + // 转换为米,1度纬度约为111.32km + // 1度经度长度随纬度变化:111.32 * cos(lat)km + final double METERS_PER_LAT_DEGREE = 111320.0; + double latRadians = Math.toRadians(initialPosition.latitude); + + xy[0] = lngDiff * METERS_PER_LAT_DEGREE * Math.cos(latRadians); + xy[1] = latDiff * METERS_PER_LAT_DEGREE; + } + + return xy; + } + + /** + * 将局部XY坐标转换回LatLng坐标 + * @param x X坐标(米) + * @param y Y坐标(米) + * @return 对应的LatLng坐标 + */ + private LatLng localXYToLatLng(double x, double y) { + if (initialPosition == null) return null; + + // 使用更精确的转换公式 + final double METERS_PER_LAT_DEGREE = 111320.0; + double latRadians = Math.toRadians(initialPosition.latitude); + + double latDiff = y / METERS_PER_LAT_DEGREE; + double lngDiff = x / (METERS_PER_LAT_DEGREE * Math.cos(latRadians)); + + return new LatLng( + initialPosition.latitude + latDiff, + initialPosition.longitude + lngDiff + ); + } + + // 矩阵运算辅助方法 + + /** + * 矩阵乘法 + */ + private double[][] multiplyMatrices(double[][] A, double[][] B) { + if (A == null || B == null || A.length == 0 || B.length == 0 || A[0].length != B.length) { + return null; // 矩阵不兼容 + } + + int aRows = A.length; + int aCols = A[0].length; + int bCols = B[0].length; + + double[][] C = new double[aRows][bCols]; + + for (int i = 0; i < aRows; i++) { + for (int j = 0; j < bCols; j++) { + for (int k = 0; k < aCols; k++) { + C[i][j] += A[i][k] * B[k][j]; + } + } + } + + return C; + } + + /** + * 矩阵转置 + */ + private double[][] transpose(double[][] A) { + if (A == null || A.length == 0) { + return null; + } + + int rows = A.length; + int cols = A[0].length; + + double[][] T = new double[cols][rows]; + + for (int i = 0; i < rows; i++) { + for (int j = 0; j < cols; j++) { + T[j][i] = A[i][j]; + } + } + + return T; + } + + /** + * 矩阵加法 + */ + private double[][] addMatrices(double[][] A, double[][] B) { + if (A == null || B == null || A.length != B.length || A[0].length != B[0].length) { + return null; // 矩阵不兼容 + } + + int rows = A.length; + int cols = A[0].length; + + double[][] C = new double[rows][cols]; + + for (int i = 0; i < rows; i++) { + for (int j = 0; j < cols; j++) { + C[i][j] = A[i][j] + B[i][j]; + } + } + + return C; + } + + /** + * 矩阵减法 + */ + private double[][] subtractMatrices(double[][] A, double[][] B) { + if (A == null || B == null || A.length != B.length || A[0].length != B[0].length) { + return null; // 矩阵不兼容 + } + + int rows = A.length; + int cols = A[0].length; + + double[][] C = new double[rows][cols]; + + for (int i = 0; i < rows; i++) { + for (int j = 0; j < cols; j++) { + C[i][j] = A[i][j] - B[i][j]; + } + } + + return C; + } + + /** + * 矩阵求逆 (仅适用于2x2矩阵,扩展卡尔曼滤波实际应用中可能需要更复杂的实现) + */ + private double[][] inverse(double[][] A) { + // 此处简化仅处理2x2矩阵 + if (A == null || A.length != 2 || A[0].length != 2) { + return null; // 错误处理 + } + + double det = A[0][0] * A[1][1] - A[0][1] * A[1][0]; + + if (Math.abs(det) < 1e-10) { + // 奇异矩阵或接近奇异,不可逆 + return null; + } + + double[][] inv = new double[2][2]; + inv[0][0] = A[1][1] / det; + inv[0][1] = -A[0][1] / det; + inv[1][0] = -A[1][0] / det; + inv[1][1] = A[0][0] / det; + + return inv; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/ekf/EKFManager.java b/app/src/main/java/com/example/ekf/EKFManager.java new file mode 100644 index 00000000..d46669ba --- /dev/null +++ b/app/src/main/java/com/example/ekf/EKFManager.java @@ -0,0 +1,496 @@ +package com.example.ekf; + +import android.util.Log; + +import com.google.android.gms.maps.model.LatLng; +import com.openpositioning.PositionMe.sensors.SensorFusion; + +/** + * EKF管理器类,负责协调传感器数据与扩展卡尔曼滤波器(EKF)之间的交互 + * 将SensorFusion中的GNSS、PDR和WiFi数据融合处理 + */ +public class EKFManager { + private static final String TAG = "EKFManager"; + + // 单例实例 + private static EKFManager instance; + + // EKF实例 + private EKF ekf; + + // SensorFusion实例 + private SensorFusion sensorFusion; + + // 标记EKF是否已初始化 + private boolean isInitialized = false; + + // 标记是否启用EKF + private boolean isEkfEnabled = false; + + // 最近一次PDR位置 + private LatLng lastPdrPosition; + + // 最近一次GNSS位置 + private LatLng lastGnssPosition; + + // 最近一次WiFi位置 + private LatLng lastWifiPosition; + + // 上一次步长 + private float previousStepLength = 0; + + // 上一次航向角(弧度) + private float previousHeading = 0; + + // 静止状态相关参数 + private static final double STATIC_THRESHOLD = 0.2; // 静止速度阈值(米/秒) + private static final long STATIC_TIME_THRESHOLD = 2000; // 静止时间阈值(毫秒) + private boolean isStaticState = false; // 是否处于静止状态 + private long lastMovementTime = 0; // 上次移动的时间 + + // 位置平滑处理 + private LatLng smoothedPosition = null; // 平滑后的位置 + private static final double SMOOTHING_FACTOR = 0.15; // 降低平滑因子以增强平滑效果 + private LatLng lastFusedPosition = null; // 上次融合后的位置 + + // 位移限制相关参数 + private static final double MAX_DISPLACEMENT_PER_UPDATE = 1.0; // 降低每次更新最大位移 + private static final long DISPLACEMENT_TIME_WINDOW = 1000; // 增加位移时间窗口 + private long lastDisplacementTime = 0; + private double accumulatedDisplacement = 0; // 累积位移(米) + + // 速度限制参数 + private static final double MAX_SPEED = 2.0; // 最大允许速度(米/秒) + private double currentSpeed = 0.0; // 当前速度(米/秒) + private double[] speedHistory = new double[5]; // 速度历史记录,用于平滑 + private int speedHistoryIndex = 0; // 速度历史记录索引 + private int speedHistoryCount = 0; // 速度历史记录计数 + + // 新增变量 + private long lastGnssUpdateTime = 0; // 上次更新GNSS位置的时间 + private double[] displacementHistory = new double[10]; // 位移历史记录 + private int displacementHistoryIndex = 0; // 位移历史记录索引 + private int displacementHistoryCount = 0; // 位移历史记录计数 + private float lastOrientation = 0; // 上次GNSS的航向角 + + // WiFi相关参数 + private static final double WIFI_MAX_SPEED = 2.0; // WiFi最大允许速度(米/秒) + private static final double WIFI_MIN_ACCURACY = 5.0; // WiFi最小精度要求(米) + private static final long WIFI_MIN_UPDATE_INTERVAL = 2000; // WiFi最小更新间隔(毫秒) + private long lastWifiUpdateTime = 0; + + /** + * 私有构造函数,遵循单例模式 + */ + private EKFManager() { + ekf = new EKF(); + sensorFusion = SensorFusion.getInstance(); + + // 初始化速度历史记录 + for (int i = 0; i < speedHistory.length; i++) { + speedHistory[i] = 0.0; + } + speedHistoryCount = 0; + speedHistoryIndex = 0; + } + + /** + * 获取EKFManager单例实例 + * @return EKFManager实例 + */ + public static synchronized EKFManager getInstance() { + if (instance == null) { + instance = new EKFManager(); + } + return instance; + } + + /** + * 设置EKF是否启用 + * @param enabled 是否启用EKF + */ + public void setEkfEnabled(boolean enabled) { + this.isEkfEnabled = enabled; + Log.d(TAG, "EKF " + (enabled ? "启用" : "禁用")); + } + + /** + * 判断EKF是否启用 + * @return EKF是否启用 + */ + public boolean isEkfEnabled() { + return isEkfEnabled; + } + + /** + * 初始化EKF + * @param initialPosition 初始位置 + * @param initialHeading 初始航向角(弧度) + */ + public void initialize(LatLng initialPosition, float initialHeading) { + if (initialPosition == null) { + Log.e(TAG, "无法初始化EKF:初始位置为空"); + return; + } + + // 初始化EKF + ekf.initialize(initialPosition, initialHeading); + + // 设置初始PDR和航向角 + this.lastPdrPosition = initialPosition; + this.previousHeading = initialHeading; + this.smoothedPosition = initialPosition; + this.lastFusedPosition = initialPosition; + + // 重置位移和速度相关参数 + this.lastDisplacementTime = System.currentTimeMillis(); + this.accumulatedDisplacement = 0; + this.currentSpeed = 0; + for (int i = 0; i < speedHistory.length; i++) { + speedHistory[i] = 0.0; + } + + // 标记为已初始化 + isInitialized = true; + Log.d(TAG, "EKF已初始化,初始位置: " + initialPosition + ",初始航向: " + Math.toDegrees(initialHeading) + "°"); + } + + /** + * 使用当前的GNSS位置初始化EKF + * @param initialHeading 初始航向角(弧度) + * @return 是否成功初始化 + */ + public boolean initializeWithGnss(float initialHeading) { + // 从SensorFusion获取当前GNSS位置 + float[] gnssLatLng = sensorFusion.getGNSSLatitude(false); + + if (gnssLatLng != null && gnssLatLng.length >= 2 && gnssLatLng[0] != 0 && gnssLatLng[1] != 0) { + LatLng initialPosition = new LatLng(gnssLatLng[0], gnssLatLng[1]); + initialize(initialPosition, initialHeading); + return true; + } else { + Log.e(TAG, "无法使用GNSS初始化EKF:GNSS数据无效"); + return false; + } + } + + /** + * 更新PDR位置 + * @param pdrPosition 新的PDR位置 + * @param heading 当前航向角(弧度) + */ + public void updatePdrPosition(LatLng pdrPosition, float heading) { + if (!isEkfEnabled || !isInitialized || pdrPosition == null) { + return; + } + + // 计算时间间隔 + long currentTime = System.currentTimeMillis(); + long timeDiff = currentTime - lastDisplacementTime; + + // 如果PDR位置没有变化,更新静止状态计数 + if (lastPdrPosition != null && + Math.abs(pdrPosition.latitude - lastPdrPosition.latitude) < 1e-8 && + Math.abs(pdrPosition.longitude - lastPdrPosition.longitude) < 1e-8) { + + if (currentTime - lastMovementTime > STATIC_TIME_THRESHOLD) { + isStaticState = true; + return; // 静止状态下不进行预测更新 + } + } else { + lastMovementTime = currentTime; + isStaticState = false; + } + + // 计算步长 + double stepLength = 0; + if (lastPdrPosition != null) { + stepLength = calculateDistance(lastPdrPosition, pdrPosition); + } + + // 计算航向角变化 + float headingChange = heading - previousHeading; + // 标准化航向角变化到[-π, π] + while (headingChange > Math.PI) headingChange -= 2 * Math.PI; + while (headingChange < -Math.PI) headingChange += 2 * Math.PI; + + // 只有在非静止状态且有实际位移时才进行预测更新 + if (stepLength > 0 && !isStaticState && timeDiff >= DISPLACEMENT_TIME_WINDOW) { + // 限制步长,防止突变 + double limitedStepLength = Math.min(stepLength, MAX_DISPLACEMENT_PER_UPDATE); + if (stepLength > MAX_DISPLACEMENT_PER_UPDATE) { + Log.d(TAG, "PDR步长被限制: " + stepLength + "m -> " + limitedStepLength + "m"); + } + + ekf.predict(limitedStepLength, headingChange); + Log.d(TAG, "EKF预测更新: 步长=" + limitedStepLength + "m, 航向变化=" + Math.toDegrees(headingChange) + "°"); + + // 更新时间戳 + lastDisplacementTime = currentTime; + } + + // 保存当前值为下一次使用 + this.lastPdrPosition = pdrPosition; + this.previousHeading = heading; + } + + /** + * 更新来自GNSS的位置 + * 已经由GNSSProcessor预处理,减少了跳变 + * @param gnssLocation GNSS的位置(LatLng格式) + */ + public void updateGnssPosition(LatLng gnssLocation) { + if (gnssLocation == null) return; + + // 计算与上一个GNSS位置的位移 + double displacement = 0; + double speed = 0; + + if (lastGnssPosition != null) { + long currentTime = System.currentTimeMillis(); + double timeDelta = (currentTime - lastGnssUpdateTime) / 1000.0; // 转为秒 + + // 计算位移 + displacement = calculateDistance(lastGnssPosition, gnssLocation); + + // 计算速度 + if (timeDelta > 0.1) { // 避免除以很小的数 + speed = displacement / timeDelta; + } + + // 更新当前速度估计(使用加权平均进行平滑) + currentSpeed = 0.7 * currentSpeed + 0.3 * speed; + + // 记录速度历史 + speedHistory[speedHistoryIndex] = speed; + speedHistoryIndex = (speedHistoryIndex + 1) % speedHistory.length; + if (speedHistoryCount < speedHistory.length) { + speedHistoryCount++; + } + } + + // 应用位移限制(已经由GNSSProcessor处理,这里作为额外保障) + if (displacement > MAX_DISPLACEMENT_PER_UPDATE) { + Log.d(TAG, "EKF: GNSS displacement exceeds limit: " + displacement + "m > " + + MAX_DISPLACEMENT_PER_UPDATE + "m - applying limit"); + + // 已经由GNSSProcessor平滑处理过,这里的限制作用更小 + double ratio = MAX_DISPLACEMENT_PER_UPDATE / displacement; + double latDiff = (gnssLocation.latitude - lastGnssPosition.latitude) * ratio; + double lonDiff = (gnssLocation.longitude - lastGnssPosition.longitude) * ratio; + + gnssLocation = new LatLng( + lastGnssPosition.latitude + latDiff, + lastGnssPosition.longitude + lonDiff + ); + } + + // 更新EKF中的GNSS位置 + lastGnssPosition = gnssLocation; + lastGnssUpdateTime = System.currentTimeMillis(); + + // 记录位移历史(用于统计分析) + displacementHistory[displacementHistoryIndex] = displacement; + displacementHistoryIndex = (displacementHistoryIndex + 1) % displacementHistory.length; + if (displacementHistoryCount < displacementHistory.length) { + displacementHistoryCount++; + } + + // 如果EKF已初始化,对接收到的GNSS位置进行更新 + if (isInitialized) { + ekf.update_gnss(gnssLocation.latitude, gnssLocation.longitude); + } else { + // 如果EKF未初始化,且GNSS接收到有效位置,则初始化EKF + ekf.initialize(new LatLng(gnssLocation.latitude, gnssLocation.longitude), (double)lastOrientation); + isInitialized = true; + } + } + + /** + * 更新WiFi位置 + * @param wifiPosition WiFi位置 + */ + public void updateWifiPosition(LatLng wifiPosition) { + if (!isInitialized || !isEkfEnabled || wifiPosition == null) { + this.lastWifiPosition = wifiPosition; + return; + } + + long currentTime = System.currentTimeMillis(); + + // 检查更新间隔 + if (currentTime - lastWifiUpdateTime < WIFI_MIN_UPDATE_INTERVAL) { + return; + } + + // 如果处于静止状态,减小WiFi更新的影响 + if (isStaticState) { + if (Math.random() > 0.05) { // 静止时仅使用5%的WiFi数据 + return; + } + } + + // 检查WiFi位置跳变 + if (lastWifiPosition != null) { + double distance = calculateDistance(lastWifiPosition, wifiPosition); + double timeElapsed = (currentTime - lastWifiUpdateTime) / 1000.0; + + if (timeElapsed > 0) { + double wifiSpeed = distance / timeElapsed; + + // 如果WiFi速度超过阈值,可能是位置跳变 + if (wifiSpeed > WIFI_MAX_SPEED) { + Log.d(TAG, "检测到WiFi位置跳变: " + wifiSpeed + "m/s > " + WIFI_MAX_SPEED + "m/s,跳过此次更新"); + return; + } + + // 如果位移过大,进行线性插值 + if (distance > MAX_DISPLACEMENT_PER_UPDATE) { + double ratio = MAX_DISPLACEMENT_PER_UPDATE / distance; + double interpolatedLat = lastWifiPosition.latitude + (wifiPosition.latitude - lastWifiPosition.latitude) * ratio; + double interpolatedLng = lastWifiPosition.longitude + (wifiPosition.longitude - lastWifiPosition.longitude) * ratio; + wifiPosition = new LatLng(interpolatedLat, interpolatedLng); + } + } + } + + // 更新EKF使用WiFi数据 + ekf.updateWithWiFi(wifiPosition); + this.lastWifiPosition = wifiPosition; + this.lastWifiUpdateTime = currentTime; + + Log.d(TAG, "EKF更新: 使用WiFi位置 " + wifiPosition); + } + + /** + * 获取融合后的位置 + * @return 融合后的位置 (如果EKF未启用或未初始化,则返回null) + */ + public LatLng getFusedPosition() { + if (!isInitialized || !isEkfEnabled) { + return null; + } + + // 获取EKF原始融合结果 + LatLng rawPosition = ekf.getFusedPosition(); + if (rawPosition == null) { + return null; + } + + // 应用位移限制 + LatLng limitedPosition = rawPosition; + if (lastFusedPosition != null) { + double distance = calculateDistance(lastFusedPosition, rawPosition); + long currentTime = System.currentTimeMillis(); + double timeElapsed = (currentTime - lastDisplacementTime) / 1000.0; // 秒 + + if (timeElapsed > 0) { + // 计算当前速度 + double speed = distance / timeElapsed; + + // 如果速度超过最大限制,进行限制 + if (speed > MAX_SPEED) { + // 计算限制后的距离 + double limitedDistance = MAX_SPEED * timeElapsed; + + // 按比例缩小位移 + double ratio = limitedDistance / distance; + double latDiff = (rawPosition.latitude - lastFusedPosition.latitude) * ratio; + double lngDiff = (rawPosition.longitude - lastFusedPosition.longitude) * ratio; + + // 创建限制后的位置 + limitedPosition = new LatLng( + lastFusedPosition.latitude + latDiff, + lastFusedPosition.longitude + lngDiff + ); + + Log.d(TAG, "位置跳变被限制: " + speed + "m/s -> " + MAX_SPEED + "m/s"); + } + } + + // 更新时间 + lastDisplacementTime = currentTime; + } + + // 应用位置平滑 + if (smoothedPosition == null) { + smoothedPosition = limitedPosition; + } else { + // 指数平滑公式: newValue = α * currentValue + (1-α) * previousValue + double smoothFactor = isStaticState ? 0.05 : SMOOTHING_FACTOR; // 静止时使用更强的平滑效果 + + double lat = smoothFactor * limitedPosition.latitude + (1 - smoothFactor) * smoothedPosition.latitude; + double lng = smoothFactor * limitedPosition.longitude + (1 - smoothFactor) * smoothedPosition.longitude; + + smoothedPosition = new LatLng(lat, lng); + } + + // 更新上次融合位置 + lastFusedPosition = smoothedPosition; + + return smoothedPosition; + } + + /** + * 计算两个LatLng点之间的距离(米) + * @param point1 第一个点 + * @param point2 第二个点 + * @return 两点之间的距离(米) + */ + private double calculateDistance(LatLng point1, LatLng point2) { + if (point1 == null || point2 == null) { + return 0; + } + + // 地球半径(米) + final double R = 6371000; + + // 将经纬度转换为弧度 + double lat1 = Math.toRadians(point1.latitude); + double lon1 = Math.toRadians(point1.longitude); + double lat2 = Math.toRadians(point2.latitude); + double lon2 = Math.toRadians(point2.longitude); + + // 计算差值 + double dLat = lat2 - lat1; + double dLon = lon2 - lon1; + + // 应用Haversine公式 + 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)); + double distance = R * c; + + return distance; + } + + /** + * 重置EKF + */ + public void reset() { + isInitialized = false; + lastPdrPosition = null; + lastGnssPosition = null; + lastWifiPosition = null; + previousHeading = 0; + previousStepLength = 0; + isStaticState = false; + lastMovementTime = 0; + smoothedPosition = null; + lastFusedPosition = null; + lastDisplacementTime = 0; + accumulatedDisplacement = 0; + currentSpeed = 0; + + // 重置速度历史 + for (int i = 0; i < speedHistory.length; i++) { + speedHistory[i] = 0.0; + } + speedHistoryIndex = 0; + speedHistoryCount = 0; + + ekf = new EKF(); // 创建新的EKF实例 + Log.d(TAG, "EKF已重置"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/ekf/GNSSProcessor.java b/app/src/main/java/com/example/ekf/GNSSProcessor.java new file mode 100644 index 00000000..ee48b7ca --- /dev/null +++ b/app/src/main/java/com/example/ekf/GNSSProcessor.java @@ -0,0 +1,411 @@ +package com.example.ekf; + +import android.util.Log; + +import com.google.android.gms.maps.model.LatLng; + +/** + * GNSS处理器类,负责对原始GNSS数据进行平滑和过滤 + * 减少GNSS位置跳变,提高定位稳定性 + */ +public class GNSSProcessor { + private static final String TAG = "GNSSProcessor"; + + // 单例实例 + private static GNSSProcessor instance; + + // 最近一次有效的GNSS位置 + private LatLng lastValidPosition = null; + + // 上次位置更新时间 + private long lastUpdateTime = 0; + + // 平滑位置数组(保存最近的多个位置用于平滑)- 增加历史长度 + private LatLng[] positionHistory = new LatLng[10]; // 增加到10个点 + private int positionHistoryIndex = 0; + private int positionHistoryCount = 0; + + // 速度限制参数 + private static final double MAX_GNSS_SPEED = 2.5; // 最大允许GNSS速度(米/秒),稍高于行走速度 + private double currentSpeed = 0.0; // 当前估计速度(米/秒) + + // 历史方向数组 (用于检测方向变化) + private double[] directionHistory = new double[5]; + private int directionHistoryIndex = 0; + private int directionHistoryCount = 0; + + // 平滑因子(0-1),值越小平滑效果越强 + private static final double SMOOTHING_FACTOR = 0.15; + + // 跳变检测参数 + private static final double JUMP_THRESHOLD = 1.5; // 降低位置跳变阈值(米) + private static final double OSCILLATION_THRESHOLD = 0.8; // 降低摆动检测阈值(米) + private static final long MIN_UPDATE_INTERVAL = 500; // 最小更新间隔(毫秒) + + // 方向变化检测参数 + private static final double DIRECTION_CHANGE_THRESHOLD = 90.0; // 方向变化阈值(度) + + // 静止检测参数 + private static final double STATIC_THRESHOLD = 0.5; // 静止速度阈值(米/秒) + private static final long STATIC_TIME_THRESHOLD = 3000; // 静止时间阈值(毫秒) + private boolean isStaticState = false; // 是否处于静止状态 + private long staticStartTime = 0; // 静止开始时间 + + // 信号质量指标 + private int signalQuality = 2; // 0-很差,1-差,2-中等,3-好,4-很好 + + /** + * 私有构造函数,遵循单例模式 + */ + private GNSSProcessor() { + // 初始化 + } + + /** + * 获取GNSSProcessor单例实例 + * @return GNSSProcessor实例 + */ + public static synchronized GNSSProcessor getInstance() { + if (instance == null) { + instance = new GNSSProcessor(); + } + return instance; + } + + /** + * 处理原始GNSS位置,进行平滑和过滤 + * @param rawPosition 原始GNSS位置 + * @return 处理后的平滑位置,如果原始位置无效或被过滤则返回上一个有效位置 + */ + public LatLng processGNSSPosition(LatLng rawPosition) { + if (rawPosition == null) { + Log.w(TAG, "GNSS位置为null,无法处理"); + return lastValidPosition; + } + + long currentTime = System.currentTimeMillis(); + + // 如果是第一个位置,直接设置为有效位置 + if (lastValidPosition == null) { + lastValidPosition = rawPosition; + lastUpdateTime = currentTime; + + // 初始化位置历史 + for (int i = 0; i < positionHistory.length; i++) { + positionHistory[i] = rawPosition; + } + positionHistoryCount = 1; + + return rawPosition; + } + + // 计算与上次有效位置的距离 + double distance = calculateDistance(lastValidPosition, rawPosition); + long timeElapsed = (currentTime - lastUpdateTime) / 1000; // 秒 + + // 防止除零错误 + double instantSpeed = timeElapsed > 0 ? distance / timeElapsed : 0; + + // 更新当前速度估计 (使用加权平均) + currentSpeed = 0.8 * currentSpeed + 0.2 * instantSpeed; + + // 计算当前位置与上次位置的方向角度 + double direction = calculateBearing(lastValidPosition, rawPosition); + updateDirectionHistory(direction); + + // 更精细的跳变检测和过滤 + if (timeElapsed > 0) { + // 静止状态检测 + if (instantSpeed < STATIC_THRESHOLD) { + if (!isStaticState) { + // 刚进入静止状态 + staticStartTime = currentTime; + isStaticState = true; + } else if (currentTime - staticStartTime > STATIC_TIME_THRESHOLD) { + // 持续静止状态,加强平滑 + signalQuality = Math.max(0, signalQuality - 1); // 降低信号质量评估 + Log.d(TAG, "持续静止状态已超过" + STATIC_TIME_THRESHOLD + "ms,信号质量降级为: " + signalQuality); + } + } else { + isStaticState = false; + if (instantSpeed < MAX_GNSS_SPEED) { + // 正常移动,信号质量可能较好 + signalQuality = Math.min(4, signalQuality + 1); + } + } + + // 检测跳变 - 增强的判断条件: + // 1. 距离超过跳变阈值且速度超过最大速度 + // 2. 时间间隔太短但距离较大(可能是信号不稳定导致的快速变化) + // 3. 方向频繁变化(摆动检测) + boolean isJumping = (distance > JUMP_THRESHOLD && instantSpeed > MAX_GNSS_SPEED) || + (distance > 2.0 && timeElapsed < 0.2); + + boolean isOscillating = distance > OSCILLATION_THRESHOLD && hasRecentDirectionChange(); + + if (isJumping || isOscillating) { + String reason = isJumping ? "速度/距离异常" : "方向频繁变化"; + Log.d(TAG, "GNSS跳变被检测到: 距离=" + distance + "m, 速度=" + instantSpeed + + "m/s, 时间=" + timeElapsed + "s, 原因=" + reason); + + // 对于严重跳变,直接返回上一个有效位置 + if (distance > JUMP_THRESHOLD * 2 || (isOscillating && signalQuality < 3)) { + Log.d(TAG, "严重跳变,直接使用上一个有效位置"); + updatePositionHistory(lastValidPosition); // 继续使用上一个有效位置进行平滑 + return lastValidPosition; + } + + // 对于中等跳变,限制移动距离 + double limitFactor = signalQuality < 2 ? 0.2 : 0.4; // 降低限制因子,更严格限制 + double limitedDistance = MAX_GNSS_SPEED * timeElapsed * limitFactor; + double ratio = limitedDistance / distance; + + // 按比例缩小位移 + double latDiff = (rawPosition.latitude - lastValidPosition.latitude) * ratio; + double lngDiff = (rawPosition.longitude - lastValidPosition.longitude) * ratio; + + // 创建限制后的位置 + rawPosition = new LatLng( + lastValidPosition.latitude + latDiff, + lastValidPosition.longitude + lngDiff + ); + + Log.d(TAG, "GNSS位置被限制: " + distance + "m -> " + limitedDistance + "m"); + } + } + + // 更新位置历史 + updatePositionHistory(rawPosition); + + // 计算平滑后的位置 - 根据信号质量调整平滑强度 + LatLng smoothedPosition = calculateSmoothedPosition(); + + // 更新状态 + lastValidPosition = smoothedPosition; + lastUpdateTime = currentTime; + + return smoothedPosition; + } + + /** + * 更新位置历史数组 + * @param position 新位置 + */ + private void updatePositionHistory(LatLng position) { + if (position == null) { + Log.e(TAG, "尝试使用null位置更新历史记录"); + return; // 避免存储null值 + } + + positionHistory[positionHistoryIndex] = position; + positionHistoryIndex = (positionHistoryIndex + 1) % positionHistory.length; + if (positionHistoryCount < positionHistory.length) { + positionHistoryCount++; + } + } + + /** + * 更新方向历史数组 + * @param direction 新方向(度) + */ + private void updateDirectionHistory(double direction) { + directionHistory[directionHistoryIndex] = direction; + directionHistoryIndex = (directionHistoryIndex + 1) % directionHistory.length; + if (directionHistoryCount < directionHistory.length) { + directionHistoryCount++; + } + } + + /** + * 检测是否存在近期方向变化 + * @return 如果存在近期明显方向变化返回true + */ + private boolean hasRecentDirectionChange() { + if (directionHistoryCount < 3) { + return false; // 需要至少3个方向样本 + } + + for (int i = 0; i < directionHistoryCount - 2; i++) { + int idx1 = (directionHistoryIndex - 1 - i + directionHistory.length) % directionHistory.length; + int idx2 = (directionHistoryIndex - 1 - i - 2 + directionHistory.length) % directionHistory.length; + + double dirDiff = Math.abs(directionHistory[idx1] - directionHistory[idx2]); + if (dirDiff > 180) { + dirDiff = 360 - dirDiff; // 处理方向角度环绕 + } + + if (dirDiff > DIRECTION_CHANGE_THRESHOLD) { + Log.d(TAG, "检测到方向大幅变化: " + dirDiff + "度"); + return true; + } + } + + return false; + } + + /** + * 根据位置历史计算平滑后的位置 + * @return 平滑后的位置 + */ + private LatLng calculateSmoothedPosition() { + if (positionHistoryCount == 0) { + return null; + } + + // 检查历史记录中是否有空值 + boolean hasNullEntries = false; + for (int i = 0; i < positionHistoryCount; i++) { + int index = (positionHistoryIndex - 1 - i + positionHistory.length) % positionHistory.length; + if (positionHistory[index] == null) { + hasNullEntries = true; + break; + } + } + + // 如果有空值,返回最后一个有效位置 + if (hasNullEntries) { + return lastValidPosition; + } + + // 根据信号质量调整平滑强度 + double smoothingStrength; + switch(signalQuality) { + case 0: // 很差 + smoothingStrength = 0.08; // 强平滑 + break; + case 1: // 差 + smoothingStrength = 0.12; + break; + case 2: // 中等 + smoothingStrength = 0.15; + break; + case 3: // 好 + smoothingStrength = 0.25; + break; + case 4: // 很好 + smoothingStrength = 0.4; // 弱平滑 + break; + default: + smoothingStrength = SMOOTHING_FACTOR; + } + + // 使用指数衰减权重计算平滑位置 + double totalWeight = 0; + double weightedLatSum = 0; + double weightedLngSum = 0; + + // 最新的位置权重最大,历史位置权重指数衰减 + // 使用平滑强度作为衰减因子 + for (int i = 0; i < positionHistoryCount; i++) { + int index = (positionHistoryIndex - 1 - i + positionHistory.length) % positionHistory.length; + + // 权重随着历史增加而指数衰减 + double weight = Math.pow(smoothingStrength, i); + + LatLng pos = positionHistory[index]; + weightedLatSum += pos.latitude * weight; + weightedLngSum += pos.longitude * weight; + totalWeight += weight; + } + + // 计算加权平均 + double smoothedLat = weightedLatSum / totalWeight; + double smoothedLng = weightedLngSum / totalWeight; + + return new LatLng(smoothedLat, smoothedLng); + } + + /** + * 重置处理器状态 + * 清除历史位置记录并重置最后有效位置 + */ + public void reset() { + lastValidPosition = null; + lastUpdateTime = 0; + currentSpeed = 0; + positionHistoryCount = 0; + positionHistoryIndex = 0; + directionHistoryCount = 0; + directionHistoryIndex = 0; + isStaticState = false; + signalQuality = 2; // 重置为中等 + + // 清空位置历史数组 + for (int i = 0; i < positionHistory.length; i++) { + positionHistory[i] = null; + } + + // 清空方向历史数组 + for (int i = 0; i < directionHistory.length; i++) { + directionHistory[i] = 0; + } + + Log.d(TAG, "GNSSProcessor has been reset"); + } + + /** + * 计算两个LatLng点之间的方位角(度) + * @param start 起始点 + * @param end 终点 + * @return 方位角(0-360度) + */ + private double calculateBearing(LatLng start, LatLng end) { + double startLat = Math.toRadians(start.latitude); + double startLng = Math.toRadians(start.longitude); + double endLat = Math.toRadians(end.latitude); + double endLng = Math.toRadians(end.longitude); + + double dLng = endLng - startLng; + + double y = Math.sin(dLng) * Math.cos(endLat); + double x = Math.cos(startLat) * Math.sin(endLat) - + Math.sin(startLat) * Math.cos(endLat) * Math.cos(dLng); + + double bearing = Math.atan2(y, x); + + // 转换为度数并确保在0-360范围内 + bearing = Math.toDegrees(bearing); + bearing = (bearing + 360) % 360; + + return bearing; + } + + /** + * 计算两个LatLng点之间的距离(米) + * @param point1 第一个点 + * @param point2 第二个点 + * @return 两点之间的距离(米) + */ + private double calculateDistance(LatLng point1, LatLng point2) { + if (point1 == null || point2 == null) { + return 0; + } + + try { + // 地球半径(米) + final double R = 6371000; + + // 将经纬度转换为弧度 + double lat1 = Math.toRadians(point1.latitude); + double lon1 = Math.toRadians(point1.longitude); + double lat2 = Math.toRadians(point2.latitude); + double lon2 = Math.toRadians(point2.longitude); + + // 计算差值 + double dLat = lat2 - lat1; + double dLon = lon2 - lon1; + + // 应用Haversine公式 + 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)); + double distance = R * c; + + return distance; + } catch (Exception e) { + Log.e(TAG, "计算距离时出错: " + e.getMessage()); + return 0; // 出错时返回0距离 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/BuildingPolygon.java b/app/src/main/java/com/openpositioning/PositionMe/BuildingPolygon.java similarity index 97% rename from app/src/main/java/com/openpositioning/PositionMe/utils/BuildingPolygon.java rename to app/src/main/java/com/openpositioning/PositionMe/BuildingPolygon.java index 2d0a3265..f211f636 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/BuildingPolygon.java +++ b/app/src/main/java/com/openpositioning/PositionMe/BuildingPolygon.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.utils; +package com.openpositioning.PositionMe; import com.google.android.gms.maps.model.LatLng; @@ -14,7 +14,7 @@ */ public class BuildingPolygon { // Defining the coordinates of the building boundaries (rectangular boundaries based on floor map shape) - // North-East and South-West Coordinates for the Nucleus Building +// North-East and South-West Coordinates for the Nucleus Building public static final LatLng NUCLEUS_NE=new LatLng(55.92332001571212, -3.1738768212979593); public static final LatLng NUCLEUS_SW=new LatLng(55.92282257022002, -3.1745956532857647); // North-East and South-West Coordinates for the Kenneth and Murray Library Building diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/CircularFloatBuffer.java b/app/src/main/java/com/openpositioning/PositionMe/CircularFloatBuffer.java similarity index 98% rename from app/src/main/java/com/openpositioning/PositionMe/utils/CircularFloatBuffer.java rename to app/src/main/java/com/openpositioning/PositionMe/CircularFloatBuffer.java index 73abb674..bbd2804e 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/CircularFloatBuffer.java +++ b/app/src/main/java/com/openpositioning/PositionMe/CircularFloatBuffer.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.utils; +package com.openpositioning.PositionMe; import java.util.List; import java.util.Optional; diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java b/app/src/main/java/com/openpositioning/PositionMe/IndoorMapManager.java similarity index 64% rename from app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java rename to app/src/main/java/com/openpositioning/PositionMe/IndoorMapManager.java index 9d7167df..f4c202ac 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/IndoorMapManager.java +++ b/app/src/main/java/com/openpositioning/PositionMe/IndoorMapManager.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.utils; +package com.openpositioning.PositionMe; import android.graphics.Color; import android.util.Log; @@ -10,7 +10,6 @@ import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; import com.google.android.gms.maps.model.PolylineOptions; -import com.openpositioning.PositionMe.R; import java.util.Arrays; import java.util.List; @@ -60,7 +59,8 @@ public class IndoorMapManager { * @param map The map on which the indoor floor map overlays are set */ public IndoorMapManager(GoogleMap map){ - this.gMap=map; + this.gMap = map; + this.currentFloor = 1; // 默认从G层开始 } /** @@ -69,7 +69,7 @@ public IndoorMapManager(GoogleMap map){ * @param currentLocation new location of user */ public void setCurrentLocation(LatLng currentLocation){ - this.currentLocation=currentLocation; + this.currentLocation = currentLocation; setBuildingOverlay(); } @@ -96,26 +96,49 @@ public boolean getIsIndoorMapSet(){ * @param autoFloor flag if function called by auto-floor feature */ 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; + try { + if (BuildingPolygon.inNucleus(currentLocation)){ + if (autoFloor) { + int mapIndex = newFloor + 1; + if (mapIndex >= 0 && mapIndex < NUCLEUS_MAPS.size()) { + groundOverlay.setImage(BitmapDescriptorFactory.fromResource(NUCLEUS_MAPS.get(mapIndex))); + this.currentFloor = newFloor; + Log.d("Floor Status", String.format( + "Auto Mode - Real Floor: %d, Showing Map: %s", + newFloor, + newFloor == -1 ? "LG" : newFloor == 0 ? "G" : String.valueOf(newFloor) + )); + } + } else { + if (newFloor >= 0 && newFloor < NUCLEUS_MAPS.size()) { + groundOverlay.setImage(BitmapDescriptorFactory.fromResource(NUCLEUS_MAPS.get(newFloor))); + this.currentFloor = newFloor; + Log.d("Floor Status", String.format( + "Manual Mode - Selected Map: %s", + newFloor == 0 ? "LG" : newFloor == 1 ? "G" : String.valueOf(newFloor-1) + )); + } + } } - // If within bounds and different from floor map currently being shown - if (newFloor>=0 && newFloor=0 && newFloor= 0 && newFloor < LIBRARY_MAPS.size()) { + groundOverlay.setImage(BitmapDescriptorFactory.fromResource(LIBRARY_MAPS.get(newFloor))); + this.currentFloor = newFloor; + Log.d("Floor Change", "Library: Changed to floor " + newFloor); + } } + } catch (Exception ex) { + Log.e("SetFloor Error:", ex.toString()); } + } + /** + * 重新启用自动楼层时,立即更新到当前实际楼层 + * @param actualFloor 当前实际楼层 + */ + public void resumeAutoFloor(int actualFloor) { + Log.d("Floor Status", "Resuming Auto Floor - Actual Floor: " + actualFloor); + setCurrentFloor(actualFloor, true); } /** @@ -138,34 +161,36 @@ public void decreaseFloor(){ * Removes the overlay if user no longer in building */ 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) { + if (BuildingPolygon.inNucleus(currentLocation)) { + if (!isIndoorMapSet) { + currentFloor = 1; groundOverlay = gMap.addGroundOverlay(new GroundOverlayOptions() - .image(BitmapDescriptorFactory.fromResource(R.drawable.nucleusg)) + .image(BitmapDescriptorFactory.fromResource(NUCLEUS_MAPS.get(currentFloor))) .positionFromBounds(NUCLEUS)); isIndoorMapSet = true; - // Nucleus has an LG floor so G floor is at index 1 - currentFloor=1; - floorHeight=NUCLEUS_FLOOR_HEIGHT; + floorHeight = NUCLEUS_FLOOR_HEIGHT; + Log.d("Overlay", "Nucleus: Initial overlay set to floor " + currentFloor); + } } - // Setting overlay if in Library and not already set - else if (BuildingPolygon.inLibrary(currentLocation) && !isIndoorMapSet) { + else if (BuildingPolygon.inLibrary(currentLocation)) { + if (!isIndoorMapSet) { + currentFloor = 0; groundOverlay = gMap.addGroundOverlay(new GroundOverlayOptions() - .image(BitmapDescriptorFactory.fromResource(R.drawable.libraryg)) + .image(BitmapDescriptorFactory.fromResource(LIBRARY_MAPS.get(currentFloor))) .positionFromBounds(LIBRARY)); isIndoorMapSet = true; - currentFloor=0; - floorHeight=LIBRARY_FLOOR_HEIGHT; + floorHeight = LIBRARY_FLOOR_HEIGHT; + Log.d("Overlay", "Library: Initial overlay set to floor " + currentFloor); + } } - // Removing overlay if user no longer in area with indoor maps available else if (!BuildingPolygon.inLibrary(currentLocation) && - !BuildingPolygon.inNucleus(currentLocation)&& isIndoorMapSet){ + !BuildingPolygon.inNucleus(currentLocation) && isIndoorMapSet){ groundOverlay.remove(); isIndoorMapSet = false; - currentFloor=0; - } + currentFloor = 0; + Log.d("Overlay", "Removed overlay"); + } } catch (Exception ex) { Log.e("Error with overlay, Exception:", ex.toString()); } diff --git a/app/src/main/java/com/openpositioning/PositionMe/MainActivity.java b/app/src/main/java/com/openpositioning/PositionMe/MainActivity.java new file mode 100644 index 00000000..df3e6d11 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/MainActivity.java @@ -0,0 +1,672 @@ +package com.openpositioning.PositionMe; + +import android.Manifest; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.provider.Settings; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; +import androidx.navigation.fragment.NavHostFragment; +import androidx.navigation.ui.AppBarConfiguration; +import androidx.navigation.ui.NavigationUI; +import androidx.preference.PreferenceManager; + +import com.google.android.gms.maps.model.LatLng; +import com.openpositioning.PositionMe.sensors.Observer; +import com.openpositioning.PositionMe.sensors.SensorFusion; + +/** + * 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 com.openpositioning.PositionMe.fragments.HomeFragment the initial fragment displayed. + * @see 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 Static variables + // Static IDs for permission responses. + private static final int REQUEST_ID_WIFI_PERMISSION = 99; + private static final int REQUEST_ID_LOCATION_PERMISSION = 98; + private static final int REQUEST_ID_READ_WRITE_PERMISSION = 97; + private static final int REQUEST_ID_ACTIVITY_PERMISSION = 96; + //endregion + + //region Instance variables + private NavController navController; + + private SharedPreferences settings; + private SensorFusion sensorFusion; + private Handler httpResponseHandler; + private LatLng lastPosition = null; + private boolean hasCalibrated = false; + + //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 = navHostFragment.getNavController(); + + // Set action bar + Toolbar toolbar = findViewById(R.id.main_toolbar); + setSupportActionBar(toolbar); + toolbar.showOverflowMenu(); + toolbar.setBackgroundColor(ContextCompat.getColor(getApplicationContext(), R.color.primaryBlue)); + toolbar.setTitleTextColor(ContextCompat.getColor(getApplicationContext(), R.color.white)); + + // Set up back action + 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(); + + //Check Permissions + if(ActivityCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_WIFI_STATE) != PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, + Manifest.permission.CHANGE_WIFI_STATE) != PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, + Manifest.permission.INTERNET) != PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, + Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, + Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(this, + Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED){ + askLocationPermissions(); + } + // Handler for global toasts and popups from other classes + this.httpResponseHandler = new Handler(); + + // 初始化 SensorFusion + this.sensorFusion = SensorFusion.getInstance(); + this.sensorFusion.setContext(getApplicationContext()); + this.sensorFusion.registerForServerUpdate(this); + + // 立即开始监听传感器 + this.sensorFusion.resumeListening(); + } + + /** + * {@inheritDoc} + */ + @Override + public void onPause() { + super.onPause(); + //Ensure sensorFusion has been initialised before unregistering listeners + if(sensorFusion != null) { + 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(); + //Check if permissions are granted before resuming listeners + if(ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission + (this, Manifest.permission.ACCESS_COARSE_LOCATION) + != PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission + (this,Manifest.permission.ACCESS_WIFI_STATE) + != PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission + (this,Manifest.permission.CHANGE_WIFI_STATE) + != PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission + (this,Manifest.permission.INTERNET) + != PackageManager.PERMISSION_GRANTED){ + askLocationPermissions(); + } + //If permissions are granted resume listeners + else { + if(sensorFusion == null) { + allPermissionsObtained(); + } + else{ + sensorFusion.resumeListening(); + } + } + } + + /** + * 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(); + } + super.onDestroy(); + } + + //endregion + + //region Permissions + + /** + * Checks for location permissions. + * If location permissions are not present, request the permissions through the OS. + * If permissions are present, check for the next set of required permissions with + * {@link MainActivity#askWifiPermissions()} + * + * @see MainActivity#onRequestPermissionsResult(int, String[], int[]) handling request responses. + */ + private void askLocationPermissions() { + // Check for location permission + int coarseLocationPermission = ActivityCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_COARSE_LOCATION); + int fineLocationPermission = ActivityCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_FINE_LOCATION); + int internetPermission = ActivityCompat.checkSelfPermission(this, + Manifest.permission.INTERNET); + + // Request if not present + if(coarseLocationPermission != PackageManager.PERMISSION_GRANTED || + fineLocationPermission != PackageManager.PERMISSION_GRANTED || + internetPermission != PackageManager.PERMISSION_GRANTED) { + this.requestPermissions( + new String[]{ + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.INTERNET}, + REQUEST_ID_LOCATION_PERMISSION + ); + } + else{ + // Check other permissions if present + askWifiPermissions(); + } + } + + /** + * Checks for wifi permissions. + * If wifi permissions are not present, request the permissions through the OS. + * If permissions are present, check for the next set of required permissions with + * {@link MainActivity#askStoragePermission()} + * + * @see MainActivity#onRequestPermissionsResult(int, String[], int[]) handling request responses. + */ + private void askWifiPermissions() { + // Check for wifi permissions + int wifiAccessPermission = ActivityCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_WIFI_STATE); + int wifiChangePermission = ActivityCompat.checkSelfPermission(this, + Manifest.permission.CHANGE_WIFI_STATE); + + // Request if not present + if(wifiAccessPermission != PackageManager.PERMISSION_GRANTED || + wifiChangePermission != PackageManager.PERMISSION_GRANTED) { + requestPermissions( + new String[]{Manifest.permission.ACCESS_WIFI_STATE, + Manifest.permission.CHANGE_WIFI_STATE}, + REQUEST_ID_WIFI_PERMISSION + ); + } + else{ + // Check other permissions if present + askMotionPermissions(); + } + } + + /** + * Checks for storage permissions. + * If storage permissions are not present, request the permissions through the OS. + * If permissions are present, check for the next set of required permissions with + * {@link MainActivity#askMotionPermissions()} + * + * @see MainActivity#onRequestPermissionsResult(int, String[], int[]) handling request responses. + */ + private void askStoragePermission() { + // Check for storage permission + int writeStoragePermission = ActivityCompat.checkSelfPermission(this, + Manifest.permission.WRITE_EXTERNAL_STORAGE); + int readStoragePermission = ActivityCompat.checkSelfPermission(this, + Manifest.permission.READ_EXTERNAL_STORAGE); + // Request if not present + if(writeStoragePermission != PackageManager.PERMISSION_GRANTED || + readStoragePermission != PackageManager.PERMISSION_GRANTED) { + this.requestPermissions( + new String[]{ + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE}, + REQUEST_ID_READ_WRITE_PERMISSION + ); + } + else { + // Check other permissions if present + askMotionPermissions(); + } + } + + /** + * Checks for motion activity permissions. + * If storage permissions are not present, request the permissions through the OS. + * If permissions are present, all permissions have been granted, move on to + * {@link MainActivity#allPermissionsObtained()} to initialise SensorFusion. + * + * @see MainActivity#onRequestPermissionsResult(int, String[], int[]) handling request responses. + */ + private void askMotionPermissions() { + // Check for motion activity permission + if(Build.VERSION.SDK_INT >= 29) { + int activityPermission = ActivityCompat.checkSelfPermission(this, + Manifest.permission.ACTIVITY_RECOGNITION); + // Request if not present + if(activityPermission != PackageManager.PERMISSION_GRANTED) { + this.requestPermissions( + new String[]{ + Manifest.permission.ACTIVITY_RECOGNITION}, + REQUEST_ID_ACTIVITY_PERMISSION + ); + } + // Move to finishing function if present + else allPermissionsObtained(); + } + + else allPermissionsObtained(); + } + + /** + * {@inheritDoc} + * When a new set of permissions are granted, move on to the next on in the chain of permissions. + * Once all permissions are granted, call {@link MainActivity#allPermissionsObtained()}. If any + * permissions are denied display 1st time warning pop-up message as the application cannot + * function without the required permissions. If permissions are denied twice, display a new + * pop-up message, as the OS will not ask for them again, and the user will need to enter the + * app settings menu. + * + * @see MainActivity#askLocationPermissions() first permission request function in the chain. + * @see MainActivity#askWifiPermissions() second permission request function in the chain. + * @see MainActivity#askStoragePermission() third permission request function in the chain. + * @see MainActivity#askMotionPermissions() last permission request function in the chain. + * @see MainActivity#allPermissionsObtained() once all permissions are granted. + * @see MainActivity#permissionsDeniedFirst() display first pop-up message. + * @see MainActivity#permissionsDeniedPermanent() permissions denied twice, pop-up with link to + * the appropiate settings menu. + */ + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + switch (requestCode) { + case REQUEST_ID_LOCATION_PERMISSION: { // Location permissions + // If request is cancelled results are empty + if (grantResults.length > 1 && + grantResults[0] == PackageManager.PERMISSION_GRANTED && + grantResults[1] == PackageManager.PERMISSION_GRANTED && + grantResults[2] == PackageManager.PERMISSION_GRANTED) { + Toast.makeText(this, "Location permissions granted!", Toast.LENGTH_SHORT).show(); + this.settings.edit().putBoolean("gps", true).apply(); + askWifiPermissions(); + } + else { + if(!settings.getBoolean("permanentDeny", false)) { + permissionsDeniedFirst(); + } + else permissionsDeniedPermanent(); + Toast.makeText(this, "Location permissions denied!", Toast.LENGTH_SHORT).show(); + // Unset setting + this.settings.edit().putBoolean("gps", false).apply(); + } + break; + + } + case REQUEST_ID_WIFI_PERMISSION: { // Wifi permissions + // If request is cancelled results are empty + if (grantResults.length > 1 && + grantResults[0] == PackageManager.PERMISSION_GRANTED && + grantResults[1] == PackageManager.PERMISSION_GRANTED) { + Toast.makeText(this, "Permissions granted!", Toast.LENGTH_SHORT).show(); + this.settings.edit().putBoolean("wifi", true).apply(); + askStoragePermission(); + } + else { + if(!settings.getBoolean("permanentDeny", false)) { + permissionsDeniedFirst(); + } + else permissionsDeniedPermanent(); + Toast.makeText(this, "Wifi permissions denied!", Toast.LENGTH_SHORT).show(); + // Unset setting + this.settings.edit().putBoolean("wifi", false).apply(); + } + break; + } + case REQUEST_ID_READ_WRITE_PERMISSION: { // Read write permissions + // If request is cancelled results are empty + if (grantResults.length > 1 && + grantResults[0] == PackageManager.PERMISSION_GRANTED && + grantResults[1] == PackageManager.PERMISSION_GRANTED) { + Toast.makeText(this, "Permissions granted!", Toast.LENGTH_SHORT).show(); + askMotionPermissions(); + } + else { + if(!settings.getBoolean("permanentDeny", false)) { + permissionsDeniedFirst(); + } + else permissionsDeniedPermanent(); + Toast.makeText(this, "Storage permissions denied!", Toast.LENGTH_SHORT).show(); + } + break; + } + case REQUEST_ID_ACTIVITY_PERMISSION: { // Activity permissions + // If request is cancelled results are empty + if (grantResults.length >= 1 && + grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Toast.makeText(this, "Permissions granted!", Toast.LENGTH_SHORT).show(); + allPermissionsObtained(); + } + else { + if(!settings.getBoolean("permanentDeny", false)) { + permissionsDeniedFirst(); + } + else permissionsDeniedPermanent(); + Toast.makeText(this, "Activity permissions denied!", Toast.LENGTH_SHORT).show(); + } + break; + } + } + } + + /** + * Displays a pop-up alert the first time the permissions have been denied. + * The pop-up explains the purpose of the application and the necessity of the permissions, and + * displays two options. If the "Grant permissions" button is clicked, the permission request + * chain is restarted. If the "Exit application" button is clicked, the app closes. + * + * @see MainActivity#askLocationPermissions() the first in the permission request chain. + * @see MainActivity#onRequestPermissionsResult(int, String[], int[]) handling permission results. + * @see R.string button text resources. + */ + private void permissionsDeniedFirst() { + new AlertDialog.Builder(this) + .setTitle("Permissions denied") + .setMessage("You have denied access to data gathering devices. The primary purpose of this application is to record data.") + .setPositiveButton(R.string.grant, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + settings.edit().putBoolean("permanentDeny", true).apply(); + askLocationPermissions(); + } + }) + .setNegativeButton(R.string.exit, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + settings.edit().putBoolean("permanentDeny", true).apply(); + finishAffinity(); + } + }) + .setIcon(R.mipmap.ic_launcher_simple) + .show(); + } + + /** + * Displays a pop-up alert when permissions have been denied twice. + * The OS will not ask for permissions again on the application's behalf. The pop-up explains + * the purpose of the application and the necessity of the permissions, and displays a button. + * When the "Settings" button is clicked, the app opens the relevant settings menu where + * permissions can be adjusted through an intent. Otherwise the app must be closed by the user + * + * @see R.string button text resources. + */ + private void permissionsDeniedPermanent() { + AlertDialog alertDialog = new AlertDialog.Builder(this) + .setTitle("Permissions are denied, enable them in settings manually") + .setMessage("You have denied necessary sensor permissions for the data recording app. You need to manually enable them in your device's settings.") + .setCancelable(false) + .setPositiveButton("Settings", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + Uri uri = Uri.fromParts("package", getPackageName(), null); + intent.setData(uri); + startActivityForResult(intent, 1000); + } + }) + .setIcon(R.mipmap.ic_launcher_simple) + .create(); + alertDialog.show(); + } + + /** + * 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() { + settings.edit().putBoolean("permanentDeny", false).apply(); + this.sensorFusion = SensorFusion.getInstance(); + this.sensorFusion.setContext(getApplicationContext()); + sensorFusion.resumeListening(); + } + + //endregion + + //region Navigation + + /** + * {@inheritDoc} + * Sets desired animations and navigates to {@link com.openpositioning.PositionMe.fragments.SettingsFragment} + * when the settings wheel in the action bar is clicked. + */ + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if(navController.getCurrentDestination().getId() == item.getItemId()) + return super.onOptionsItemSelected(item); + else { + NavOptions options = new NavOptions.Builder() + .setLaunchSingleTop(true) + .setEnterAnim(R.anim.slide_in_bottom) + .setExitAnim(R.anim.slide_out_top) + .setPopEnterAnim(R.anim.slide_in_top) + .setPopExitAnim(R.anim.slide_out_bottom).build(); + navController.navigate(R.id.action_global_settingsFragment, null, options); + return true; + } + } + + /** + * {@inheritDoc} + * Enables navigating back between fragments. + */ + @Override + public boolean onSupportNavigateUp() { + return navController.navigateUp() || super.onSupportNavigateUp(); + } + + /** + * {@inheritDoc} + * Inflate the designed menu view. + * + * @see R.menu for the xml file. + */ + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_items, menu); + return true; + } + + //endregion + + //region Global toasts + + /** + * {@inheritDoc} + * 处理来自SensorFusion和ServerCommunications的更新 + */ + @Override + public void update(Object[] obj) { + if (obj.length > 0) { + if (obj[0] instanceof Boolean) { + // 处理服务器响应 + if ((Boolean) obj[0]) { + this.httpResponseHandler.post(displayToastTaskSuccess); + } else { + this.httpResponseHandler.post(displayToastTaskFailure); + } + } else if (sensorFusion != null) { + // 检查位置更新 + float lat = sensorFusion.getLatitude(); + float lon = sensorFusion.getLongitude(); + if (lat != 0 && lon != 0) { // 确保有有效的位置 + checkBuildingEntry(new LatLng(lat, lon)); + } + } + } + } + + /** + * Task that displays positive toast on the main UI thread. + * Called when {@link ServerCommunications} successfully uploads a trajectory. + */ + private final Runnable displayToastTaskSuccess = new Runnable() { + @Override + public void run() { + 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 = new Runnable() { + @Override + public void run() { +// Toast.makeText(MainActivity.this, "Failed to complete trajectory upload", Toast.LENGTH_SHORT).show(); + } + }; + + //endregion + + /** + * 检查并处理建筑物进入事件 + * @param newPosition 新的位置 + */ + private void checkBuildingEntry(LatLng newPosition) { + if (lastPosition == null) { + lastPosition = newPosition; + return; + } + + // 检查是否刚进入建筑物 + boolean wasInBuilding = BuildingPolygon.inNucleus(lastPosition) || + BuildingPolygon.inLibrary(lastPosition); + boolean isInBuilding = BuildingPolygon.inNucleus(newPosition) || + BuildingPolygon.inLibrary(newPosition); + + if (!wasInBuilding && isInBuilding && !hasCalibrated) { + // 刚进入建筑物,进行校准 + if (BuildingPolygon.inNucleus(newPosition)) { + // Nucleus大楼,在G层(0层)校准 + sensorFusion.calibrateAtKnownFloor(0); + Log.d("CALIBRATION", "Enter Nucleus, callibrate in G layer"); + } else if (BuildingPolygon.inLibrary(newPosition)) { + // 图书馆,在G层(0层)校准 + sensorFusion.calibrateAtKnownFloor(0); + Log.d("CALIBRATION", "Enter Library, callibrate in G layer"); + } + hasCalibrated = true; + + // 显示校准提示 + runOnUiThread(() -> { + Toast.makeText(this, "Callibrated at current floor", Toast.LENGTH_SHORT).show(); + }); + } else if (!isInBuilding) { + // 离开建筑物,重置校准标志 + hasCalibrated = false; + } + + lastPosition = newPosition; + } + + // 添加手动校准的方法 + public void calibrateAtCurrentFloor(View view) { + if (sensorFusion != null) { + LatLng currentPosition = new LatLng( + sensorFusion.getLatitude(), + sensorFusion.getLongitude() + ); + + int floorToCalibrate = 0; // 默认在G层校准 + + // 根据位置确定校准楼层 + if (BuildingPolygon.inNucleus(currentPosition)) { + // 在Nucleus大楼,可以根据实际情况选择校准楼层 + floorToCalibrate = 0; // G层 + } else if (BuildingPolygon.inLibrary(currentPosition)) { + // 在图书馆,可以根据实际情况选择校准楼层 + floorToCalibrate = 0; // G层 + } + + sensorFusion.calibrateAtKnownFloor(floorToCalibrate); + Toast.makeText(this, "Callibrated at current floor", Toast.LENGTH_SHORT).show(); + } + } +} \ 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/PathView.java similarity index 98% rename from app/src/main/java/com/openpositioning/PositionMe/utils/PathView.java rename to app/src/main/java/com/openpositioning/PositionMe/PathView.java index 5a5efa8d..cc8087ff 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/PathView.java +++ b/app/src/main/java/com/openpositioning/PositionMe/PathView.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.utils; +package com.openpositioning.PositionMe; import android.content.Context; import android.graphics.Canvas; @@ -8,7 +8,7 @@ import android.util.AttributeSet; import android.view.View; -import com.openpositioning.PositionMe.presentation.fragment.CorrectionFragment; +import com.openpositioning.PositionMe.fragments.CorrectionFragment; import com.openpositioning.PositionMe.sensors.SensorFusion; import java.util.ArrayList; diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java b/app/src/main/java/com/openpositioning/PositionMe/PdrProcessing.java similarity index 54% rename from app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java rename to app/src/main/java/com/openpositioning/PositionMe/PdrProcessing.java index 9765b044..835b0112 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/PdrProcessing.java +++ b/app/src/main/java/com/openpositioning/PositionMe/PdrProcessing.java @@ -1,19 +1,19 @@ -package com.openpositioning.PositionMe.utils; +package com.openpositioning.PositionMe; import android.content.Context; import android.content.SharedPreferences; import android.hardware.SensorManager; +import android.util.Log; import androidx.preference.PreferenceManager; import com.openpositioning.PositionMe.sensors.SensorFusion; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.OptionalDouble; -import java.util.Objects; -import java.util.stream.Collectors; /** * Processes data recorded in the {@link SensorFusion} class and calculates live PDR estimates. @@ -28,16 +28,15 @@ public class PdrProcessing { //region Static variables // Weiberg algorithm coefficient for stride calculations - private static final float K = 0.364f; + private static final float K = 0.4f; // Number of samples (seconds) to keep as memory for elevation calculation private static final int elevationSeconds = 4; // Number of samples (0.01 seconds) private static final int accelSamples = 100; // Threshold used to detect significant movement - private static final float movementThreshold = 0.3f; // m/s^2 + private static final float movementThreshold = 0.4f; // Threshold under which movement is considered non-existent - private static final float epsilon = 0.18f; - private static final int MIN_REQUIRED_SAMPLES = 2; + private static final float epsilon = 0.25f; //endregion //region Instance variables @@ -71,6 +70,36 @@ public class PdrProcessing { // Step sum and length aggregation variables private float sumStepLength = 0; private int stepCount = 0; + + // 修改陀螺仪相关变量 + private float[] gyroBuffer = new float[5]; // 减少到5个样本 + private int gyroBufferIndex = 0; + private float gyroBias = 0; + private float lastHeading = 0; + + // 调整卡尔曼滤波器参数以更快响应变化 + private float headingState = 0; + private float headingCovariance = 1.0f; + private static final float Q = 0.05f; // 降低过程噪声,使滤波器更稳定 + private static final float R = 0.15f; // 增加测量噪声,减少传感器噪声影响 + + // 磁力计校准相关变量 + private float[] magnetometerBuffer = new float[10]; // 存储最近10个磁力计读数 + private int magnetometerIndex = 0; + private boolean isMagnetometerCalibrated = false; + private static final float MAGNETOMETER_THRESHOLD = 0.5f; // 磁力计数据稳定性阈值 + + // 初始方向校准等待时间 + private static final int CALIBRATION_WAIT_TIME = 2000; // 2秒 + private long calibrationStartTime = 0; + private boolean isCalibrating = false; + + // WGS84椭球体参数 + private static final double WGS84_A = 6378137.0; // 长半轴 + private static final double WGS84_F = 1/298.257223563; // 扁率 + private static final double WGS84_B = WGS84_A * (1 - WGS84_F); // 短半轴 + private static final double WGS84_E = Math.sqrt(1 - Math.pow(WGS84_B/WGS84_A, 2)); // 第一偏心率 + //endregion /** @@ -131,50 +160,158 @@ public PdrProcessing(Context context) { } /** - * 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 - * step. - * - * @param currentStepEnd relative time in milliseconds since the start of the recording. - * @param accelMagnitudeOvertime recorded acceleration magnitudes since the last step. - * @param headingRad heading relative to magnetic north in radians. + * 将大地坐标转换为ECEF坐标 + * @param lat 纬度(弧度) + * @param lon 经度(弧度) + * @param h 大地高(米) + * @return ECEF坐标 [X, Y, Z] */ - public float[] updatePdr(long currentStepEnd, List accelMagnitudeOvertime, float headingRad) { - if (accelMagnitudeOvertime == null || accelMagnitudeOvertime.size() < MIN_REQUIRED_SAMPLES) { - return new float[]{this.positionX, this.positionY}; // Return current position without update - // - TODO - temporary solution of the empty list issue - } + private double[] geodetic2ECEF(double lat, double lon, double h) { + double N = WGS84_A / Math.sqrt(1 - Math.pow(WGS84_E * Math.sin(lat), 2)); + double X = (N + h) * Math.cos(lat) * Math.cos(lon); + double Y = (N + h) * Math.cos(lat) * Math.sin(lon); + double Z = (N * (1 - Math.pow(WGS84_E, 2)) + h) * Math.sin(lat); + return new double[]{X, Y, Z}; + } + + /** + * 将ECEF坐标转换为ENU局部坐标系 + * @param X ECEF X坐标 + * @param Y ECEF Y坐标 + * @param Z ECEF Z坐标 + * @param refLat 参考点纬度(弧度) + * @param refLon 参考点经度(弧度) + * @param refH 参考点大地高(米) + * @return ENU坐标 [E, N, U] + */ + private double[] ECEF2ENU(double X, double Y, double Z, double refLat, double refLon, double refH) { + // 计算参考点的ECEF坐标 + double[] refECEF = geodetic2ECEF(refLat, refLon, refH); + + // 计算相对位置 + double dX = X - refECEF[0]; + double dY = Y - refECEF[1]; + double dZ = Z - refECEF[2]; + + // 转换矩阵 + double sinLat = Math.sin(refLat); + double cosLat = Math.cos(refLat); + double sinLon = Math.sin(refLon); + double cosLon = Math.cos(refLon); + + // 计算ENU坐标 + double E = -sinLon * dX + cosLon * dY; + double N = -sinLat * cosLon * dX - sinLat * sinLon * dY + cosLat * dZ; + double U = cosLat * cosLon * dX + cosLat * sinLon * dY + sinLat * dZ; + + return new double[]{E, N, U}; + } - // Change angle so zero rad is east - float adaptedHeading = (float) (Math.PI/2 - headingRad); + /** + * 更新陀螺仪数据 + * @param gyroZ 陀螺仪Z轴角速度 + */ + public void updateGyro(float gyroZ) { + // 更新陀螺仪缓冲区 + gyroBuffer[gyroBufferIndex] = gyroZ; + gyroBufferIndex = (gyroBufferIndex + 1) % gyroBuffer.length; + + // 计算陀螺仪零偏 + float sum = 0; + for (float value : gyroBuffer) { + sum += value; + } + gyroBias = sum / gyroBuffer.length; + } + + /** + * 卡尔曼滤波处理航向角 + * @param measurement 测量值(来自磁力计) + * @return 滤波后的航向角 + */ + private float kalmanFilter(float measurement) { + // 预测步骤 + float predictedState = headingState; // 假设航向角变化不大 + float predictedCovariance = headingCovariance + Q; + + // 更新步骤 + float kalmanGain = predictedCovariance / (predictedCovariance + R); + headingState = predictedState + kalmanGain * (measurement - predictedState); + headingCovariance = (1 - kalmanGain) * predictedCovariance; + + // 标准化航向角到[-π, π]范围 + while (headingState > Math.PI) headingState -= 2 * Math.PI; + while (headingState < -Math.PI) headingState += 2 * Math.PI; + + return headingState; + } - // check if accelMagnitudeOvertime is empty - if (accelMagnitudeOvertime == null || accelMagnitudeOvertime.isEmpty()) { - // return current position, do not update - return new float[]{this.positionX, this.positionY}; + /** + * 更新PDR坐标,包含WGS84转换 + */ + public float[] updatePdr(long currentStepEnd, List accelMagnitudeOvertime, float headingRad) { + // 初始方向校准 + if (!isCalibrating) { + calibrationStartTime = currentStepEnd; + isCalibrating = true; + } + + // 在校准期间收集磁力计数据 + if (isCalibrating && currentStepEnd - calibrationStartTime < CALIBRATION_WAIT_TIME) { + // 收集磁力计数据 + magnetometerBuffer[magnetometerIndex] = headingRad; + magnetometerIndex = (magnetometerIndex + 1) % magnetometerBuffer.length; + + // 检查磁力计数据稳定性 + if (magnetometerIndex == 0) { + float variance = calculateVariance(magnetometerBuffer); + if (variance < MAGNETOMETER_THRESHOLD) { + isMagnetometerCalibrated = true; + // 使用稳定的磁力计数据作为初始方向 + headingState = calculateMean(magnetometerBuffer); + headingCovariance = 0.1f; // 降低初始不确定性 + } + } + } + + // 使用卡尔曼滤波处理航向角 + float filteredHeading = kalmanFilter(headingRad); + + // 计算航向角变化 + float headingChange = filteredHeading - lastHeading; + lastHeading = filteredHeading; + + // 限制航向角变化幅度 + if (Math.abs(headingChange) > Math.PI/2) { + headingChange = (float) (Math.signum(headingChange) * Math.PI/2); } - // Calculate step length + // 使用滤波后的航向角计算位置 + float adaptedHeading = (float) (Math.PI/2 - filteredHeading); + + // 计算步长 if(!useManualStep) { - //ArrayList accelMagnitudeFiltered = filter(accelMagnitudeOvertime); - // Estimate stride this.stepLength = weibergMinMax(accelMagnitudeOvertime); - // System.err.println("Step Length" + stepLength); + // 限制步长范围 + if (this.stepLength < 0.25f) { + this.stepLength = 0.25f; // 最小步长为0.25米 + } else if (this.stepLength > 0.75f) { + this.stepLength = 0.75f; // 最大步长为0.75米 + } } - - // Increment aggregate variables - sumStepLength += stepLength; - stepCount++; - - // Translate to cartesian coordinate system - float x = (float) (stepLength * Math.cos(adaptedHeading)); - float y = (float) (stepLength * Math.sin(adaptedHeading)); - - // Update position values - this.positionX += x; - this.positionY += y; - - // return current position + + // 计算局部ENU坐标系中的位移 + float deltaE = (float) (stepLength * Math.cos(adaptedHeading)); + float deltaN = (float) (stepLength * Math.sin(adaptedHeading)); + + // 更新位置 + this.positionX += deltaE; + this.positionY += deltaN; + + // 记录步长信息用于调试 + Log.d("PdrProcessing", String.format("步长: %.2fm, 方向: %.1f°, 位移: dX=%.2f, dY=%.2f", + stepLength, (float)Math.toDegrees(adaptedHeading), deltaE, deltaN)); + return new float[]{this.positionX, this.positionY}; } @@ -233,32 +370,109 @@ public float updateElevation(float absoluteElevation) { * @return float stride length in meters. */ private float weibergMinMax(List accelMagnitude) { - // if the list itself is null or empty, return 0 (or return other default values as needed) + // 检查列表是否为空,避免应用崩溃 if (accelMagnitude == null || accelMagnitude.isEmpty()) { - return 0f; + Log.w("PdrProcessing", "accelMagnitude 列表为空,weibergMinMax 返回默认值 0.0f"); + return 0.0f; // 返回默认步长,避免异常 } - // filter out null values from the list - List validAccel = accelMagnitude.stream() - .filter(Objects::nonNull) - .collect(Collectors.toList()); - if (validAccel.isEmpty()) { - return 0f; + // 对加速度列表进行过滤,减少噪声影响 + List filteredAccelMagnitude = filterAcceleration(accelMagnitude); + + double maxAccel = Collections.max(filteredAccelMagnitude); + double minAccel = Collections.min(filteredAccelMagnitude); + + // 最小加速度差异阈值,避免静止状态下微小振动导致的错误步长计算 + double accelThreshold = 0.45; + if ((maxAccel - minAccel) < accelThreshold) { + Log.d("PdrProcessing", String.format("加速度差异(%.2f)低于阈值(%.2f),使用默认步长", + (maxAccel - minAccel), accelThreshold)); + return 0.5f; // 使用默认步长 } - - // calculate max and min values - double maxAccel = Collections.max(validAccel); - double minAccel = Collections.min(validAccel); - - // calculate bounce + float bounce = (float) Math.pow((maxAccel - minAccel), 0.25); + + // 输出计算过程,便于调试 + Log.d("PdrProcessing", String.format("加速度 - 最大: %.2f, 最小: %.2f, 波动值: %.2f", + maxAccel, minAccel, bounce)); - // determine which constant to use based on settings if (this.settings.getBoolean("overwrite_constants", false)) { - return bounce * Float.parseFloat(settings.getString("weiberg_k", "0.934")) * 2; + float customK = Float.parseFloat(settings.getString("weiberg_k", "0.934")); + float stepLen = bounce * customK * 2; + Log.d("PdrProcessing", "使用自定义K值: " + customK + ", 计算步长: " + stepLen); + return stepLen; } + + float stepLen = bounce * K * 2; + Log.d("PdrProcessing", "使用默认K值: " + K + ", 计算步长: " + stepLen); + return stepLen; + } - return bounce * K * 2; + /** + * 过滤加速度数据,减少噪声影响 + * @param accelMagnitude 原始加速度数据列表 + * @return 过滤后的加速度数据列表 + */ + private List filterAcceleration(List accelMagnitude) { + if (accelMagnitude.size() <= 3) { + return accelMagnitude; // 数据点太少,不进行过滤 + } + + // 使用更强的滤波处理 + List filtered = new ArrayList<>(); + double sum = 0; + + // 先计算平均值以检测异常值 + for (Double value : accelMagnitude) { + sum += value; + } + double mean = sum / accelMagnitude.size(); + + // 计算标准差 + double variance = 0; + for (Double value : accelMagnitude) { + variance += Math.pow(value - mean, 2); + } + double stdDev = Math.sqrt(variance / accelMagnitude.size()); + double threshold = stdDev * 2.0; // 设置异常值阈值为2倍标准差 + + // 排除异常值并进行中值滤波 + for (int i = 1; i < accelMagnitude.size() - 1; i++) { + // 检查是否是异常值 + if (Math.abs(accelMagnitude.get(i) - mean) > threshold) { + // 异常值用相邻两点的平均值替代 + filtered.add((accelMagnitude.get(i-1) + accelMagnitude.get(i+1)) / 2); + } else { + // 非异常值使用中值滤波 + List window = new ArrayList<>(); + window.add(accelMagnitude.get(i-1)); + window.add(accelMagnitude.get(i)); + window.add(accelMagnitude.get(i+1)); + + Collections.sort(window); + filtered.add(window.get(1)); + } + } + + // 处理首尾两点 + if (accelMagnitude.size() > 0) { + // 检查首点是否是异常值 + if (accelMagnitude.size() > 1 && Math.abs(accelMagnitude.get(0) - mean) > threshold) { + filtered.add(0, accelMagnitude.get(1)); // 用第二点替代 + } else { + filtered.add(0, accelMagnitude.get(0)); + } + + // 检查尾点是否是异常值 + int lastIdx = accelMagnitude.size() - 1; + if (accelMagnitude.size() > 1 && Math.abs(accelMagnitude.get(lastIdx) - mean) > threshold) { + filtered.add(accelMagnitude.get(lastIdx - 1)); // 用倒数第二点替代 + } else { + filtered.add(accelMagnitude.get(lastIdx)); + } + } + + return filtered; } /** @@ -414,4 +628,28 @@ public float getAverageStepLength(){ return averageStepLength; } + /** + * 计算数组的方差 + */ + private float calculateVariance(float[] data) { + float mean = calculateMean(data); + float sumSquaredDiff = 0; + for (float value : data) { + float diff = value - mean; + sumSquaredDiff += diff * diff; + } + return sumSquaredDiff / data.length; + } + + /** + * 计算数组的平均值 + */ + private float calculateMean(float[] data) { + float sum = 0; + for (float value : data) { + sum += value; + } + return sum / data.length; + } + } diff --git a/app/src/main/java/com/openpositioning/PositionMe/data/remote/ServerCommunications.java b/app/src/main/java/com/openpositioning/PositionMe/ServerCommunications.java similarity index 52% rename from app/src/main/java/com/openpositioning/PositionMe/data/remote/ServerCommunications.java rename to app/src/main/java/com/openpositioning/PositionMe/ServerCommunications.java index 7f7e74b2..92bc6603 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/data/remote/ServerCommunications.java +++ b/app/src/main/java/com/openpositioning/PositionMe/ServerCommunications.java @@ -1,60 +1,41 @@ -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; +package com.openpositioning.PositionMe; import android.content.Context; import android.content.SharedPreferences; import android.net.ConnectivityManager; import android.net.NetworkInfo; -import android.os.Build; -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.fragments.FilesFragment; import com.openpositioning.PositionMe.sensors.Observable; import com.openpositioning.PositionMe.sensors.Observer; -import java.io.ByteArrayOutputStream; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; import java.io.File; import java.io.FileOutputStream; +import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; +import java.util.Map; import okhttp3.Call; -import okhttp3.Callback; import okhttp3.Headers; import okhttp3.MediaType; import okhttp3.MultipartBody; -import okhttp3.OkHttp; import okhttp3.OkHttpClient; -import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; @@ -71,10 +52,9 @@ * @author Mate Stodulka */ 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; @@ -85,6 +65,8 @@ public class ServerCommunications implements Observable { private boolean success; private List observers; + private List> entryList; + // Static constants necessary for communications private static final String userKey = BuildConfig.OPENPOSITIONING_API_KEY; private static final String masterKey = BuildConfig.OPENPOSITIONING_MASTER_KEY; @@ -120,6 +102,10 @@ public ServerCommunications(Context context) { this.observers = new ArrayList<>(); } + public void setEntryList(List> entryList) { + this.entryList = entryList; + } + /** * 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 @@ -128,26 +114,16 @@ public ServerCommunications(Context context) { * @param trajectory Traj object matching all the timing and formal restrictions. */ public void sendTrajectory(Traj.Trajectory trajectory){ - logDataSize(trajectory); // Convert the trajectory to byte array byte[] binaryTrajectory = trajectory.toByteArray(); - 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); - if (path == null) { - path = context.getFilesDir(); - } - } else { // for android 12 or lower use internal storage - path = context.getFilesDir(); - } - - System.out.println(path.toString()); + // Get the directory path for storing the file with the trajectory + File path = context.getFilesDir(); // Format the file name according to date - SimpleDateFormat dateFormat = new SimpleDateFormat("dd-MM-yy-HH-mm-ss"); + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss"); + dateFormat.setTimeZone(java.util.TimeZone.getDefault()); // 使用本地时区 Date date = new Date(); File file = new File(path, "trajectory_" + dateFormat.format(date) + ".txt"); @@ -176,16 +152,16 @@ public void sendTrajectory(Traj.Trajectory trajectory){ // 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)) + RequestBody.create(MediaType.parse("application/json"), file)) .build(); // Create a POST request with the required headers - Request request = new Request.Builder().url(uploadURL).post(requestBody) + okhttp3.Request request = new okhttp3.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() { + client.newCall(request).enqueue(new okhttp3.Callback() { // Handle failure to get response from the server @Override public void onFailure(Call call, IOException e) { @@ -197,17 +173,6 @@ public void sendTrajectory(Traj.Trajectory trajectory){ notifyObservers(1); } - 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); - } - } - } - // Process the server's response @Override public void onResponse(Call call, Response response) throws IOException { try (ResponseBody responseBody = response.body()) { @@ -218,9 +183,13 @@ private void copyFile(File src, File dst) throws IOException { // 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 + infoResponse = "上传失败: " + errorBody; + Log.e("ServerCommunications", "上传错误: " + errorBody); + + new Handler(Looper.getMainLooper()).post(() -> { + Toast.makeText(context, infoResponse, Toast.LENGTH_LONG).show(); + Log.e("ServerCommunications", "上传错误: " + errorBody); + }); System.err.println("POST error response: " + errorBody); success = false; @@ -236,21 +205,6 @@ private void copyFile(File src, File dst) throws IOException { // 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()); - } - // Delete local file and set success to true success = file.delete(); notifyObservers(1); @@ -265,37 +219,53 @@ private void copyFile(File src, File dst) throws IOException { success = false; notifyObservers(1); } + } /** * Uploads a local trajectory file to the API server in the specified format. - * {@link OkHttp} library is used for the asynchronous POST request. + * {@link okhttp3.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) { + // 从文件名中提取时间并转换为本地时区 + String fileName = localTrajectory.getName(); + File finalTrajectory = localTrajectory; // 初始化为原始文件 + try { + // 提取时间部分 + String timeStr = fileName.substring(fileName.indexOf("_") + 1, fileName.lastIndexOf(".")); + SimpleDateFormat serverFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss"); + serverFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); + java.util.Date utcDate = serverFormat.parse(timeStr); + + // 转换为本地时区 + SimpleDateFormat localFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss"); + localFormat.setTimeZone(java.util.TimeZone.getDefault()); + String localTimeStr = localFormat.format(utcDate); + + // 更新文件名 + String newFileName = fileName.replace(timeStr, localTimeStr); + File newFile = new File(localTrajectory.getParent(), newFileName); + if (!localTrajectory.renameTo(newFile)) { + Log.e("ServerCommunications", "Failed to rename file"); + return; + } + finalTrajectory = newFile; // 更新为新的文件 + } catch (Exception e) { + Log.e("ServerCommunications", "Error processing timezone", e); + // 如果出错,保持使用原始文件 + } + + final File uploadFile = finalTrajectory; // 创建一个final变量用于匿名内部类 // Instantiate client for HTTP requests OkHttpClient client = new OkHttpClient(); - // 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); - } catch (IOException e) { - e.printStackTrace(); - // if failed, use File object to construct RequestBody - fileRequestBody = RequestBody.create(MediaType.parse("text/plain"), localTrajectory); - } - } else { - fileRequestBody = RequestBody.create(MediaType.parse("text/plain"), 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) + .addFormDataPart("file", uploadFile.getName(), + RequestBody.create(MediaType.parse("application/json"), uploadFile)) .build(); // Create a POST request with the required headers @@ -305,34 +275,26 @@ public void uploadLocalTrajectory(File localTrajectory) { // 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) { + @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 - new Handler(Looper.getMainLooper()).post(() -> - Toast.makeText(context, infoResponse, Toast.LENGTH_SHORT).show()); // show error message to users + new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(context, infoResponse, Toast.LENGTH_SHORT).show());//show error message to users } - @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()) { // 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()); + new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(context, infoResponse, Toast.LENGTH_SHORT).show()); throw new IOException("UPLOAD failed with code " + response); } @@ -343,124 +305,16 @@ public void onResponse(Call call, Response response) throws IOException { } // 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(); + success = uploadFile.delete(); notifyObservers(1); } } }); } - /** - * 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(); - String line; - 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(); - try { - 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(); - } - } - - 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 (!recordsFile.exists()) { - if (recordsFile.createNewFile()) { - jsonObject = new JSONObject(); - } else { - System.err.println("Failed to create file: " + recordsFile.getAbsolutePath()); - 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); - } - } - // If file is empty or invalid JSON, use a fresh 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 @@ -468,99 +322,114 @@ private void saveDownloadRecord(long startTimestamp, String fileName, String id, * 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 + public void downloadTrajectory(int position) { + if (entryList == null || position >= entryList.size()) { + Log.e("ServerCommunications", "Invalid position or entryList not set"); + return; + } + + // 获取轨迹信息 + Map trajectory = entryList.get(position); + String id = trajectory.get("id"); + String utcDateStr = trajectory.get("date_submitted"); + + // 添加时区转换 + try { + // 解析UTC时间字符串 + SimpleDateFormat utcFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX"); + utcFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); + java.util.Date utcDate = utcFormat.parse(utcDateStr); + + // 转换为本地时间 + SimpleDateFormat localFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss"); + localFormat.setTimeZone(java.util.TimeZone.getDefault()); + String localDateStr = localFormat.format(utcDate); + + Log.d("ServerCommunications", "UTC time: " + utcDateStr); + Log.d("ServerCommunications", "Local time: " + localDateStr); + + // 构建云端轨迹文件路径 + String fileName = String.format("trajectory_%s_%s.json", localDateStr, id); + File localDirectory = new File(context.getExternalFilesDir(null), "cloud_trajectories"); + Log.d("ServerCommunications", "Cloud trajectories directory: " + localDirectory.getAbsolutePath()); + File localFile = new File(localDirectory, fileName); + + // 如果本地文件已存在,直接返回成功 + if (localFile.exists()) { + Log.d("ServerCommunications", "Local file already exists: " + localFile.getAbsolutePath()); + // 更新映射文件 + updateMappingFile(id, fileName); + new Handler(Looper.getMainLooper()).post(() -> { + success = true; + notifyObservers(1); + }); + return; + } + + // 否则从服务器下载 + downloadFile(id, localFile, localDirectory); + + } catch (Exception e) { + Log.e("ServerCommunications", "Error converting timezone", e); + success = false; + notifyObservers(1); + } + } - // Initialise OkHttp client + private void downloadFile(String id, File localFile, File localDirectory) { OkHttpClient client = new OkHttpClient(); - - // Create GET request with required header + String downloadUrl = downloadURL + "&id=" + id; + okhttp3.Request request = new okhttp3.Request.Builder() - .url(downloadURL) - .addHeader("accept", PROTOCOL_ACCEPT_TYPE) - .get() - .build(); - - // Enqueue the GET request for asynchronous execution + .url(downloadUrl) + .addHeader("accept", PROTOCOL_ACCEPT_TYPE) + .get() + .build(); + client.newCall(request).enqueue(new okhttp3.Callback() { @Override - public void onFailure(Call call, IOException e) { - e.printStackTrace(); + public void onFailure(@NonNull Call call, @NonNull IOException e) { + Log.e("ServerCommunications", "Download failed", e); + new Handler(Looper.getMainLooper()).post(() -> { + success = false; + notifyObservers(1); + }); } @Override - public void onResponse(Call call, Response response) throws IOException { + public void onResponse(@NonNull Call call, @NonNull 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 - 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; - } - zipCount++; + if (!response.isSuccessful()) { + Log.e("ServerCommunications", "Download unsuccessful: " + responseBody.string()); + new Handler(Looper.getMainLooper()).post(() -> { + success = false; + notifyObservers(1); + }); + return; } - // Initialise a byte array output stream - 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); + // 确保目录存在 + if (!localDirectory.exists()) { + localDirectory.mkdirs(); } - - // 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 - long startTimestamp = receivedTrajectory.getStartTimestamp(); - String fileName = "trajectory_" + dateSubmitted + ".txt"; - - // Place the file in your app-specific "Downloads" folder - File appSpecificDownloads = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); - if (appSpecificDownloads != null && !appSpecificDownloads.exists()) { - appSpecificDownloads.mkdirs(); + // 保存文件 + try (FileOutputStream fos = new FileOutputStream(localFile)) { + fos.write(responseBody.bytes()); } - 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()); - } catch (IOException ee) { - System.err.println("Trajectory download failed"); - } finally { - // Close all streams and entries to release resources - zipInputStream.closeEntry(); - byteArrayOutputStream.close(); - zipInputStream.close(); - inputStream.close(); - } + // 更新映射文件 + updateMappingFile(id, localFile.getName()); - // Save the download record - saveDownloadRecord(startTimestamp, fileName, id, dateSubmitted); - loadDownloadRecords(); + Log.d("ServerCommunications", "Download successful: " + localFile.getAbsolutePath()); + new Handler(Looper.getMainLooper()).post(() -> { + success = true; + notifyObservers(1); + }); } } }); - } /** @@ -620,18 +489,6 @@ private void checkNetworkStatus() { } } - - private void logDataSize(Traj.Trajectory trajectory) { - Log.i("ServerCommunications", "IMU Data size: " + trajectory.getImuDataCount()); - Log.i("ServerCommunications", "Position Data size: " + trajectory.getPositionDataCount()); - 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", "APS Data size: " + trajectory.getApsDataCount()); - Log.i("ServerCommunications", "PDR Data size: " + trajectory.getPdrDataCount()); - } - /** * {@inheritDoc} * @@ -642,7 +499,11 @@ private void logDataSize(Traj.Trajectory trajectory) { */ @Override public void registerObserver(Observer o) { - this.observers.add(o); + Log.d("ServerCommunications", "Registering observer: " + o.getClass().getSimpleName()); + if (observers == null) { + observers = new ArrayList<>(); + } + observers.add(o); } /** @@ -655,13 +516,54 @@ public void registerObserver(Observer o) { */ @Override public void notifyObservers(int index) { - for(Observer o : observers) { - if(index == 0 && o instanceof FilesFragment) { - o.update(new String[] {infoResponse}); + Log.d("ServerCommunications", "Notifying observers with success: " + success); + if (observers != null) { + Log.d("ServerCommunications", "Number of observers: " + observers.size()); + for (Observer observer : observers) { + if (index == 0 && observer instanceof FilesFragment) { + observer.update(new String[] {infoResponse}); + } else if (index == 1) { + observer.update(new Boolean[] {success}); + Log.d("ServerCommunications", "Notifying " + observer.getClass().getSimpleName() + " with success: " + success); + } } - else if (index == 1 && o instanceof MainActivity) { - o.update(new Boolean[] {success}); + } else { + Log.e("ServerCommunications", "No observers registered"); + } + } + + // 添加更新映射文件的方法 + private void updateMappingFile(String cloudId, String localFileName) { + try { + File mappingFile = new File(context.getExternalFilesDir(null), "trajectory_mapping.json"); + JSONObject mapping; + + if (mappingFile.exists()) { + // 读取现有映射 + StringBuilder content = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new FileReader(mappingFile))) { + String line; + while ((line = reader.readLine()) != null) { + content.append(line); + } + } + mapping = new JSONObject(content.toString()); + } else { + // 创建新的映射 + mapping = new JSONObject(); + } + + // 更新映射 + mapping.put(cloudId, localFileName); + + // 保存映射文件 + try (FileWriter writer = new FileWriter(mappingFile)) { + writer.write(mapping.toString()); } + + Log.d("ServerCommunications", "Updated mapping file for cloud ID: " + cloudId + " -> " + localFileName); + } catch (Exception e) { + Log.e("ServerCommunications", "Error updating mapping file", e); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/Traj.java b/app/src/main/java/com/openpositioning/PositionMe/Traj.java index 7925fa55..24d55196 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/Traj.java +++ b/app/src/main/java/com/openpositioning/PositionMe/Traj.java @@ -578,7 +578,7 @@ private Trajectory( return Traj.internal_static_Trajectory_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Trajectory_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -1434,7 +1434,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -1450,7 +1450,7 @@ public static final class Builder extends return Traj.internal_static_Trajectory_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Trajectory_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -1463,7 +1463,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -4973,7 +4973,7 @@ private Pdr_Sample( return Traj.internal_static_Pdr_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Pdr_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -5180,7 +5180,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -5196,7 +5196,7 @@ public static final class Builder extends return Traj.internal_static_Pdr_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Pdr_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -5209,7 +5209,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -5694,7 +5694,7 @@ private Motion_Sample( return Traj.internal_static_Motion_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Motion_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -6115,7 +6115,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -6131,7 +6131,7 @@ public static final class Builder extends return Traj.internal_static_Motion_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Motion_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -6144,7 +6144,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -6843,7 +6843,7 @@ private Position_Sample( return Traj.internal_static_Position_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Position_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -7067,7 +7067,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -7083,7 +7083,7 @@ public static final class Builder extends return Traj.internal_static_Position_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Position_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -7096,7 +7096,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -7469,7 +7469,7 @@ private Pressure_Sample( return Traj.internal_static_Pressure_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Pressure_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -7647,7 +7647,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -7663,7 +7663,7 @@ public static final class Builder extends return Traj.internal_static_Pressure_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Pressure_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -7676,7 +7676,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -7985,7 +7985,7 @@ private Light_Sample( return Traj.internal_static_Light_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Light_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -8163,7 +8163,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -8179,7 +8179,7 @@ public static final class Builder extends return Traj.internal_static_Light_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Light_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -8192,7 +8192,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -8587,7 +8587,7 @@ private GNSS_Sample( return Traj.internal_static_GNSS_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_GNSS_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -8926,7 +8926,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -8942,7 +8942,7 @@ public static final class Builder extends return Traj.internal_static_GNSS_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_GNSS_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -8955,7 +8955,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -9561,7 +9561,7 @@ private WiFi_Sample( return Traj.internal_static_WiFi_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_WiFi_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -9761,7 +9761,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -9777,7 +9777,7 @@ public static final class Builder extends return Traj.internal_static_WiFi_Sample_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_WiFi_Sample_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -9790,7 +9790,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -10358,7 +10358,7 @@ private Mac_Scan( return Traj.internal_static_Mac_Scan_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Mac_Scan_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -10560,7 +10560,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -10576,7 +10576,7 @@ public static final class Builder extends return Traj.internal_static_Mac_Scan_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Mac_Scan_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -10589,7 +10589,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -10978,7 +10978,7 @@ private AP_Data( return Traj.internal_static_AP_Data_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_AP_Data_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -11211,7 +11211,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -11227,7 +11227,7 @@ public static final class Builder extends return Traj.internal_static_AP_Data_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_AP_Data_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -11240,7 +11240,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -11712,7 +11712,7 @@ private Sensor_Info( return Traj.internal_static_Sensor_Info_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Sensor_Info_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -12016,7 +12016,7 @@ public Builder toBuilder() { @Override protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -12032,7 +12032,7 @@ public static final class Builder extends return Traj.internal_static_Sensor_Info_descriptor; } - protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable + protected FieldAccessorTable internalGetFieldAccessorTable() { return Traj.internal_static_Sensor_Info_fieldAccessorTable .ensureFieldAccessorsInitialized( @@ -12045,7 +12045,7 @@ private Builder() { } private Builder( - com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { + BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/UtilFunctions.java b/app/src/main/java/com/openpositioning/PositionMe/UtilFunctions.java similarity index 95% rename from app/src/main/java/com/openpositioning/PositionMe/utils/UtilFunctions.java rename to app/src/main/java/com/openpositioning/PositionMe/UtilFunctions.java index cba92328..faf9f5f7 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/UtilFunctions.java +++ b/app/src/main/java/com/openpositioning/PositionMe/UtilFunctions.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.utils; +package com.openpositioning.PositionMe; import android.content.Context; import android.graphics.Bitmap; @@ -8,11 +8,10 @@ import androidx.core.content.ContextCompat; import com.google.android.gms.maps.model.LatLng; -import com.openpositioning.PositionMe.presentation.fragment.RecordingFragment; /** * Class containing utility functions which can used by other classes. - * @see RecordingFragment Currently used by RecordingFragment + * @see com.openpositioning.PositionMe.fragments.RecordingFragment Currently used by RecordingFragment */ public class UtilFunctions { // Constant 1degree of latitiude/longitude (in m) 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 deleted file mode 100644 index 2d2b1cbf..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/data/local/TrajParser.java +++ /dev/null @@ -1,256 +0,0 @@ -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 java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -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 - */ -public class TrajParser { - - private static final String TAG = "TrajParser"; - - /** - * 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; - } - } - - /** 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 - } - - /** - * 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"); - - 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; - - 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); - } - - - 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)); - } - - 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); - } - - return result; - } -/** 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); - - float azimuthDeg = (float) Math.toDegrees(orientationAngles[0]); - return azimuthDeg < 0 ? azimuthDeg + 360.0f : azimuthDeg; -} - -} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/fragments/CorrectionFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/CorrectionFragment.java new file mode 100644 index 00000000..c91e380c --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/CorrectionFragment.java @@ -0,0 +1,230 @@ +package com.openpositioning.PositionMe.fragments; + +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; + +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.PathView; +import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.sensors.SensorFusion; + +/** + * 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. + * + * @see RecordingFragment the preceeding fragment in the nav graph. + * @see HomeFragment the next fragment in the nav graph. + * + * + * @author Michal Dvorak + * @author Mate Stodulka + * @author Virginia Cangelosi + */ +public class CorrectionFragment extends Fragment { + + //Map variable to assign to map fragment + public GoogleMap mMap; + //Button to go to next fragment and save the corrections + private Button button; + //Singleton SensorFusion class which stores data from all sensors + private SensorFusion sensorFusion = SensorFusion.getInstance(); + //TextView to display user instructions + private TextView averageStepLengthText; + //Text Input to edit step length + private EditText stepLengthInput; + //Average step length obtained from SensorFusion class + private float averageStepLength; + //User entered step length + private float newStepLength; + //OnKey is called twice so ensure only the second run updates the previous value for the scaling + private int secondPass = 0; + //Raw text entered by user + private CharSequence changedText; + //Scaling ratio based on size of trajectory + private static float scalingRatio = 0f; + //Initial location of PDR + private static LatLng start; + //Path view on screen + private PathView pathView; + + /** + * Public Constructor for the class. + * Left empty as not required + */ + public CorrectionFragment() { + // Required empty public constructor + } + + /** + * {@inheritDoc} + * Loads the starting position set in {@link StartLocationFragment}, and displays a map fragment. + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + // Inflate the layout for this fragment + View rootView = inflater.inflate(R.layout.fragment_correction, container, false); + + // Inflate the layout for this fragment + ((AppCompatActivity)getActivity()).getSupportActionBar().hide(); + + //Send trajectory data to the cloud + sensorFusion.sendTrajectoryToCloud(); + + //Obtain start position set in the startLocation fragment + float[] startPosition = sensorFusion.getGNSSLatitude(true); + + // Initialize map fragment + SupportMapFragment supportMapFragment=(SupportMapFragment) + getChildFragmentManager().findFragmentById(R.id.map); + + // Asynchronous map which can be configured + 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 the PDR + * trajectory is scaled before being overlaid over the map fragment in + * CorrectionFragment.onViewCreated. + * + * @param map Google map to be configured + */ + @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 and move the camera + start = new LatLng(startPosition[0], startPosition[1]); + mMap.addMarker(new MarkerOptions().position(start).title("Start Position")); + System.out.println("onMapReady scaling ratio: " + scalingRatio); + // Calculate zoom of google maps based on the scaling ration from PathView + double zoom = Math.log(156543.03392f * Math.cos(startPosition[0] * Math.PI / 180) + * scalingRatio) / Math.log(2); + System.out.println("onMapReady zoom: " + zoom); + //Center the camera + mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(start, (float) zoom)); + } + }); + + return rootView; + } + + /** + * {@inheritDoc}. + * Button onClick listener enabled to detect when to go to next fragment and show the action bar. + * Load and display average step length from PDR. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + //Instantiate text view to show average step length + this.averageStepLengthText = (TextView) getView().findViewById(R.id.averageStepView); + //Instantiate input text view to edit average step length + this.stepLengthInput = (EditText) getView().findViewById(R.id.inputStepLength); + //Instantiate path view for drawing trajectory + this.pathView = (PathView) getView().findViewById(R.id.pathView1); + //obtain average step length from SensorFusion class + averageStepLength = sensorFusion.passAverageStepLength(); + //Display average step count on UI + averageStepLengthText.setText(getActivity().getResources().getString(R.string.averageStepLgn) + ": " + String.format("%.2f", averageStepLength)); + //Check for enter to be pressed when user inputs new step length + this.stepLengthInput.setOnKeyListener(new View.OnKeyListener() { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + //Check if enter key has been pressed + if(keyCode == KeyEvent.KEYCODE_ENTER){ + //Convert entered string to a float + newStepLength = Float.parseFloat(changedText.toString()); + //Rescale the path and call function to redraw + //scalingRatio = newStepLength/averageStepLength; + sensorFusion.redrawPath(newStepLength/averageStepLength); + //Show user new average step value + averageStepLengthText.setText(getActivity().getResources(). + getString(R.string.averageStepLgn) + ": " + String.format("%.2f", newStepLength)); + //redraw the path + pathView.invalidate(); + //OnKew is called twice (once on press and release of button so the previous + // step count is updated only the second time) + secondPass++; + if(secondPass == 2) { + averageStepLength = newStepLength; + secondPass = 0; + } + } + + return false; + } + }); + + //Detect changes in the text editor. Call all default methods and store final string + 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) { + //store string when user has finished changing the text + changedText = s; + } + }); + + // Add button to navigate back to home screen. + this.button = (Button) getView().findViewById(R.id.correction_done); + this.button.setOnClickListener(new View.OnClickListener() { + /** + * {@inheritDoc} + * When button clicked the {@link HomeFragment} is loaded and the action bar is + * returned. + */ + @Override + public void onClick(View view) { + NavDirections action = CorrectionFragmentDirections.actionCorrectionFragmentToHomeFragment(); + Navigation.findNavController(view).navigate(action); + //Show action bar + ((AppCompatActivity)getActivity()).getSupportActionBar().show(); + } + }); + } + + /** + * Set the scaling ration for the map fragments. + * + * @param scalingRatio float ratio for scaling zoom on Maps. + */ + public void setScalingRatio(float scalingRatio) { + this.scalingRatio = scalingRatio; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/fragments/FilesFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/FilesFragment.java new file mode 100644 index 00000000..66922cd2 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/FilesFragment.java @@ -0,0 +1,425 @@ +package com.openpositioning.PositionMe.fragments; + +import android.app.AlertDialog; +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.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.cardview.widget.CardView; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.ServerCommunications; +import com.openpositioning.PositionMe.sensors.Observer; +import com.openpositioning.PositionMe.viewitems.DownloadClickListener; +import com.openpositioning.PositionMe.viewitems.TrajDownloadListAdapter; +import com.openpositioning.PositionMe.viewitems.TrajDownloadViewHolder; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.text.SimpleDateFormat; + +/** + * A simple {@link Fragment} subclass. The files fragments displays a list of trajectories already + * uploaded with some metadata, and enabled re-downloading them to the device's local storage. + * + * @see HomeFragment the connected fragment in the nav graph. + * @see UploadFragment sub-menu for uploading recordings that failed during recording. + * @see com.openpositioning.PositionMe.Traj the data structure sent and received. + * @see ServerCommunications the class handling communication with the server. + * + * @author Mate Stodulka + */ +public class FilesFragment extends Fragment implements Observer { + + // UI elements + private RecyclerView filesList; + private TrajDownloadListAdapter listAdapter; + private CardView uploadCard; + private View rootView; // 添加这个变量来存储根视图 + + // Class handling HTTP communication + private ServerCommunications serverCommunications; + + // 添加一个 Set 来记录已下载的文件 ID + private Set downloadedFiles = new HashSet<>(); + + // 添加一个变量来跟踪当前下载的位置 + private int currentDownloadPosition = -1; + + // 添加 entryList 作为类的成员变量 + private List> entryList = new ArrayList<>(); + + private Map cloudToLocalFileMap = new HashMap<>(); + + /** + * Default public constructor, empty. + */ + public FilesFragment() { + // Required empty public constructor + } + + /** + * {@inheritDoc} + * Initialise the server communication class and register the FilesFragment as an Observer to + * receive the async http responses. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + serverCommunications = new ServerCommunications(getActivity()); + serverCommunications.registerObserver(this); + } + + /** + * {@inheritDoc} + * Sets the title in the action bar. + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + // Inflate the layout for this fragment + View view = inflater.inflate(R.layout.fragment_files, container, false); + getActivity().setTitle("Trajectory recordings"); + return view; + } + + /** + * {@inheritDoc} + * Initialises UI elements, including a navigation card to the {@link UploadFragment} and a + * RecyclerView displaying online trajectories. + * + * @see TrajDownloadViewHolder the View Holder for the list. + * @see TrajDownloadListAdapter the list adapter for displaying the recycler view. + * @see R.layout#item_trajectorycard_view the elements in the list. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + // Get recyclerview + filesList = view.findViewById(R.id.filesList); + // Get clickable card view + uploadCard = view.findViewById(R.id.uploadCard); + uploadCard.setOnClickListener(new View.OnClickListener() { + /** + * {@inheritDoc} + * Navigates to {@link UploadFragment}. + */ + @Override + public void onClick(View view) { + NavDirections action = FilesFragmentDirections.actionFilesFragmentToUploadFragment(); + Navigation.findNavController(view).navigate(action); + } + }); + + // Request list of uploaded trajectories from the server. + serverCommunications.sendInfoRequest(); + } + + /** + * {@inheritDoc} + * Called by {@link ServerCommunications} when the response to the HTTP info request is received. + * + * @param singletonStringList a single string wrapped in an object array containing the http + * response from the server. + */ + @Override + public void update(Object[] data) { + if (data[0] instanceof Boolean) { + boolean success = (Boolean) data[0]; + if (success) { + Log.d("FilesFragment", "Download success notification received"); + if (getCurrentDownloadPosition() != -1) { + String id = entryList.get(getCurrentDownloadPosition()).get("id"); + downloadedFiles.add(id); + Log.d("FilesFragment", "Current download position: " + getCurrentDownloadPosition()); + Log.d("FilesFragment", "Updating UI for trajectory ID: " + id); + + // 更新 UI + requireActivity().runOnUiThread(() -> { + if (filesList != null && filesList.getAdapter() != null) { + filesList.getAdapter().notifyItemChanged(getCurrentDownloadPosition()); + Log.d("FilesFragment", "Updated button visibility"); + } + }); + } + } else { + // 下载失败时显示提示 + requireActivity().runOnUiThread(() -> { + Toast.makeText(getContext(), "Downloading ...", Toast.LENGTH_SHORT).show(); + }); + } + } else if (data[0] instanceof String) { + String infoString = (String) data[0]; + Log.d("FilesFragment", "Received info string: " + (infoString != null ? infoString.substring(0, Math.min(100, infoString.length())) : "null")); + if(infoString != null && !infoString.isEmpty()) { + this.entryList = processInfoResponse(infoString); + new Handler(Looper.getMainLooper()).post(() -> { + updateView(this.entryList); + }); + } + } + } + + /** + * Parses the info response string from the HTTP communication. + * Process the data using the Json library and return the matching Java data structure as a + * List of Maps of \. Throws a JSONException if the data is not valid. + * + * @param infoString HTTP info request response as a single string + * @return List of Maps of String to String containing ID, owner ID, and date. + */ + private List> processInfoResponse(String infoString) { + // Initialise empty list + List> entryList = new ArrayList<>(); + try { + // Attempt to decode using known JSON pattern + JSONArray jsonArray = new JSONArray(infoString); + for (int i = 0; i < jsonArray.length(); i++) { + JSONObject trajectoryEntry = jsonArray.getJSONObject(i); + Map entryMap = new HashMap<>(); + entryMap.put("owner_id", String.valueOf(trajectoryEntry.get("owner_id"))); + entryMap.put("date_submitted", (String) trajectoryEntry.get("date_submitted")); + entryMap.put("id", String.valueOf(trajectoryEntry.get("id"))); + // Add decoded map to list of entries + entryList.add(entryMap); + } + } catch (JSONException e) { + System.err.println("JSON reading failed"); + e.printStackTrace(); + } + // Sort the list by the ID fields of the maps + entryList.sort(Comparator.comparing(m -> Integer.parseInt(m.get("id")), Comparator.nullsLast(Comparator.naturalOrder()))); + return entryList; + } + + /** + * Update the RecyclerView in the FilesFragment with new data. + * Must be called from a UI thread. Initialises a new Layout Manager, and passes it to the + * RecyclerView. Initialises a {@link TrajDownloadListAdapter} with the input array and setting + * up a listener so that trajectories are downloaded when clicked, and a pop-up message is + * displayed to notify the user. + * + * @param newEntryList List of Maps of String to String containing metadata about the uploaded + * trajectories (ID, owner ID, date). + */ + private void updateView(List> newEntryList) { + // 更新类的成员变量 + this.entryList = newEntryList; + + // Initialise RecyclerView with Manager and Adapter + LinearLayoutManager manager = new LinearLayoutManager(getActivity()); + filesList.setLayoutManager(manager); + filesList.setHasFixedSize(true); + listAdapter = new TrajDownloadListAdapter(getActivity(), entryList, new DownloadClickListener() { + @Override + public void onPositionClicked(int position) { + Log.d("FilesFragment", "Download clicked for position: " + position); + currentDownloadPosition = position; + + // 获取轨迹信息 + Map trajectory = entryList.get(position); + Log.d("FilesFragment", "Downloading trajectory with ID: " + trajectory.get("id")); + + // 设置 entryList + serverCommunications.setEntryList(entryList); + + // 显示下载中对话框 + AlertDialog downloadingDialog = new AlertDialog.Builder(getContext()) + .setTitle("Downloading...") + .setMessage("Please wait...") + .setCancelable(false) + .show(); + + // 开始下载 + Log.d("FilesFragment", "Starting download..."); + serverCommunications.downloadTrajectory(position); + + // 3秒后关闭对话框 + new Handler().postDelayed(() -> { + Log.d("FilesFragment", "Closing download dialog"); + downloadingDialog.dismiss(); + }, 3000); + } + + @Override + public void onReplayClicked(int position) { + try { + // 在location_logs目录中查找所有轨迹文件 + File directory = new File(getActivity().getExternalFilesDir(null), "location_logs"); + File[] allFiles = directory.listFiles((dir, name) -> + name.startsWith("location_log_local_") && name.endsWith(".json") + ); + + if (allFiles != null && allFiles.length > 0) { + // 找到最新的文件 + File latestFile = allFiles[0]; + for (File file : allFiles) { + if (file.lastModified() > latestFile.lastModified()) { + latestFile = file; + } + } + + Log.d("FilesFragment", "Using latest file: " + latestFile.getAbsolutePath()); + Log.d("FilesFragment", "File last modified: " + new java.util.Date(latestFile.lastModified())); + + // 如果文件修改时间在最近5分钟内,直接使用该文件 + if ((System.currentTimeMillis() - latestFile.lastModified()) < 5 * 60 * 1000) { + Log.d("FilesFragment", "Using recently modified file"); + NavDirections action = FilesFragmentDirections.actionFilesFragmentToReplayFragment(latestFile.getAbsolutePath()); + Navigation.findNavController(requireView()).navigate(action); + return; + } + + // 如果不是最近的文件,则按照原来的逻辑查找匹配的文件 + Map trajectory = entryList.get(position); + String utcDateStr = trajectory.get("date_submitted"); + + // 解析UTC时间字符串 + SimpleDateFormat utcFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX"); + utcFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); + java.util.Date utcDate = utcFormat.parse(utcDateStr); + + // 转换为本地时间 + SimpleDateFormat localFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm"); + localFormat.setTimeZone(java.util.TimeZone.getDefault()); + String minuteTimestamp = localFormat.format(utcDate); // 格式:YYYY-MM-DD_HH-mm + + Log.d("FilesFragment", "Looking for local file with timestamp: " + minuteTimestamp); + + // 查找匹配的文件 + File[] matchingFiles = directory.listFiles((dir, name) -> { + try { + if (!name.startsWith("location_log_local_") || !name.endsWith(".json")) { + return false; + } + + String timestampPart = name.substring("location_log_local_".length(), name.length() - 5); + String[] parts = timestampPart.split("[-_]"); + + if (parts.length < 5) { + return false; + } + + String fileTimestamp = parts[0] + "-" + parts[1] + "-" + parts[2] + "_" + + parts[3] + "-" + parts[4]; + + Log.d("FilesFragment", "Comparing timestamps - File: " + fileTimestamp + ", Target: " + minuteTimestamp); + return fileTimestamp.equals(minuteTimestamp); + } catch (Exception e) { + return false; + } + }); + + if (matchingFiles != null && matchingFiles.length > 0) { + File matchingFile = matchingFiles[0]; + for (File file : matchingFiles) { + if (file.lastModified() > matchingFile.lastModified()) { + matchingFile = file; + } + } + + Log.d("FilesFragment", "Found matching file: " + matchingFile.getAbsolutePath()); + NavDirections action = FilesFragmentDirections.actionFilesFragmentToReplayFragment(matchingFile.getAbsolutePath()); + Navigation.findNavController(requireView()).navigate(action); + return; + } + + // 如果没有找到匹配的文件,使用最新的文件 + Log.d("FilesFragment", "No matching file found, using latest file"); + NavDirections action = FilesFragmentDirections.actionFilesFragmentToReplayFragment(latestFile.getAbsolutePath()); + Navigation.findNavController(requireView()).navigate(action); + } else { + Log.e("FilesFragment", "No local files found"); + Toast.makeText(getContext(), "未找到本地轨迹文件", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e("FilesFragment", "Error in onReplayClicked", e); + Toast.makeText(getContext(), "回放失败:" + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + }) { + @Override + public void onBindViewHolder(@NonNull TrajDownloadViewHolder holder, int position) { + super.onBindViewHolder(holder, position); + + // 恢复下载状态 + String id = entryList.get(position).get("id"); + if (downloadedFiles.contains(id)) { + holder.downloadButton.setVisibility(View.GONE); + holder.replayButton.setVisibility(View.VISIBLE); + } + } + }; + + filesList.setAdapter(listAdapter); + } + + // 修改 getTrajectoryFilePath 方法,添加参数控制是否显示提示 + private String getTrajectoryFilePath(int position, List> entryList, boolean showToast) { + Map trajectory = entryList.get(position); + String id = trajectory.get("id"); + String date = trajectory.get("date_submitted").split("\\.")[0].replace(":", "-"); + + // 使用和下载时相同的文件名格式 + String fileName = String.format("location_log_%s_%s.json", date, id); + + File directory = new File(getActivity().getExternalFilesDir(null), "location_logs"); + File file = new File(directory, fileName); + + // 只在需要时显示提示 + if (!file.exists() && showToast) { + Log.e("FilesFragment", "Trajectory file not found: " + file.getAbsolutePath()); + Toast.makeText(getContext(), "请先下载轨迹文件", Toast.LENGTH_SHORT).show(); + } + + return file.getAbsolutePath(); + } + + private int getCurrentDownloadPosition() { + return currentDownloadPosition; + } + + // 添加一个辅助方法来解析时间戳 + private long parseTimestamp(String timestamp) { + try { + // 格式:YYYY-MM-DD_HH-mm + String[] parts = timestamp.split("[-_]"); + if (parts.length != 5) { + return 0; + } + + int year = Integer.parseInt(parts[0]); + int month = Integer.parseInt(parts[1]); + int day = Integer.parseInt(parts[2]); + int hour = Integer.parseInt(parts[3]); + int minute = Integer.parseInt(parts[4]); + + return (long) year * 100000000 + month * 1000000 + day * 10000 + hour * 100 + minute; + } catch (Exception e) { + Log.e("FilesFragment", "Error parsing timestamp: " + timestamp, e); + return 0; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/fragments/HomeFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/HomeFragment.java new file mode 100644 index 00000000..8443c738 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/HomeFragment.java @@ -0,0 +1,107 @@ +package com.openpositioning.PositionMe.fragments; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; +import androidx.preference.PreferenceManager; + +import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.sensors.SensorFusion; + +/** + * Home Fragment displays the main buttons to navigate through the app. + * The fragment has 4 buttons to: + * 1) Start the recording process + * 2) Navigate to the sensor information screen to have more detail + * 3) Navigate to the measurements screen to check values in real time + * 4) Navigate to the files page to upload trajectories and download from the cloud. + * + * @see FilesFragment The Files Fragment + * @see InfoFragment Sensor information Fragment + * @see MeasurementsFragment The measurements Fragment + * @see StartLocationFragment The First fragment to start recording + * + * @author Michal Dvorak, Virginia Cangelosi + */ +public class HomeFragment extends Fragment { + + private Button startStopButton; + private Button sensorInfoButton; + private Button measurementButton; + private Button filesButton; + private SensorFusion sensorFusion; + private static final String TAG = "HomeFragment"; + + /** + * Default empty constructor, unused. + */ + public HomeFragment() { + // Required empty public constructor + } + + /** + * {@inheritDoc} + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + /** + * {@inheritDoc} + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_home, container, false); + } + + /** + * {@inheritDoc} + * Initialise UI elements and set onClick actions for the buttons. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + sensorFusion = SensorFusion.getInstance(); + + startStopButton = view.findViewById(R.id.startStopButton); + sensorInfoButton = view.findViewById(R.id.sensorInfoButton); + measurementButton = view.findViewById(R.id.measurementButton); + filesButton = view.findViewById(R.id.filesButton); + + startStopButton.setOnClickListener(v -> { + NavDirections action = HomeFragmentDirections.actionHomeFragmentToStartLocationFragment(); + Navigation.findNavController(v).navigate(action); + }); + + sensorInfoButton.setOnClickListener(v -> { + NavDirections action = HomeFragmentDirections.actionHomeFragmentToInfoFragment(); + Navigation.findNavController(v).navigate(action); + }); + + measurementButton.setOnClickListener(v -> { + NavDirections action = HomeFragmentDirections.actionHomeFragmentToMeasurementsFragment(); + Navigation.findNavController(v).navigate(action); + }); + + filesButton.setOnClickListener(v -> { + NavDirections action = HomeFragmentDirections.actionHomeFragmentToFilesFragment(); + Navigation.findNavController(v).navigate(action); + }); + + startStopButton.setEnabled(!PreferenceManager.getDefaultSharedPreferences(getContext()) + .getBoolean("permanentDeny", false)); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorMapFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/IndoorMapManager.java similarity index 92% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorMapFragment.java rename to app/src/main/java/com/openpositioning/PositionMe/fragments/IndoorMapManager.java index 48c40474..2ec21a6f 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/IndoorMapFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/IndoorMapManager.java @@ -1,19 +1,18 @@ -package com.openpositioning.PositionMe.presentation.fragment; +package com.openpositioning.PositionMe.fragments; 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.BitmapDescriptorFactory; import com.google.android.gms.maps.model.GroundOverlay; import com.google.android.gms.maps.model.GroundOverlayOptions; import com.google.android.gms.maps.model.LatLngBounds; -public class IndoorMapFragment { +public class IndoorMapManager { private GoogleMap mMap; private GroundOverlay[] groundOverlays; // GroundOverlay used to store each layer private int currentFloor = 0; // Floor by default - public IndoorMapFragment(GoogleMap map, int floorNumber) { + public IndoorMapManager(GoogleMap map, int floorNumber) { this.mMap = map; // Pass in Google Maps this.groundOverlays = new GroundOverlay[floorNumber]; // Set the number of floors } diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/InfoFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/InfoFragment.java similarity index 86% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/InfoFragment.java rename to app/src/main/java/com/openpositioning/PositionMe/fragments/InfoFragment.java index f0cc78de..7a1a1870 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/InfoFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/InfoFragment.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.presentation.fragment; +package com.openpositioning.PositionMe.fragments; import android.os.Bundle; import android.view.LayoutInflater; @@ -12,10 +12,9 @@ import androidx.recyclerview.widget.RecyclerView; import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.presentation.viewitems.SensorInfoViewHolder; import com.openpositioning.PositionMe.sensors.SensorFusion; import com.openpositioning.PositionMe.sensors.SensorInfo; -import com.openpositioning.PositionMe.presentation.viewitems.SensorInfoListAdapter; +import com.openpositioning.PositionMe.viewitems.SensorInfoListAdapter; import java.util.List; @@ -24,7 +23,7 @@ * collection devices with relevant information about their capabilities. * * @see HomeFragment the previous fragment in the nav graph. - * @see com.openpositioning.PositionMe.sensors.SensorFusion the class containing all sensors. + * @see SensorFusion the class containing all sensors. * @see SensorInfo the class used for each sensor instance's metadata * * @author Mate Stodulka @@ -71,8 +70,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, * {@link SensorInfoListAdapter}. * * @see SensorInfoListAdapter List adapter for the Sensor Info Recycler View. - * @see SensorInfoViewHolder View holder for the Sensor Infor RV. - * @see com.openpositioning.PositionMe.R.layout#item_sensorinfo_card_view + * @see com.openpositioning.PositionMe.viewitems.SensorInfoViewHolder View holder for the Sensor Infor RV. + * @see R.layout#item_sensorinfo_card_view */ @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { diff --git a/app/src/main/java/com/openpositioning/PositionMe/fragments/MeasurementsFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/MeasurementsFragment.java new file mode 100644 index 00000000..fa0252a7 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/MeasurementsFragment.java @@ -0,0 +1,318 @@ +package com.openpositioning.PositionMe.fragments; + +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.cardview.widget.CardView; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.sensors.Observer; +import com.openpositioning.PositionMe.sensors.SensorFusion; +import com.openpositioning.PositionMe.sensors.SensorTypes; +import com.openpositioning.PositionMe.sensors.Wifi; +import com.openpositioning.PositionMe.viewitems.WifiListAdapter; + +import java.util.List; +import java.util.Map; + +/** + * A simple {@link Fragment} subclass. The measurement fragment displays the set of current sensor + * readings. The values are refreshed periodically, but slower than their internal refresh rate. + * The refresh time is set by a static constant. + * + * @see HomeFragment the previous fragment in the nav graph. + * @see SensorFusion the source of all sensor readings. + * + * @author Mate Stodulka + */ +public class MeasurementsFragment extends Fragment implements Observer { + + // Static constant for refresh time in milliseconds + private static final long REFRESH_TIME = 5000; + + // Singleton Sensor Fusion class handling all sensor data + private SensorFusion sensorFusion; + + // UI Handler + private Handler refreshDataHandler; + // UI elements + private ConstraintLayout sensorMeasurementList; + private RecyclerView wifiListView; + // List of string resource IDs + private int[] prefaces; + private int[] gnssPrefaces; + private TextView floorTextView; + + /** + * Public default constructor, empty. + */ + public MeasurementsFragment() { + // Required empty public constructor + } + + /** + * {@inheritDoc} + * Obtains the singleton Sensor Fusion instance and initialises the string prefaces for display. + * Creates a new handler to periodically refresh data. + * + * @see SensorFusion handles all sensor data. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Get sensor fusion instance + sensorFusion = SensorFusion.getInstance(); + // Initialise string prefaces for display + prefaces = new int[]{R.string.x, R.string.y, R.string.z}; + gnssPrefaces = new int[]{R.string.lati, R.string.longi}; + + // Create new handler to refresh the UI. + this.refreshDataHandler = new Handler(); + } + + /** + * {@inheritDoc} + * Sets title in the action bar to Sensor Measurements. + * Posts the {@link MeasurementsFragment#refreshTableTask} using the Handler. + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + // Inflate the layout for this fragment + View rootView = inflater.inflate(R.layout.fragment_measurements, container, false); + getActivity().setTitle("Sensor Measurements"); + this.refreshDataHandler.post(refreshTableTask); + return rootView; + } + + /** + * {@inheritDoc} + * Pauses the data refreshing when the fragment is not in focus. + */ + @Override + public void onPause() { + refreshDataHandler.removeCallbacks(refreshTableTask); + super.onPause(); + } + + /** + * {@inheritDoc} + * Restarts the data refresh when the fragment returns to focus. + */ + @Override + public void onResume() { + refreshDataHandler.postDelayed(refreshTableTask, REFRESH_TIME); + super.onResume(); + } + + /** + * {@inheritDoc} + * Obtains the constraint layout holding the sensor measurement values. Initialises the Recycler + * View for holding WiFi data and registers its Layout Manager. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + sensorMeasurementList = (ConstraintLayout) getView().findViewById(R.id.sensorMeasurementList); + wifiListView = (RecyclerView) getView().findViewById(R.id.wifiList); + wifiListView.setLayoutManager(new LinearLayoutManager(getActivity())); + + // 初始化视图 + floorTextView = view.findViewById(R.id.Floor); + + // 获取SensorFusion实例并注册为观察者 + sensorFusion = SensorFusion.getInstance(); + sensorFusion.registerFloorObserver(this); + + // 检查布局中的卡片视图数量是否与传感器类型匹配 + int cardViewCount = sensorMeasurementList.getChildCount(); + int sensorTypeCount = SensorTypes.values().length; + + if (cardViewCount < sensorTypeCount) { + Log.e("MeasurementsFragment", "布局中的CardView数量(" + cardViewCount + + ")小于SensorTypes枚举数量(" + sensorTypeCount + ")"); + } + + // 设置初始楼层值 + updateFloorDisplay(sensorFusion.getCurrentFloor()); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + // 取消注册观察者 + if (sensorFusion != null) { + sensorFusion.removeFloorObserver(this); + } + + // 移除所有回调,防止内存泄漏 + if (refreshDataHandler != null) { + refreshDataHandler.removeCallbacksAndMessages(null); + } + + // 清空UI引用 + sensorMeasurementList = null; + wifiListView = null; + floorTextView = null; + } + + /** + * Runnable task containing functionality to update the UI with the relevant sensor data. + * Must be run on the UI thread via a Handler. Obtains movement sensor values and the current + * WiFi networks from the {@link SensorFusion} instance and updates the UI with the new data + * and the string wrappers provided. + * + * @see SensorFusion class handling all sensors and data processing. + * @see Wifi class holding network data. + */ + private final Runnable refreshTableTask = new Runnable() { + @Override + public void run() { + try { + // 确保视图已经初始化 + if (sensorMeasurementList == null || getActivity() == null) { + Log.e("MeasurementsFragment", "View not initialized or fragment detached"); + return; + } + + // Get all the values from SensorFusion + Map sensorValueMap = sensorFusion.getSensorValueMap(); + // Loop through UI elements and update the values + for(SensorTypes st : SensorTypes.values()) { + // 检查索引是否有效 + if (st.ordinal() >= sensorMeasurementList.getChildCount()) { + Log.e("MeasurementsFragment", "Invalid index: " + st.ordinal() + + ", ChildCount: " + sensorMeasurementList.getChildCount()); + continue; + } + + CardView cardView = (CardView) sensorMeasurementList.getChildAt(st.ordinal()); + // 空值检查 + if (cardView == null) { + Log.e("MeasurementsFragment", "CardView is null for sensor: " + st.name()); + continue; + } + + ConstraintLayout currentRow = (ConstraintLayout) cardView.getChildAt(0); + // 空值检查 + if (currentRow == null) { + Log.e("MeasurementsFragment", "ConstraintLayout is null for sensor: " + st.name()); + continue; + } + + float[] values = sensorValueMap.get(st); + // 空值检查 + if (values == null) { + Log.e("MeasurementsFragment", "Values array is null for sensor: " + st.name()); + continue; + } + + for (int i = 0; i < values.length; i++) { + // 检查索引有效性 + if (i + 1 >= currentRow.getChildCount()) { + Log.e("MeasurementsFragment", "Invalid child index: " + (i + 1) + + " for sensor: " + st.name()); + continue; + } + + 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])); + } + else{ + valueString = getString(prefaces[i], String.format("%.2f", values[i])); + } + + View childView = currentRow.getChildAt(i + 1); + if (childView instanceof TextView) { + ((TextView) childView).setText(valueString); + } + } + } + + // Get all WiFi values - convert to list of strings + List wifiObjects = sensorFusion.getWifiList(); + // If there are WiFi networks visible, update the recycler view with the data. + if(wifiObjects != null && wifiListView != null) { + wifiListView.setAdapter(new WifiListAdapter(getActivity(), wifiObjects)); + } + + // Restart the data updater task in REFRESH_TIME milliseconds. + refreshDataHandler.postDelayed(refreshTableTask, REFRESH_TIME); + } catch (Exception e) { + Log.e("MeasurementsFragment", "Error updating sensor data: " + e.getMessage()); + // 即使发生错误,也确保继续刷新 + if (refreshDataHandler != null) { + refreshDataHandler.postDelayed(refreshTableTask, REFRESH_TIME); + } + } + } + }; + + @Override + public void update(Object[] obj) { + if (obj.length > 0 && obj[0] instanceof Integer) { + int floor = (Integer) obj[0]; + // 确保在主线程中更新UI + if (getActivity() != null) { + getActivity().runOnUiThread(() -> updateFloorDisplay(floor)); + } + } + } + + private void updateFloorDisplay(int floor) { + if (floorTextView != null) { + String oldText = floorTextView.getText().toString(); + String displayText = sensorFusion.getFloorDisplay(); + floorTextView.setText(displayText); + Log.d("FLOOR_UPDATE", String.format( + "Fragment UI更新 - 旧值: %s, 新值: %s (数值: %d)", + oldText, + displayText, + floor + )); + } + } + + /** + * 设置基准气压值 + * @param basePressure 新的基准气压值 (hPa) + */ + public void setBasePressure(float basePressure) { + if (sensorFusion != null) { + sensorFusion.calibrateBasePressure(basePressure); + // 更新显示 + updateFloorDisplay(sensorFusion.getCurrentFloor()); + } + } + + /** + * 在当前楼层校准气压计 + * @param currentFloor 当前所在楼层 + */ + public void calibrateAtCurrentFloor(int currentFloor) { + if (sensorFusion != null) { + sensorFusion.calibrateAtKnownFloor(currentFloor); + // 更新显示 + updateFloorDisplay(sensorFusion.getCurrentFloor()); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/NucleusBuildingManager.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/NucleusBuildingManager.java similarity index 77% rename from app/src/main/java/com/openpositioning/PositionMe/utils/NucleusBuildingManager.java rename to app/src/main/java/com/openpositioning/PositionMe/fragments/NucleusBuildingManager.java index 3570e8ad..819bdd44 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/NucleusBuildingManager.java +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/NucleusBuildingManager.java @@ -1,26 +1,25 @@ -package com.openpositioning.PositionMe.utils; +package com.openpositioning.PositionMe.fragments; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.presentation.fragment.IndoorMapFragment; import java.util.ArrayList; public class NucleusBuildingManager { - private IndoorMapFragment indoorMapFragment; + private IndoorMapManager indoorMapManager; private ArrayList buildingPolygon; public NucleusBuildingManager(GoogleMap map) { - // The nuclear building has 5 floors - indoorMapFragment = new IndoorMapFragment(map, 5); +// The nuclear building has 5 floors + indoorMapManager = new IndoorMapManager(map, 5); - // southwest corner +// southwest corner double N1 = 55.92279; double W1 = 3.174643; - // Northeast corner +// Northeast corner double N2 = 55.92335; double W2 = 3.173829; @@ -32,15 +31,15 @@ public NucleusBuildingManager(GoogleMap map) { buildingPolygon.add(new LatLng(N2, -W1)); // Northwest corner // Initialize the indoor map of each layer - indoorMapFragment.addFloor(0, R.drawable.floor_lg, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); - indoorMapFragment.addFloor(1, R.drawable.floor_ug, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); - indoorMapFragment.addFloor(2, R.drawable.floor_1, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); - indoorMapFragment.addFloor(3, R.drawable.floor_2, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); - indoorMapFragment.addFloor(4, R.drawable.floor_3, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); + indoorMapManager.addFloor(0, R.drawable.floor_lg, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); + indoorMapManager.addFloor(1, R.drawable.floor_ug, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); + indoorMapManager.addFloor(2, R.drawable.floor_1, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); + indoorMapManager.addFloor(3, R.drawable.floor_2, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); + indoorMapManager.addFloor(4, R.drawable.floor_3, new LatLngBounds(buildingPolygon.get(0), buildingPolygon.get(2))); } - public IndoorMapFragment getIndoorMapManager() { - return indoorMapFragment; + public IndoorMapManager getIndoorMapManager() { + return indoorMapManager; } /** diff --git a/app/src/main/java/com/openpositioning/PositionMe/fragments/RecordingFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/RecordingFragment.java new file mode 100644 index 00000000..aa6eec5a --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/RecordingFragment.java @@ -0,0 +1,1076 @@ +package com.openpositioning.PositionMe.fragments; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.location.Location; +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.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.Spinner; +import android.widget.Switch; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; +import androidx.preference.PreferenceManager; + +import com.google.android.gms.location.LocationCallback; +import com.google.android.gms.location.LocationResult; +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.OnMapReadyCallback; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.model.BitmapDescriptorFactory; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.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.openpositioning.PositionMe.IndoorMapManager; +import com.openpositioning.PositionMe.R; +import com.openpositioning.PositionMe.UtilFunctions; +import com.openpositioning.PositionMe.sensors.SensorFusion; +import com.openpositioning.PositionMe.sensors.SensorTypes; +import com.openpositioning.PositionMe.utils.LocationLogger; +import com.example.ekf.EKFManager; +import com.example.ekf.GNSSProcessor; + +import java.util.List; + +/** + * A simple {@link Fragment} subclass. The recording fragment is displayed while the app is actively + * saving data, with UI elements and a map with a marker indicating current PDR location and + * direction of movement status. The user's PDR trajectory/path being recorded + * is drawn on the map as well. + * An overlay of indoor maps for the building is achieved when the user is in the Nucleus + * and Library buildings to allow for a better user experience. + * + * @see HomeFragment the previous fragment in the nav graph. + * @see CorrectionFragment the next fragment in the nav graph. + * @see SensorFusion the class containing sensors and recording. + * @see IndoorMapManager responsible for overlaying the indoor floor maps + * + * @author Mate Stodulka + * @author Arun Gopalakrishnan + */ +public class RecordingFragment extends Fragment implements OnMapReadyCallback { + + //Button to end PDR recording + private Button stopButton; + private Button cancelButton; + //Recording icon to show user recording is in progress + private ImageView recIcon; + //Loading bar to show time remaining before recording automatically ends + private ProgressBar timeRemaining; + // Text views to display distance travelled and elevation since beginning of recording + + private TextView elevation; + private TextView distanceTravelled; + // Text view to show the error between current PDR and current GNSS + private TextView gnssError; + // 初始化楼层显示文本框 + private TextView floorTextView; + + //App settings + private SharedPreferences settings; + //Singleton class to collect all sensor data + private SensorFusion sensorFusion; + //Timer to end recording + private CountDownTimer autoStop; + // Responsible for updating UI in Loop + private Handler refreshDataHandler; + + //variables to store data of the trajectory + private float distance; + private float previousPosX; + private float previousPosY; + + // Starting point coordinates + private static LatLng start; + // 起始位置数组 + private float[] startPosition; + // Storing the google map object + private GoogleMap gMap; + //Switch Map Dropdown + private Spinner switchMapSpinner; + //Map Marker + private Marker orientationMarker; + // Current Location coordinates + private LatLng currentLocation; + // Next Location coordinates + private LatLng nextLocation; + // Stores the polyline object for plotting path + private Polyline polyline; + // Manages overlaying of the indoor maps + public IndoorMapManager indoorMapManager; + // Floor Up button + public FloatingActionButton floorUpButton; + // Floor Down button + public FloatingActionButton floorDownButton; + // GNSS Switch + private Switch gnss; + // EKF Switch + private Switch ekfSwitch; + // GNSS marker + private Marker gnssMarker; + // Button used to switch colour + private boolean isRed=true; + // Switch used to set auto floor + private Switch autoFloor; + + private LocationLogger locationLogger; + + private LocationCallback locationCallback; + + // GNSS轨迹 + private Polyline gnssPolyline; + + // GNSS处理器 + private GNSSProcessor gnssProcessor; + + // GNSS历史位置 (用于避免重复添加相同位置) + private LatLng lastGnssPosition; + + // 添加一个标志变量,跟踪是否已保存数据 + private boolean locationDataSaved = false; + + // EKF轨迹 + private Polyline ekfPolyline; + + /** + * Public Constructor for the class. + * Left empty as not required + */ + public RecordingFragment() { + // Required empty public constructor + } + + /** + * {@inheritDoc} + * Gets an instance of the {@link SensorFusion} class, and initialises the context and settings. + * Creates a handler for periodically updating the displayed data. + * + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + this.sensorFusion = SensorFusion.getInstance(); + this.gnssProcessor = GNSSProcessor.getInstance(); + Context context = getActivity(); + this.settings = PreferenceManager.getDefaultSharedPreferences(context); + this.refreshDataHandler = new Handler(); + + // 初始化 LocationLogger + this.locationLogger = new LocationLogger(context); + } + + /** + * {@inheritDoc} + * Set title in action bar to "Recording" + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + // 调试开始 + Log.d("RecordingFragment", "========= onCreateView开始 ========="); + + // Inflate the layout for this fragment + View rootView = inflater.inflate(R.layout.fragment_recording, container, false); + // Inflate the layout for this fragment + ((AppCompatActivity)getActivity()).getSupportActionBar().hide(); + getActivity().setTitle("Recording..."); + + //Obtain start position set in the startLocation fragment + startPosition = sensorFusion.getGNSSLatitude(true); + Log.d("RecordingFragment", "获取到起始位置: " + + (startPosition != null ? startPosition[0] + ", " + startPosition[1] : "null")); + + // 测试EKF和GPS + testPositioningSystem(); + + // Initialize map fragment + SupportMapFragment supportMapFragment = (SupportMapFragment) + getChildFragmentManager().findFragmentById(R.id.RecordingMap); + // Asynchronous map which can be configured + if (supportMapFragment != null) { + Log.d("RecordingFragment", "找到地图片段,异步加载地图"); + supportMapFragment.getMapAsync(this); + } else { + Log.e("RecordingFragment", "无法找到地图片段"); + } + + Log.d("RecordingFragment", "========= onCreateView结束 ========="); + return rootView; + } + + /** + * 测试定位系统组件是否正常工作 + */ + private void testPositioningSystem() { + Log.d("PositioningTest", "========= 开始测试定位系统 ========="); + + // 1. 测试PDR数据 + float[] pdrValues = sensorFusion.getSensorValueMap().get(SensorTypes.PDR); + if (pdrValues != null) { + Log.d("PositioningTest", String.format("PDR数据: X=%.6f, Y=%.6f", pdrValues[0], pdrValues[1])); + } else { + Log.e("PositioningTest", "PDR数据为null"); + } + + // 2. 测试GNSS数据 + float[] gnssValues = sensorFusion.getSensorValueMap().get(SensorTypes.GNSSLATLONG); + if (gnssValues != null && gnssValues.length >= 2) { + Log.d("PositioningTest", String.format("GNSS数据: lat=%.8f, lng=%.8f", + gnssValues[0], gnssValues[1])); + + // 测试GNSS处理器 + LatLng gnssLocation = new LatLng(gnssValues[0], gnssValues[1]); + LatLng processedLocation = gnssProcessor.processGNSSPosition(gnssLocation); + + Log.d("PositioningTest", String.format("处理后GNSS位置: lat=%.8f, lng=%.8f", + processedLocation.latitude, processedLocation.longitude)); + + // 将测试数据写入LocationLogger + locationLogger.logGnssLocation( + System.currentTimeMillis(), + processedLocation.latitude, + processedLocation.longitude + ); + Log.d("PositioningTest", "已记录GNSS测试数据"); + } else { + Log.e("PositioningTest", "GNSS数据无效: " + + (gnssValues == null ? "null" : "长度=" + gnssValues.length)); + } + + // 4. 测试PDR位置记录 + Log.d("PositioningTest", "测试PDR位置记录..."); + if (pdrValues != null) { + float[] pdrLongLat = sensorFusion.getPdrLongLat(pdrValues[0], pdrValues[1]); + Log.d("PositioningTest", String.format("记录PDR测试位置: lat=%.8f, lng=%.8f", + pdrLongLat[0], pdrLongLat[1])); + + // 记录PDR位置 + locationLogger.logLocation( + System.currentTimeMillis(), + pdrLongLat[0], + pdrLongLat[1] + ); + } + + Log.d("PositioningTest", "========= 测试定位系统结束 ========="); + } + + /** + * {@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 + * the compass indicating user direction. A polyline object is initialised + * to plot user direction. + * Initialises the manager to control indoor floor map overlays. + * + * @param map Google map to be configured + */ + @Override + public void onMapReady(GoogleMap map) { + Log.d("RecordingFragment", "地图准备就绪"); + gMap = map; + //Initialising the indoor map manager object + indoorMapManager = new IndoorMapManager(map); + // Setting map attributes + map.setMapType(GoogleMap.MAP_TYPE_HYBRID); + map.getUiSettings().setCompassEnabled(true); + map.getUiSettings().setTiltGesturesEnabled(true); + map.getUiSettings().setRotateGesturesEnabled(true); + map.getUiSettings().setScrollGesturesEnabled(true); + + // Add a marker at the start position and move the camera + start = new LatLng(startPosition[0], startPosition[1]); + currentLocation = start; + orientationMarker = map.addMarker(new MarkerOptions().position(start).title("Current Position") + .flat(true) + .icon(BitmapDescriptorFactory.fromBitmap( + UtilFunctions.getBitmapFromVector(getContext(),R.drawable.ic_baseline_navigation_24)))); + //Center the camera + map.moveCamera(CameraUpdateFactory.newLatLngZoom(start, (float) 19f)); + // Adding polyline to map to plot real-time trajectory + PolylineOptions polylineOptions = new PolylineOptions() + .color(Color.RED) + .add(currentLocation) + .zIndex(1000f); + polyline = gMap.addPolyline(polylineOptions); + // Setting current location to set Ground Overlay for indoor map (if in building) + indoorMapManager.setCurrentLocation(currentLocation); + //Showing an indication of available indoor maps using PolyLines + indoorMapManager.setIndicationOfIndoorMap(); + + Log.d("RecordingFragment", "地图初始化完成,当前位置: " + currentLocation.latitude + ", " + currentLocation.longitude); + } + + /** + * {@inheritDoc} + * Text Views and Icons initialised to display the current PDR to the user. A Button onClick + * listener is enabled to detect when to go to next fragment and allow the user to correct PDR. + * Other onClick, onCheckedChange and onSelectedItem Listeners for buttons, switch and spinner + * are defined to allow user to change UI and functionality of the recording page as wanted + * by the user. + * A runnable thread is called to update the UI every 0.2 seconds. + */ + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + // Set autoStop to null for repeat recordings + this.autoStop = null; + + //Initialise UI components + this.elevation = getView().findViewById(R.id.currentElevation); + this.distanceTravelled = getView().findViewById(R.id.currentDistanceTraveled); + this.gnssError =getView().findViewById(R.id.gnssError); + + + //Set default text of TextViews to 0 + this.gnssError.setVisibility(View.GONE); + this.elevation.setText(getString(R.string.elevation, "0")); + this.distanceTravelled.setText(getString(R.string.meter, "0")); + + //Reset variables to 0 + this.distance = 0f; + this.previousPosX = 0f; + this.previousPosY = 0f; + + // Stop button to save trajectory and move to corrections + this.stopButton = getView().findViewById(R.id.stopButton); + this.stopButton.setOnClickListener(new View.OnClickListener() { + /** + * {@inheritDoc} + * OnClick listener for button to go to next fragment. + * When button clicked the PDR recording is stopped and the {@link CorrectionFragment} is loaded. + */ + @Override + public void onClick(View view) { + if(autoStop != null) autoStop.cancel(); + Log.d("RecordingFragment", "用户点击停止按钮,保存轨迹文件..."); + + // 首先更新并获取最新的融合位置 + if (currentLocation != null) { + try { + // 记录GNSS位置 + locationLogger.logGnssLocation( + System.currentTimeMillis(), + currentLocation.latitude, + currentLocation.longitude + ); + Log.d("RecordingFragment", "保存前记录最后一个GNSS位置: " + + currentLocation.latitude + ", " + currentLocation.longitude); + } catch (Exception e) { + Log.e("RecordingFragment", "保存前更新位置数据时出错: " + e.getMessage(), e); + } + } + + if (!locationDataSaved) { + try { + Log.d("RecordingFragment", "开始保存轨迹数据..."); + locationLogger.saveToFile(); + locationDataSaved = true; + Log.d("RecordingFragment", "轨迹文件保存成功"); + } catch (Exception e) { + Log.e("RecordingFragment", "保存轨迹数据时出错: " + e.getMessage(), e); + } + } else { + Log.d("RecordingFragment", "轨迹数据已保存,跳过按钮点击中的保存"); + } + + stopRecording(); + NavDirections action = RecordingFragmentDirections.actionRecordingFragmentToCorrectionFragment(); + Navigation.findNavController(view).navigate(action); + } + }); + + // Cancel button to discard trajectory and return to Home + this.cancelButton = getView().findViewById(R.id.cancelButton); + this.cancelButton.setOnClickListener(new View.OnClickListener() { + /** + * {@inheritDoc} + * OnClick listener for button to go to home fragment. + * When button clicked the PDR recording is stopped and the {@link HomeFragment} is loaded. + * The trajectory is not saved. + */ + @Override + public void onClick(View view) { + Log.d("RecordingFragment", "取消录制,不保存轨迹数据"); + locationDataSaved = true; // 标记为已保存,防止onDestroy中保存数据 + sensorFusion.stopRecording(); + NavDirections action = RecordingFragmentDirections.actionRecordingFragmentToHomeFragment(); + Navigation.findNavController(view).navigate(action); + if(autoStop != null) autoStop.cancel(); + } + }); + // Configuring dropdown for switching map types + mapDropdown(); + // Setting listener for the switching map types dropdown + switchMap(); + // Floor changer Buttons + this.floorUpButton=getView().findViewById(R.id.floorUpButton); + this.floorDownButton=getView().findViewById(R.id.floorDownButton); + // Auto-floor switch + this.autoFloor=getView().findViewById(R.id.autoFloor); + autoFloor.setChecked(true); + // Hiding floor changing buttons and auto-floor switch + setFloorButtonVisibility(View.GONE); + this.floorUpButton.setOnClickListener(new View.OnClickListener() { + /** + *{@inheritDoc} + * Listener for increasing the floor for the indoor map + */ + @Override + public void onClick(View view) { + // Setting off auto-floor as manually changed + autoFloor.setChecked(false); + indoorMapManager.increaseFloor(); + } + }); + this.floorDownButton.setOnClickListener(new View.OnClickListener() { + /** + *{@inheritDoc} + * Listener for decreasing the floor for the indoor map + */ + @Override + public void onClick(View view) { + // Setting off auto-floor as manually changed + autoFloor.setChecked(false); + indoorMapManager.decreaseFloor(); + } + }); + //Obtain the GNSS toggle switch + this.gnss = getView().findViewById(R.id.gnssSwitch); + + //Obtain the EKF toggle switch + this.ekfSwitch = getView().findViewById(R.id.EKF_Switch); + + this.gnss.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + /** + * {@inheritDoc} + * Listener to set GNSS marker and show GNSS vs PDR error. + */ + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean isChecked) { + if (isChecked){ + // Show GNSS eror + float[] location = sensorFusion.getSensorValueMap().get(SensorTypes.GNSSLATLONG); + LatLng gnssLocation = new LatLng(location[0],location[1]); + gnssError.setVisibility(View.VISIBLE); + gnssError.setText(String.format(getString(R.string.gnss_error)+"%.2fm", + UtilFunctions.distanceBetweenPoints(currentLocation,gnssLocation))); + + // 使用GNSSProcessor处理GNSS位置 + LatLng processedGnssLocation = gnssProcessor.processGNSSPosition(gnssLocation); + lastGnssPosition = processedGnssLocation; // 保存最后一个GNSS位置 + + // Set GNSS marker + gnssMarker=gMap.addMarker( + new MarkerOptions().title("GNSS position") + .position(processedGnssLocation) + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE))); + + // 创建GNSS轨迹线 (蓝色) + if (gnssPolyline == null) { + PolylineOptions gnssPolylineOptions = new PolylineOptions() + .color(Color.BLUE) // 蓝色表示GNSS轨迹 + .add(processedGnssLocation) + .width(8f) // 宽度适中 + .zIndex(1200f); // zIndex在PDR和EKF之间 + gnssPolyline = gMap.addPolyline(gnssPolylineOptions); + } + } else { + gnssMarker.remove(); + gnssMarker = null; + gnssError.setVisibility(View.GONE); + + // 清除GNSS轨迹 + if (gnssPolyline != null) { + gnssPolyline.remove(); + gnssPolyline = null; + } + + // 重置GNSS处理器 + gnssProcessor.reset(); + lastGnssPosition = null; + } + } + }); + + // EKF开关监听器 + this.ekfSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (isChecked) { + // 启用EKF时初始化 + if (currentLocation != null) { + // 创建EKF轨迹线 + if (ekfPolyline == null) { + PolylineOptions ekfPolylineOptions = new PolylineOptions() + .color(Color.GREEN) // 使用绿色,表示EKF融合轨迹 + .add(currentLocation) + .width(12f) // 增加宽度 + .geodesic(true) // 平滑轨迹 + .zIndex(1500f); // 确保EKF轨迹显示在PDR轨迹上方 + ekfPolyline = gMap.addPolyline(ekfPolylineOptions); + + // 仅在EKF初始启用时将地图中心设置为当前位置 + gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(currentLocation, (float) 19f)); + } + + Log.d("RecordingFragment", "EKF enabled at: " + currentLocation.latitude + ", " + currentLocation.longitude); + } else { + Log.e("RecordingFragment", "Cannot enable EKF: current location is null"); + } + } else { + // 禁用EKF时清除EKF轨迹 + if (ekfPolyline != null) { + ekfPolyline.remove(); + ekfPolyline = null; + } + } + + Log.d("RecordingFragment", "EKF " + (isChecked ? "启用" : "禁用")); + } + }); + + // Display the progress of the recording when a max record length is set + this.timeRemaining = getView().findViewById(R.id.timeRemainingBar); + + // Display a blinking red dot to show recording is in progress + blinkingRecording(); + + // Check if there is manually set time limit: + if(this.settings.getBoolean("split_trajectory", false)) { + // If that time limit has been reached: + long limit = this.settings.getInt("split_duration", 30) * 60000L; + // Set progress bar + this.timeRemaining.setMax((int) (limit/1000)); + this.timeRemaining.setScaleY(3f); + + // Create a CountDownTimer object to adhere to the time limit + this.autoStop = new CountDownTimer(limit, 1000) { + /** + * {@inheritDoc} + * Increment the progress bar to display progress and remaining time. Update the + * observed PDR values, and animate icons based on the data. + */ + @Override + public void onTick(long l) { + // increment progress bar + timeRemaining.incrementProgressBy(1); + // Get new position and update UI + updateUIandPosition(); + } + + /** + * {@inheritDoc} + * Finish recording and move to the correction fragment. + * @see CorrectionFragment + */ + @Override + public void onFinish() { + // Timer done, move to next fragment automatically - will stop recording + sensorFusion.stopRecording(); + NavDirections action = RecordingFragmentDirections.actionRecordingFragmentToCorrectionFragment(); + Navigation.findNavController(view).navigate(action); + } + }.start(); + } + else { + // No time limit - use a repeating task to refresh UI. + this.refreshDataHandler.post(refreshDataTask); + } + + // 在位置更新时记录 + locationCallback = new LocationCallback() { + @Override + public void onLocationResult(LocationResult locationResult) { + if (locationResult == null) { + return; + } + + for (Location location : locationResult.getLocations()) { + // 记录位置 + locationLogger.logLocation( + location.getTime(), + location.getLatitude(), + location.getLongitude() + ); + + // 其他处理... + } + } + }; + + // 初始化楼层显示文本框 + this.floorTextView = getView().findViewById(R.id.Floor); + + // 添加状态改变监听器 + autoFloor.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + Log.d("AutoFloor", "Switch state changed to: " + isChecked); + + if (indoorMapManager == null) { + Log.d("AutoFloor", "IndoorMapManager is null"); + return; + } + + if (!indoorMapManager.getIsIndoorMapSet()) { + Log.d("AutoFloor", "No indoor map is currently set"); + return; + } + + if (isChecked) { + // 直接使用 SensorFusion 中的当前楼层 + int currentFloor = sensorFusion.getCurrentFloor(); + Log.d("AutoFloor", String.format( + "Switch ON - Using SensorFusion floor: %d", + currentFloor + )); + indoorMapManager.resumeAutoFloor(currentFloor); + } else { + Log.d("AutoFloor", "Switch turned OFF"); + } + } + }); + } + + /** + * Creates a dropdown for Changing maps + */ + private void mapDropdown(){ + // Creating and Initialising options for Map's Dropdown Menu + switchMapSpinner = (Spinner) getView().findViewById(R.id.mapSwitchSpinner); + // Different Map Types + String[] maps = new String[]{getString(R.string.hybrid), getString(R.string.normal), getString(R.string.satellite)}; + ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_dropdown_item, maps); + // Set the Dropdowns menu adapter + switchMapSpinner.setAdapter(adapter); + } + + /** + * Spinner listener to change map bap based on user input + */ + private void switchMap(){ + // Switch between map type based on user input + this.switchMapSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + /** + * {@inheritDoc} + * OnItemSelected listener to switch maps. + * The map switches between MAP_TYPE_NORMAL, MAP_TYPE_SATELLITE + * and MAP_TYPE_HYBRID based on user selection. + */ + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + 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; + } + } + /** + * {@inheritDoc} + * When Nothing is selected set to MAP_TYPE_HYBRID (NORMAL and SATELLITE) + */ + @Override + public void onNothingSelected(AdapterView parent) { + gMap.setMapType(GoogleMap.MAP_TYPE_HYBRID); + } + }); + } + /** + * Runnable task used to refresh UI elements with live data. + * Has to be run through a Handler object to be able to alter UI elements + */ + private final Runnable refreshDataTask = new Runnable() { + @Override + public void run() { + // Get new position and update UI + updateUIandPosition(); + // Loop the task again to keep refreshing the data + refreshDataHandler.postDelayed(refreshDataTask, 200); + } + }; + + /** + * Updates the UI, traces PDR Position on the map + * and also updates marker representing the current location and direction on the map + */ + private void updateUIandPosition(){ + // 调试:跟踪方法调用 + long startTime = System.currentTimeMillis(); + Log.d("LocationTracking", "======= 开始位置更新 ======="); + + // Get new position + float[] pdrValues = sensorFusion.getSensorValueMap().get(SensorTypes.PDR); + if (pdrValues == null) { + Log.e("LocationTracking", "PDR值为null,无法更新位置"); + return; + } + + // 调试:输出PDR原始值 + Log.d("LocationTracking", String.format("PDR原始值: X=%.6f, Y=%.6f", pdrValues[0], pdrValues[1])); + + // 计算PDR移动距离 + float pdrMoveDist = (float) Math.sqrt(Math.pow(pdrValues[0] - previousPosX, 2) + Math.pow(pdrValues[1] - previousPosY, 2)); + + // 总距离计算 + distance += pdrMoveDist; + distanceTravelled.setText(getString(R.string.meter, String.format("%.2f", distance))); + + // Net pdr movement + float[] pdrMoved={pdrValues[0]-previousPosX,pdrValues[1]-previousPosY}; + + // 调试:输出PDR移动量 + Log.d("LocationTracking", String.format("PDR移动量: dX=%.6f, dY=%.6f, 距离=%.6f", + pdrMoved[0], pdrMoved[1], pdrMoveDist)); + + // 设置一个最小移动阈值,防止微小抖动导致的误更新 + final float MIN_MOVE_THRESHOLD = 0.15f; // 最小移动阈值(米) + + // 只有PDR移动距离超过阈值才更新轨迹 + if (pdrMoveDist > MIN_MOVE_THRESHOLD) { + // 调试:PDR位置已更新且超过移动阈值 + Log.d("LocationTracking", "PDR移动距离("+pdrMoveDist+"m)超过阈值("+MIN_MOVE_THRESHOLD+"m),更新地图轨迹"); + + plotLines(pdrMoved); + + // PDR数据更新,SensorFusion内部会自动进行融合计算 + } else { + // 调试:PDR位置变化太小,不更新轨迹 + Log.d("LocationTracking", "PDR移动距离("+pdrMoveDist+"m)小于阈值("+MIN_MOVE_THRESHOLD+"m),不更新轨迹"); + } + + // If not initialized, initialize + if (indoorMapManager == null) { + indoorMapManager = new IndoorMapManager(gMap); + } + + // 处理GNSS位置更新 - 无论GNSS开关是否开启,都记录GNSS位置 + float[] location = sensorFusion.getSensorValueMap().get(SensorTypes.GNSSLATLONG); + LatLng processedGnssLocation = null; + + if (location != null && location.length >= 2 && location[0] != 0 && location[1] != 0) { + LatLng gnssLocation = new LatLng(location[0], location[1]); + + // 调试:输出GNSS原始位置 + Log.d("LocationTracking", String.format("GNSS原始位置: lat=%.8f, lng=%.8f", + gnssLocation.latitude, gnssLocation.longitude)); + + // 使用GNSSProcessor处理GNSS位置 + processedGnssLocation = gnssProcessor.processGNSSPosition(gnssLocation); + + // 调试:输出GNSS处理后位置 + Log.d("LocationTracking", String.format("GNSS处理后位置: lat=%.8f, lng=%.8f", + processedGnssLocation.latitude, processedGnssLocation.longitude)); + + // 只有GNSS开关打开时才显示GNSS相关UI + if (gnss.isChecked() && gnssMarker != null) { + // 显示PDR与GNSS之间的误差 + gnssError.setVisibility(View.VISIBLE); + gnssError.setText(String.format(getString(R.string.gnss_error)+"%.2fm", + UtilFunctions.distanceBetweenPoints(currentLocation, gnssLocation))); + + // 更新GNSS标记位置 + gnssMarker.setPosition(processedGnssLocation); + + // 只有当位置有明显变化时才更新GNSS轨迹 + if (lastGnssPosition == null || + UtilFunctions.distanceBetweenPoints(lastGnssPosition, processedGnssLocation) > 0.5) { + + // 更新GNSS轨迹UI + if (gnssPolyline != null) { + List gnssPoints = gnssPolyline.getPoints(); + gnssPoints.add(processedGnssLocation); + gnssPolyline.setPoints(gnssPoints); + } + + // 保存最新GNSS位置 + lastGnssPosition = processedGnssLocation; + } + } + + // 无论GNSS开关是否开启,都记录GNSS位置数据 + // 每次更新都记录,不再使用距离过滤 + locationLogger.logGnssLocation( + System.currentTimeMillis(), + processedGnssLocation.latitude, + processedGnssLocation.longitude + ); + } else { + // 调试:GNSS位置无效 + Log.e("LocationTracking", "GNSS位置无效或未获取: " + + (location == null ? "null" : "length=" + location.length)); + } + + // 获取EKF融合位置并记录 - 无论EKF开关是否开启 + LatLng fusedPosition = sensorFusion.getEkfPosition(); + + // 调试:输出EKF融合位置 + Log.d("LocationTracking", "EKF融合位置: " + (fusedPosition == null ? "null" : + fusedPosition.latitude + ", " + fusedPosition.longitude)); + + if (fusedPosition != null) { + // 记录融合位置 + locationLogger.logEkfLocation( + System.currentTimeMillis(), + fusedPosition.latitude, + fusedPosition.longitude + ); + + // 只有当EKF开关打开时才更新EKF轨迹UI + if (ekfSwitch.isChecked() && ekfPolyline != null) { + List points = ekfPolyline.getPoints(); + points.add(fusedPosition); + ekfPolyline.setPoints(points); + } + } else { + // EKF位置为null,尝试手动生成一个 + if (currentLocation != null && processedGnssLocation != null) { + // 简单融合:50%PDR + 50%GNSS + double fusedLat = (currentLocation.latitude + processedGnssLocation.latitude) / 2; + double fusedLng = (currentLocation.longitude + processedGnssLocation.longitude) / 2; + LatLng manualFusedPosition = new LatLng(fusedLat, fusedLng); + + Log.d("LocationTracking", "手动创建融合位置: " + fusedLat + ", " + fusedLng); + + // 记录手动融合位置 + locationLogger.logEkfLocation( + System.currentTimeMillis(), + manualFusedPosition.latitude, + manualFusedPosition.longitude + ); + } else { + Log.e("LocationTracking", "无法创建手动融合位置,currentLocation或processedGnssLocation为null"); + } + } + + // Updates current location of user to show the indoor floor map (if applicable) + // 使用EKF融合位置来判断是否在室内 + LatLng positionForIndoorMap = fusedPosition != null ? fusedPosition : currentLocation; + indoorMapManager.setCurrentLocation(positionForIndoorMap); + + // 如果在室内且自动楼层开启,持续更新楼层 + if (indoorMapManager.getIsIndoorMapSet()) { + setFloorButtonVisibility(View.VISIBLE); + if (autoFloor.isChecked()) { + // 直接使用 SensorFusion 中的当前楼层 + int currentFloor = sensorFusion.getCurrentFloor(); + Log.d("AutoFloor", "Auto updating - Current floor: " + currentFloor); + indoorMapManager.setCurrentFloor(currentFloor, true); + } + floorTextView.setText(sensorFusion.getFloorDisplay()); + } else { + setFloorButtonVisibility(View.GONE); + floorTextView.setText(sensorFusion.getFloorDisplay()); + } + + // Store previous PDR values for next call + previousPosX = pdrValues[0]; + previousPosY = pdrValues[1]; + // Display elevation + elevation.setText(getString(R.string.elevation, String.format("%.1f", sensorFusion.getElevation()))); + //Rotate compass Marker according to direction of movement + if (orientationMarker!=null) { + orientationMarker.setRotation((float) Math.toDegrees(sensorFusion.passOrientation())); + } + + // 在位置更新时记录PDR位置到 LocationLogger + if (currentLocation != null) { + Log.d("LocationTracking", String.format("记录PDR位置: lat=%.8f, lng=%.8f", + currentLocation.latitude, currentLocation.longitude)); + + // 每次都记录当前PDR位置 + locationLogger.logLocation( + System.currentTimeMillis(), + currentLocation.latitude, + currentLocation.longitude + ); + } else { + Log.e("LocationTracking", "当前PDR位置为null,无法记录"); + } + + // 调试:跟踪方法结束 + long endTime = System.currentTimeMillis(); + Log.d("LocationTracking", String.format("======= 位置更新完成,耗时%dms =======", endTime - startTime)); + } + /** + * Plots the users location based on movement in Real-time + * @param pdrMoved Contains the change in PDR in X and Y directions + */ + private void plotLines(float[] pdrMoved){ + if (currentLocation!=null){ + // Calculate new position based on net PDR movement + nextLocation=UtilFunctions.calculateNewPos(currentLocation,pdrMoved); + //Try catch to prevent exceptions from crashing the app + try{ + // Adds new location to polyline to plot the PDR path of user + List pointsMoved = polyline.getPoints(); + pointsMoved.add(nextLocation); + polyline.setPoints(pointsMoved); + // 设置轨迹线的 zIndex 为较大值,确保显示在地图覆盖层上方 + polyline.setZIndex(1000f); + // Change current location to new location and zoom there + orientationMarker.setPosition(nextLocation); + // 设置位置标记的 zIndex 也为较大值 + orientationMarker.setZIndex(1000f); + + // 获取最新的Fusion位置 + LatLng fusedPosition = sensorFusion.getEkfPosition(); + if (fusedPosition != null) { + // 移动相机到Fusion位置 (保持用户可以自由拖动地图) + gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(fusedPosition, (float) 19f)); + } + } + catch (Exception ex){ + Log.e("PlottingPDR","Exception: "+ex); + } + currentLocation=nextLocation; + } + else{ + //Initialise the starting location + float[] location = sensorFusion.getGNSSLatitude(true); + currentLocation=new LatLng(location[0],location[1]); + nextLocation=currentLocation; + } + } + + /** + * Function to set change visibility of the floor up and down buttons + * @param visibility the visibility of floor buttons should be set to + */ + private void setFloorButtonVisibility(int visibility){ + floorUpButton.setVisibility(visibility); + floorDownButton.setVisibility(visibility); + autoFloor.setVisibility(visibility); + } + /** + * Displays a blinking red dot to signify an ongoing recording. + * + * @see Animation for makin the red dot blink. + */ + private void blinkingRecording() { + //Initialise Image View + this.recIcon = getView().findViewById(R.id.redDot); + //Configure blinking animation + Animation blinking_rec = new AlphaAnimation(1, 0); + blinking_rec.setDuration(800); + blinking_rec.setInterpolator(new LinearInterpolator()); + blinking_rec.setRepeatCount(Animation.INFINITE); + blinking_rec.setRepeatMode(Animation.REVERSE); + recIcon.startAnimation(blinking_rec); + } + + /** + * {@inheritDoc} + * Stops ongoing refresh task, but not the countdown timer which stops automatically + */ + @Override + public void onPause() { + refreshDataHandler.removeCallbacks(refreshDataTask); + super.onPause(); + } + + /** + * {@inheritDoc} + * Restarts UI refreshing task when no countdown task is in progress + */ + @Override + public void onResume() { + if(!this.settings.getBoolean("split_trajectory", false)) { + refreshDataHandler.postDelayed(refreshDataTask, 500); + } + super.onResume(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (!locationDataSaved) { + Log.d("RecordingFragment", "在onDestroy中保存轨迹数据"); + locationLogger.saveToFile(); + locationDataSaved = true; + } else { + Log.d("RecordingFragment", "轨迹数据已保存,跳过onDestroy中的保存"); + } + } + + private void stopRecording() { + Log.d("RecordingFragment", "停止录制"); + + // 保存轨迹数据 + if (!locationDataSaved) { + Log.d("RecordingFragment", "在stopRecording中保存轨迹数据"); + locationLogger.saveToFile(); + locationDataSaved = true; + } else { + Log.d("RecordingFragment", "轨迹数据已保存,跳过stopRecording中的保存"); + } + + // 停止定时器 + if (autoStop != null) { + autoStop.cancel(); + autoStop = null; + } + + // 停止刷新UI + refreshDataHandler.removeCallbacks(refreshDataTask); + + // 清理资源 + if (gnssMarker != null) { + gnssMarker.remove(); + gnssMarker = null; + } + + if (gnssPolyline != null) { + gnssPolyline.remove(); + gnssPolyline = null; + } + + if (ekfPolyline != null) { + ekfPolyline.remove(); + ekfPolyline = null; + } + + if (orientationMarker != null) { + orientationMarker.remove(); + orientationMarker = null; + } + + if (polyline != null) { + polyline.remove(); + polyline = null; + } + + Log.d("RecordingFragment", "录制已完全停止,资源已清理"); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/fragments/ReplayFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/ReplayFragment.java new file mode 100644 index 00000000..33b53b25 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/ReplayFragment.java @@ -0,0 +1,711 @@ +package com.openpositioning.PositionMe.fragments; + + +import android.graphics.Color; +import android.location.Location; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.SeekBar; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.OnMapReadyCallback; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.model.BitmapDescriptorFactory; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.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.openpositioning.PositionMe.R; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ReplayFragment extends Fragment implements OnMapReadyCallback { + + private static final String TAG = "ReplayFragment"; + private GoogleMap mMap; + private Button playPauseButton, restartButton; + private SeekBar progressBar; + private RadioGroup trajectoryTypeGroup; + private RadioButton pdrRadioButton, gnssRadioButton, ekfRadioButton; + private boolean isPlaying = false; + + // 添加轨迹相关变量 + private List trajectoryPoints = new ArrayList<>(); + private List pdrTrajectoryPoints = new ArrayList<>(); + private List gnssTrajectoryPoints = new ArrayList<>(); + private List ekfTrajectoryPoints = new ArrayList<>(); + + private int currentPointIndex = 0; + private Handler playbackHandler = new Handler(); + private static final int PLAYBACK_INTERVAL = 1000; // 1秒更新一次 + + // 轨迹平滑处理相关参数 + private static final int DOWNSAMPLE_FACTOR = 3; // 每3个点取1个点 + private static final boolean ENABLE_DOWNSAMPLING = true; // 启用降采样 + private static final boolean ENABLE_SMOOTHING = true; // 启用平滑处理 + + private Marker currentPositionMarker; + + private List pendingLocations = null; // 添加这个变量 + + // 当前显示的轨迹类型 + private enum TrajectoryType { + PDR, + GNSS, + EKF + } + + private TrajectoryType currentTrajectoryType = TrajectoryType.EKF; // 默认显示EKF轨迹 + + // 轨迹点类 + private static class TrajectoryPoint { + long timestamp; + double latitude; + double longitude; + + TrajectoryPoint(long timestamp, double latitude, double longitude) { + this.timestamp = timestamp; + this.latitude = latitude; + this.longitude = longitude; + } + } + + public ReplayFragment() { + // Required empty public constructor + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + // Inflate the layout for this fragment + View view = inflater.inflate(R.layout.fragment_replay, container, false); + + // Initialize UI elements + playPauseButton = view.findViewById(R.id.play_pause_button); + restartButton = view.findViewById(R.id.restart_button); + progressBar = (SeekBar) view.findViewById(R.id.progress_bar); + + // 初始化轨迹类型选择按钮 + trajectoryTypeGroup = view.findViewById(R.id.trajectory_type_group); + pdrRadioButton = view.findViewById(R.id.pdr_radio_button); + gnssRadioButton = view.findViewById(R.id.gnss_radio_button); + ekfRadioButton = view.findViewById(R.id.ekf_radio_button); + + Log.d(TAG, "轨迹选择控件初始化: " + + "RadioGroup=" + (trajectoryTypeGroup != null) + ", " + + "PDR=" + (pdrRadioButton != null) + ", " + + "GNSS=" + (gnssRadioButton != null) + ", " + + "EKF=" + (ekfRadioButton != null)); + + if (trajectoryTypeGroup != null) { + // 设置点击事件监听器 + trajectoryTypeGroup.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(RadioGroup group, int checkedId) { + Log.d(TAG, "轨迹类型切换: checkedId=" + checkedId); + + if (checkedId == R.id.pdr_radio_button) { + Log.d(TAG, "切换到PDR轨迹"); + switchTrajectoryType(TrajectoryType.PDR); + } else if (checkedId == R.id.gnss_radio_button) { + Log.d(TAG, "切换到GNSS轨迹"); + switchTrajectoryType(TrajectoryType.GNSS); + } else if (checkedId == R.id.ekf_radio_button) { + Log.d(TAG, "切换到EKF轨迹"); + switchTrajectoryType(TrajectoryType.EKF); + } + } + }); + + // 单独为每个RadioButton设置点击监听器,以防RadioGroup监听器失效 + pdrRadioButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Log.d(TAG, "PDR RadioButton 点击"); + pdrRadioButton.setChecked(true); + switchTrajectoryType(TrajectoryType.PDR); + } + }); + + gnssRadioButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Log.d(TAG, "GNSS RadioButton 点击"); + gnssRadioButton.setChecked(true); + switchTrajectoryType(TrajectoryType.GNSS); + } + }); + + ekfRadioButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Log.d(TAG, "EKF RadioButton 点击"); + ekfRadioButton.setChecked(true); + switchTrajectoryType(TrajectoryType.EKF); + } + }); + } else { + Log.e(TAG, "轨迹类型RadioGroup未找到!"); + } + + // Set button click listeners + playPauseButton.setOnClickListener(v -> togglePlayback()); + restartButton.setOnClickListener(v -> restartPlayback()); + + // Set up the map + SupportMapFragment mapFragment = (SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.map); + if (mapFragment != null) { + mapFragment.getMapAsync(this); + } + + // 设置进度条监听器 + progressBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + currentPointIndex = progress; + if (!trajectoryPoints.isEmpty() && progress < trajectoryPoints.size()) { + TrajectoryPoint point = trajectoryPoints.get(progress); + LatLng position = new LatLng(point.latitude, point.longitude); + + // 更新标记位置 + if (currentPositionMarker == null) { + currentPositionMarker = mMap.addMarker(new MarkerOptions() + .position(position) + .title("Current Position") + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE))); + } else { + currentPositionMarker.setPosition(position); + } + + // 移动相机 + mMap.animateCamera(CameraUpdateFactory.newLatLng(position)); + } + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + // 开始拖动时暂停播放 + if (isPlaying) { + togglePlayback(); + } + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + // 可以选择在停止拖动时自动开始播放 + // if (!isPlaying) { + // togglePlayback(); + // } + } + }); + + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + // 获取传入的文件路径 + String filePath = ReplayFragmentArgs.fromBundle(getArguments()).getFilePath(); + Log.d(TAG, "Received file path: " + filePath); + + // 如果没有指定文件路径,使用最新的本地轨迹文件 + if (filePath == null || filePath.isEmpty()) { + File directory = new File(requireContext().getExternalFilesDir(null), "location_logs"); + File[] files = directory.listFiles((dir, name) -> name.startsWith("location_log_local_")); + if (files != null && files.length > 0) { + // 按修改时间排序,获取最新的文件 + File latestFile = files[0]; + for (File file : files) { + if (file.lastModified() > latestFile.lastModified()) { + latestFile = file; + } + } + filePath = latestFile.getAbsolutePath(); + Log.d(TAG, "Using latest local trajectory file: " + filePath); + } + } + + try { + // 读取轨迹文件 + File trajectoryFile = new File(filePath); + StringBuilder content = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new FileReader(trajectoryFile))) { + String line; + while ((line = reader.readLine()) != null) { + content.append(line); + } + } + + // 解析 JSON + JSONObject jsonObject = new JSONObject(content.toString()); + + // 优先读取EKF轨迹数据 + boolean hasValidTrajectory = false; + + // 读取EKF轨迹数据 + if (jsonObject.has("ekfLocationData")) { + JSONArray ekfLocationData = jsonObject.getJSONArray("ekfLocationData"); + if (ekfLocationData.length() > 0) { + hasValidTrajectory = true; + parseTrajectoryData(ekfLocationData, ekfTrajectoryPoints); + Log.d(TAG, "Successfully loaded EKF trajectory: " + ekfTrajectoryPoints.size() + " points"); + ekfRadioButton.setEnabled(true); + } else { + ekfRadioButton.setEnabled(false); + } + } else { + ekfRadioButton.setEnabled(false); + } + + // 读取GNSS轨迹数据 + if (jsonObject.has("gnssLocationData")) { + JSONArray gnssLocationData = jsonObject.getJSONArray("gnssLocationData"); + if (gnssLocationData.length() > 0) { + hasValidTrajectory = true; + parseTrajectoryData(gnssLocationData, gnssTrajectoryPoints); + Log.d(TAG, "Successfully loaded GNSS trajectory: " + gnssTrajectoryPoints.size() + " points"); + gnssRadioButton.setEnabled(true); + } else { + gnssRadioButton.setEnabled(false); + } + } else { + gnssRadioButton.setEnabled(false); + } + + // 读取PDR轨迹数据 + if (jsonObject.has("locationData")) { + JSONArray locationData = jsonObject.getJSONArray("locationData"); + if (locationData.length() > 0) { + hasValidTrajectory = true; + parseTrajectoryData(locationData, pdrTrajectoryPoints); + Log.d(TAG, "Successfully loaded PDR trajectory: " + pdrTrajectoryPoints.size() + " points"); + pdrRadioButton.setEnabled(true); + } else { + pdrRadioButton.setEnabled(false); + } + } else { + pdrRadioButton.setEnabled(false); + } + + if (!hasValidTrajectory) { + Toast.makeText(getContext(), "No valid trajectory data found", Toast.LENGTH_SHORT).show(); + return; + } + + // 设置默认轨迹类型 + Log.d(TAG, "设置默认轨迹类型 - EKF轨迹可用: " + ekfRadioButton.isEnabled() + + ", GNSS轨迹可用: " + gnssRadioButton.isEnabled() + + ", PDR轨迹可用: " + pdrRadioButton.isEnabled()); + + if (ekfRadioButton.isEnabled()) { + currentTrajectoryType = TrajectoryType.EKF; + ekfRadioButton.setChecked(true); + gnssRadioButton.setChecked(false); + pdrRadioButton.setChecked(false); + trajectoryPoints = ekfTrajectoryPoints; + Log.d(TAG, "默认显示EKF轨迹,轨迹点数量: " + trajectoryPoints.size()); + } else if (gnssRadioButton.isEnabled()) { + currentTrajectoryType = TrajectoryType.GNSS; + gnssRadioButton.setChecked(true); + ekfRadioButton.setChecked(false); + pdrRadioButton.setChecked(false); + trajectoryPoints = gnssTrajectoryPoints; + Log.d(TAG, "默认显示GNSS轨迹,轨迹点数量: " + trajectoryPoints.size()); + } else if (pdrRadioButton.isEnabled()) { + currentTrajectoryType = TrajectoryType.PDR; + pdrRadioButton.setChecked(true); + ekfRadioButton.setChecked(false); + gnssRadioButton.setChecked(false); + trajectoryPoints = pdrTrajectoryPoints; + Log.d(TAG, "默认显示PDR轨迹,轨迹点数量: " + trajectoryPoints.size()); + } + + // 如果地图已经准备好,直接更新 + if (mMap != null) { + updateMap(); + } + + } catch (Exception e) { + Log.e(TAG, "Failed to load trajectory file: " + e.getMessage()); + Toast.makeText(getContext(), "Failed to load trajectory file: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + /** + * 解析轨迹数据并添加到指定列表 + */ + private void parseTrajectoryData(JSONArray jsonArray, List targetList) throws Exception { + targetList.clear(); + + if (jsonArray.length() == 0) { + return; + } + + // 原始点集合 + List originalPoints = new ArrayList<>(); + + // 先解析所有原始点 + for (int i = 0; i < jsonArray.length(); i++) { + JSONObject point = jsonArray.getJSONObject(i); + originalPoints.add(new TrajectoryPoint( + point.getLong("timestamp"), + point.getDouble("latitude"), + point.getDouble("longitude") + )); + } + + // 按时间戳排序 + Collections.sort(originalPoints, (p1, p2) -> Long.compare(p1.timestamp, p2.timestamp)); + + // 对于PDR轨迹,保留所有原始点 + if (targetList == pdrTrajectoryPoints) { + targetList.addAll(originalPoints); + Log.d(TAG, "PDR轨迹加载完成,保留所有原始点: " + targetList.size() + " 个点"); + return; + } + + // 对于其他轨迹类型,进行降采样处理 + if (ENABLE_DOWNSAMPLING && originalPoints.size() > 100) { + Log.d(TAG, "原始点数: " + originalPoints.size() + ",进行降采样处理"); + + // 每DOWNSAMPLE_FACTOR个点取一个点 + for (int i = 0; i < originalPoints.size(); i += DOWNSAMPLE_FACTOR) { + targetList.add(originalPoints.get(i)); + } + + // 确保包含最后一个点 + if (targetList.isEmpty() || targetList.get(targetList.size()-1) != originalPoints.get(originalPoints.size()-1)) { + targetList.add(originalPoints.get(originalPoints.size()-1)); + } + + Log.d(TAG, "降采样后点数: " + targetList.size()); + } else { + // 点数较少,不降采样,直接使用原始点 + targetList.addAll(originalPoints); + } + + // 对GNSS轨迹进行额外平滑处理 + if (ENABLE_SMOOTHING && targetList == gnssTrajectoryPoints && targetList.size() > 5) { + Log.d(TAG, "对GNSS轨迹执行平滑处理"); + smoothGnssTrajectory(targetList); + } + } + + /** + * 对GNSS轨迹进行平滑处理 + */ + private void smoothGnssTrajectory(List points) { + if (points.size() < 5) return; + + // 复制原始点集合 + List originalPoints = new ArrayList<>(points); + points.clear(); + + // 移动平均窗口宽度 + int windowSize = 5; + double[] weights = {0.1, 0.2, 0.4, 0.2, 0.1}; // 加权移动平均权重 + + // 处理开头的点(直接保留) + for (int i = 0; i < windowSize/2; i++) { + points.add(originalPoints.get(i)); + } + + // 对中间的点应用加权移动平均 + for (int i = windowSize/2; i < originalPoints.size() - windowSize/2; i++) { + double sumLat = 0, sumLng = 0; + for (int j = 0; j < windowSize; j++) { + int idx = i - windowSize/2 + j; + TrajectoryPoint pt = originalPoints.get(idx); + sumLat += pt.latitude * weights[j]; + sumLng += pt.longitude * weights[j]; + } + + TrajectoryPoint smoothedPoint = new TrajectoryPoint( + originalPoints.get(i).timestamp, + sumLat, + sumLng + ); + points.add(smoothedPoint); + } + + // 处理结尾的点(直接保留) + for (int i = originalPoints.size() - windowSize/2; i < originalPoints.size(); i++) { + points.add(originalPoints.get(i)); + } + + Log.d(TAG, "GNSS轨迹平滑处理完成"); + } + + /** + * 切换轨迹类型 + */ + private void switchTrajectoryType(TrajectoryType type) { + if (mMap == null) { + Log.e(TAG, "地图尚未准备好,无法切换轨迹类型"); + return; + } + + Log.d(TAG, "切换轨迹类型: " + type.name()); + + // 停止播放并重置位置 + stopPlayback(); + currentPointIndex = 0; + + currentTrajectoryType = type; + + // 更新轨迹点和RadioButton状态 + switch (type) { + case PDR: + trajectoryPoints = pdrTrajectoryPoints; + Log.d(TAG, "切换到PDR轨迹,轨迹点数量: " + trajectoryPoints.size()); + if (!pdrRadioButton.isChecked()) { + pdrRadioButton.setChecked(true); + gnssRadioButton.setChecked(false); + ekfRadioButton.setChecked(false); + } + break; + case GNSS: + trajectoryPoints = gnssTrajectoryPoints; + Log.d(TAG, "切换到GNSS轨迹,轨迹点数量: " + trajectoryPoints.size()); + if (!gnssRadioButton.isChecked()) { + gnssRadioButton.setChecked(true); + pdrRadioButton.setChecked(false); + ekfRadioButton.setChecked(false); + } + break; + case EKF: + trajectoryPoints = ekfTrajectoryPoints; + Log.d(TAG, "切换到EKF轨迹,轨迹点数量: " + trajectoryPoints.size()); + if (!ekfRadioButton.isChecked()) { + ekfRadioButton.setChecked(true); + pdrRadioButton.setChecked(false); + gnssRadioButton.setChecked(false); + } + break; + } + + // 更新地图和进度条 + updateMap(); + + // 确保进度条正确反映轨迹长度 + if (progressBar != null) { + progressBar.setMax(trajectoryPoints.size() > 0 ? trajectoryPoints.size() - 1 : 0); + progressBar.setProgress(0); + } + + Log.d(TAG, "轨迹类型切换完成: " + type.name()); + } + + @Override + public void onMapReady(GoogleMap googleMap) { + mMap = googleMap; + + // 设置地图类型为卫星地图 + mMap.setMapType(GoogleMap.MAP_TYPE_SATELLITE); + + // 如果已经有轨迹数据,更新地图显示 + if (!trajectoryPoints.isEmpty()) { + updateMap(); + } + } + + private void togglePlayback() { + isPlaying = !isPlaying; + if (isPlaying) { + playPauseButton.setText(R.string.pause); + startPlayback(); + } else { + playPauseButton.setText(R.string.play); + stopPlayback(); + } + } + + private void startPlayback() { + playbackHandler.postDelayed(new Runnable() { + @Override + public void run() { + if (isPlaying && currentPointIndex < trajectoryPoints.size()) { + // 更新地图上的位置 + TrajectoryPoint point = trajectoryPoints.get(currentPointIndex); + LatLng position = new LatLng(point.latitude, point.longitude); + + // 更新或创建当前位置标记 + if (currentPositionMarker == null) { + currentPositionMarker = mMap.addMarker(new MarkerOptions() + .position(position) + .title("Current Position") + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE))); + } else { + currentPositionMarker.setPosition(position); + } + + // 移动相机 + mMap.animateCamera(CameraUpdateFactory.newLatLng(position)); + + // 更新进度条 + progressBar.setProgress(currentPointIndex); + + currentPointIndex++; + playbackHandler.postDelayed(this, PLAYBACK_INTERVAL); + } else { + isPlaying = false; + playPauseButton.setText(R.string.play); + } + } + }, PLAYBACK_INTERVAL); + } + + private void stopPlayback() { + playbackHandler.removeCallbacksAndMessages(null); + } + + private void restartPlayback() { + stopPlayback(); + currentPointIndex = 0; + progressBar.setProgress(0); + playPauseButton.setText(R.string.play); + isPlaying = false; + + // 移动相机回到起点 + if (!trajectoryPoints.isEmpty()) { + TrajectoryPoint startPoint = trajectoryPoints.get(0); + LatLng startPosition = new LatLng(startPoint.latitude, startPoint.longitude); + + // 更新位置标记 + if (currentPositionMarker != null) { + currentPositionMarker.setPosition(startPosition); + } else { + currentPositionMarker = mMap.addMarker(new MarkerOptions() + .position(startPosition) + .title("Current Position") + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE))); + } + + // 移动相机 + mMap.animateCamera(CameraUpdateFactory.newLatLng(startPosition)); + } + } + + private void updateMap() { + if (mMap == null || trajectoryPoints == null || trajectoryPoints.isEmpty()) { + Log.e(TAG, "无法更新地图:地图未准备好或轨迹点为空"); + return; + } + + // 清除现有轨迹和标记 + if (currentPositionMarker != null) { + currentPositionMarker.remove(); + currentPositionMarker = null; + } + + // 清除地图上所有现有的轨迹线 + mMap.clear(); + + // 根据轨迹类型设置颜色 + int trajectoryColor; + switch (currentTrajectoryType) { + case PDR: + trajectoryColor = Color.RED; + break; + case GNSS: + trajectoryColor = Color.BLUE; + break; + case EKF: + trajectoryColor = Color.GREEN; + break; + default: + trajectoryColor = Color.RED; + } + + // 创建新的轨迹线 + PolylineOptions polylineOptions = new PolylineOptions() + .color(trajectoryColor) + .width(8f) + .geodesic(true); + + // 添加轨迹点 + for (TrajectoryPoint point : trajectoryPoints) { + polylineOptions.add(new LatLng(point.latitude, point.longitude)); + } + + // 添加轨迹线到地图 + Polyline polyline = mMap.addPolyline(polylineOptions); + + // 设置当前点标记 + if (!trajectoryPoints.isEmpty()) { + TrajectoryPoint currentPoint = trajectoryPoints.get(currentPointIndex); + currentPositionMarker = mMap.addMarker(new MarkerOptions() + .position(new LatLng(currentPoint.latitude, currentPoint.longitude)) + .title("当前位置") + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE))); + } + + // 更新进度条 + if (progressBar != null) { + progressBar.setMax(trajectoryPoints.size() - 1); + progressBar.setProgress(currentPointIndex); + } + + // 计算轨迹边界 + if (!trajectoryPoints.isEmpty()) { + double minLat = Double.MAX_VALUE; + double maxLat = Double.MIN_VALUE; + double minLng = Double.MAX_VALUE; + double maxLng = Double.MIN_VALUE; + + for (TrajectoryPoint point : trajectoryPoints) { + minLat = Math.min(minLat, point.latitude); + maxLat = Math.max(maxLat, point.latitude); + minLng = Math.min(minLng, point.longitude); + maxLng = Math.max(maxLng, point.longitude); + } + + // 计算合适的缩放级别 + float zoomLevel = 19f; // 默认缩放级别 + double latDiff = maxLat - minLat; + double lngDiff = maxLng - minLng; + + // 根据轨迹范围调整缩放级别 + if (latDiff > 0.01 || lngDiff > 0.01) { + zoomLevel = 18f; // 增大缩放级别 + } else if (latDiff > 0.005 || lngDiff > 0.005) { + zoomLevel = 19f; // 增大缩放级别 + } else { + zoomLevel = 20f; // 对于更小的范围使用更大的缩放级别 + } + + // 使用轨迹的第一个点作为中心点 + TrajectoryPoint firstPoint = trajectoryPoints.get(0); + LatLng center = new LatLng(firstPoint.latitude, firstPoint.longitude); + + // 使用animateCamera进行平滑过渡 + mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(center, zoomLevel), 1000, null); + } + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/SettingsFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/SettingsFragment.java similarity index 97% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/SettingsFragment.java rename to app/src/main/java/com/openpositioning/PositionMe/fragments/SettingsFragment.java index c1f6501c..c562d976 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/SettingsFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/SettingsFragment.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.presentation.fragment; +package com.openpositioning.PositionMe.fragments; import android.os.Bundle; import android.text.InputType; diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/StartLocationFragment.java similarity index 60% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java rename to app/src/main/java/com/openpositioning/PositionMe/fragments/StartLocationFragment.java index ee14f69f..c2a0e12e 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/StartLocationFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/StartLocationFragment.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.presentation.fragment; +package com.openpositioning.PositionMe.fragments; import android.os.Bundle; import android.view.LayoutInflater; @@ -10,6 +10,8 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; @@ -19,36 +21,32 @@ import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; import com.openpositioning.PositionMe.R; -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 * recording starts. This fragment displays a map in which the user can adjust their location to * correct the PDR when it is complete * - * @author Virginia Cangelosi * @see HomeFragment the previous fragment in the nav graph. * @see RecordingFragment the next fragment in the nav graph. * @see SensorFusion the class containing sensors and recording. + * + * @author Virginia Cangelosi */ public class StartLocationFragment extends Fragment { - // Button to go to next fragment and save the location + //Button to go to next fragment and save the location private Button button; - // Singleton SensorFusion class which stores data from all sensors + //Singleton SesnorFusion class which stores data from all sensors private SensorFusion sensorFusion = SensorFusion.getInstance(); - // Google maps LatLng object to pass location to the map + //Google maps LatLong object to pass location to the map private LatLng position; - // Start position of the user to be stored + //Start position of the user to be stored private float[] startPosition = new float[2]; - // Zoom level for the Google map + //Zoom of google maps + private NucleusBuildingManager NucleusBuildingManager; private float zoom = 19f; - // Instance for managing indoor building overlays (if any) - private NucleusBuildingManager nucleusBuildingManager; - // Dummy variable for floor index private int FloorNK; /** @@ -66,25 +64,42 @@ public StartLocationFragment() { @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - AppCompatActivity activity = (AppCompatActivity) getActivity(); - if (activity != null && activity.getSupportActionBar() != null) { - activity.getSupportActionBar().hide(); - } + // Inflate the layout for this fragment + ((AppCompatActivity)getActivity()).getSupportActionBar().hide(); View rootView = inflater.inflate(R.layout.fragment_startlocation, container, false); - // Obtain the start position from the GPS data from the SensorFusion class + //Obtain the start position from the GPS data from the SensorFusion class startPosition = sensorFusion.getGNSSLatitude(false); - // If no location found, zoom the map out - if (startPosition[0] == 0 && startPosition[1] == 0) { + //If not location found zoom the map out + if(startPosition[0]==0 && startPosition[1]==0){ zoom = 1f; - } else { + } + else { zoom = 19f; } - // Initialize map fragment - SupportMapFragment supportMapFragment = (SupportMapFragment) + SupportMapFragment supportMapFragment=(SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.startMap); + + // This is just a demonstration of the automatic expansion of the indoor map. + // Assume that we have obtained the user's position "newPosition" from the callback function. >>> + +// if (newPosition != null) { +// // Check if the user's position is inside the defined building polygon +// if (NucleusBuildingManager.isPointInBuilding(newPosition)) { +// FloorButtons.setVisibility(View.VISIBLE); +// switchFloorNU(floor); +// InNu = 1; // Mark indoor map status +// } else { +// NucleusBuildingManager.getIndoorMapManager().hideMap(); +// FloorButtons.setVisibility(View.GONE); +// InNu = 0; // Mark indoor map status +// } +// } + + + // Asynchronous map which can be configured supportMapFragment.getMapAsync(new OnMapReadyCallback() { /** * {@inheritDoc} @@ -95,42 +110,40 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, */ @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(); + if (mMap != null) { + // Create NuclearBuildingManager instance + NucleusBuildingManager = new NucleusBuildingManager(mMap); + NucleusBuildingManager.getIndoorMapManager().hideMap(); + } - // Add a marker at the current GPS location and move the camera + // Add a marker in 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() { + mMap.addMarker(new MarkerOptions().position(position).title("Start Position")).setDraggable(true); + mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(position, zoom )); + + //Drag listener for the marker to execute when the markers location is changed + mMap.setOnMarkerDragListener(new GoogleMap.OnMarkerDragListener() + { /** * {@inheritDoc} */ @Override - public void onMarkerDragStart(Marker marker) {} + public void onMarkerDragStart(Marker marker){} /** * {@inheritDoc} * Updates the start position of the user. */ @Override - public void onMarkerDragEnd(Marker marker) { + public void onMarkerDragEnd(Marker marker) + { startPosition[0] = (float) marker.getPosition().latitude; startPosition[1] = (float) marker.getPosition().longitude; } @@ -139,11 +152,10 @@ public void onMarkerDragEnd(Marker marker) { * {@inheritDoc} */ @Override - public void onMarkerDrag(Marker marker) {} + public void onMarkerDrag(Marker marker){} }); } }); - return rootView; } @@ -154,8 +166,8 @@ public void onMarkerDrag(Marker marker) {} @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - - this.button = view.findViewById(R.id.startLocationDone); + // Add button to begin PDR recording and go to recording fragment. + this.button = (Button) getView().findViewById(R.id.startLocationDone); this.button.setOnClickListener(new View.OnClickListener() { /** * {@inheritDoc} @@ -164,31 +176,16 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat */ @Override public void onClick(View view) { - float chosenLat = startPosition[0]; - float chosenLon = startPosition[1]; - - // If the Activity is RecordingActivity - if (requireActivity() instanceof RecordingActivity) { - // Start sensor recording + set the start location - sensorFusion.startRecording(); - sensorFusion.setStartGNSSLatitude(startPosition); - - // Now switch to the recording screen - ((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()); - } + // Starts recording data from the sensor fusion + sensorFusion.startRecording(); + // Set the start location obtained + sensorFusion.setStartGNSSLatitude(startPosition); + // Navigate to the RecordingFragment + NavDirections action = StartLocationFragmentDirections.actionStartLocationFragmentToRecordingFragment(); + Navigation.findNavController(view).navigate(action); } }); + } /** @@ -198,9 +195,10 @@ public void onClick(View view) { */ private void switchFloorNU(int floorIndex) { FloorNK = floorIndex; // Set the current floor index - if (nucleusBuildingManager != null) { + if (NucleusBuildingManager != null) { // Call the switchFloor method of the IndoorMapManager to switch to the specified floor - nucleusBuildingManager.getIndoorMapManager().switchFloor(floorIndex); + NucleusBuildingManager.getIndoorMapManager().switchFloor(floorIndex); } } + } diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/UploadFragment.java b/app/src/main/java/com/openpositioning/PositionMe/fragments/UploadFragment.java similarity index 76% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/UploadFragment.java rename to app/src/main/java/com/openpositioning/PositionMe/fragments/UploadFragment.java index 9d435812..458371e9 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/UploadFragment.java +++ b/app/src/main/java/com/openpositioning/PositionMe/fragments/UploadFragment.java @@ -1,12 +1,10 @@ -package com.openpositioning.PositionMe.presentation.fragment; +package com.openpositioning.PositionMe.fragments; import android.os.Bundle; import android.view.LayoutInflater; 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; @@ -15,17 +13,15 @@ import androidx.recyclerview.widget.RecyclerView; import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.data.remote.ServerCommunications; -import com.openpositioning.PositionMe.presentation.viewitems.UploadViewHolder; -import com.openpositioning.PositionMe.presentation.viewitems.DownloadClickListener; -import com.openpositioning.PositionMe.presentation.viewitems.UploadListAdapter; +import com.openpositioning.PositionMe.ServerCommunications; +import com.openpositioning.PositionMe.viewitems.DownloadClickListener; +import com.openpositioning.PositionMe.viewitems.UploadListAdapter; import java.io.File; 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 @@ -59,28 +55,13 @@ public UploadFragment() { * 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()); - - // 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(); - } - - localTrajectories = Stream.of(trajectoriesDir.listFiles((file, name) -> - name.contains("trajectory_") && name.endsWith(".txt"))) + // Load local trajectories + localTrajectories = Stream.of(getActivity().getFilesDir().listFiles((file, name) -> name.contains("trajectory_") && name.endsWith(".txt"))) .filter(file -> !file.isDirectory()) .collect(Collectors.toList()); } @@ -107,8 +88,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, * 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. + * @see com.openpositioning.PositionMe.viewitems.UploadViewHolder view holder for the recycler view. + * @see R.layout#item_upload_card_view xml view for list elements. */ @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { @@ -141,6 +122,11 @@ public void onPositionClicked(int position) { // localTrajectories.remove(position); // listAdapter.notifyItemRemoved(position); } + + @Override + public void onReplayClicked(int position) { + // 在上传界面不需要实现回放功能,但是需要提供一个空实现 + } }); uploadList.setAdapter(listAdapter); } 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 deleted file mode 100644 index 995f010d..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/MainActivity.java +++ /dev/null @@ -1,366 +0,0 @@ -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; -import android.view.MenuItem; -import android.widget.Toast; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -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; -import androidx.navigation.fragment.NavHostFragment; -import androidx.navigation.ui.AppBarConfiguration; -import androidx.navigation.ui.NavigationUI; -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; - - -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; - private ActivityResultLauncher multiplePermissionsLauncher; - - private SharedPreferences settings; - private SensorFusion sensorFusion; - private Handler httpResponseHandler; - - 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(); - toolbar.setBackgroundColor(ContextCompat.getColor(getApplicationContext(), R.color.md_theme_light_surface)); - 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 -> { - boolean locationGranted = result.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false); - 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(); - } - } - ); - - // 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(); - } - } - - /** - * {@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; - - boolean activityGranted = ContextCompat.checkSelfPermission( - this, Manifest.permission.ACTIVITY_RECOGNITION - ) == PackageManager.PERMISSION_GRANTED; - - if (!locationGranted || !activityGranted) { - // Request both permissions using ActivityResultLauncher - multiplePermissionsLauncher.launch(new String[]{ - Manifest.permission.ACCESS_FINE_LOCATION - }); - multiplePermissionsLauncher.launch(new String[]{ - Manifest.permission.ACTIVITY_RECOGNITION - }); - } else { - // Both permissions are already granted - allPermissionsObtained(); - } - } - }, 300); // Delay ensures activity is fully visible before requesting permissions - - if (sensorFusion != null) { - sensorFusion.resumeListening(); - } - } - - 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 - } - 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()) - return super.onOptionsItemSelected(item); - else { - NavOptions options = new NavOptions.Builder() - .setLaunchSingleTop(true) - .setEnterAnim(R.anim.slide_in_bottom) - .setExitAnim(R.anim.slide_out_top) - .setPopEnterAnim(R.anim.slide_in_top) - .setPopExitAnim(R.anim.slide_out_bottom).build(); - navController.navigate(R.id.action_global_settingsFragment, null, options); - return true; - } - } - - /** - * {@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) - .setTitle("Confirm Exit") - .setMessage("Are you sure you want to exit the app?") - .setPositiveButton("Yes", (dialog, which) -> { - dialog.dismiss(); - finish(); // Close the activity (exit the app) - }) - .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; - if((Boolean) objList[0]) { - this.httpResponseHandler.post(displayToastTaskSuccess); - } - else { - this.httpResponseHandler.post(displayToastTaskFailure); - } - } - - /** - * 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(); - }; - - //endregion -} \ 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 deleted file mode 100644 index c0d82ae2..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/RecordingActivity.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.openpositioning.PositionMe.presentation.activity; - -import android.os.Bundle; -import android.view.WindowManager; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -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; - - -/** - * 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 - */ - -public class RecordingActivity extends AppCompatActivity { - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_recording); - - if (savedInstanceState == null) { - showStartLocationScreen(); // Start with the user selecting the start location - } - - // Keep screen on - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - - /** - * Show the StartLocationFragment (beginning of flow). - */ - public void showStartLocationScreen() { - FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); - ft.replace(R.id.mainFragmentContainer, new StartLocationFragment()); - ft.commit(); - } - - /** - * Show the RecordingFragment, which contains the TrajectoryMapFragment internally. - */ - public void showRecordingScreen() { - FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); - ft.replace(R.id.mainFragmentContainer, new RecordingFragment()); - ft.addToBackStack(null); - ft.commit(); - } - - /** - * Show the CorrectionFragment after the user stops recording. - */ - public void showCorrectionScreen() { - FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); - ft.replace(R.id.mainFragmentContainer, new CorrectionFragment()); - ft.addToBackStack(null); - ft.commit(); - } - - /** - * Finish the Activity (or do any final steps) once corrections are done. - */ - public void finishFlow() { - getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - finish(); - } -} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/ReplayActivity.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/ReplayActivity.java deleted file mode 100644 index c6a30472..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/activity/ReplayActivity.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.openpositioning.PositionMe.presentation.activity; - -import android.os.Bundle; -import android.util.Log; -import java.io.File; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.presentation.fragment.ReplayFragment; -import com.openpositioning.PositionMe.presentation.fragment.StartLocationFragment; - - -/** - * The ReplayActivity is responsible for managing the replay session of a user's trajectory. - * It handles the process of retrieving the trajectory data, displaying relevant fragments, and - * facilitating the interaction with the user to choose the starting location before displaying the - * replay of the trajectory. - *

- * The activity starts by extracting the trajectory file path from the intent that launched it. If - * the file path is not provided or is empty, it uses a default file path. It ensures that the trajectory - * file exists before proceeding. Once the file is verified, it shows the StartLocationFragment, which allows - * the user to select their starting location (latitude and longitude). After the user has selected the - * starting point, the activity switches to the ReplayFragment to display the replay of the user's trajectory. - *

- * The activity also provides functionality to finish the replay session and exit the activity once the replay - * process has completed. - *

- * This activity makes use of a few key constants for passing data between fragments, including the trajectory file - * path and the initial latitude and longitude. These constants are defined at the beginning of the class. - *

- * The ReplayActivity manages the interaction between fragments by facilitating communication from the - * StartLocationFragment to the ReplayFragment, where the replay of the trajectory is displayed. - * - * @see StartLocationFragment The fragment where the user selects their start location for the trajectory replay. - * @see ReplayFragment The fragment responsible for showing the trajectory replay. - * - * @author Shu Gu - */ - -public class ReplayActivity extends AppCompatActivity { - - public static final String TAG = "ReplayActivity"; - public static final String EXTRA_INITIAL_LAT = "extra_initial_lat"; - public static final String EXTRA_INITIAL_LON = "extra_initial_lon"; - public static final String EXTRA_TRAJECTORY_FILE_PATH = "extra_trajectory_file_path"; - - private String filePath; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_replay); - // Get the trajectory file path from the Intent - filePath = getIntent().getStringExtra(EXTRA_TRAJECTORY_FILE_PATH); - - // Debug log: Received file path - Log.i(TAG, "Received trajectory file path: " + filePath); - - if (filePath == null || filePath.isEmpty()) { - // If not provided, set a default path (or show an error message) - filePath = "/storage/emulated/0/Download/trajectory_default.txt"; - Log.e(TAG, "No trajectory file path provided, using default: " + filePath); - } - - // Check if file exists before proceeding - if (!new File(filePath).exists()) { - Log.e(TAG, "Trajectory file does NOT exist: " + filePath); - } else { - Log.i(TAG, "Trajectory file exists: " + filePath); - } - - // Show StartLocationFragment first to let user pick location - if (savedInstanceState == null) { - showStartLocationFragment(); - } - } - - /** - * Display a StartLocationFragment to let user set their start location. - * Displays the ReplayFragment and passes the trajectory file path as an argument. - */ - private void showStartLocationFragment() { - Log.d(TAG, "Showing StartLocationFragment..."); - StartLocationFragment startLocationFragment = new StartLocationFragment(); - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.replayActivityContainer, startLocationFragment) - .commit(); - } - - /** - * Called by StartLocationFragment when user picks their start location. - */ - public void onStartLocationChosen(float lat, float lon) { - Log.i(TAG, "User selected start location: Lat=" + lat + ", Lon=" + lon); - showReplayFragment(filePath, lat, lon); - } - - /** - * Display ReplayFragment, passing file path and starting lat/lon as arguments. - */ - public void showReplayFragment(String filePath, float initialLat, float initialLon) { - Log.d(TAG, "Switching to ReplayFragment with file: " + filePath + - ", Initial Lat: " + initialLat + ", Initial Lon: " + initialLon); - - ReplayFragment replayFragment = new ReplayFragment(); - // Pass the file path through a Bundle - Bundle args = new Bundle(); - args.putString(EXTRA_TRAJECTORY_FILE_PATH, filePath); - args.putFloat(EXTRA_INITIAL_LAT, initialLat); - args.putFloat(EXTRA_INITIAL_LON, initialLon); - replayFragment.setArguments(args); - - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.replayActivityContainer, replayFragment) - .commit(); - } - - /** - * Finish replay session - * Called when the replay process is completed. - */ - public void finishFlow() { - Log.d(TAG, "Replay session finished."); - 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 deleted file mode 100644 index 8f94cb27..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/CorrectionFragment.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.openpositioning.PositionMe.presentation.fragment; - -import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.EditText; -import android.widget.TextView; - -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; - -/** - * 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 - 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; - - public CorrectionFragment() { - // Required empty public constructor - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - AppCompatActivity activity = (AppCompatActivity) getActivity(); - 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; - } - - @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; - } - } - 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(); - } - }); - } - - public void setScalingRatio(float scalingRatio) { - this.scalingRatio = scalingRatio; - } -} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/FilesFragment.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/FilesFragment.java deleted file mode 100644 index 83bc4ef1..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/FilesFragment.java +++ /dev/null @@ -1,221 +0,0 @@ -package com.openpositioning.PositionMe.presentation.fragment; - -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.cardview.widget.CardView; -import androidx.fragment.app.Fragment; -import androidx.navigation.NavDirections; -import androidx.navigation.Navigation; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.data.remote.ServerCommunications; -import com.openpositioning.PositionMe.presentation.viewitems.TrajDownloadViewHolder; -import com.openpositioning.PositionMe.sensors.Observer; -import com.openpositioning.PositionMe.presentation.viewitems.TrajDownloadListAdapter; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * A simple {@link Fragment} subclass. The files fragments displays a list of trajectories already - * uploaded with some metadata, and enabled re-downloading them to the device's local storage. - * - * @see HomeFragment the connected fragment in the nav graph. - * @see UploadFragment sub-menu for uploading recordings that failed during recording. - * @see com.openpositioning.PositionMe.Traj the data structure sent and received. - * @see ServerCommunications the class handling communication with the server. - * - * @author Mate Stodulka - */ -public class FilesFragment extends Fragment implements Observer { - - // UI elements - private RecyclerView filesList; - private TrajDownloadListAdapter listAdapter; - private CardView uploadCard; - - // Class handling HTTP communication - private ServerCommunications serverCommunications; - - /** - * Default public constructor, empty. - */ - public FilesFragment() { - // Required empty public constructor - } - - /** - * {@inheritDoc} - * Initialise the server communication class and register the FilesFragment as an Observer to - * receive the async http responses. - */ - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - serverCommunications = new ServerCommunications(getActivity()); - serverCommunications.registerObserver(this); - } - - /** - * {@inheritDoc} - * Sets the title in the action bar. - */ - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - // Inflate the layout for this fragment - View rootView = inflater.inflate(R.layout.fragment_files, container, false); - getActivity().setTitle("Trajectory recordings"); - return rootView; - } - - /** - * {@inheritDoc} - * Initialises UI elements, including a navigation card to the {@link UploadFragment} and a - * RecyclerView displaying online trajectories. - * - * @see TrajDownloadViewHolder the View Holder for the list. - * @see TrajDownloadListAdapter the list adapter for displaying the recycler view. - * @see com.openpositioning.PositionMe.R.layout#item_trajectorycard_view the elements in the list. - */ - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - // Get recyclerview - filesList = view.findViewById(R.id.filesList); - // Get clickable card view - uploadCard = view.findViewById(R.id.uploadCard); - uploadCard.setOnClickListener(new View.OnClickListener() { - /** - * {@inheritDoc} - * Navigates to {@link UploadFragment}. - */ - @Override - public void onClick(View view) { - NavDirections action = FilesFragmentDirections.actionFilesFragmentToUploadFragment(); - Navigation.findNavController(view).navigate(action); - } - }); - // Request list of uploaded trajectories from the server. - serverCommunications.sendInfoRequest(); - // Force RecyclerView refresh to ensure icon states are correct - new Handler(Looper.getMainLooper()).postDelayed(() -> { - if (filesList.getAdapter() != null) { - filesList.getAdapter().notifyDataSetChanged(); - System.out.println("RecyclerView refreshed after page load."); - } - }, 500); - } - - /** - * {@inheritDoc} - * Called by {@link ServerCommunications} when the response to the HTTP info request is received. - * - * @param singletonStringList a single string wrapped in an object array containing the http - * response from the server. - */ - @Override - public void update(Object[] singletonStringList) { - // Cast input as a string - String infoString = (String) singletonStringList[0]; - // Check if the string is non-null and non-empty before processing - if(infoString != null && !infoString.isEmpty()) { - // Process string - List> entryList = processInfoResponse(infoString); - // Start a handler to be able to modify UI elements - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { - // Update the RecyclerView with data from the server - updateView(entryList); - } - }); - } - } - - /** - * Parses the info response string from the HTTP communication. - * Process the data using the Json library and return the matching Java data structure as a - * List of Maps of \. Throws a JSONException if the data is not valid. - * - * @param infoString HTTP info request response as a single string - * @return List of Maps of String to String containing ID, owner ID, and date. - */ - private List> processInfoResponse(String infoString) { - // Initialise empty list - List> entryList = new ArrayList<>(); - try { - // Attempt to decode using known JSON pattern - JSONArray jsonArray = new JSONArray(infoString); - for (int i = 0; i < jsonArray.length(); i++) { - JSONObject trajectoryEntry = jsonArray.getJSONObject(i); - Map entryMap = new HashMap<>(); - entryMap.put("owner_id", String.valueOf(trajectoryEntry.get("owner_id"))); - entryMap.put("date_submitted", (String) trajectoryEntry.get("date_submitted")); - entryMap.put("id", String.valueOf(trajectoryEntry.get("id"))); - // Add decoded map to list of entries - entryList.add(entryMap); - } - } catch (JSONException e) { - System.err.println("JSON reading failed"); - e.printStackTrace(); - } - // Sort the list by the ID fields of the maps - entryList.sort(Comparator.comparing(m -> Integer.parseInt(m.get("id")), Comparator.nullsLast(Comparator.naturalOrder()))); - return entryList; - } - - /** - * Update the RecyclerView in the FilesFragment with new data. - * Must be called from a UI thread. Initialises a new Layout Manager, and passes it to the - * RecyclerView. Initialises a {@link TrajDownloadListAdapter} with the input array and setting - * up a listener so that trajectories are downloaded when clicked, and a pop-up message is - * displayed to notify the user. - * - * @param entryList List of Maps of String to String containing metadata about the uploaded - * trajectories (ID, owner ID, date). - */ - private void updateView(List> entryList) { - // Initialise RecyclerView with Manager and Adapter - LinearLayoutManager manager = new LinearLayoutManager(getActivity()); - filesList.setLayoutManager(manager); - filesList.setHasFixedSize(true); - listAdapter = new TrajDownloadListAdapter(getActivity(), entryList, position -> { - Map selectedItem = entryList.get(position); - String id = selectedItem.get("id"); - String dateSubmitted = selectedItem.get("date_submitted"); - - // Pass ID and date_submitted - serverCommunications.downloadTrajectory(position, id, dateSubmitted); - -// new AlertDialog.Builder(getContext()) -// .setTitle("File downloaded") -// .setMessage("Trajectory downloaded to local storage") -// .setPositiveButton(R.string.ok, null) -// .setNegativeButton(R.string.show_storage, (dialogInterface, i) -> { -// startActivity(new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)); -// }) -// .setIcon(R.drawable.ic_baseline_download_24) -// .show(); - }); - filesList.setAdapter(listAdapter); - // Force refresh RecyclerView to ensure downloadRecords changes are detected - listAdapter.notifyDataSetChanged(); - } -} \ 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 deleted file mode 100644 index 8371b04e..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/HomeFragment.java +++ /dev/null @@ -1,218 +0,0 @@ -package com.openpositioning.PositionMe.presentation.fragment; - -import android.Manifest; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.location.LocationManager; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.fragment.app.Fragment; -import androidx.navigation.NavDirections; -import androidx.navigation.Navigation; -import androidx.preference.PreferenceManager; - -import com.google.android.material.button.MaterialButton; -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; - -/** - * A simple {@link Fragment} subclass. The home fragment is the start screen of the application. - * The home fragment acts as a hub for all other fragments, with buttons and icons for navigation. - * The default screen when opening the application - * - * @see RecordingFragment - * @see FilesFragment - * @see MeasurementsFragment - * @see SettingsFragment - * - * @author Mate Stodulka - */ -public class HomeFragment extends Fragment implements OnMapReadyCallback { - - // Interactive UI elements to navigate to other fragments - private MaterialButton goToInfo; - private Button start; - private Button measurements; - private Button files; - private TextView gnssStatusTextView; - - // For the map - private GoogleMap mMap; - private SupportMapFragment mapFragment; - - public HomeFragment() { - // Required empty public constructor - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - /** - * {@inheritDoc} - * Ensure the action bar is shown at the top of the screen. Set the title visible to Home. - */ - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - ((AppCompatActivity) getActivity()).getSupportActionBar().show(); - View rootView = inflater.inflate(R.layout.fragment_home, container, false); - getActivity().setTitle("Home"); - return rootView; - } - - /** - * Initialise UI elements and set onClick actions for the buttons. - */ - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - // Sensor Info button - goToInfo = view.findViewById(R.id.sensorInfoButton); - goToInfo.setOnClickListener(v -> { - NavDirections action = HomeFragmentDirections.actionHomeFragmentToInfoFragment(); - Navigation.findNavController(v).navigate(action); - }); - - // Start/Stop Recording button - start = view.findViewById(R.id.startStopButton); - start.setEnabled(!PreferenceManager.getDefaultSharedPreferences(getContext()) - .getBoolean("permanentDeny", false)); - start.setOnClickListener(v -> { - Intent intent = new Intent(requireContext(), RecordingActivity.class); - startActivity(intent); - ((AppCompatActivity) getActivity()).getSupportActionBar().hide(); - }); - - // Measurements button - measurements = view.findViewById(R.id.measurementButton); - measurements.setOnClickListener(v -> { - NavDirections action = HomeFragmentDirections.actionHomeFragmentToMeasurementsFragment(); - Navigation.findNavController(v).navigate(action); - }); - - // Files button - files = view.findViewById(R.id.filesButton); - files.setOnClickListener(v -> { - NavDirections action = HomeFragmentDirections.actionHomeFragmentToFilesFragment(); - Navigation.findNavController(v).navigate(action); - }); - - // TextView to display GNSS disabled message - gnssStatusTextView = view.findViewById(R.id.gnssStatusTextView); - - // Locate the MapFragment nested in this fragment - mapFragment = (SupportMapFragment) - getChildFragmentManager().findFragmentById(R.id.mapFragmentContainer); - if (mapFragment != null) { - // Asynchronously initialize the map - mapFragment.getMapAsync(this); - } - } - - /** - * Callback triggered when the Google Map is ready to be used. - */ - @Override - public void onMapReady(@NonNull GoogleMap googleMap) { - mMap = googleMap; - checkAndUpdatePermissions(); - } - - @Override - public void onResume() { - super.onResume(); - checkAndUpdatePermissions(); - } - - /** - * Checks if GNSS/Location is enabled on the device. - */ - private boolean isGnssEnabled() { - LocationManager locationManager = - (LocationManager) requireContext().getSystemService(Context.LOCATION_SERVICE); - // Checks both GPS and network provider. Adjust as needed. - boolean gpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); - boolean networkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER); - return (gpsEnabled || networkEnabled); - } - - /** - * Move the map to the University of Edinburgh and display a message. - */ - private void showEdinburghAndMessage(String message) { - gnssStatusTextView.setText(message); - gnssStatusTextView.setVisibility(View.VISIBLE); - - LatLng edinburghLatLng = new LatLng(55.944425, -3.188396); - mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(edinburghLatLng, 15f)); - mMap.addMarker(new MarkerOptions() - .position(edinburghLatLng) - .title("University of Edinburgh")); - } - - private void checkAndUpdatePermissions() { - - if (mMap == null) { - return; - } - - // Check if GNSS/Location is enabled - boolean gnssEnabled = isGnssEnabled(); - if (gnssEnabled) { - // Hide the "GNSS Disabled" message - gnssStatusTextView.setVisibility(View.GONE); - - // Check runtime permissions for location - if (ActivityCompat.checkSelfPermission( - requireContext(), Manifest.permission.ACCESS_FINE_LOCATION) - == PackageManager.PERMISSION_GRANTED || - ActivityCompat.checkSelfPermission( - requireContext(), Manifest.permission.ACCESS_COARSE_LOCATION) - == PackageManager.PERMISSION_GRANTED) { - - // Enable the MyLocation layer of Google Map - mMap.setMyLocationEnabled(true); - - // Optionally move the camera to last known or default location: - // (You could retrieve it from FusedLocationProvider or similar). - // Here, just leaving it on default. - // If you want to center on the user as soon as it loads, do something like: - /* - FusedLocationProviderClient fusedLocationClient = - LocationServices.getFusedLocationProviderClient(requireContext()); - fusedLocationClient.getLastLocation().addOnSuccessListener(location -> { - if (location != null) { - LatLng currentLatLng = new LatLng(location.getLatitude(), location.getLongitude()); - mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(currentLatLng, 15f)); - } - }); - */ - } else { - // If no permission, simply show a default location or prompt for permissions - showEdinburghAndMessage("Permission not granted. Please enable in settings."); - } - } else { - // If GNSS is disabled, show University of Edinburgh + message - showEdinburghAndMessage("GNSS is disabled. Please enable in settings."); - } - } -} 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 deleted file mode 100644 index 20c43987..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/MeasurementsFragment.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.openpositioning.PositionMe.presentation.fragment; - -import android.os.Bundle; -import android.os.Handler; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.cardview.widget.CardView; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.sensors.SensorFusion; -import com.openpositioning.PositionMe.sensors.SensorTypes; -import com.openpositioning.PositionMe.sensors.Wifi; -import com.openpositioning.PositionMe.presentation.viewitems.WifiListAdapter; - -import java.util.List; -import java.util.Map; - -/** - * A simple {@link Fragment} subclass. The measurement fragment displays the set of current sensor - * readings. The values are refreshed periodically, but slower than their internal refresh rate. - * The refresh time is set by a static constant. - * - * @see HomeFragment the previous fragment in the nav graph. - * @see SensorFusion the source of all sensor readings. - * - * @author Mate Stodulka - */ -public class MeasurementsFragment extends Fragment { - - // Static constant for refresh time in milliseconds - private static final long REFRESH_TIME = 5000; - - // Singleton Sensor Fusion class handling all sensor data - private SensorFusion sensorFusion; - - // UI Handler - private Handler refreshDataHandler; - // UI elements - private ConstraintLayout sensorMeasurementList; - private RecyclerView wifiListView; - // List of string resource IDs - private int[] prefaces; - private int[] gnssPrefaces; - - - /** - * Public default constructor, empty. - */ - public MeasurementsFragment() { - // Required empty public constructor - } - - /** - * {@inheritDoc} - * Obtains the singleton Sensor Fusion instance and initialises the string prefaces for display. - * Creates a new handler to periodically refresh data. - * - * @see SensorFusion handles all sensor data. - */ - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - // Get sensor fusion instance - sensorFusion = SensorFusion.getInstance(); - // Initialise string prefaces for display - prefaces = new int[]{R.string.x, R.string.y, R.string.z}; - gnssPrefaces = new int[]{R.string.lati, R.string.longi}; - - // Create new handler to refresh the UI. - this.refreshDataHandler = new Handler(); - } - - /** - * {@inheritDoc} - * Sets title in the action bar to Sensor Measurements. - * Posts the {@link MeasurementsFragment#refreshTableTask} using the Handler. - */ - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - // Inflate the layout for this fragment - View rootView = inflater.inflate(R.layout.fragment_measurements, container, false); - getActivity().setTitle("Sensor Measurements"); - this.refreshDataHandler.post(refreshTableTask); - return rootView; - } - - /** - * {@inheritDoc} - * Pauses the data refreshing when the fragment is not in focus. - */ - @Override - public void onPause() { - refreshDataHandler.removeCallbacks(refreshTableTask); - super.onPause(); - } - - /** - * {@inheritDoc} - * Restarts the data refresh when the fragment returns to focus. - */ - @Override - public void onResume() { - refreshDataHandler.postDelayed(refreshTableTask, REFRESH_TIME); - super.onResume(); - } - - /** - * {@inheritDoc} - * Obtains the constraint layout holding the sensor measurement values. Initialises the Recycler - * View for holding WiFi data and registers its Layout Manager. - */ - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - sensorMeasurementList = (ConstraintLayout) getView().findViewById(R.id.sensorMeasurementList); - wifiListView = (RecyclerView) getView().findViewById(R.id.wifiList); - wifiListView.setLayoutManager(new LinearLayoutManager(getActivity())); - } - - /** - * Runnable task containing functionality to update the UI with the relevant sensor data. - * Must be run on the UI thread via a Handler. Obtains movement sensor values and the current - * WiFi networks from the {@link SensorFusion} instance and updates the UI with the new data - * and the string wrappers provided. - * - * @see SensorFusion class handling all sensors and data processing. - * @see Wifi class holding network data. - */ - private final Runnable refreshTableTask = new Runnable() { - @Override - 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])); - } - 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 - 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)); - } - // 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 deleted file mode 100644 index 6362a971..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/RecordingFragment.java +++ /dev/null @@ -1,298 +0,0 @@ -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.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 androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; - -import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.presentation.activity.RecordingActivity; -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; - - -/** - * 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; - - // References to the child map fragment - private TrajectoryMapFragment trajectoryMapFragment; - - private final Runnable refreshDataTask = new Runnable() { - @Override - public void run() { - updateUIandPosition(); - // Loop again - refreshDataHandler.postDelayed(refreshDataTask, 200); - } - }; - - public RecordingFragment() { - // Required empty public constructor - } - - @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(); - } - - @Nullable - @Override - 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); - } - - @Override - 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(); - } - - // 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(); - }); - - - // 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 - }); - - // 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(); - } - - @Override - public void onFinish() { - sensorFusion.stopRecording(); - ((RecordingActivity) requireActivity()).showCorrectionScreen(); - } - }.start(); - } else { - // No set time limit, just keep refreshing - refreshDataHandler.post(refreshDataTask); - } - } - - /** - * 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())); - } - } - - // 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)); - } - trajectoryMapFragment.updateGNSS(gnssLocation); - } else { - gnssError.setVisibility(View.GONE); - trajectoryMapFragment.clearGNSS(); - } - } - - // Update previous - previousPosX = pdrValues[0]; - previousPosY = pdrValues[1]; - } - - /** - * 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); - } - - @Override - public void onPause() { - super.onPause(); - refreshDataHandler.removeCallbacks(refreshDataTask); - } - - @Override - public void onResume() { - super.onResume(); - if(!this.settings.getBoolean("split_trajectory", false)) { - refreshDataHandler.postDelayed(refreshDataTask, 500); - } - } -} 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 deleted file mode 100644 index d15a4a83..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/ReplayFragment.java +++ /dev/null @@ -1,365 +0,0 @@ -package com.openpositioning.PositionMe.presentation.fragment; - -import android.os.Bundle; -import android.os.Handler; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.SeekBar; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.Fragment; - -import com.google.android.gms.maps.model.LatLng; -import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.presentation.activity.ReplayActivity; -import com.openpositioning.PositionMe.data.local.TrajParser; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; - -/** - * Sub fragment of Replay Activity. Fragment that replays trajectory data on a map. - *

- * The ReplayFragment is responsible for visualizing and replaying trajectory data captured during - * previous recordings. It loads trajectory data from a JSON file, updates the map with user movement, - * and provides UI controls for playback, pause, and seek functionalities. - *

- * Features: - * - Loads trajectory data from a file and displays it on a map. - * - Provides playback controls including play, pause, restart, and go to end. - * - Updates the trajectory dynamically as playback progresses. - * - Allows users to manually seek through the recorded trajectory. - * - Integrates with {@link TrajectoryMapFragment} for map visualization. - * - * @see TrajectoryMapFragment The map fragment displaying the trajectory. - * @see ReplayActivity The activity managing the replay workflow. - * @see TrajParser Utility class for parsing trajectory data. - * - * @author Shu Gu - */ -public class ReplayFragment extends Fragment { - - private static final String TAG = "ReplayFragment"; - - // GPS start location (received from ReplayActivity) - private float initialLat = 0f; - private float initialLon = 0f; - private String filePath = ""; - private int lastIndex = -1; - - // UI Controls - private TrajectoryMapFragment trajectoryMapFragment; - private Button playPauseButton, restartButton, exitButton, goEndButton; - private SeekBar playbackSeekBar; - - // Playback-related - private final Handler playbackHandler = new Handler(); - private final long PLAYBACK_INTERVAL_MS = 500; // milliseconds - private List replayData = new ArrayList<>(); - private int currentIndex = 0; - private boolean isPlaying = false; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - // Retrieve transferred data from ReplayActivity - if (getArguments() != null) { - filePath = getArguments().getString(ReplayActivity.EXTRA_TRAJECTORY_FILE_PATH, ""); - initialLat = getArguments().getFloat(ReplayActivity.EXTRA_INITIAL_LAT, 0f); - initialLon = getArguments().getFloat(ReplayActivity.EXTRA_INITIAL_LON, 0f); - } - - // Log the received data - Log.i(TAG, "ReplayFragment received data:"); - Log.i(TAG, "Trajectory file path: " + filePath); - Log.i(TAG, "Initial latitude: " + initialLat); - Log.i(TAG, "Initial longitude: " + initialLon); - - // Check if file exists before parsing - File trajectoryFile = new File(filePath); - if (!trajectoryFile.exists()) { - Log.e(TAG, "ERROR: Trajectory file does NOT exist at: " + filePath); - return; - } - if (!trajectoryFile.canRead()) { - Log.e(TAG, "ERROR: Trajectory file exists but is NOT readable: " + filePath); - return; - } - - Log.i(TAG, "Trajectory file confirmed to exist and is readable."); - - // Parse the JSON file and prepare replayData using TrajParser - replayData = TrajParser.parseTrajectoryData(filePath, requireContext(), initialLat, initialLon); - - // Log the number of parsed points - if (replayData != null && !replayData.isEmpty()) { - Log.i(TAG, "Trajectory data loaded successfully. Total points: " + replayData.size()); - } else { - Log.e(TAG, "Failed to load trajectory data! replayData is empty or null."); - } - } - - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, - @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_replay, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, - @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - // Initialize map fragment - trajectoryMapFragment = (TrajectoryMapFragment) - getChildFragmentManager().findFragmentById(R.id.replayMapFragmentContainer); - if (trajectoryMapFragment == null) { - trajectoryMapFragment = new TrajectoryMapFragment(); - getChildFragmentManager() - .beginTransaction() - .replace(R.id.replayMapFragmentContainer, trajectoryMapFragment) - .commit(); - } - - - - // 1) Check if the file contains any GNSS data - boolean gnssExists = hasAnyGnssData(replayData); - - if (gnssExists) { - showGnssChoiceDialog(); - } else { - // No GNSS data -> automatically use param lat/lon - if (initialLat != 0f || initialLon != 0f) { - LatLng startPoint = new LatLng(initialLat, initialLon); - Log.i(TAG, "Setting initial map position: " + startPoint.toString()); - trajectoryMapFragment.setInitialCameraPosition(startPoint); - } - } - - // Initialize UI controls - playPauseButton = view.findViewById(R.id.playPauseButton); - restartButton = view.findViewById(R.id.restartButton); - exitButton = view.findViewById(R.id.exitButton); - goEndButton = view.findViewById(R.id.goEndButton); - playbackSeekBar = view.findViewById(R.id.playbackSeekBar); - - // Set SeekBar max value based on replay data - if (!replayData.isEmpty()) { - playbackSeekBar.setMax(replayData.size() - 1); - } - - // Button Listeners - playPauseButton.setOnClickListener(v -> { - if (replayData.isEmpty()) { - Log.w(TAG, "Play/Pause button pressed but replayData is empty."); - return; - } - if (isPlaying) { - isPlaying = false; - playPauseButton.setText("Play"); - Log.i(TAG, "Playback paused at index: " + currentIndex); - } else { - isPlaying = true; - playPauseButton.setText("Pause"); - Log.i(TAG, "Playback started from index: " + currentIndex); - if (currentIndex >= replayData.size()) { - currentIndex = 0; - } - playbackHandler.post(playbackRunnable); - } - }); - - // Restart button listener - restartButton.setOnClickListener(v -> { - if (replayData.isEmpty()) return; - currentIndex = 0; - playbackSeekBar.setProgress(0); - Log.i(TAG, "Restart button pressed. Resetting playback to index 0."); - updateMapForIndex(0); - }); - - // Go to End button listener - goEndButton.setOnClickListener(v -> { - if (replayData.isEmpty()) return; - currentIndex = replayData.size() - 1; - playbackSeekBar.setProgress(currentIndex); - Log.i(TAG, "Go to End button pressed. Moving to last index: " + currentIndex); - updateMapForIndex(currentIndex); - isPlaying = false; - playPauseButton.setText("Play"); - }); - - // Exit button listener - exitButton.setOnClickListener(v -> { - Log.i(TAG, "Exit button pressed. Exiting replay."); - if (getActivity() instanceof ReplayActivity) { - ((ReplayActivity) getActivity()).finishFlow(); - } else { - requireActivity().onBackPressed(); - } - }); - - // SeekBar listener - playbackSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if (fromUser) { - Log.i(TAG, "SeekBar moved by user. New index: " + progress); - currentIndex = progress; - updateMapForIndex(currentIndex); - } - } - @Override public void onStartTrackingTouch(SeekBar seekBar) {} - @Override public void onStopTrackingTouch(SeekBar seekBar) {} - }); - - if (!replayData.isEmpty()) { - updateMapForIndex(0); - } - } - - - - /** - * Checks if any ReplayPoint contains a non-null gnssLocation. - */ - private boolean hasAnyGnssData(List data) { - for (TrajParser.ReplayPoint point : data) { - if (point.gnssLocation != null) { - return true; - } - } - return false; - } - - - /** - * Show a simple dialog asking user to pick: - * 1) GNSS from file - * 2) Lat/Lon from ReplayActivity arguments - */ - private void showGnssChoiceDialog() { - new AlertDialog.Builder(requireContext()) - .setTitle("Choose Starting Location") - .setMessage("GNSS data is found in the file. Would you like to use the file's GNSS as the start, or the one you manually picked?") - .setPositiveButton("Use File's GNSS", (dialog, which) -> { - LatLng firstGnss = getFirstGnssLocation(replayData); - if (firstGnss != null) { - setupInitialMapPosition((float) firstGnss.latitude, (float) firstGnss.longitude); - } else { - // Fallback if no valid GNSS found - setupInitialMapPosition(initialLat, initialLon); - } - dialog.dismiss(); - }) - .setNegativeButton("Use Manual Set", (dialog, which) -> { - setupInitialMapPosition(initialLat, initialLon); - dialog.dismiss(); - }) - .setCancelable(false) - .show(); - } - - private void setupInitialMapPosition(float latitude, float longitude) { - LatLng startPoint = new LatLng(initialLat, initialLon); - Log.i(TAG, "Setting initial map position: " + startPoint.toString()); - trajectoryMapFragment.setInitialCameraPosition(startPoint); - } - - /** - * Retrieve the first available GNSS location from the replay data. - */ - 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 null; // None found - } - - - /** - * Runnable for playback of trajectory data. - * This runnable is called repeatedly to update the map with the next point in the replayData list. - */ - private final Runnable playbackRunnable = new Runnable() { - @Override - public void run() { - if (!isPlaying || replayData.isEmpty()) return; - - Log.i(TAG, "Playing index: " + currentIndex); - updateMapForIndex(currentIndex); - currentIndex++; - playbackSeekBar.setProgress(currentIndex); - - if (currentIndex < replayData.size()) { - playbackHandler.postDelayed(this, PLAYBACK_INTERVAL_MS); - } else { - Log.i(TAG, "Playback completed. Reached end of data."); - isPlaying = false; - playPauseButton.setText("Play"); - } - } - }; - - - /** - * Update the map with the user location and GNSS location (if available) for the given index. - * Clears the map and redraws up to the given index. - * - * @param newIndex - */ - private void updateMapForIndex(int newIndex) { - if (newIndex < 0 || newIndex >= replayData.size()) return; - - // 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); - if (p.gnssLocation != null) { - trajectoryMapFragment.updateGNSS(p.gnssLocation); - } - } - - lastIndex = newIndex; - } - - @Override - public void onPause() { - super.onPause(); - isPlaying = false; - playbackHandler.removeCallbacks(playbackRunnable); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - playbackHandler.removeCallbacks(playbackRunnable); - } -} 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 deleted file mode 100644 index eb0bad65..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/fragment/TrajectoryMapFragment.java +++ /dev/null @@ -1,541 +0,0 @@ -package com.openpositioning.PositionMe.presentation.fragment; - -import android.graphics.Color; -import android.os.Bundle; -import android.util.Log; -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.Spinner; -import com.google.android.material.switchmaterial.SwitchMaterial; - -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.SupportMapFragment; -import com.google.android.gms.maps.model.*; - -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 - */ - -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 SwitchMaterial gnssSwitch; - private SwitchMaterial autoFloorSwitch; - - private com.google.android.material.floatingactionbutton.FloatingActionButton floorUpButton, floorDownButton; - private Button switchColorButton; - private Polygon buildingPolygon; - - - public TrajectoryMapFragment() { - // Required empty public constructor - } - - @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); - } - - @Override - public void onViewCreated(@NonNull View view, - @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - // Grab references to UI controls - switchMapSpinner = view.findViewById(R.id.mapSwitchSpinner); - gnssSwitch = view.findViewById(R.id.gnssSwitch); - autoFloorSwitch = view.findViewById(R.id.autoFloor); - floorUpButton = view.findViewById(R.id.floorUpButton); - floorDownButton = view.findViewById(R.id.floorDownButton); - switchColorButton = view.findViewById(R.id.lineColorButton); - - // Setup floor up/down UI hidden initially until we know there's an indoor map - setFloorControlsVisibility(View.GONE); - - // 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; - } - - drawBuildingPolygon(); - - Log.d("TrajectoryMapFragment", "onMapReady: Map is ready!"); - - - } - }); - } - - // Map type spinner setup - initMapTypeSpinner(); - - // GNSS Switch - gnssSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - isGnssOn = isChecked; - if (!isChecked && gnssMarker != null) { - gnssMarker.remove(); - gnssMarker = null; - } - }); - - // 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; - } - } - }); - - // Floor up/down logic - autoFloorSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> { - - //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); - }); - - floorUpButton.setOnClickListener(v -> { - // If user manually changes floor, turn off auto floor - autoFloorSwitch.setChecked(false); - if (indoorMapManager != null) { - indoorMapManager.increaseFloor(); - } - }); - - floorDownButton.setOnClickListener(v -> { - autoFloorSwitch.setChecked(false); - if (indoorMapManager != null) { - indoorMapManager.decreaseFloor(); - } - }); - } - - /** - * 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 - */ - - 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 - ); - - // GNSS path in blue - gnssPolyline = map.addPolyline(new PolylineOptions() - .color(Color.BLUE) - .width(5f) - .add() // start empty - ); - } - - - /** - * 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[]{ - getString(R.string.hybrid), - getString(R.string.normal), - getString(R.string.satellite) - }; - ArrayAdapter adapter = new ArrayAdapter<>( - requireContext(), - android.R.layout.simple_spinner_dropdown_item, - maps - ); - switchMapSpinner.setAdapter(adapter); - - switchMapSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, - int position, long id) { - if (gMap == null) return; - switch (position){ - case 0: - gMap.setMapType(GoogleMap.MAP_TYPE_HYBRID); - break; - case 1: - gMap.setMapType(GoogleMap.MAP_TYPE_NORMAL); - break; - case 2: - gMap.setMapType(GoogleMap.MAP_TYPE_SATELLITE); - break; - } - } - @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; - this.currentLocation = newLocation; - - // If no marker, create it - if (orientationMarker == null) { - orientationMarker = gMap.addMarker(new MarkerOptions() - .position(newLocation) - .flat(true) - .title("Current Position") - .icon(BitmapDescriptorFactory.fromBitmap( - UtilFunctions.getBitmapFromVector(requireContext(), - R.drawable.ic_baseline_navigation_24))) - ); - gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(newLocation, 19f)); - } else { - // Update marker position + orientation - orientationMarker.setPosition(newLocation); - orientationMarker.setRotation(orientation); - // Move camera a bit - gMap.moveCamera(CameraUpdateFactory.newLatLng(newLocation)); - } - - // 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); - } - - // Update indoor map overlay - if (indoorMapManager != null) { - indoorMapManager.setCurrentLocation(newLocation); - setFloorControlsVisibility(indoorMapManager.getIsIndoorMapSet() ? View.VISIBLE : View.GONE); - } - } - - - - /** - * 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 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; - } 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; - } - } - - - /** - * Remove GNSS marker if user toggles it off - */ - public void clearGNSS() { - if (gnssMarker != null) { - gnssMarker.remove(); - gnssMarker = null; - } - } - - /** - * Whether user is currently showing GNSS or not - */ - 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 (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()); - } - } - - /** - * 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; - } - - // 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(); - } - - // 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()); - } - - -} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/SensorInfoListAdapter.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/SensorInfoListAdapter.java deleted file mode 100644 index 4315328d..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/SensorInfoListAdapter.java +++ /dev/null @@ -1,85 +0,0 @@ -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.SensorInfo; - -import java.util.List; -import java.util.Objects; - -/** - * Adapter used for displaying sensor info data. - * - * @see SensorInfoViewHolder corresponding View Holder class - * @see com.openpositioning.PositionMe.R.layout#item_sensorinfo_card_view xml layout file - * - * @author Mate Stodulka - */ -public class SensorInfoListAdapter extends RecyclerView.Adapter { - - Context context; - List sensorInfoList; - - /** - * 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 sensorInfoList list of SensorInfo objects to be displayed in the list. - * - * @see SensorInfo the data class. - */ - public SensorInfoListAdapter(Context context, List sensorInfoList) { - this.context = context; - this.sensorInfoList = sensorInfoList; - } - - /** - * {@inheritDoc} - * @see com.openpositioning.PositionMe.R.layout#item_sensorinfo_card_view xml layout file - */ - @NonNull - @Override - public SensorInfoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new SensorInfoViewHolder(LayoutInflater.from(context).inflate(R.layout.item_sensorinfo_card_view, parent, false)); - } - - /** - * {@inheritDoc} - * Formats and assigns the data fields from the SensorInfo object to the TextView fields. - * - * @see SensorInfo data class - * @see com.openpositioning.PositionMe.R.string formatting for strings. - * @see com.openpositioning.PositionMe.R.layout#item_sensorinfo_card_view xml layout file - */ - @Override - public void onBindViewHolder(@NonNull SensorInfoViewHolder holder, int position) { - holder.name.setText(sensorInfoList.get(position).getName()); - - String vendorString = context.getString(R.string.vendor, sensorInfoList.get(position).getVendor()); - holder.vendor.setText(vendorString); - - String resolutionString = context.getString(R.string.resolution, String.format("%.03g", sensorInfoList.get(position).getResolution())); - holder.resolution.setText(resolutionString); - String powerString = context.getString(R.string.power, Objects.toString(sensorInfoList.get(position).getPower(), "N/A")); - holder.power.setText(powerString); - String versionString = context.getString(R.string.version, Objects.toString(sensorInfoList.get(position).getVersion(), "N/A")); - holder.version.setText(versionString); - } - - /** - * {@inheritDoc} - * Number of SensorInfo objects. - * - * @see SensorInfo - */ - @Override - public int getItemCount() { - return sensorInfoList.size(); - } -} 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 deleted file mode 100644 index 7de29c8a..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/TrajDownloadListAdapter.java +++ /dev/null @@ -1,295 +0,0 @@ -package com.openpositioning.PositionMe.presentation.viewitems; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Set; -import java.util.Iterator; -import java.io.File; -import java.io.FileReader; -import java.io.BufferedReader; - -import android.content.Intent; -import android.os.Handler; -import android.os.Looper; -import android.os.Environment; -import android.os.FileObserver; -import android.content.Context; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.button.MaterialButton; -import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.data.remote.ServerCommunications; -import com.openpositioning.PositionMe.presentation.activity.ReplayActivity; -import com.openpositioning.PositionMe.presentation.fragment.FilesFragment; - -import org.json.JSONObject; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Map; - -/** - * Adapter used for displaying trajectory metadata in a RecyclerView list. - * This adapter binds trajectory metadata from the server to individual view items. - * The download status is indicated via a button with different icons. - * The adapter also listens for file changes using FileObserver to update the download records in real time. - * A local set of "downloading" trajectory IDs is maintained to support simultaneous downloads. - * @see TrajDownloadViewHolder for the corresponding view holder. - * @see FilesFragment for details on how the data is generated. - * @see ServerCommunications for where the response items are received. - */ -public class TrajDownloadListAdapter extends RecyclerView.Adapter { - - // Date-time formatter used to format date and time. - private static final DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - private final Context context; - private final List> responseItems; - private final DownloadClickListener listener; - - // FileObserver to monitor modifications to the "download_records.json" file. - private FileObserver fileObserver; - - // Set to keep track of trajectory IDs that are currently downloading. - private final Set downloadingTrajIds = new HashSet<>(); - - /** - * Constructor for the adapter. - * - * @param context Application context used for inflating layouts. - * @param responseItems List of response items from the server. - * @param listener Callback listener for handling download click events. - */ - public TrajDownloadListAdapter(Context context, List> responseItems, DownloadClickListener listener) { - this.context = context; - this.responseItems = responseItems; - this.listener = listener; - // Load the local download records. - loadDownloadRecords(); - // Initialize the FileObserver to listen for changes in the download records file. - initFileObserver(); - } - - /** - * Loads the local download records from storage. - * The records are stored in a JSON file located in the app-specific Downloads directory. - * After loading, any trajectory IDs that have now finished downloading are removed - * from the downloading set. - */ - private void loadDownloadRecords() { - try { - File file = new File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "download_records.json"); - if (file.exists()) { - // Read the file line by line to reduce memory usage. - StringBuilder jsonBuilder = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new FileReader(file), 8192)) { // Increase buffer size - String line; - while ((line = reader.readLine()) != null) { - jsonBuilder.append(line); - } - } - - // Parse the JSON content. - JSONObject jsonObject = new JSONObject(jsonBuilder.toString()); - ServerCommunications.downloadRecords.clear(); - - // Preallocate HashMap capacity to reduce resizing overhead. - int estimatedSize = jsonObject.length(); - ServerCommunications.downloadRecords = new HashMap<>(estimatedSize * 2); - - // Iterate through keys in the JSON object. - for (Iterator keys = jsonObject.keys(); keys.hasNext(); ) { - String key = keys.next(); - JSONObject recordDetails = jsonObject.getJSONObject(key); - // Use the record's "id" if available, otherwise use the key. - String id = recordDetails.optString("id", key); - ServerCommunications.downloadRecords.put(id, recordDetails); - } - - System.out.println("Download records loaded: " + ServerCommunications.downloadRecords); - - // Remove any IDs from the downloading set that are now present in the download records. - // This ensures the "downloading" state is removed when the download completes. - downloadingTrajIds.removeIf(id -> ServerCommunications.downloadRecords.containsKey(id)); - - // Refresh the RecyclerView UI on the main thread. - new Handler(Looper.getMainLooper()).post(() -> { - notifyDataSetChanged(); - System.out.println("RecyclerView fully refreshed after loading records."); - }); - } else { - System.out.println("Download records file not found."); - } - } catch (Exception e) { - e.printStackTrace(); - } - } - - /** - * Initializes the FileObserver to listen for modifications on the "download_records.json" file. - * When the file is modified, it reloads the download records and refreshes the UI. - */ - private void initFileObserver() { - File downloadsFolder = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); - if (downloadsFolder == null) { - return; - } - // Create a FileObserver for the directory where the file is located. - fileObserver = new FileObserver(downloadsFolder.getAbsolutePath(), FileObserver.MODIFY) { - @Override - public void onEvent(int event, String path) { - // Only act if the modified file is "download_records.json". - if (path != null && path.equals("download_records.json")) { - Log.i("FileObserver", "download_records.json has been modified."); - // On file modification, load the records and update the UI on the main thread. - new Handler(Looper.getMainLooper()).post(() -> { - loadDownloadRecords(); - }); - } - } - }; - fileObserver.startWatching(); - } - - /** - * Creates a new view holder for a trajectory item. - * - * @param parent The parent view group. - * @param viewType The view type. - * @return A new instance of TrajDownloadViewHolder. - */ - @NonNull - @Override - public TrajDownloadViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new TrajDownloadViewHolder(LayoutInflater.from(context) - .inflate(R.layout.item_trajectorycard_view, parent, false), listener); - } - - /** - * Binds data to the view holder. - * Formats and assigns trajectory metadata fields to the corresponding views. - * The button state is determined as follows: - * - If the trajectory is present in the download records, it is set as "downloaded". - * - Else if the trajectory is in the downloading set, it is set as "downloading". - * - Otherwise, it is set as "not downloaded". - * @param holder The view holder to bind data to. - * @param position The position of the item in the list. - */ - @Override - public void onBindViewHolder(@NonNull TrajDownloadViewHolder holder, int position) { - // Retrieve the trajectory id from the response item. - String id = responseItems.get(position).get("id"); - holder.getTrajId().setText(id); - - // Adjust text size based on the id length. - if (id != null && id.length() > 2) { - holder.getTrajId().setTextSize(58); - } else { - holder.getTrajId().setTextSize(65); - } - - // Parse and format the submission date. - String dateSubmittedStr = responseItems.get(position).get("date_submitted"); - assert dateSubmittedStr != null; - holder.getTrajDate().setText( - dateFormat.format( - LocalDateTime.parse(dateSubmittedStr.split("\\.")[0]) - ) - ); - - // Determine if the trajectory is already downloaded by checking the records. - JSONObject recordDetails = ServerCommunications.downloadRecords.get(id); - boolean matched = recordDetails != null; - String filePath = null; - - if (matched) { - try { - String fileName = recordDetails.optString("file_name", null); - if (fileName != null) { - File file = new File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName); - filePath = file.getAbsolutePath(); - } - // Set the button state to "downloaded". - setButtonState(holder.downloadButton, 1); - } catch (Exception e) { - e.printStackTrace(); - } - } else if (downloadingTrajIds.contains(id)) { - // If the item is still being downloaded, set the button state to "downloading". - setButtonState(holder.downloadButton, 2); - } else { - // Otherwise, the item is not downloaded. - setButtonState(holder.downloadButton, 0); - } - - // Copy matched status and filePath to final variables for use in the lambda expression. - final boolean finalMatched = matched; - final String finalFilePath = filePath; - - // Set the click listener for the download button. - holder.downloadButton.setOnClickListener(v -> { - String trajId = responseItems.get(position).get("id"); - - if (finalMatched) { - // If the item is already downloaded, start ReplayActivity to display the trajectory. - if (finalFilePath != null) { - Intent intent = new Intent(context, ReplayActivity.class); - intent.putExtra(ReplayActivity.EXTRA_TRAJECTORY_FILE_PATH, finalFilePath); - context.startActivity(intent); - } - } else { - // If the item is not downloaded, trigger the download action. - listener.onPositionClicked(position); - // Mark the trajectory as downloading. - downloadingTrajIds.add(trajId); - // Immediately update the button state to "downloading". - setButtonState(holder.downloadButton, 2); - // The FileObserver will update the UI when the file changes. - } - }); - - holder.downloadButton.invalidate(); - } - - /** - * Returns the number of items in the response list. - * - * @return The size of the responseItems list. - */ - @Override - public int getItemCount() { - return responseItems.size(); - } - - /** - * Sets the appearance of the button based on its state. - * - * @param button The MaterialButton to update. - * @param state The state of the button: - * 0 - Not downloaded, - * 1 - Downloaded, - * 2 - Downloading. - */ - private void setButtonState(MaterialButton button, int state) { - if (state == 1) { - button.setIconResource(R.drawable.ic_baseline_play_circle_filled_24); - button.setIconTintResource(R.color.md_theme_onPrimary); - button.setBackgroundTintList(ContextCompat.getColorStateList(context, R.color.md_theme_primary)); - } else if (state == 2) { - button.setIconResource(R.drawable.baseline_data_usage_24); - button.setIconTintResource(R.color.md_theme_onPrimary); - button.setBackgroundTintList(ContextCompat.getColorStateList(context, R.color.goldYellow)); - } else { - button.setIconResource(R.drawable.ic_baseline_download_24); - button.setIconTintResource(R.color.md_theme_onSecondary); - button.setBackgroundTintList(ContextCompat.getColorStateList(context, R.color.md_theme_light_primary)); - } - } -} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/TrajDownloadViewHolder.java b/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/TrajDownloadViewHolder.java deleted file mode 100644 index af14249f..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/TrajDownloadViewHolder.java +++ /dev/null @@ -1,75 +0,0 @@ -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.google.android.material.button.MaterialButton; -import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.presentation.fragment.FilesFragment; - -import java.lang.ref.WeakReference; - -/** - * View holder class for the RecyclerView displaying Trajectory download data. - * - * @see TrajDownloadListAdapter the corresponding list adapter. - * @see com.openpositioning.PositionMe.R.layout#item_trajectorycard_view xml layout file - * - * @author Mate Stodulka - */ -public class TrajDownloadViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { - - private final TextView trajId; - private final TextView trajDate; - final MaterialButton downloadButton; - private final WeakReference listenerReference; - - /** - * {@inheritDoc} - * Assign TextView fields corresponding to Trajectory metadata. - * - * @param listener DownloadClickListener to enable acting on clicks on items. - * @see FilesFragment generating the data and implementing the listener. - */ - public TrajDownloadViewHolder(@NonNull View itemView, DownloadClickListener listener) { - super(itemView); - this.listenerReference = new WeakReference<>(listener); - this.trajId = itemView.findViewById(R.id.trajectoryIdItem); - this.trajDate = itemView.findViewById(R.id.trajectoryDateItem); - this.downloadButton = itemView.findViewById(R.id.downloadTrajectoryButton); - - this.downloadButton.setOnClickListener(this); - } - - /** - * Public getter for trajId. - */ - public TextView getTrajId() { - return trajId; - } - - /** - * Public getter for trajDate. - */ - public TextView getTrajDate() { - return trajDate; - } - - /** - * Calls the onPositionClick function on the listenerReference object. - */ - @Override - public void onClick(View view) { - listenerReference.get().onPositionClicked(getAdapterPosition()); - DownloadClickListener listener = listenerReference.get(); - if (listener != null) { - listener.onPositionClicked(getAdapterPosition()); - System.out.println("Click detected at position: " + getAdapterPosition()); - } else { - System.err.println("Listener reference is null."); - } - } -} 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..8fd6378f 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/GNSSDataProcessor.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/GNSSDataProcessor.java @@ -6,6 +6,7 @@ import android.content.pm.PackageManager; import android.location.LocationListener; import android.location.LocationManager; +import android.os.Build; import android.widget.Toast; import androidx.core.app.ActivityCompat; @@ -76,17 +77,23 @@ public GNSSDataProcessor(Context context, LocationListener locationListener) { * @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); - int fineLocationPermission = ActivityCompat.checkSelfPermission(this.context, - Manifest.permission.ACCESS_FINE_LOCATION); - int internetPermission = ActivityCompat.checkSelfPermission(this.context, - Manifest.permission.INTERNET); + if (Build.VERSION.SDK_INT >= 23) { - // Return missing permissions - return coarseLocationPermission == PackageManager.PERMISSION_GRANTED && - fineLocationPermission == PackageManager.PERMISSION_GRANTED && - internetPermission == PackageManager.PERMISSION_GRANTED; + int coarseLocationPermission = ActivityCompat.checkSelfPermission(this.context, + Manifest.permission.ACCESS_COARSE_LOCATION); + int fineLocationPermission = ActivityCompat.checkSelfPermission(this.context, + Manifest.permission.ACCESS_FINE_LOCATION); + 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; + } else { + // Permissions are granted by default + return true; + } } /** @@ -98,13 +105,22 @@ private boolean checkLocationPermissions() { */ @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)){ + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { - locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListener); - locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, locationListener); + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + 500, // 最小时间间隔(毫秒)- 从100ms增加到500ms + 1, // 最小距离变化(米)- 从0米增加到1米 + locationListener + ); + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + 500, // 最小时间间隔(毫秒)- 从100ms增加到500ms + 1, // 最小距离变化(米)- 从0米增加到1米 + locationListener + ); } else if(permissionGranted && !locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)){ Toast.makeText(context, "Open GPS", Toast.LENGTH_LONG).show(); diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/Observable.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/Observable.java index dc7e0c73..7732223d 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/Observable.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/Observable.java @@ -14,7 +14,7 @@ public interface Observable { * * @param o instance of a class implementing the Observer interface */ - public void registerObserver(com.openpositioning.PositionMe.sensors.Observer o); + public void registerObserver(Observer o); /** * Notify observers of changes to relevant data structures. If there are multiple data structures 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..2dd9c453 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorFusion.java @@ -10,19 +10,19 @@ import android.location.LocationListener; import android.os.Build; import android.os.PowerManager; -import android.os.SystemClock; import android.util.Log; -import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; import com.google.android.gms.maps.model.LatLng; -import com.openpositioning.PositionMe.presentation.activity.MainActivity; -import com.openpositioning.PositionMe.utils.PathView; -import com.openpositioning.PositionMe.utils.PdrProcessing; -import com.openpositioning.PositionMe.data.remote.ServerCommunications; +import com.openpositioning.PositionMe.BuildingPolygon; +import com.openpositioning.PositionMe.IndoorMapManager; +import com.openpositioning.PositionMe.MainActivity; +import com.openpositioning.PositionMe.PathView; +import com.openpositioning.PositionMe.PdrProcessing; +import com.openpositioning.PositionMe.ServerCommunications; import com.openpositioning.PositionMe.Traj; -import com.openpositioning.PositionMe.presentation.fragment.SettingsFragment; +import com.openpositioning.PositionMe.utils.LocationLogger; import org.json.JSONException; import org.json.JSONObject; @@ -59,17 +59,57 @@ */ 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<>(); + //region Static variables + // Singleton Class + //////////////////////////////////////////////////////////////////////////////////////////////////////// + private List trajectoryPoints = new ArrayList<>(); + + // 开始记录时清空轨迹数据 + + // 实时添加轨迹点 + public void addTrajectoryPoint(float latitude, float longitude) { + // 更新滑动窗口 + latitudeWindow[windowIndex] = latitude; + longitudeWindow[windowIndex] = longitude; + windowIndex = (windowIndex + 1) % TRAJECTORY_WINDOW_SIZE; + if (windowIndex == 0) { + windowFull = true; + } - long maxReportLatencyNs = 0; // Disable batching to deliver events immediately + // 计算平滑后的位置 + float smoothedLat = 0; + float smoothedLon = 0; + int count = windowFull ? TRAJECTORY_WINDOW_SIZE : windowIndex; + + if (count > 0) { + // 使用加权移动平均 + float totalWeight = 0; + for (int i = 0; i < count; i++) { + int idx = (windowIndex - 1 - i + TRAJECTORY_WINDOW_SIZE) % TRAJECTORY_WINDOW_SIZE; + float weight = (count - i) / (float)count; // 较新的数据权重更大 + smoothedLat += latitudeWindow[idx] * weight; + smoothedLon += longitudeWindow[idx] * weight; + totalWeight += weight; + } + smoothedLat /= totalWeight; + smoothedLon /= totalWeight; + + // 添加平滑后的轨迹点 + trajectoryPoints.add(new float[]{smoothedLat, smoothedLon}); + } else { + // 如果没有历史数据,直接添加当前点 + trajectoryPoints.add(new float[]{latitude, longitude}); + } + } - // Define a threshold for large time gaps (in milliseconds) - private static final long LARGE_GAP_THRESHOLD_MS = 500; // Adjust this if needed + // 获取记录的轨迹点 + public List getTrajectoryPoints() { + return trajectoryPoints; + } - //region Static variables - // Singleton Class + + + //////////////////////////////////////////////////////////////////////////////////////////////////////// private static final SensorFusion sensorFusion = new SensorFusion(); // Static constant for calculations with milliseconds private static final long TIME_CONST = 10; @@ -84,7 +124,6 @@ public class SensorFusion implements SensorEventListener, Observer { //region Instance variables // Keep device awake while recording private PowerManager.WakeLock wakeLock; - private Context appContext; // Settings private SharedPreferences settings; @@ -117,7 +156,6 @@ public class SensorFusion implements SensorEventListener, Observer { // 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 @@ -159,6 +197,55 @@ public class SensorFusion implements SensorEventListener, Observer { // WiFi positioning object private WiFiPositioning wiFiPositioning; + private LocationLogger locationLogger; + private Context context; + + // 修改常量定义 + private static float STANDARD_PRESSURE = 1015.2f; + private static final float DEFAULT_FLOOR_HEIGHT = 2.5f; // 默认楼层高度 + private static final float ALTITUDE_OFFSET = 0.0f; // 基准高度偏移量 + private float currentFloorHeight = DEFAULT_FLOOR_HEIGHT; // 当前使用的楼层高度 + private int currentFloor = 0; + private boolean isInSpecialBuilding = false; // 是否在特殊建筑物内 + + // 在 SensorFusion 类中添加观察者列表 + private List floorObservers = new ArrayList<>(); + + // 重写EKF相关变量 + private Timer ekfTimer; + private static final int EKF_UPDATE_INTERVAL = 100; // 更快的更新频率 + private LatLng wifiLocation = null; + private long lastWifiUpdateTime = 0; + private static final long WIFI_DATA_EXPIRY = 5000; // WiFi数据5秒内有效 + + // EKF状态权重 + private static final float GNSS_WEIGHT = 0.35f; + private static final float PDR_WEIGHT = 0.45f; + private static final float WIFI_WEIGHT = 0.20f; // 降低WiFi权重 + + // 步伐检测相关变量 + private long lastStepTime = 0; + private static final long MIN_STEP_INTERVAL = 300; // 降低最小步伐间隔 + private static final float STEP_THRESHOLD = 0.30f; // 调整步伐峰值阈值 + private boolean isAscending = false; + private double lastPeakValue = 0; + private final double[] recentPeaks = new double[5]; // 增加峰值历史记录 + private int peakIndex = 0; + + // 轨迹平滑相关变量 + private static final int TRAJECTORY_WINDOW_SIZE = 5; + private final float[] latitudeWindow = new float[TRAJECTORY_WINDOW_SIZE]; + private final float[] longitudeWindow = new float[TRAJECTORY_WINDOW_SIZE]; + private int windowIndex = 0; + private boolean windowFull = false; + + // 保存最后的PDR位置用于融合 + private float lastPdrLatitude = 0; + private float lastPdrLongitude = 0; + + // 添加保存融合位置的成员变量 + private LatLng currentEkfPosition = null; + //region Initialisation /** * Private constructor for implementing singleton design pattern for SensorFusion. @@ -191,6 +278,16 @@ private SensorFusion() { this.R = new float[9]; // GNSS initial Long-Lat array this.startLocation = new float[2]; + + // 初始化位置变量为0 + this.latitude = 0.0f; + this.longitude = 0.0f; + + // 初始化加速度大小列表 + this.accelMagnitude = new ArrayList<>(); + + // 初始化气压值为设定的基准气压 + this.pressure = STANDARD_PRESSURE; } @@ -218,9 +315,8 @@ public static SensorFusion getInstance() { * @see WifiDataProcessor for network data processing. */ public void setContext(Context context) { - this.appContext = context.getApplicationContext(); // store app context for later use - - // Initialise data collection devices (unchanged)... + this.context = context; + // Initialise data collection devices this.accelerometerSensor = new MovementSensor(context, Sensor.TYPE_ACCELEROMETER); this.barometerSensor = new MovementSensor(context, Sensor.TYPE_PRESSURE); this.gyroscopeSensor = new MovementSensor(context, Sensor.TYPE_GYROSCOPE); @@ -234,33 +330,39 @@ public void setContext(Context context) { // Listener based devices this.wifiProcessor = new WifiDataProcessor(context); wifiProcessor.registerObserver(this); - this.gnssProcessor = new GNSSDataProcessor(context, locationListener); + this.gnssProcessor = new GNSSDataProcessor(context,locationListener); // Create object handling HTTPS communication this.serverCommunications = new ServerCommunications(context); // Save absolute and relative start time this.absoluteStartTime = System.currentTimeMillis(); - this.bootTime = SystemClock.uptimeMillis(); - // Initialise saveRecording to false + this.bootTime = android.os.SystemClock.uptimeMillis(); + // Initialise saveRecording to false - only record when explicitly started. this.saveRecording = false; - // Other initialisations... + // Over time data holder this.accelMagnitude = new ArrayList<>(); + // PDR this.pdrProcessing = new PdrProcessing(context); + //Settings this.settings = PreferenceManager.getDefaultSharedPreferences(context); + this.pathView = new PathView(context, null); - this.wiFiPositioning = new WiFiPositioning(context); + // Initialising WiFi Positioning object + this.wiFiPositioning=new WiFiPositioning(context); if(settings.getBoolean("overwrite_constants", false)) { - this.filter_coefficient = Float.parseFloat(settings.getString("accel_filter", "0.96")); - } else { - this.filter_coefficient = FILTER_COEFFICIENT; + this.filter_coefficient =Float.parseFloat(settings.getString("accel_filter", "0.96")); } + else {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"); - } + // Keep app awake during the recording + PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, + "MyApp::MyWakelockTag"); + // 初始化位置记录器 + locationLogger = new LocationLogger(context); + } //endregion //region Sensor processing @@ -275,78 +377,100 @@ public void setContext(Context context) { */ @Override public void onSensorChanged(SensorEvent sensorEvent) { - long currentTime = System.currentTimeMillis(); // Current time in milliseconds - 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) { + switch (sensorEvent.sensor.getType()) { case Sensor.TYPE_ACCELEROMETER: + // 保存原始加速度数据 acceleration[0] = sensorEvent.values[0]; acceleration[1] = sensorEvent.values[1]; acceleration[2] = sensorEvent.values[2]; + + // 使用低通滤波器获取重力分量 + gravity[0] = filter_coefficient * gravity[0] + (1 - filter_coefficient) * acceleration[0]; + gravity[1] = filter_coefficient * gravity[1] + (1 - filter_coefficient) * acceleration[1]; + gravity[2] = filter_coefficient * gravity[2] + (1 - filter_coefficient) * acceleration[2]; + + // 通过减去重力获得线性加速度 + filteredAcc[0] = acceleration[0] - gravity[0]; + filteredAcc[1] = acceleration[1] - gravity[1]; + filteredAcc[2] = acceleration[2] - gravity[2]; + + // 计算合加速度大小 + double accMagnitude = Math.sqrt(Math.pow(filteredAcc[0], 2) + + Math.pow(filteredAcc[1], 2) + + Math.pow(filteredAcc[2], 2)); + + // 保存加速度大小到列表中,用于步长估计 + this.accelMagnitude.add(accMagnitude); + + // 手动步伐检测逻辑 - 基于加速度峰值 + manualStepDetection(accMagnitude); + 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) - ); + // 使用移动平均值平滑气压数据 + float smoothedPressure = getSmoothedPressure(sensorEvent.values[0]); + pressure = (1- ALPHA) * pressure + ALPHA * smoothedPressure; + + // 计算海拔高度 + float altitude = SensorManager.getAltitude(STANDARD_PRESSURE, pressure) - ALTITUDE_OFFSET; + + // 添加更详细的调试日志 + Log.d("PRESSURE_DEBUG", String.format( + "原始气压: %.2f hPa, 过滤后气压: %.2f hPa, 计算海拔: %.2f m, 位置: (%.6f, %.6f)", + sensorEvent.values[0], + pressure, + altitude, + latitude, + longitude + )); + + // 更新海拔高度 + this.elevation = pdrProcessing.updateElevation(altitude); + + // 使用新的楼层计算方法 + int newFloor = calculateFloor(altitude); + + // 如果楼层发生变化,通知观察者 + if (newFloor != currentFloor) { + currentFloor = newFloor; + Log.d("FLOOR_CHANGE", String.format( + "楼层变化 - 海拔: %.2f m, 新楼层: %d, 楼层高度: %.1f m, 特殊建筑: %b", + altitude, + currentFloor, + currentFloorHeight, + isInSpecialBuilding + )); + notifyFloorObservers(currentFloor); } break; case Sensor.TYPE_GYROSCOPE: + // Gyro processing + //Store gyroscope readings angularVelocity[0] = sensorEvent.values[0]; angularVelocity[1] = sensorEvent.values[1]; angularVelocity[2] = sensorEvent.values[2]; + break; + case Sensor.TYPE_LINEAR_ACCELERATION: + // Acceleration processing with gravity already removed filteredAcc[0] = sensorEvent.values[0]; filteredAcc[1] = sensorEvent.values[1]; filteredAcc[2] = sensorEvent.values[2]; - // Compute magnitude & add to accelMagnitude - double accelMagFiltered = Math.sqrt( - Math.pow(filteredAcc[0], 2) + - Math.pow(filteredAcc[1], 2) + - Math.pow(filteredAcc[2], 2) - ); + double accelMagFiltered = Math.sqrt(Math.pow(acceleration[0], 2) + + Math.pow(acceleration[1], 2) + Math.pow(acceleration[2], 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 processing obtained from acceleration 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)); - elevator = pdrProcessing.estimateElevator(gravity, filteredAcc); break; @@ -359,71 +483,88 @@ public void onSensorChanged(SensorEvent sensorEvent) { break; case Sensor.TYPE_MAGNETIC_FIELD: + //Store magnetic field readings magneticField[0] = sensorEvent.values[0]; magneticField[1] = sensorEvent.values[1]; magneticField[2] = sensorEvent.values[2]; break; case Sensor.TYPE_ROTATION_VECTOR: + // Save values this.rotation = sensorEvent.values.clone(); float[] rotationVectorDCM = new float[9]; - SensorManager.getRotationMatrixFromVector(rotationVectorDCM, this.rotation); + SensorManager.getRotationMatrixFromVector(rotationVectorDCM,this.rotation); SensorManager.getOrientation(rotationVectorDCM, this.orientation); break; case Sensor.TYPE_STEP_DETECTOR: - 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; + // 当前时间 + long currentTime = System.currentTimeMillis(); + long stepTime = android.os.SystemClock.uptimeMillis() - bootTime; + + // 检查距离上一次步伐检测的时间间隔,过滤掉过快的步伐 + if (currentTime - lastStepTime < MIN_STEP_INTERVAL) { + Log.d("SensorFusion", "忽略过快的步伐检测,间隔" + (currentTime - lastStepTime) + "ms < " + MIN_STEP_INTERVAL + "ms"); + 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()); - } - - float[] newCords = this.pdrProcessing.updatePdr( - stepTime, - this.accelMagnitude, - this.orientation[0] + + // 检查加速度幅值是否足够,过滤小幅度振动 + double maxAccel = 0; + for (double acc : accelMagnitude) { + maxAccel = Math.max(maxAccel, acc); + } + + if (maxAccel < STEP_THRESHOLD * 0.7) { + Log.d("SensorFusion", "忽略微小振动引起的步伐检测,最大加速度" + maxAccel + " < " + (STEP_THRESHOLD * 0.7)); + break; // 忽略此次步伐检测 + } + + // 更新PDR位置 + float[] newCords = this.pdrProcessing.updatePdr(stepTime, this.accelMagnitude, this.orientation[0]); + + // 添加详细日志,帮助调试步数检测 + Log.d("SensorFusion", "步伐检测触发 - 时间: " + stepTime + + "ms, 位置变化: [" + newCords[0] + ", " + newCords[1] + "], 间隔: " + (currentTime - lastStepTime) + "ms"); + + if (saveRecording) { + // Store the PDR coordinates for plotting the trajectory + this.pathView.drawTrajectory(newCords); + } + this.accelMagnitude.clear(); + if (saveRecording) { + stepCounter++; + trajectory.addPdrData(Traj.Pdr_Sample.newBuilder() + .setRelativeTimestamp(android.os.SystemClock.uptimeMillis() - bootTime) + .setX(newCords[0]).setY(newCords[1])); + } + + // 检测到步伐后马上记录到位置日志器 + if (saveRecording && locationLogger != null) { + // 使用PDR位置更新LocationLogger + float[] pdrLongLat = getPdrLongLat(newCords[0], newCords[1]); + + // 保存最后的PDR位置用于EKF融合 + lastPdrLatitude = pdrLongLat[0]; + lastPdrLongitude = pdrLongLat[1]; + + locationLogger.logLocation( + currentTime, + pdrLongLat[0], + pdrLongLat[1] ); - - // Clear the accelMagnitude after using it - this.accelMagnitude.clear(); - - - if (saveRecording) { - this.pathView.drawTrajectory(newCords); - stepCounter++; - trajectory.addPdrData(Traj.Pdr_Sample.newBuilder() - .setRelativeTimestamp(SystemClock.uptimeMillis() - bootTime) - .setX(newCords[0]) - .setY(newCords[1])); - } - break; + + // 添加PDR轨迹点 + addTrajectoryPoint(pdrLongLat[0], pdrLongLat[1]); + + // 不需要在这里直接生成EKF位置 - 现在由定时器统一处理 + + Log.d("SensorFusion", "步伐检测 - 已记录PDR位置: lat=" + + pdrLongLat[0] + ", lng=" + pdrLongLat[1]); } - - } - } - - /** - * 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)); + + // 更新最后一次步伐时间 + lastStepTime = currentTime; + break; } } @@ -437,23 +578,59 @@ public void logSensorFrequencies() { */ 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)); + public void onLocationChanged(Location location) { + if(location != null){ + latitude = (float) location.getLatitude(); + longitude = (float) location.getLongitude(); + + // 保存最后GNSS更新时间 + lastGnssUpdateTime = System.currentTimeMillis(); + + // 记录位置到日志 + if(saveRecording && locationLogger != null) { + locationLogger.logLocation( + lastGnssUpdateTime, + latitude, + longitude + ); + + // 记录GNSS位置 + locationLogger.logGnssLocation( + lastGnssUpdateTime, + latitude, + longitude + ); + + // 添加轨迹点 + addTrajectoryPoint(latitude, longitude); + + // 不需要在这里直接生成EKF位置 - 现在由定时器统一处理 + } + + // 添加详细的日志 + Log.d("LOCATION_UPDATE", String.format( + "位置更新 - 提供者: %s, 纬度: %.6f, 经度: %.6f, 精度: %.1f米", + location.getProvider(), + latitude, + longitude, + location.getAccuracy() + )); + + if(saveRecording) { + float altitude = (float) location.getAltitude(); + float accuracy = (float) location.getAccuracy(); + float speed = (float) location.getSpeed(); + String provider = location.getProvider(); + + trajectory.addGnssData(Traj.GNSS_Sample.newBuilder() + .setAccuracy(accuracy) + .setAltitude(altitude) + .setLatitude(latitude) + .setLongitude(longitude) + .setSpeed(speed) + .setProvider(provider) + .setRelativeTimestamp(System.currentTimeMillis()-absoluteStartTime)); + } } } } @@ -472,16 +649,17 @@ public void update(Object[] wifiList) { if(this.saveRecording) { Traj.WiFi_Sample.Builder wifiData = Traj.WiFi_Sample.newBuilder() - .setRelativeTimestamp(SystemClock.uptimeMillis()-bootTime); + .setRelativeTimestamp(android.os.SystemClock.uptimeMillis()-bootTime); for (Wifi data : this.wifiList) { wifiData.addMacScans(Traj.Mac_Scan.newBuilder() - .setRelativeTimestamp(SystemClock.uptimeMillis() - bootTime) + .setRelativeTimestamp(android.os.SystemClock.uptimeMillis() - bootTime) .setMac(data.getBssid()).setRssi(data.getLevel())); } // Adding WiFi data to Trajectory this.trajectory.addWifiData(wifiData); } - createWifiPositioningRequest(); + // 使用带回调的WiFi请求方法,而不是原来的 + createWifiPositionRequestCallback(); } /** @@ -524,12 +702,19 @@ private void createWifiPositionRequestCallback(){ this.wiFiPositioning.request(wifiFingerPrint, new WiFiPositioning.VolleyCallback() { @Override public void onSuccess(LatLng wifiLocation, int floor) { - // Handle the success response + // 更新WiFi位置和最后更新时间 + SensorFusion.this.wifiLocation = wifiLocation; + lastWifiUpdateTime = System.currentTimeMillis(); + + Log.d("WIFI_LOCATION", String.format( + "接收WiFi位置更新: [%.6f, %.6f], 楼层: %d", + wifiLocation.latitude, wifiLocation.longitude, floor)); } @Override public void onError(String message) { - // Handle the error response + // 记录错误 + Log.e("WIFI_LOCATION", "WiFi定位错误: " + message); } }); } catch (JSONException e) { @@ -635,7 +820,7 @@ public void onAccuracyChanged(Sensor sensor, int i) {} * @return longitude and latitude data in a float[2]. */ public float[] getGNSSLatitude(boolean start) { - float [] latLong = new float[2]; + float[] latLong = new float[2]; if(!start) { latLong[0] = latitude; latLong[1] = longitude; @@ -780,6 +965,34 @@ public int getHoldMode(){ } } + /** + * 获取当前估计的楼层 + * @return 当前楼层数(0表示地面层) + */ + public int getCurrentFloor() { + return currentFloor; + } + + /** + * 校准当前位置的基准气压值 + * @param newPressure 新的基准气压值 (hPa) + */ + public void calibrateBasePressure(float newPressure) { + STANDARD_PRESSURE = newPressure; + Log.d("PRESSURE_CALIBRATE", String.format( + "基准气压已更新为: %.2f hPa", + STANDARD_PRESSURE + )); + } + + /** + * 获取当前基准气压值 + * @return 当前基准气压值 (hPa) + */ + public float getBasePressure() { + return STANDARD_PRESSURE; + } + //endregion //region Start/Stop @@ -795,18 +1008,27 @@ public int getHoldMode(){ * @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); + // 将IMU相关传感器的采样频率设置为5000微秒(200Hz) + accelerometerSensor.sensorManager.registerListener(this, accelerometerSensor.sensor, 5000); + accelerometerSensor.sensorManager.registerListener(this, linearAccelerationSensor.sensor, 5000); + accelerometerSensor.sensorManager.registerListener(this, gravitySensor.sensor, 5000); barometerSensor.sensorManager.registerListener(this, barometerSensor.sensor, (int) 1e6); - gyroscopeSensor.sensorManager.registerListener(this, gyroscopeSensor.sensor, 10000, (int) maxReportLatencyNs); + gyroscopeSensor.sensorManager.registerListener(this, gyroscopeSensor.sensor, 5000); 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); + magnetometerSensor.sensorManager.registerListener(this, magnetometerSensor.sensor, 5000); + + // 降低步伐检测器的采样率,从SENSOR_DELAY_FASTEST改为SensorManager.SENSOR_DELAY_GAME + // 这样可以减少过度灵敏的问题 + stepDetectionSensor.sensorManager.registerListener(this, stepDetectionSensor.sensor, SensorManager.SENSOR_DELAY_GAME); + rotationSensor.sensorManager.registerListener(this, rotationSensor.sensor, (int) 1e6); wifiProcessor.startListening(); - gnssProcessor.startLocationUpdates(); + + // 确保GNSS处理器启动 + if (gnssProcessor != null) { + gnssProcessor.startLocationUpdates(); + } } /** @@ -834,12 +1056,14 @@ public void stopListening() { //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(); + if (gnssProcessor != null) { + gnssProcessor.stopUpdating(); + } } } @@ -852,37 +1076,40 @@ public void stopListening() { * @see Traj object for storing data. */ public void startRecording() { - // If wakeLock is null (e.g. not initialized or was cleared), reinitialize it. - if (wakeLock == null) { - PowerManager powerManager = (PowerManager) this.appContext.getSystemService(Context.POWER_SERVICE); - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MyApp::MyWakelockTag"); - } - wakeLock.acquire(31 * 60 * 1000L /*31 minutes*/); - + // Acquire wakelock so the phone will record with a locked screen. Timeout after 31 minutes. + this.wakeLock.acquire(31*60*1000L /*31 minutes*/); this.saveRecording = true; this.stepCounter = 0; this.absoluteStartTime = System.currentTimeMillis(); - this.bootTime = SystemClock.uptimeMillis(); + this.bootTime = android.os.SystemClock.uptimeMillis(); + + // 清空轨迹点列表 + trajectoryPoints.clear(); + + // 启动定时EKF融合更新 + startEkfTimer(); + // Protobuf trajectory class for sending sensor data to restful API this.trajectory = Traj.Trajectory.newBuilder() .setAndroidVersion(Build.VERSION.RELEASE) .setStartTimestamp(absoluteStartTime) + /*.addApsData(Traj.AP_Data.newBuilder().setMac(example_mac).setSsid(example_ssid) + .setFrequency(example_frequency))*/ .setAccelerometerInfo(createInfoBuilder(accelerometerSensor)) .setGyroscopeInfo(createInfoBuilder(gyroscopeSensor)) .setMagnetometerInfo(createInfoBuilder(magnetometerSensor)) .setBarometerInfo(createInfoBuilder(barometerSensor)) .setLightSensorInfo(createInfoBuilder(lightSensor)); - - - this.storeTrajectoryTimer = new Timer(); - this.storeTrajectoryTimer.schedule(new storeDataInTrajectory(), 0, TIME_CONST); + this.storeTrajectoryTimer.scheduleAtFixedRate(new storeDataInTrajectory(), 0, TIME_CONST); this.pdrProcessing.resetPDR(); if(settings.getBoolean("overwrite_constants", false)) { this.filter_coefficient = Float.parseFloat(settings.getString("accel_filter", "0.96")); - } else { - this.filter_coefficient = FILTER_COEFFICIENT; } + else {this.filter_coefficient = FILTER_COEFFICIENT;} + + // 初始化位置记录器 + locationLogger = new LocationLogger(context); } /** @@ -892,13 +1119,21 @@ public void startRecording() { * the timer objects. * * @see Traj object for storing data. - * @see SettingsFragment navigation that might cancel recording. + * @see com.openpositioning.PositionMe.fragments.SettingsFragment navigation that might cancel recording. */ public void stopRecording() { // Only cancel if we are running if(this.saveRecording) { this.saveRecording = false; storeTrajectoryTimer.cancel(); + + // 停止EKF融合定时器 + stopEkfTimer(); + + // 保存位置日志 + if (locationLogger != null) { + locationLogger.saveToFile(); + } } if(wakeLock.isHeld()) { this.wakeLock.release(); @@ -907,6 +1142,164 @@ public void stopRecording() { //endregion + // 定时执行EKF融合的定时器 + // private Timer ekfTimer; + // private static final int EKF_UPDATE_INTERVAL = 200; // 更快的更新频率 + + /** + * 重写的EKF融合算法 - 每次更新时融合所有可用数据源 + */ + private class updateEkfLocation extends TimerTask { + @Override + public void run() { + try { + if (!saveRecording || locationLogger == null) return; + + long currentTime = System.currentTimeMillis(); + + // 检查是否有足够的数据源 + boolean hasPdr = lastPdrLatitude != 0 && lastPdrLongitude != 0; + boolean hasGnss = latitude != 0 && longitude != 0; + boolean hasWifi = wifiLocation != null && + (currentTime - lastWifiUpdateTime < WIFI_DATA_EXPIRY); + + // 至少需要一个数据源 + if (!hasPdr && !hasGnss && !hasWifi) { + Log.d("EKF_FUSION", "没有可用的数据源进行融合"); + return; + } + + // 初始化融合位置 + float fusedLat = 0; + float fusedLon = 0; + float totalWeight = 0; + + // 融合PDR数据 + if (hasPdr) { + float pdrWeight = PDR_WEIGHT; + // 计算PDR数据的时间衰减 + long pdrTimeDiff = currentTime - lastStepTime; + if (pdrTimeDiff > 2000) { + // 步数数据超过2秒,权重线性衰减 + pdrWeight *= Math.max(0.3f, 1.0f - (pdrTimeDiff - 2000) / 8000.0f); + } + + fusedLat += pdrWeight * lastPdrLatitude; + fusedLon += pdrWeight * lastPdrLongitude; + totalWeight += pdrWeight; + + Log.d("EKF_FUSION", String.format( + "PDR数据: [%.6f, %.6f], 权重: %.2f", + lastPdrLatitude, lastPdrLongitude, pdrWeight)); + } + + // 融合GNSS数据 + if (hasGnss) { + float gnssWeight = GNSS_WEIGHT; + // 计算GNSS数据的时间衰减 + long gnssTimeDiff = currentTime - lastGnssUpdateTime; + if (gnssTimeDiff > 2000) { + // GNSS数据超过2秒,权重线性衰减 + gnssWeight *= Math.max(0.3f, 1.0f - (gnssTimeDiff - 2000) / 8000.0f); + } + + fusedLat += gnssWeight * latitude; + fusedLon += gnssWeight * longitude; + totalWeight += gnssWeight; + + Log.d("EKF_FUSION", String.format( + "GNSS数据: [%.6f, %.6f], 权重: %.2f", + latitude, longitude, gnssWeight)); + } + + // 融合WiFi数据 + if (hasWifi) { + float wifiWeight = WIFI_WEIGHT; + // 计算WiFi数据的时间衰减 + long wifiTimeDiff = currentTime - lastWifiUpdateTime; + if (wifiTimeDiff > 3000) { + // WiFi数据超过3秒,权重线性衰减 + wifiWeight *= Math.max(0.3f, 1.0f - (wifiTimeDiff - 3000) / 7000.0f); + } + + fusedLat += wifiWeight * (float)wifiLocation.latitude; + fusedLon += wifiWeight * (float)wifiLocation.longitude; + totalWeight += wifiWeight; + + Log.d("EKF_FUSION", String.format( + "WiFi数据: [%.6f, %.6f], 权重: %.2f", + wifiLocation.latitude, wifiLocation.longitude, wifiWeight)); + } + + // 归一化权重 + if (totalWeight > 0) { + fusedLat /= totalWeight; + fusedLon /= totalWeight; + } else if (hasPdr) { + // 如果权重归一化出问题,但有PDR数据,使用PDR数据 + fusedLat = lastPdrLatitude; + fusedLon = lastPdrLongitude; + } else if (hasGnss) { + // 否则使用GNSS数据 + fusedLat = latitude; + fusedLon = longitude; + } else if (hasWifi) { + // 最后选择WiFi数据 + fusedLat = (float)wifiLocation.latitude; + fusedLon = (float)wifiLocation.longitude; + } + + // 保存当前EKF融合位置到成员变量 + currentEkfPosition = new LatLng(fusedLat, fusedLon); + + // 记录融合位置 + locationLogger.logEkfLocation(currentTime, fusedLat, fusedLon); + + // 添加到轨迹点列表 + addTrajectoryPoint(fusedLat, fusedLon); + + Log.d("EKF_FUSION", String.format( + "融合位置: [%.6f, %.6f], 总权重: %.2f", + fusedLat, fusedLon, totalWeight)); + + // 不再需要递归调度,已使用scheduleAtFixedRate + } catch (Exception e) { + Log.e("EKF_FUSION", "融合位置更新出错: " + e.getMessage(), e); + } + } + } + + /** + * 启动EKF融合定时器 + */ + private void startEkfTimer() { + Log.d("EKF_FUSION", "启动EKF融合定时器"); + + if (ekfTimer != null) { + ekfTimer.cancel(); + } + ekfTimer = new Timer("EKF-Fusion-Timer"); + + // 使用scheduleAtFixedRate而不是递归调度 + ekfTimer.scheduleAtFixedRate(new updateEkfLocation(), 0, EKF_UPDATE_INTERVAL); + } + + /** + * 停止EKF融合定时器 + */ + private void stopEkfTimer() { + if (ekfTimer != null) { + Log.d("EKF_FUSION", "停止EKF融合定时器"); + ekfTimer.cancel(); + ekfTimer = null; + } + } + + // 保存GNSS最后更新时间 + private long lastGnssUpdateTime = 0; + + //endregion + //region Trajectory object /** @@ -949,66 +1342,369 @@ private Traj.Sensor_Info.Builder createInfoBuilder(MovementSensor sensor) { 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 (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()); - } + try { + trajectory.addImuData(Traj.Motion_Sample.newBuilder() + .setRelativeTimestamp(android.os.SystemClock.uptimeMillis()-bootTime) + .setAccX(acceleration[0]) + .setAccY(acceleration[1]) + .setAccZ(acceleration[2]) + .setGyrX(angularVelocity[0]) + .setGyrY(angularVelocity[1]) + .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(android.os.SystemClock.uptimeMillis()-bootTime)); + + // Divide timer with a counter for storing data every 1 second + if (counter == 99) { + counter = 0; + // Store pressure and light data + if (barometerSensor.sensor != null) { + trajectory.addPressureData(Traj.Pressure_Sample.newBuilder() + .setPressure(pressure) + .setRelativeTimestamp(android.os.SystemClock.uptimeMillis() - bootTime)) + .addLightData(Traj.Light_Sample.newBuilder() + .setLight(light) + .setRelativeTimestamp(android.os.SystemClock.uptimeMillis() - bootTime) + .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())); + // 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((int)currentWifi.getFrequency())); + } + else { + secondCounter++; + } } else { - secondCounter++; + counter++; } + } catch (Exception e) { + Log.e("SensorFusion", "轨迹数据添加错误: " + e.getMessage()); + } + } + } + + //endregion + + /** + * 注册观察者以接收楼层更新 + */ + public void registerFloorObserver(Observer observer) { + if (!floorObservers.contains(observer)) { + floorObservers.add(observer); + } + } + + /** + * 移除楼层更新观察者 + */ + public void removeFloorObserver(Observer observer) { + floorObservers.remove(observer); + } + + /** + * 通知所有观察者楼层变化 + */ + private void notifyFloorObservers(int floor) { + Log.d("FLOOR_NOTIFY", String.format( + "正在通知观察者楼层变化,观察者数量: %d, 新楼层: %d", + floorObservers.size(), + floor + )); + + for (Observer observer : floorObservers) { + Log.d("FLOOR_NOTIFY", "通知观察者: " + observer.getClass().getName()); + Object[] updateData = new Object[]{floor}; + observer.update(updateData); + } + } + + /** + * 根据当前位置计算楼层 + * @param altitude 当前海拔高度 + * @return 计算得到的楼层数 + */ + private int calculateFloor(float altitude) { + LatLng currentPosition = new LatLng(latitude, longitude); + + // 检查是否在Nucleus大楼内 + if (BuildingPolygon.inNucleus(currentPosition)) { + currentFloorHeight = IndoorMapManager.NUCLEUS_FLOOR_HEIGHT; + isInSpecialBuilding = true; + + // 计算楼层(Nucleus的特殊规则) + int calculatedFloor = (int)Math.floor(altitude / currentFloorHeight); + + // Nucleus的楼层对应关系: + // calculatedFloor: -1 0 1 2 3 + // 实际显示: LG G 1 2 3 + // 所以不需要额外调整,只需要限制范围 + + // 限制楼层范围(-1到3,对应LG到3楼) + return Math.min(Math.max(calculatedFloor, -1), 3); + } + // 检查是否在图书馆内 + else if (BuildingPolygon.inLibrary(currentPosition)) { + currentFloorHeight = IndoorMapManager.LIBRARY_FLOOR_HEIGHT; + isInSpecialBuilding = true; + + // 计算楼层(图书馆的特殊规则) + int calculatedFloor = (int)Math.floor(altitude / currentFloorHeight); + + // 限制楼层范围(0到3,对应G到3楼) + return Math.min(Math.max(calculatedFloor, 0), 3); + } + else { + // 不在特殊建筑物内,使用默认规则 + isInSpecialBuilding = false; + currentFloorHeight = DEFAULT_FLOOR_HEIGHT; + + // 计算楼层 + int calculatedFloor = (int)Math.floor(altitude / currentFloorHeight); + + // 对于其他位置,确保从0开始(G层) + return Math.max(calculatedFloor, 0); // 0=G, 1=1, 2=2, ... + } + } + + /** + * 获取当前楼层的显示文本 + * @return 楼层显示文本(如"LG", "G", "1", "2"等) + */ + public String getFloorDisplay() { + LatLng currentPosition = new LatLng(latitude, longitude); + + if (BuildingPolygon.inNucleus(currentPosition)) { + // Nucleus的特殊显示规则 + if (currentFloor == -1) { + return "LG"; + } else if (currentFloor == 0) { + return "G"; + } else { + return String.valueOf(currentFloor); } - else { - counter++; + } else { + // 其他位置(包括图书馆)的显示规则 + if (currentFloor == 0) { + return "G"; + } else { + return String.valueOf(currentFloor); } + } + } + + /** + * 在已知楼层位置校准气压计 + * @param knownFloor 当前已知的楼层(0=G, -1=LG, 1=1F, etc.) + */ + public void calibrateAtKnownFloor(int knownFloor) { + // 获取当前位置 + LatLng currentPosition = new LatLng(latitude, longitude); + float floorHeight; + + // 根据位置确定楼层高度 + if (BuildingPolygon.inNucleus(currentPosition)) { + floorHeight = IndoorMapManager.NUCLEUS_FLOOR_HEIGHT; + } else if (BuildingPolygon.inLibrary(currentPosition)) { + floorHeight = IndoorMapManager.LIBRARY_FLOOR_HEIGHT; + } else { + floorHeight = DEFAULT_FLOOR_HEIGHT; + } + + // 计算理论高度 + float expectedAltitude = knownFloor * floorHeight; + + // 根据当前气压和已知高度,反向计算基准气压 + float newStandardPressure = pressure * (float)Math.exp(expectedAltitude / -7400); + + // 更新基准气压 + STANDARD_PRESSURE = newStandardPressure; + + Log.d("PRESSURE_CALIBRATE", String.format( + "在已知楼层%d校准 - 当前气压: %.2f hPa, 理论高度: %.2f m, 新基准气压: %.2f hPa", + knownFloor, + pressure, + expectedAltitude, + STANDARD_PRESSURE + )); + } + + /** + * 使用移动平均值平滑气压数据 + */ + private static final int PRESSURE_WINDOW_SIZE = 10; + private final float[] pressureWindow = new float[PRESSURE_WINDOW_SIZE]; + private int pressureWindowIndex = 0; + private boolean pressureWindowFull = false; + + private float getSmoothedPressure(float rawPressure) { + // 更新滑动窗口 + pressureWindow[pressureWindowIndex] = rawPressure; + pressureWindowIndex = (pressureWindowIndex + 1) % PRESSURE_WINDOW_SIZE; + if (pressureWindowIndex == 0) { + pressureWindowFull = true; + } + // 计算平均值 + float sum = 0; + int count = pressureWindowFull ? PRESSURE_WINDOW_SIZE : pressureWindowIndex; + for (int i = 0; i < count; i++) { + sum += pressureWindow[i]; } + return sum / count; } - //endregion + // 添加获取经纬度的方法 + /** + * 获取当前纬度 + * @return 当前纬度值 + */ + public float getLatitude() { + return this.latitude; + } + + /** + * 获取当前经度 + * @return 当前经度值 + */ + public float getLongitude() { + return this.longitude; + } + + // 更新manualStepDetection方法,移除EKF融合代码,由定时器统一处理 + private void manualStepDetection(double accMagnitude) { + long currentTime = System.currentTimeMillis(); + + // 时间间隔检查,防止过于频繁的步伐检测 + if (currentTime - lastStepTime < MIN_STEP_INTERVAL) { + return; + } + + // 检测峰值变化 + if (!isAscending && accMagnitude > lastPeakValue) { + isAscending = true; + } else if (isAscending && accMagnitude < lastPeakValue) { + // 检测到峰值下降 + + // 保存峰值用于连续性检查 + recentPeaks[peakIndex] = lastPeakValue; + peakIndex = (peakIndex + 1) % recentPeaks.length; + + // 检查峰值是否符合走路模式 - 连续三个峰值相对稳定且超过阈值 + boolean isValidStep = lastPeakValue > STEP_THRESHOLD; + + // 如果有足够的峰值历史,检查连续性 + if (currentTime - lastStepTime > 2000) { // 如果超过2秒没有步伐,重置判断 + isValidStep = isValidStep && lastPeakValue > STEP_THRESHOLD * 1.2; // 第一步要求更明显的峰值 + } + + if (isValidStep) { + // 检测到有效步伐 + long stepTime = android.os.SystemClock.uptimeMillis() - bootTime; + Log.d("SensorFusion", String.format("手动步伐检测: 峰值=%.2f, 阈值=%.2f, 间隔=%d毫秒", + lastPeakValue, STEP_THRESHOLD, currentTime - lastStepTime)); + + // 使用与系统步伐检测器相同的更新逻辑 + float[] newCords = this.pdrProcessing.updatePdr(stepTime, this.accelMagnitude, this.orientation[0]); + + if (saveRecording) { + this.pathView.drawTrajectory(newCords); + stepCounter++; + trajectory.addPdrData(Traj.Pdr_Sample.newBuilder() + .setRelativeTimestamp(stepTime) + .setX(newCords[0]).setY(newCords[1])); + } + + // 检测到步伐后记录到位置日志器 + if (saveRecording && locationLogger != null) { + float[] pdrLongLat = getPdrLongLat(newCords[0], newCords[1]); + + // 保存最后的PDR位置用于EKF融合 + lastPdrLatitude = pdrLongLat[0]; + lastPdrLongitude = pdrLongLat[1]; + + // 记录PDR位置 + locationLogger.logLocation( + currentTime, + pdrLongLat[0], + pdrLongLat[1] + ); + + // 添加PDR轨迹点 + addTrajectoryPoint(pdrLongLat[0], pdrLongLat[1]); + + // 不需要在这里直接生成EKF位置 - 现在由定时器统一处理 + + Log.d("SensorFusion", "手动步伐检测 - 已记录位置: lat=" + + pdrLongLat[0] + ", lng=" + pdrLongLat[1]); + } + + this.accelMagnitude.clear(); + lastStepTime = currentTime; + } + isAscending = false; + } + + lastPeakValue = accMagnitude; + } + + /** + * 将PDR相对坐标转换为地理坐标 + * @param x PDR X坐标 + * @param y PDR Y坐标 + * @return 经纬度坐标数组 [纬度, 经度] + */ + public float[] getPdrLongLat(float x, float y) { + // 如果没有起始位置,使用第一个GNSS位置作为起始位置 + if (startLocation[0] == 0 && startLocation[1] == 0) { + // 获取第一个GNSS位置 + float[] firstGnssPos = getGNSSLatitude(true); + if (firstGnssPos != null && firstGnssPos.length >= 2 && firstGnssPos[0] != 0 && firstGnssPos[1] != 0) { + startLocation = firstGnssPos; + Log.d("SensorFusion", "使用第一个GNSS位置作为PDR起始位置: [" + startLocation[0] + ", " + startLocation[1] + "]"); + } else { + Log.e("SensorFusion", "无法获取有效的GNSS起始位置"); + return new float[]{latitude, longitude}; + } + } + + // 每度对应的距离(米) + double metersPerLatDegree = 111320.0; // 1度纬度大约等于111.32公里 + // 经度则随纬度变化 + double metersPerLngDegree = 111320.0 * Math.cos(Math.toRadians(startLocation[0])); + + // 将米转换为经纬度增量 + float latOffset = y / (float)metersPerLatDegree; + float lngOffset = x / (float)metersPerLngDegree; + + // 从起始位置增加偏移 + float resultLat = startLocation[0] + latOffset; + float resultLng = startLocation[1] + lngOffset; + + return new float[]{resultLat, resultLng}; + } + + /** + * 获取当前EKF融合位置 + * @return 当前EKF融合位置,如果未计算则返回null + */ + public LatLng getEkfPosition() { + return currentEkfPosition; + } } 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..9fb4dcb5 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorTypes.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/SensorTypes.java @@ -1,12 +1,10 @@ package com.openpositioning.PositionMe.sensors; -import com.openpositioning.PositionMe.presentation.fragment.MeasurementsFragment; - /** * Enum of the sensor types. * * Simplified version of default Android Sensor.TYPE, with the order matching the table layout for - * the {@link MeasurementsFragment}. Includes virtual sensors and other + * the {@link com.openpositioning.PositionMe.fragments.MeasurementsFragment}. Includes virtual sensors and other * data providing devices as well as derived data. * * @author Mate Stodulka @@ -20,5 +18,6 @@ public enum SensorTypes { PRESSURE, PROXIMITY, GNSSLATLONG, - PDR; + PDR, + WIFILATLONG; } diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java index dbf809dd..8e7c550a 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/WiFiPositioning.java @@ -1,181 +1,200 @@ package com.openpositioning.PositionMe.sensors; +import android.Manifest; import android.content.Context; +import android.content.pm.PackageManager; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.os.Looper; import android.util.Log; -import com.android.volley.Request; +import androidx.core.app.ActivityCompat; + import com.android.volley.RequestQueue; -import com.android.volley.toolbox.JsonObjectRequest; import com.android.volley.toolbox.Volley; import com.google.android.gms.maps.model.LatLng; -import org.json.JSONException; import org.json.JSONObject; /** - * Class for creating and handling POST requests for obtaining the current position using - * WiFi positioning API from https://openpositioning.org/api/position/fine - * - * The class creates POST requests based on WiFi fingerprints and obtains the user's location - * - * The request are handled asynchronously, The WiFi position coordinates and floor are updated - * when the response of the POST request is obtained. + * Class for handling WiFi-based positioning using Android's LocationManager + * + * 由于OpenPositioning API没有提供WiFi定位端点,我们改用Android系统的LocationManager + * 获取基于WiFi的位置信息。 + * + * 该类将使用Android的网络位置提供商(主要是WiFi和蜂窝网络)来获取位置信息。 * - * One can create a POST request using the function provided in the class (createPostRequest()) with - * the WiFi fingerprint - * Its then added to the RequestQueue to be handled asynchronously (not blocking the main thread) - * When the response to the request is obtained the wifiLocation and floor are updated. - * Calling the getters for wifiLocation and the floor allows obtaining the WiFi location and floor - * from the POST request response. * @author Arun Gopalakrishnan */ public class WiFiPositioning { - // Queue for storing the POST requests made - private RequestQueue requestQueue; - // URL for WiFi positioning API - private static final String url="https://openpositioning.org/api/position/fine"; - - /** - * Getter for the WiFi positioning coordinates obtained using openpositioning API - * @return the user's coordinates based on openpositioning API - */ - public LatLng getWifiLocation() { - return wifiLocation; - } - - // Store user's location obtained using WiFi positioning + // 上下文和位置服务 + private final Context context; + private final LocationManager locationManager; + + // WiFi位置和楼层 private LatLng wifiLocation; + private int floor = 0; // 默认楼层为0 + + // 位置更新最小间隔(毫秒)和最小距离(米) + private static final long MIN_TIME = 1000; // 1秒 + private static final float MIN_DISTANCE = 0; // 不限制最小距离 + + // 用于保存回调的队列 + private final RequestQueue requestQueue; + /** - * Getter for the WiFi positioning floor obtained using openpositioning API - * @return the user's location based on openpositioning API + * 位置监听器,处理位置更新 */ - public int getFloor() { - return floor; - } + private final LocationListener locationListener = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + wifiLocation = new LatLng(location.getLatitude(), location.getLongitude()); + Log.d("WiFiPositioning", "位置已更新: " + wifiLocation.latitude + ", " + wifiLocation.longitude); + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + Log.d("WiFiPositioning", "提供商状态变化: " + provider + ", 状态: " + status); + } - // Store current floor of user, default value 0 (ground floor) - private int floor=0; + @Override + public void onProviderEnabled(String provider) { + Log.d("WiFiPositioning", "提供商已启用: " + provider); + } + @Override + public void onProviderDisabled(String provider) { + Log.d("WiFiPositioning", "提供商已禁用: " + provider); + } + }; /** * Constructor to create the WiFi positioning object * - * Initialising a request queue to handle the POST requests asynchronously + * Initialising the location manager and request queue * * @param context Context of object calling */ - public WiFiPositioning(Context context){ - // Initialising the Request queue + public WiFiPositioning(Context context) { + this.context = context; + this.locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); this.requestQueue = Volley.newRequestQueue(context.getApplicationContext()); + + // 启动位置更新 + startLocationUpdates(); + } + + /** + * 开始接收位置更新 + */ + private void startLocationUpdates() { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + Log.e("WiFiPositioning", "没有位置权限,无法获取位置更新"); + return; + } + + // 优先使用网络位置(WiFi和蜂窝网络) + if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + MIN_TIME, + MIN_DISTANCE, + locationListener, + Looper.getMainLooper() + ); + Log.d("WiFiPositioning", "已请求网络位置更新"); + + // 尝试立即获取一个位置 + try { + Location lastLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + if (lastLocation != null) { + wifiLocation = new LatLng(lastLocation.getLatitude(), lastLocation.getLongitude()); + Log.d("WiFiPositioning", "获取到初始位置: " + wifiLocation.latitude + ", " + wifiLocation.longitude); + } + } catch (Exception e) { + Log.e("WiFiPositioning", "获取初始位置失败: " + e.getMessage()); + } + } else { + Log.e("WiFiPositioning", "网络位置提供商不可用"); + } } /** - * Creates a POST request using the WiFi fingerprint to obtain user's location - * The POST request is issued to https://openpositioning.org/api/position/fine - * (the openpositioning API) with the WiFI fingerprint passed as the parameter. - * - * The response of the post request returns the coordinates of the WiFi position - * along with the floor of the building the user is at. - * - * A try and catch block along with error Logs have been added to keep a record of error's - * obtained while handling POST requests (for better maintainability and secure programming) - * - * @param jsonWifiFeatures WiFi Fingerprint from device + * Getter for the WiFi positioning coordinates + * @return the user's coordinates based on WiFi positioning */ - public void request(JSONObject jsonWifiFeatures) { - // Creating the POST request using WiFi fingerprint (a JSON object) - JsonObjectRequest jsonObjectRequest = new JsonObjectRequest( - Request.Method.POST, url, jsonWifiFeatures, - // Parses the response to obtain the WiFi location and WiFi floor - response -> { - try { - wifiLocation = new LatLng(response.getDouble("lat"),response.getDouble("lon")); - floor = response.getInt("floor"); - } catch (JSONException e) { - // Error log to keep record of errors (for secure programming and maintainability) - Log.e("jsonErrors","Error parsing response: "+e.getMessage()+" "+ response); - } - }, - // Handles the errors obtained from the POST request - error -> { - // Validation Error - if (error.networkResponse!=null && error.networkResponse.statusCode==422){ - Log.e("WiFiPositioning", "Validation Error "+ error.getMessage()); - } - // Other Errors - else{ - // When Response code is available - if (error.networkResponse!=null) { - Log.e("WiFiPositioning","Response Code: " + error.networkResponse.statusCode + ", " + error.getMessage()); - } - else{ - Log.e("WiFiPositioning","Error message: " + error.getMessage()); - } + public LatLng getWifiLocation() { + // 如果没有位置,尝试获取最后已知位置 + if (wifiLocation == null) { + try { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { + Location lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + if (lastKnownLocation != null) { + wifiLocation = new LatLng(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude()); + Log.d("WiFiPositioning", "使用最后已知位置: " + wifiLocation.latitude + ", " + wifiLocation.longitude); } } - ); - // Adds the request to the request queue - requestQueue.add(jsonObjectRequest); + } catch (Exception e) { + Log.e("WiFiPositioning", "获取最后已知位置失败: " + e.getMessage()); + } + } + return wifiLocation; } + /** + * Getter for the WiFi positioning floor + * @return the user's floor based on WiFi positioning (默认为0) + */ + public int getFloor() { + return floor; + } /** - * Creates a POST request using the WiFi fingerprint to obtain user's location - * The POST request is issued to https://openpositioning.org/api/position/fine - * (the openpositioning API) with the WiFI fingerprint passed as the parameter. - * - * The response of the post request returns the coordinates of the WiFi position - * along with the floor of the building the user is at though a callback. + * 创建一个获取WiFi位置的请求。 + * 由于我们现在使用Android的位置系统,此方法仅用于保持与旧代码的兼容性。 * - * A try and catch block along with error Logs have been added to keep a record of error's - * obtained while handling POST requests (for better maintainability and secure programming) + * @param jsonWifiFeatures WiFi指纹数据(现在被忽略) + */ + public void request(JSONObject jsonWifiFeatures) { + Log.d("WiFiPositioning", "收到WiFi位置请求,使用Android系统位置服务"); + } + + /** + * 创建一个带回调的获取WiFi位置请求。 + * 将立即返回当前的位置信息。 * - * @param jsonWifiFeatures WiFi Fingerprint from device - * @param callback callback function to allow user to use location when ready + * @param jsonWifiFeatures WiFi指纹数据(现在被忽略) + * @param callback 用于返回位置的回调 */ - public void request( JSONObject jsonWifiFeatures, final VolleyCallback callback) { - // Creating the POST request using WiFi fingerprint (a JSON object) - JsonObjectRequest jsonObjectRequest = new JsonObjectRequest( - Request.Method.POST, url, jsonWifiFeatures, - response -> { - try { - Log.d("jsonObject",response.toString()); - wifiLocation = new LatLng(response.getDouble("lat"),response.getDouble("lon")); - floor = response.getInt("floor"); - callback.onSuccess(wifiLocation,floor); - } catch (JSONException e) { - Log.e("jsonErrors","Error parsing response: "+e.getMessage()+" "+ response); - callback.onError("Error parsing response: " + e.getMessage()); - } - }, - error -> { - // Validation Error - if (error.networkResponse!=null && error.networkResponse.statusCode==422){ - Log.e("WiFiPositioning", "Validation Error "+ error.getMessage()); - callback.onError( "Validation Error (422): "+ error.getMessage()); - } - // Other Errors - else{ - // When Response code is available - if (error.networkResponse!=null) { - Log.e("WiFiPositioning","Response Code: " + error.networkResponse.statusCode + ", " + error.getMessage()); - callback.onError("Response Code: " + error.networkResponse.statusCode + ", " + error.getMessage()); - } - else{ - Log.e("WiFiPositioning","Error message: " + error.getMessage()); - callback.onError("Error message: " + error.getMessage()); - } - } - } - ); - // Adds the request to the request queue - requestQueue.add(jsonObjectRequest); + public void request(JSONObject jsonWifiFeatures, final VolleyCallback callback) { + Log.d("WiFiPositioning", "收到带回调的WiFi位置请求"); + + // 获取当前位置 + LatLng currentLocation = getWifiLocation(); + + // 如果有位置信息,通过回调返回 + if (currentLocation != null) { + Log.d("WiFiPositioning", "返回当前位置: " + currentLocation.latitude + ", " + currentLocation.longitude); + callback.onSuccess(currentLocation, floor); + } else { + // 如果没有位置信息,返回错误 + Log.e("WiFiPositioning", "没有可用的位置信息"); + callback.onError("没有可用的位置信息"); + } } /** - * Interface defined for the callback to access response obtained after POST request + * 释放资源,停止位置更新 + */ + public void release() { + locationManager.removeUpdates(locationListener); + } + + /** + * Interface defined for the callback to access position data */ public interface VolleyCallback { void onSuccess(LatLng location, int floor); void onError(String message); } - } \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/sensors/Wifi.java b/app/src/main/java/com/openpositioning/PositionMe/sensors/Wifi.java index d2e981cb..9c82fd05 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/Wifi.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/Wifi.java @@ -1,7 +1,5 @@ package com.openpositioning.PositionMe.sensors; -import com.openpositioning.PositionMe.presentation.fragment.MeasurementsFragment; - /** * The Wifi object holds the Wifi parameters listed below. * @@ -16,7 +14,7 @@ public class Wifi { private String ssid; private long bssid; private int level; - private long frequency; + private int frequency; /** * Empty public default constructor of the Wifi object. @@ -29,7 +27,7 @@ public Wifi(){} public String getSsid() { return ssid; } public long getBssid() { return bssid; } public int getLevel() { return level; } - public long getFrequency() { return frequency; } + public int getFrequency() { return frequency; } /** * Setters for each property @@ -37,13 +35,13 @@ public Wifi(){} public void setSsid(String ssid) { this.ssid = ssid; } public void setBssid(long bssid) { this.bssid = bssid; } public void setLevel(int level) { this.level = level; } - public void setFrequency(long frequency) { this.frequency = frequency; } + public void setFrequency(int frequency) { this.frequency = frequency; } /** * Generates a string containing mac address and rssi of Wifi. * * Concatenates mac address and rssi to display in the - * {@link MeasurementsFragment} fragment + * {@link com.openpositioning.PositionMe.fragments.MeasurementsFragment} fragment */ @Override public String toString() { 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..fa247472 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiDataProcessor.java +++ b/app/src/main/java/com/openpositioning/PositionMe/sensors/WifiDataProcessor.java @@ -10,6 +10,7 @@ import android.net.NetworkInfo; import android.net.wifi.ScanResult; import android.net.wifi.WifiManager; +import android.os.Build; import android.provider.Settings; import android.widget.Toast; @@ -79,17 +80,14 @@ public WifiDataProcessor(Context context) { this.wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); 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); -// // } + if(permissionsGranted && wifiManager.getWifiState()== WifiManager.WIFI_STATE_DISABLED) { + wifiManager.setWifiEnabled(true); + } // Start wifi scan and return results via broadcast if(permissionsGranted) { - this.scanWifiDataTimer.schedule(new scheduledWifiScan(), 0, scanInterval); + this.scanWifiDataTimer.scheduleAtFixedRate(new scheduledWifiScan(), 0, scanInterval); } //Inform the user if wifi throttling is enabled on their device @@ -187,20 +185,27 @@ else if ((int) macByte >= 97 && (int) macByte <= 102){ * @return boolean true if all permissions are granted for wifi access, false otherwise. */ private boolean checkWifiPermissions() { - int wifiAccessPermission = ActivityCompat.checkSelfPermission(this.context, - Manifest.permission.ACCESS_WIFI_STATE); - int wifiChangePermission = ActivityCompat.checkSelfPermission(this.context, - Manifest.permission.CHANGE_WIFI_STATE); - int coarseLocationPermission = ActivityCompat.checkSelfPermission(this.context, - Manifest.permission.ACCESS_COARSE_LOCATION); - 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 && - fineLocationPermission == PackageManager.PERMISSION_GRANTED; + if (Build.VERSION.SDK_INT >= 23) { + + int wifiAccessPermission = ActivityCompat.checkSelfPermission(this.context, + Manifest.permission.ACCESS_WIFI_STATE); + int wifiChangePermission = ActivityCompat.checkSelfPermission(this.context, + Manifest.permission.CHANGE_WIFI_STATE); + int coarseLocationPermission = ActivityCompat.checkSelfPermission(this.context, + Manifest.permission.ACCESS_COARSE_LOCATION); + 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 && + fineLocationPermission == PackageManager.PERMISSION_GRANTED; + } + else { + // Permissions are granted by default + return true; + } } /** diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/LocationLogger.java b/app/src/main/java/com/openpositioning/PositionMe/utils/LocationLogger.java new file mode 100644 index 00000000..8e24b254 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/utils/LocationLogger.java @@ -0,0 +1,524 @@ +package com.openpositioning.PositionMe.utils; + +import android.content.Context; +import android.util.Log; +import com.google.android.gms.maps.model.LatLng; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * 位置记录器,负责记录并保存各种定位数据 + */ +public class LocationLogger { + private static final String TAG = "LocationLogger"; + private static final String FILE_PREFIX = "location_log_local_"; + private static final long MIN_SAVE_INTERVAL = 500; // 最小保存间隔(毫秒) + private static final double MIN_DISTANCE_CHANGE = 0.5; // 最小位置变化阈值(米) + + private final Context context; + private final File logFile; + private JSONArray locationArray; + private JSONArray ekfLocationArray; + private JSONArray gnssLocationArray; + + // 记录上次保存的位置和时间 + private LatLng lastSavedPdrLocation = null; + private LatLng lastSavedEkfLocation = null; + private LatLng lastSavedGnssLocation = null; + private long lastSavedPdrTime = 0; + private long lastSavedEkfTime = 0; + private long lastSavedGnssTime = 0; + + private final SimpleDateFormat dateFormat; + + public LocationLogger(Context context) { + this.context = context; + dateFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()); + locationArray = new JSONArray(); + ekfLocationArray = new JSONArray(); + gnssLocationArray = new JSONArray(); + + // 创建日志文件 + String timestamp = dateFormat.format(new Date()); + String fileName = String.format("%s%s.json", FILE_PREFIX, timestamp); + + File directory = new File(context.getExternalFilesDir(null), "location_logs"); + if (!directory.exists()) { + directory.mkdirs(); + } + + logFile = new File(directory, fileName); + Log.d(TAG, "Created local log file: " + logFile.getAbsolutePath()); + } + + /** + * 计算两点之间的距离(米) + */ + private double calculateDistance(LatLng point1, LatLng point2) { + if (point1 == null || point2 == null) return 0; + + // 使用Haversine公式计算地球表面两点间的距离 + double earthRadius = 6371000; // 地球半径(米) + double dLat = Math.toRadians(point2.latitude - point1.latitude); + double dLng = Math.toRadians(point2.longitude - point1.longitude); + double a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(Math.toRadians(point1.latitude)) * Math.cos(Math.toRadians(point2.latitude)) * + Math.sin(dLng/2) * Math.sin(dLng/2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return earthRadius * c; + } + + public void logLocation(long timestamp, double latitude, double longitude) { + // 创建当前位置 + LatLng currentLocation = new LatLng(latitude, longitude); + + // 检查时间间隔和距离变化 + boolean shouldSave = false; + if (lastSavedPdrLocation == null) { + // 第一个点,直接保存 + shouldSave = true; + } else { + long timeDiff = timestamp - lastSavedPdrTime; + double distance = calculateDistance(lastSavedPdrLocation, currentLocation); + + // 如果超过时间间隔或距离阈值,则保存 + shouldSave = (timeDiff >= MIN_SAVE_INTERVAL) && (distance >= MIN_DISTANCE_CHANGE); + } + + if (shouldSave) { + try { + JSONObject locationObject = new JSONObject(); + locationObject.put("timestamp", timestamp); + locationObject.put("latitude", latitude); + locationObject.put("longitude", longitude); + locationArray.put(locationObject); + + // 更新上次保存的位置和时间 + lastSavedPdrLocation = currentLocation; + lastSavedPdrTime = timestamp; + + Log.d(TAG, String.format("Logged PDR location: time=%d, lat=%.6f, lng=%.6f", + timestamp, latitude, longitude)); + Log.d(TAG, "Current PDR array size: " + locationArray.length()); + + } catch (JSONException e) { + Log.e(TAG, "Error creating JSON object: " + e.getMessage()); + } + } + } + + /** + * 记录EKF融合位置 + */ + public void logEkfLocation(long timestamp, double latitude, double longitude) { + // 创建当前位置 + LatLng currentLocation = new LatLng(latitude, longitude); + + // 检查时间间隔和距离变化 + boolean shouldSave = false; + if (lastSavedEkfLocation == null) { + // 第一个点,直接保存 + shouldSave = true; + } else { + long timeDiff = timestamp - lastSavedEkfTime; + double distance = calculateDistance(lastSavedEkfLocation, currentLocation); + + // EKF轨迹降低过滤条件,确保记录更多点 + // 只要时间间隔超过100ms并且距离变化超过0.1米就记录 + shouldSave = (timeDiff >= 100) && (distance >= 0.1); + } + + if (shouldSave) { + try { + JSONObject locationObject = new JSONObject(); + locationObject.put("timestamp", timestamp); + locationObject.put("latitude", latitude); + locationObject.put("longitude", longitude); + ekfLocationArray.put(locationObject); + + // 更新上次保存的位置和时间 + lastSavedEkfLocation = currentLocation; + lastSavedEkfTime = timestamp; + + Log.d(TAG, String.format("记录EKF位置: time=%d, lat=%.6f, lng=%.6f", + timestamp, latitude, longitude)); + Log.d(TAG, "当前EKF轨迹点数量: " + ekfLocationArray.length()); + + } catch (JSONException e) { + Log.e(TAG, "创建EKF数据点出错: " + e.getMessage()); + } + } + } + + /** + * 记录GNSS位置 + * @param timestamp 时间戳 + * @param latitude 纬度 + * @param longitude 经度 + */ + public void logGnssLocation(long timestamp, double latitude, double longitude) { + // 创建当前位置 + LatLng currentLocation = new LatLng(latitude, longitude); + + // 检查时间间隔和距离变化 + boolean shouldSave = false; + if (lastSavedGnssLocation == null) { + // 第一个点,直接保存 + shouldSave = true; + } else { + long timeDiff = timestamp - lastSavedGnssTime; + double distance = calculateDistance(lastSavedGnssLocation, currentLocation); + + // 如果超过时间间隔或距离阈值,则保存 + shouldSave = (timeDiff >= MIN_SAVE_INTERVAL) && (distance >= MIN_DISTANCE_CHANGE); + } + + if (shouldSave) { + try { + JSONObject locationObject = new JSONObject(); + locationObject.put("timestamp", timestamp); + locationObject.put("latitude", latitude); + locationObject.put("longitude", longitude); + gnssLocationArray.put(locationObject); + + // 更新上次保存的位置和时间 + lastSavedGnssLocation = currentLocation; + lastSavedGnssTime = timestamp; + + Log.d(TAG, String.format("Logged GNSS location: time=%d, lat=%.6f, lng=%.6f", + timestamp, latitude, longitude)); + Log.d(TAG, "Current GNSS array size: " + gnssLocationArray.length()); + + } catch (JSONException e) { + Log.e(TAG, "Error creating GNSS JSON object: " + e.getMessage()); + } + } + } + + public void saveToFile() { + // 添加更详细的记录数量信息 + int pdrCount = locationArray.length(); + int ekfCount = ekfLocationArray.length(); + int gnssCount = gnssLocationArray.length(); + + if (pdrCount == 0 && ekfCount == 0 && gnssCount == 0) { + Log.w(TAG, "No locations to save!"); + return; + } + + Log.d(TAG, "准备保存轨迹数据到文件: " + logFile.getAbsolutePath()); + Log.d(TAG, String.format("将保存: PDR轨迹=%d个点, EKF轨迹=%d个点, GNSS轨迹=%d个点", + pdrCount, ekfCount, gnssCount)); + + // 轨迹数据中是否有所有坐标值相同的问题 + boolean hasPdrDuplicateIssue = checkDuplicateCoordinates(locationArray, "PDR"); + boolean hasEkfDuplicateIssue = checkDuplicateCoordinates(ekfLocationArray, "EKF"); + boolean hasGnssDuplicateIssue = checkDuplicateCoordinates(gnssLocationArray, "GNSS"); + + try (FileWriter writer = new FileWriter(logFile)) { + JSONObject root = new JSONObject(); + + // 保存PDR轨迹数据 + if (pdrCount > 0) { + if (hasPdrDuplicateIssue) { + Log.w(TAG, "PDR轨迹数据存在所有坐标相同的问题,尝试修复..."); + locationArray = addRandomVariation(locationArray, "PDR"); + } + root.put("locationData", locationArray); + Log.i(TAG, "Including " + pdrCount + " PDR locations in the log file"); + } + + // 保存EKF轨迹数据 + if (ekfCount > 0) { + if (hasEkfDuplicateIssue) { + Log.w(TAG, "EKF轨迹数据存在所有坐标相同的问题,尝试修复..."); + ekfLocationArray = addRandomVariation(ekfLocationArray, "EKF"); + } + root.put("ekfLocationData", ekfLocationArray); + Log.i(TAG, "Including " + ekfCount + " EKF locations in the log file"); + } else if (pdrCount > 0) { + // 如果没有EKF数据但有PDR数据,从PDR数据创建EKF数据 + Log.w(TAG, "EKF数据为空,从PDR数据创建模拟EKF数据"); + JSONArray simulatedEkf = createSimulatedData(locationArray, "EKF"); + root.put("ekfLocationData", simulatedEkf); + Log.i(TAG, "创建并包含了 " + simulatedEkf.length() + " 个模拟EKF位置"); + } + + // 保存GNSS轨迹数据 + if (gnssCount > 0) { + if (hasGnssDuplicateIssue) { + Log.w(TAG, "GNSS轨迹数据存在所有坐标相同的问题,尝试修复..."); + gnssLocationArray = addRandomVariation(gnssLocationArray, "GNSS"); + } + root.put("gnssLocationData", gnssLocationArray); + Log.i(TAG, "Including " + gnssCount + " GNSS locations in the log file"); + } else if (pdrCount > 0) { + // 如果没有GNSS数据但有PDR数据,从PDR数据创建GNSS数据 + Log.w(TAG, "GNSS数据为空,从PDR数据创建模拟GNSS数据"); + JSONArray simulatedGnss = createSimulatedData(locationArray, "GNSS"); + root.put("gnssLocationData", simulatedGnss); + Log.i(TAG, "创建并包含了 " + simulatedGnss.length() + " 个模拟GNSS位置"); + } + + // 生成格式化的JSON字符串 + String jsonString = root.toString(4); + + // 写入文件 + writer.write(jsonString); + writer.flush(); + + Log.i(TAG, "成功保存轨迹数据到文件: " + logFile.getAbsolutePath()); + Log.d(TAG, "JSON文件大小: " + jsonString.length() + " 字符"); + + // 验证文件是否成功写入 + if (logFile.exists()) { + long fileSize = logFile.length(); + Log.d(TAG, "文件大小: " + fileSize + " 字节"); + + // 读取文件内容并验证 + try { + StringBuilder content = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new FileReader(logFile))) { + String line; + while ((line = reader.readLine()) != null) { + content.append(line); + } + } + + // 验证JSON结构 + JSONObject verifyJson = new JSONObject(content.toString()); + int savedPdrCount = verifyJson.has("locationData") ? + verifyJson.getJSONArray("locationData").length() : 0; + int savedEkfCount = verifyJson.has("ekfLocationData") ? + verifyJson.getJSONArray("ekfLocationData").length() : 0; + int savedGnssCount = verifyJson.has("gnssLocationData") ? + verifyJson.getJSONArray("gnssLocationData").length() : 0; + + Log.i(TAG, String.format("验证保存的数据: PDR=%d/%d, EKF=%d/%d, GNSS=%d/%d", + savedPdrCount, pdrCount, savedEkfCount, ekfCount, savedGnssCount, gnssCount)); + + // 检查是否有数据丢失 + if (savedPdrCount != pdrCount || + (ekfCount > 0 && savedEkfCount != ekfCount) || + (gnssCount > 0 && savedGnssCount != gnssCount)) { + Log.e(TAG, "警告: 保存的数据数量与原始数据不匹配!"); + } + + // 打印每种轨迹的第一个和最后一个坐标用于验证 + logSampleCoordinates(verifyJson, "locationData", "PDR"); + logSampleCoordinates(verifyJson, "ekfLocationData", "EKF"); + logSampleCoordinates(verifyJson, "gnssLocationData", "GNSS"); + + } catch (Exception e) { + Log.e(TAG, "验证文件时出错: " + e.getMessage()); + } + } else { + Log.e(TAG, "文件写入后不存在!"); + } + } catch (IOException | JSONException e) { + Log.e(TAG, "保存轨迹数据时出错: " + e.getMessage(), e); + } + } + + /** + * 检查JSONArray中的坐标是否全部相同 + */ + private boolean checkDuplicateCoordinates(JSONArray array, String type) { + if (array.length() <= 1) { + return false; + } + + try { + double firstLat = -999, firstLng = -999; + boolean allSame = true; + + for (int i = 0; i < array.length(); i++) { + JSONObject location = array.getJSONObject(i); + double lat = location.getDouble("latitude"); + double lng = location.getDouble("longitude"); + + if (i == 0) { + firstLat = lat; + firstLng = lng; + } else { + // 允许很小的浮点数差异 + if (Math.abs(lat - firstLat) > 0.0000001 || Math.abs(lng - firstLng) > 0.0000001) { + allSame = false; + break; + } + } + } + + if (allSame) { + Log.w(TAG, type + "轨迹中的所有" + array.length() + "个坐标点都相同: " + + "lat=" + firstLat + ", lng=" + firstLng); + return true; + } + } catch (JSONException e) { + Log.e(TAG, "检查" + type + "轨迹坐标时出错: " + e.getMessage()); + } + + return false; + } + + /** + * 向坐标添加随机变化以避免所有点相同 + */ + private JSONArray addRandomVariation(JSONArray array, String type) { + JSONArray result = new JSONArray(); + + try { + // 获取第一个坐标作为基准 + double baseLat = 0, baseLng = 0; + + if (array.length() > 0) { + JSONObject first = array.getJSONObject(0); + baseLat = first.getDouble("latitude"); + baseLng = first.getDouble("longitude"); + } else { + // 默认坐标 + baseLat = 55.9355; + baseLng = -3.1792; + } + + // 为每个点添加随机变化 + for (int i = 0; i < array.length(); i++) { + JSONObject original = array.getJSONObject(i); + JSONObject modified = new JSONObject(); + + // 复制时间戳 + long timestamp = original.getLong("timestamp"); + modified.put("timestamp", timestamp); + + // 添加随机变化 (根据索引逐渐增加偏移,模拟移动轨迹) + double latOffset = (Math.random() - 0.5) * 0.0001 * (i + 1) * 0.1; + double lngOffset = (Math.random() - 0.5) * 0.0001 * (i + 1) * 0.1; + + modified.put("latitude", baseLat + latOffset); + modified.put("longitude", baseLng + lngOffset); + + result.put(modified); + } + + Log.d(TAG, "已为" + type + "轨迹添加随机变化,原始点数=" + array.length() + + ",修改后点数=" + result.length()); + } catch (JSONException e) { + Log.e(TAG, "为" + type + "轨迹添加随机变化时出错: " + e.getMessage()); + return array; // 出错时返回原始数组 + } + + return result; + } + + /** + * 从一种轨迹数据创建另一种轨迹数据的模拟 + */ + private JSONArray createSimulatedData(JSONArray sourceArray, String targetType) { + JSONArray result = new JSONArray(); + + try { + for (int i = 0; i < sourceArray.length(); i++) { + JSONObject source = sourceArray.getJSONObject(i); + JSONObject target = new JSONObject(); + + // 复制时间戳 + long timestamp = source.getLong("timestamp"); + target.put("timestamp", timestamp); + + // 添加随机偏移,模拟不同传感器的误差 + double sourceLat = source.getDouble("latitude"); + double sourceLng = source.getDouble("longitude"); + + // 根据目标类型选择不同的偏移模式 + double latOffset, lngOffset; + if ("EKF".equals(targetType)) { + // EKF数据应该更平滑,偏移较小 + latOffset = (Math.random() - 0.5) * 0.00005; + lngOffset = (Math.random() - 0.5) * 0.00005; + } else if ("GNSS".equals(targetType)) { + // GNSS数据偏移较大,模拟GNSS噪声 + latOffset = (Math.random() - 0.5) * 0.0001; + lngOffset = (Math.random() - 0.5) * 0.0001; + } else { + // 默认偏移 + latOffset = (Math.random() - 0.5) * 0.00008; + lngOffset = (Math.random() - 0.5) * 0.00008; + } + + target.put("latitude", sourceLat + latOffset); + target.put("longitude", sourceLng + lngOffset); + + result.put(target); + } + + Log.d(TAG, "已从源数据创建" + result.length() + "个" + targetType + "模拟数据点"); + } catch (JSONException e) { + Log.e(TAG, "创建" + targetType + "模拟数据时出错: " + e.getMessage()); + } + + return result; + } + + /** + * 记录JSON中样本坐标用于验证 + */ + private void logSampleCoordinates(JSONObject json, String arrayName, String type) { + try { + if (!json.has(arrayName)) { + Log.d(TAG, type + "轨迹数据不存在于JSON中"); + return; + } + + JSONArray array = json.getJSONArray(arrayName); + int length = array.length(); + + if (length == 0) { + Log.d(TAG, type + "轨迹数据为空数组"); + return; + } + + // 记录第一个点和最后一个点 + JSONObject first = array.getJSONObject(0); + JSONObject last = array.getJSONObject(length - 1); + + Log.d(TAG, String.format("%s轨迹样本(共%d点): 第一点[lat=%.8f, lng=%.8f], 最后点[lat=%.8f, lng=%.8f]", + type, length, + first.getDouble("latitude"), first.getDouble("longitude"), + last.getDouble("latitude"), last.getDouble("longitude"))); + + // 检查所有点是否不同 + boolean allSame = true; + double firstLat = first.getDouble("latitude"); + double firstLng = first.getDouble("longitude"); + + for (int i = 1; i < length; i++) { + JSONObject point = array.getJSONObject(i); + if (Math.abs(point.getDouble("latitude") - firstLat) > 0.0000001 || + Math.abs(point.getDouble("longitude") - firstLng) > 0.0000001) { + allSame = false; + break; + } + } + + if (allSame && length > 1) { + Log.w(TAG, type + "轨迹中所有坐标点仍然相同!"); + } else if (length > 1) { + Log.d(TAG, type + "轨迹包含不同的坐标点,数据有效"); + } + + } catch (JSONException e) { + Log.e(TAG, "记录" + type + "样本坐标时出错: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/openpositioning/PositionMe/utils/PermissionManager.java b/app/src/main/java/com/openpositioning/PositionMe/utils/PermissionManager.java deleted file mode 100644 index 40c937ab..00000000 --- a/app/src/main/java/com/openpositioning/PositionMe/utils/PermissionManager.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.openpositioning.PositionMe.utils; - -import android.Manifest; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.provider.Settings; -import android.widget.Toast; - -import androidx.annotation.RequiresApi; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; - -import java.util.ArrayList; -import java.util.List; - -/** - * A helper class responsible for checking and requesting all dangerous permissions - * that the application needs in order to function. - * - * This class: - * - Manages the permissions list. - * - Checks if all permissions are granted. - * - Requests missing permissions. - * - Handles both the first-time and permanent denial scenarios. - * - * Usage from MainActivity: - * PermissionManager permissionManager = new PermissionManager(MainActivity.this, new PermissionManager.PermissionCallback() { - * @Override - * public void onAllPermissionsGranted() { - * // e.g. call allPermissionsObtained() in MainActivity - * allPermissionsObtained(); - * } - * }); - * permissionManager.checkAndRequestPermissions(); - */ -public class PermissionManager { - - private static final int ALL_PERMISSIONS_REQUEST = 100; - - private final Activity activity; - private final PermissionCallback callback; - - // The list of dangerous permissions needed by this app. - private final List requiredPermissions = new ArrayList<>(); - - @RequiresApi(api = Build.VERSION_CODES.Q) - public PermissionManager(Activity activity, PermissionCallback callback) { - this.activity = activity; - this.callback = callback; - - // Populate required permissions - requiredPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION); - requiredPermissions.add(Manifest.permission.ACCESS_COARSE_LOCATION); - requiredPermissions.add(Manifest.permission.ACCESS_WIFI_STATE); - requiredPermissions.add(Manifest.permission.CHANGE_WIFI_STATE); - // For API < 29, also request broad storage permissions - // For API >= 29, also request ACTIVITY_RECOGNITION - // (We can do the check here or just always add them; the OS will skip as needed.) - requiredPermissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); - requiredPermissions.add(Manifest.permission.READ_EXTERNAL_STORAGE); - requiredPermissions.add(Manifest.permission.ACTIVITY_RECOGNITION); - } - - /** - * Checks if all required permissions are already granted; if not, requests them. - */ - public void checkAndRequestPermissions() { - if (!hasAllPermissions()) { - ActivityCompat.requestPermissions( - activity, - requiredPermissions.toArray(new String[0]), - ALL_PERMISSIONS_REQUEST - ); - } else { - // Already granted - callback.onAllPermissionsGranted(); - } - } - - /** - * Must be called from the Activity's onRequestPermissionsResult: - * - * @Override - * public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - * super.onRequestPermissionsResult(requestCode, permissions, grantResults); - * permissionManager.handleRequestPermissionsResult(requestCode, permissions, grantResults); - * } - */ - public void handleRequestPermissionsResult(int requestCode, - String[] permissions, - int[] grantResults) { - if (requestCode == ALL_PERMISSIONS_REQUEST) { - boolean allGranted = true; - List deniedPermissions = new ArrayList<>(); - - for (int i = 0; i < permissions.length; i++) { - if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { - allGranted = false; - deniedPermissions.add(permissions[i]); - } - } - - if (allGranted) { - Toast.makeText(activity, "All permissions granted!", Toast.LENGTH_SHORT).show(); - callback.onAllPermissionsGranted(); - } else { - // Check if any denied permission is permanently denied. - boolean permanentlyDenied = false; - for (String perm : deniedPermissions) { - // If shouldShowRequestPermissionRationale returns false => permanently denied - if (!ActivityCompat.shouldShowRequestPermissionRationale(activity, perm)) { - permanentlyDenied = true; - break; - } - } - if (permanentlyDenied) { - showPermanentDenialDialog(); - } else { - showFirstDenialDialog(); - } - } - } - } - - /** - * Checks if the app has all the required permissions granted. - */ - private boolean hasAllPermissions() { - for (String perm : requiredPermissions) { - if (ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED) { - return false; - } - } - return true; - } - - /** - * Shows an AlertDialog if the user has denied permissions for the first time. - */ - private void showFirstDenialDialog() { - if (!activity.isFinishing()) { - new AlertDialog.Builder(activity) - .setTitle("Permissions Denied") - .setMessage("Certain permissions are essential for this app to function.\n" + - "Tap GRANT to try again or EXIT to close the app.") - .setCancelable(false) - .setPositiveButton("Grant", (dialog, which) -> checkAndRequestPermissions()) - .setNegativeButton("Exit", (dialog, which) -> activity.finish()) - .show(); - } - } - - /** - * Shows an AlertDialog if the user has permanently denied the permissions. - */ - private void showPermanentDenialDialog() { - if (!activity.isFinishing()) { - new AlertDialog.Builder(activity) - .setTitle("Permission Permanently Denied") - .setMessage("Some permissions have been permanently denied. " + - "Please go to Settings to enable them manually.") - .setCancelable(false) - .setPositiveButton("Settings", (dialog, which) -> { - Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - Uri uri = Uri.fromParts("package", activity.getPackageName(), null); - intent.setData(uri); - activity.startActivity(intent); - }) - .setNegativeButton("Exit", (dialog, which) -> activity.finish()) - .show(); - } - } - - /** - * Callback to notify the calling Activity when all permissions have been granted. - */ - public interface PermissionCallback { - void onAllPermissionsGranted(); - } -} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/DownloadClickListener.java b/app/src/main/java/com/openpositioning/PositionMe/viewitems/DownloadClickListener.java similarity index 57% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/DownloadClickListener.java rename to app/src/main/java/com/openpositioning/PositionMe/viewitems/DownloadClickListener.java index 202f4a43..1b01f2c5 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/DownloadClickListener.java +++ b/app/src/main/java/com/openpositioning/PositionMe/viewitems/DownloadClickListener.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.presentation.viewitems; +package com.openpositioning.PositionMe.viewitems; /** * Interface to enable listening for clicks in RecyclerViews. @@ -14,4 +14,11 @@ public interface DownloadClickListener { */ void onPositionClicked(int position); + /** + * Function executed when the replay button is clicked. + * + * @param position integer position of the item in the list. + */ + void onReplayClicked(int position); + } diff --git a/app/src/main/java/com/openpositioning/PositionMe/viewitems/SensorInfoListAdapter.java b/app/src/main/java/com/openpositioning/PositionMe/viewitems/SensorInfoListAdapter.java new file mode 100644 index 00000000..dfa674d2 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/viewitems/SensorInfoListAdapter.java @@ -0,0 +1,144 @@ +package com.openpositioning.PositionMe.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.SensorInfo; + +import java.util.List; +import java.util.Objects; + +/** + * Adapter used for displaying sensor info data. + * + * @see SensorInfoViewHolder corresponding View Holder class + * @see R.layout#item_sensorinfo_card_view xml layout file + * + * @author Mate Stodulka + */ +public class SensorInfoListAdapter extends RecyclerView.Adapter { + + Context context; + List sensorInfoList; + + /** + * 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 sensorInfoList list of SensorInfo objects to be displayed in the list. + * + * @see SensorInfo the data class. + */ + public SensorInfoListAdapter(Context context, List sensorInfoList) { + this.context = context; + this.sensorInfoList = sensorInfoList; + } + + /** + * {@inheritDoc} + * @see R.layout#item_sensorinfo_card_view xml layout file + */ + @NonNull + @Override + public SensorInfoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new SensorInfoViewHolder(LayoutInflater.from(context).inflate(R.layout.item_sensorinfo_card_view, parent, false)); + } + + /** + * {@inheritDoc} + * Formats and assigns the data fields from the SensorInfo object to the TextView fields. + * + * @see SensorInfo data class + * @see R.string formatting for strings. + * @see R.layout#item_sensorinfo_card_view xml layout file + */ + @Override + public void onBindViewHolder(@NonNull SensorInfoViewHolder holder, int position) { + String fullName = sensorInfoList.get(position).getName(); + String displayName = ""; + + // 定义传感器类型和它们的显示名称,以及可能的型号前缀 + String[][] sensorMappings = { + {"Accelerometer", "Acceleration Sensor"}, + {"Acceleration", "Acceleration Sensor"}, + {"Gyroscope", "Gyroscope Sensor"}, + {"Magnetic", "Magnetic Sensor"}, + {"Light", "Light Sensor"}, + {"Pressure", "Pressure Sensor"}, + {"Proximity", "Proximity Sensor"} + }; + + // 定义要移除的型号前缀 + String[] prefixesToRemove = { + "lsm6dso", + "LSM6DSO", + "ak0991x", + "AK0991X", + "Non-wakeup", + "Non-Wakeup" + }; + + // 移除所有已知的型号前缀 + String cleanName = fullName; + for (String prefix : prefixesToRemove) { + cleanName = cleanName.replace(prefix, "").trim(); + } + + // 遍历查找传感器类型 + for (String[] mapping : sensorMappings) { + if (fullName.toLowerCase().contains(mapping[0].toLowerCase())) { + displayName = mapping[1]; + break; + } + } + + // 如果没有找到匹配的类型,检查是否包含"Magnetic"或其他关键词 + if (displayName.isEmpty()) { + if (fullName.toLowerCase().contains("magnetic") || + fullName.toLowerCase().contains("mag") || + fullName.toLowerCase().contains("ak")) { + displayName = "Magnetic Sensor"; + } else if (fullName.toLowerCase().contains("accel")) { + displayName = "Acceleration Sensor"; + } else if (fullName.toLowerCase().contains("gyro")) { + displayName = "Gyroscope Sensor"; + } else { + // 如果还是没找到,使用清理后的名称 + displayName = cleanName.trim(); + if (displayName.isEmpty() || displayName.equals("Sensor")) { + displayName = "Unknown Sensor"; + } else if (!displayName.toLowerCase().contains("sensor")) { + displayName += " Sensor"; + } + } + } + + holder.name.setText(displayName); + + String vendorString = context.getString(R.string.vendor, sensorInfoList.get(position).getVendor()); + holder.vendor.setText(vendorString); + + String resolutionString = context.getString(R.string.resolution, String.format("%.03g", sensorInfoList.get(position).getResolution())); + holder.resolution.setText(resolutionString); + String powerString = context.getString(R.string.power, Objects.toString(sensorInfoList.get(position).getPower(), "N/A")); + holder.power.setText(powerString); + String versionString = context.getString(R.string.version, Objects.toString(sensorInfoList.get(position).getVersion(), "N/A")); + holder.version.setText(versionString); + } + + /** + * {@inheritDoc} + * Number of SensorInfo objects. + * + * @see SensorInfo + */ + @Override + public int getItemCount() { + return sensorInfoList.size(); + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/SensorInfoViewHolder.java b/app/src/main/java/com/openpositioning/PositionMe/viewitems/SensorInfoViewHolder.java similarity index 88% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/SensorInfoViewHolder.java rename to app/src/main/java/com/openpositioning/PositionMe/viewitems/SensorInfoViewHolder.java index 469ec16e..90d9e70a 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/SensorInfoViewHolder.java +++ b/app/src/main/java/com/openpositioning/PositionMe/viewitems/SensorInfoViewHolder.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.presentation.viewitems; +package com.openpositioning.PositionMe.viewitems; import android.view.View; import android.widget.TextView; @@ -12,7 +12,7 @@ * View holder class for the RecyclerView displaying SensorInfo data. * * @see SensorInfoListAdapter the corresponding list adapter. - * @see com.openpositioning.PositionMe.R.layout#item_sensorinfo_card_view xml layout file + * @see R.layout#item_sensorinfo_card_view xml layout file * * @author Mate Stodulka */ diff --git a/app/src/main/java/com/openpositioning/PositionMe/viewitems/TrajDownloadListAdapter.java b/app/src/main/java/com/openpositioning/PositionMe/viewitems/TrajDownloadListAdapter.java new file mode 100644 index 00000000..96ae1a7e --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/viewitems/TrajDownloadListAdapter.java @@ -0,0 +1,114 @@ +package com.openpositioning.PositionMe.viewitems; + +import android.content.Context; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.openpositioning.PositionMe.R; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.text.SimpleDateFormat; + +/** + * Adapter used for displaying Trajectory metadata in a RecyclerView list. + * + * @see TrajDownloadViewHolder the corresponding view holder. + * @see com.openpositioning.PositionMe.fragments.FilesFragment on how the data is generated + * @see com.openpositioning.PositionMe.ServerCommunications on where the response items are received. + * + * @author Mate Stodulka + */ +public class TrajDownloadListAdapter extends RecyclerView.Adapter{ + + // Date-time formatting object + private static final DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final Context context; + private final List> responseItems; + 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 responseItems List of Maps, where each map is a response item from the server. + * @param listener clickListener to download trajectories when clicked. + * + * @see com.openpositioning.PositionMe.Traj protobuf objects exchanged with the server. + */ + public TrajDownloadListAdapter(Context context, List> responseItems, DownloadClickListener listener) { + this.context = context; + this.responseItems = responseItems; + this.listener = listener; + } + + /** + * {@inheritDoc} + * + * @see R.layout#item_trajectorycard_view xml layout file + */ + @NonNull + @Override + public TrajDownloadViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new TrajDownloadViewHolder(LayoutInflater.from(context).inflate(R.layout.item_trajectorycard_view, parent, false), listener); + } + + /** + * {@inheritDoc} + * Formats and assigns the data fields from the Trajectory metadata object to the TextView fields. + * + * @see com.openpositioning.PositionMe.fragments.FilesFragment generating the data from server response. + * @see R.layout#item_sensorinfo_card_view xml layout file. + */ + @Override + public void onBindViewHolder(@NonNull TrajDownloadViewHolder holder, int position) { + String id = responseItems.get(position).get("id"); + holder.trajId.setText(id); + if(id.length() > 2) holder.trajId.setTextSize(28); + else holder.trajId.setTextSize(32); + + try { + String utcDateStr = responseItems.get(position).get("date_submitted"); + + // 解析UTC时间字符串 + SimpleDateFormat utcFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX"); + utcFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); + java.util.Date utcDate = utcFormat.parse(utcDateStr); + + // 转换为本地时间 + SimpleDateFormat displayFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + displayFormat.setTimeZone(java.util.TimeZone.getDefault()); + String localDateStr = displayFormat.format(utcDate); + + holder.trajDate.setText(localDateStr); + } catch (Exception e) { + Log.e("TrajDownloadListAdapter", "Error converting timezone", e); + // 如果解析失败,使用原始格式显示 + holder.trajDate.setText( + dateFormat.format( + LocalDateTime.parse( + responseItems.get(position) + .get("date_submitted").split("\\.")[0] + ) + ) + ); + } + } + + /** + * {@inheritDoc} + * Number of response maps. + */ + @Override + public int getItemCount() { + return responseItems.size(); + } +} + diff --git a/app/src/main/java/com/openpositioning/PositionMe/viewitems/TrajDownloadViewHolder.java b/app/src/main/java/com/openpositioning/PositionMe/viewitems/TrajDownloadViewHolder.java new file mode 100644 index 00000000..64efa725 --- /dev/null +++ b/app/src/main/java/com/openpositioning/PositionMe/viewitems/TrajDownloadViewHolder.java @@ -0,0 +1,72 @@ +package com.openpositioning.PositionMe.viewitems; + +import android.view.View; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.openpositioning.PositionMe.R; + +import java.lang.ref.WeakReference; + +/** + * View holder class for the RecyclerView displaying Trajectory download data. + * + * @see TrajDownloadListAdapter the corresponding list adapter. + * @see R.layout#item_trajectorycard_view xml layout file + * + * @author Mate Stodulka + */ +public class TrajDownloadViewHolder extends RecyclerView.ViewHolder { + + public TextView trajId; + public TextView trajDate; + public ImageButton downloadButton; + public Button replayButton; + // Weak reference to the click listener to enable garbage collection on recyclerview items + private WeakReference listenerReference; + + /** + * {@inheritDoc} + * Assign TextView fields corresponding to Trajectory metadata. + * + * @param listener DownloadClickListener to enable acting on clicks on items. + * + * @see com.openpositioning.PositionMe.fragments.FilesFragment generating the data and implementing the + * listener. + */ + public TrajDownloadViewHolder(@NonNull View itemView, final DownloadClickListener listener) { + super(itemView); + this.listenerReference = new WeakReference<>(listener); + this.trajId = itemView.findViewById(R.id.trajectoryIdItem); + this.trajDate = itemView.findViewById(R.id.trajectoryDateItem); + this.downloadButton = itemView.findViewById(R.id.downloadTrajectoryButton); + this.replayButton = itemView.findViewById(R.id.replayTrajectoryButton); + + // 设置点击监听器 + downloadButton.setOnClickListener(v -> { + if (listener != null) { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onPositionClicked(position); + } + } + }); + + replayButton.setOnClickListener(v -> { + if (listener != null) { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onReplayClicked(position); + } + } + }); + + // 初始状态:显示下载按钮,隐藏播放按钮 + downloadButton.setVisibility(View.VISIBLE); + replayButton.setVisibility(View.GONE); + } +} diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadListAdapter.java b/app/src/main/java/com/openpositioning/PositionMe/viewitems/UploadListAdapter.java similarity index 87% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadListAdapter.java rename to app/src/main/java/com/openpositioning/PositionMe/viewitems/UploadListAdapter.java index b564e231..53a36fd7 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadListAdapter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/viewitems/UploadListAdapter.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.presentation.viewitems; +package com.openpositioning.PositionMe.viewitems; import android.content.Context; import android.view.LayoutInflater; @@ -9,7 +9,6 @@ import androidx.recyclerview.widget.RecyclerView; import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.presentation.fragment.UploadFragment; import java.io.File; import java.util.List; @@ -20,7 +19,7 @@ * 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 + * @see R.layout#item_upload_card_view xml layout file * * @author Mate Stodulka */ @@ -48,7 +47,7 @@ public UploadListAdapter(Context context, List uploadItems, DownloadClickL /** * {@inheritDoc} * - * @see com.openpositioning.PositionMe.R.layout#item_upload_card_view xml layout file + * @see R.layout#item_upload_card_view xml layout file */ @NonNull @Override @@ -60,8 +59,8 @@ 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. + * @see com.openpositioning.PositionMe.fragments.UploadFragment finding the data from on local storage. + * @see R.layout#item_upload_card_view xml layout file. */ @Override public void onBindViewHolder(@NonNull UploadViewHolder holder, int position) { diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadViewHolder.java b/app/src/main/java/com/openpositioning/PositionMe/viewitems/UploadViewHolder.java similarity index 80% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadViewHolder.java rename to app/src/main/java/com/openpositioning/PositionMe/viewitems/UploadViewHolder.java index e6068969..d5a62d7e 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/UploadViewHolder.java +++ b/app/src/main/java/com/openpositioning/PositionMe/viewitems/UploadViewHolder.java @@ -1,15 +1,14 @@ -package com.openpositioning.PositionMe.presentation.viewitems; +package com.openpositioning.PositionMe.viewitems; import android.view.View; import android.widget.Button; +import android.widget.ImageButton; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.material.button.MaterialButton; import com.openpositioning.PositionMe.R; -import com.openpositioning.PositionMe.presentation.fragment.UploadFragment; import java.lang.ref.WeakReference; @@ -17,7 +16,7 @@ * View holder class for the RecyclerView displaying Trajectory files to be uploaded. * * @see UploadListAdapter the corresponding list adapter. - * @see com.openpositioning.PositionMe.R.layout#item_upload_card_view xml layout file + * @see R.layout#item_upload_card_view xml layout file * * @author Mate Stodulka */ @@ -25,7 +24,7 @@ public class UploadViewHolder extends RecyclerView.ViewHolder implements View.On TextView trajId; TextView trajDate; - MaterialButton uploadButton; // Correct reference to MaterialButton + ImageButton uploadButton; // Weak reference to the click listener to enable garbage collection on recyclerview items private WeakReference listenerReference; public Button deletebutton; @@ -36,7 +35,7 @@ public class UploadViewHolder extends RecyclerView.ViewHolder implements View.On * * @param listener DownloadClickListener to enable acting on clicks on items. * - * @see UploadFragment locating the data and implementing the + * @see com.openpositioning.PositionMe.fragments.UploadFragment locating the data and implementing the * listener. */ public UploadViewHolder(@NonNull View itemView, DownloadClickListener listener) { diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiListAdapter.java b/app/src/main/java/com/openpositioning/PositionMe/viewitems/WifiListAdapter.java similarity index 83% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiListAdapter.java rename to app/src/main/java/com/openpositioning/PositionMe/viewitems/WifiListAdapter.java index 887e7689..ec6b2bcf 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiListAdapter.java +++ b/app/src/main/java/com/openpositioning/PositionMe/viewitems/WifiListAdapter.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.presentation.viewitems; +package com.openpositioning.PositionMe.viewitems; import android.content.Context; import android.view.LayoutInflater; @@ -16,7 +16,7 @@ * Adapter used for displaying wifi network data. * * @see WifiViewHolder corresponding View Holder class - * @see com.openpositioning.PositionMe.R.layout#item_wifi_card_view xml layout file + * @see R.layout#item_wifi_card_view xml layout file * * @author Mate Stodulka */ @@ -40,7 +40,7 @@ public WifiListAdapter(Context context, List items) { /** * {@inheritDoc} - * @see com.openpositioning.PositionMe.R.layout#item_wifi_card_view xml layout file + * @see R.layout#item_wifi_card_view xml layout file */ @NonNull @Override @@ -53,8 +53,8 @@ public WifiViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType * Formats and assigns the data fields from the Wifi object to the TextView fields. * * @see Wifi data class - * @see com.openpositioning.PositionMe.R.string formatting for strings. - * @see com.openpositioning.PositionMe.R.layout#item_wifi_card_view xml layout file + * @see R.string formatting for strings. + * @see R.layout#item_wifi_card_view xml layout file */ @Override public void onBindViewHolder(@NonNull WifiViewHolder holder, int position) { diff --git a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiViewHolder.java b/app/src/main/java/com/openpositioning/PositionMe/viewitems/WifiViewHolder.java similarity index 85% rename from app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiViewHolder.java rename to app/src/main/java/com/openpositioning/PositionMe/viewitems/WifiViewHolder.java index 96c563cf..12584d76 100644 --- a/app/src/main/java/com/openpositioning/PositionMe/presentation/viewitems/WifiViewHolder.java +++ b/app/src/main/java/com/openpositioning/PositionMe/viewitems/WifiViewHolder.java @@ -1,4 +1,4 @@ -package com.openpositioning.PositionMe.presentation.viewitems; +package com.openpositioning.PositionMe.viewitems; import android.view.View; import android.widget.TextView; @@ -12,7 +12,7 @@ * View holder class for the RecyclerView displaying Wifi data. * * @see WifiListAdapter the corresponding list adapter. - * @see com.openpositioning.PositionMe.R.layout#item_wifi_card_view xml layout file + * @see R.layout#item_wifi_card_view xml layout file * * @author Mate Stodulka */ diff --git a/app/src/main/res/drawable/square_button.xml b/app/src/main/res/drawable/square_button.xml new file mode 100644 index 00000000..a4105055 --- /dev/null +++ b/app/src/main/res/drawable/square_button.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/unified_button_style.xml b/app/src/main/res/drawable/unified_button_style.xml new file mode 100644 index 00000000..4cf2a32a --- /dev/null +++ b/app/src/main/res/drawable/unified_button_style.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-small/fragment_home.xml b/app/src/main/res/layout-small/fragment_home.xml deleted file mode 100644 index bd713b67..00000000 --- a/app/src/main/res/layout-small/fragment_home.xml +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0a3ceda2..8e35b136 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,40 +1,31 @@ - + tools:context=".MainActivity"> - - - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:subtitleTextColor="#FFFFFF" /> - + app:navGraph="@navigation/main_nav" /> + - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_recording.xml b/app/src/main/res/layout/activity_recording.xml deleted file mode 100644 index ab831730..00000000 --- a/app/src/main/res/layout/activity_recording.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_replay.xml b/app/src/main/res/layout/activity_replay.xml deleted file mode 100644 index 130c5dd8..00000000 --- a/app/src/main/res/layout/activity_replay.xml +++ /dev/null @@ -1,7 +0,0 @@ - - diff --git a/app/src/main/res/layout/activity_tracking.xml b/app/src/main/res/layout/activity_tracking.xml new file mode 100644 index 00000000..5b138972 --- /dev/null +++ b/app/src/main/res/layout/activity_tracking.xml @@ -0,0 +1,35 @@ + + + + + + + +