Skip to content

Commit d3cd967

Browse files
jakevisRCGV1Copilotjamesarich
authored
Port “Compass view” bottom sheet from Meshtastic-Apple PR #1504 (#3896)
Signed-off-by: Jake Vis <[email protected]> Signed-off-by: James Rich <[email protected]> Co-authored-by: Benjamin Faershtein <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: James Rich <[email protected]>
1 parent 1a78745 commit d3cd967

File tree

10 files changed

+1127
-8
lines changed

10 files changed

+1127
-8
lines changed

core/strings/src/commonMain/composeResources/values/strings.xml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,6 @@
10161016
<string name="firmware_update_method_detail">Update via %1$s</string>
10171017
<string name="firmware_update_usb_instruction_title">Select DFU USB Drive</string>
10181018
<string name="firmware_update_usb_instruction_text">Your device has rebooted into DFU mode and should appear as a USB drive (e.g., RAK4631).\n\nWhen the file picker opens, please select the root of that drive to save the firmware file.</string>
1019-
10201019
<string name="interval_unset">Unset</string>
10211020
<string name="interval_always_on">Always On</string>
10221021
<plurals name="plurals_seconds">
@@ -1031,4 +1030,17 @@
10311030
<item quantity="one">1 hour</item>
10321031
<item quantity="other">%1$d hours</item>
10331032
</plurals>
1033+
1034+
<!-- Compass -->
1035+
<string name="compass_title">Compass</string>
1036+
<string name="open_compass">Open Compass</string>
1037+
<string name="compass_distance">Distance: %1$s</string>
1038+
<string name="compass_bearing">Bearing: %1$s</string>
1039+
<string name="compass_bearing_na">Bearing: N/A</string>
1040+
<string name="compass_no_magnetometer">This device does not have a compass sensor. Heading is unavailable.</string>
1041+
<string name="compass_no_location_permission">Location permission is required to show distance and bearing.</string>
1042+
<string name="compass_location_disabled">Location provider is disabled. Turn on location services.</string>
1043+
<string name="compass_no_location_fix">Waiting for a GPS fix to calculate distance and bearing.</string>
1044+
<string name="compass_uncertainty">Estimated area: \u00b1%1$s (\u00b1%2$s)</string>
1045+
<string name="compass_uncertainty_unknown">Estimated area: unknown accuracy</string>
10341046
</resources>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright (c) 2025 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package org.meshtastic.feature.node.compass
19+
20+
import android.content.Context
21+
import android.hardware.Sensor
22+
import android.hardware.SensorEvent
23+
import android.hardware.SensorEventListener
24+
import android.hardware.SensorManager
25+
import dagger.hilt.android.qualifiers.ApplicationContext
26+
import kotlinx.coroutines.channels.awaitClose
27+
import kotlinx.coroutines.flow.Flow
28+
import kotlinx.coroutines.flow.callbackFlow
29+
import javax.inject.Inject
30+
31+
private const val ROTATION_MATRIX_SIZE = 9
32+
private const val ORIENTATION_SIZE = 3
33+
private const val FULL_CIRCLE_DEGREES = 360f
34+
35+
data class HeadingState(
36+
val heading: Float? = null, // 0..360 degrees
37+
val hasSensor: Boolean = true,
38+
val accuracy: Int = SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM,
39+
)
40+
41+
class CompassHeadingProvider @Inject constructor(@ApplicationContext private val context: Context) {
42+
43+
/**
44+
* Emits compass heading in degrees (magnetic). Callers can correct for true north using the latest location data
45+
* when available.
46+
*/
47+
fun headingUpdates(): Flow<HeadingState> = callbackFlow {
48+
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
49+
if (sensorManager == null) {
50+
trySend(HeadingState(hasSensor = false))
51+
close()
52+
return@callbackFlow
53+
}
54+
55+
val rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
56+
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
57+
val magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
58+
59+
if (rotationSensor == null && (accelerometer == null || magnetometer == null)) {
60+
trySend(HeadingState(hasSensor = false))
61+
awaitClose {}
62+
return@callbackFlow
63+
}
64+
65+
val rotationMatrix = FloatArray(ROTATION_MATRIX_SIZE)
66+
val orientation = FloatArray(ORIENTATION_SIZE)
67+
val accelValues = FloatArray(ORIENTATION_SIZE)
68+
val magnetValues = FloatArray(ORIENTATION_SIZE)
69+
var hasAccel = false
70+
var hasMagnet = false
71+
72+
val listener =
73+
object : SensorEventListener {
74+
override fun onSensorChanged(event: SensorEvent) {
75+
when (event.sensor.type) {
76+
Sensor.TYPE_ROTATION_VECTOR -> {
77+
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
78+
}
79+
Sensor.TYPE_ACCELEROMETER -> {
80+
System.arraycopy(event.values, 0, accelValues, 0, accelValues.size)
81+
hasAccel = true
82+
}
83+
Sensor.TYPE_MAGNETIC_FIELD -> {
84+
System.arraycopy(event.values, 0, magnetValues, 0, magnetValues.size)
85+
hasMagnet = true
86+
}
87+
}
88+
89+
val ready = rotationSensor != null || (hasAccel && hasMagnet)
90+
if (ready) {
91+
if (rotationSensor == null) {
92+
SensorManager.getRotationMatrix(rotationMatrix, null, accelValues, magnetValues)
93+
}
94+
95+
SensorManager.getOrientation(rotationMatrix, orientation)
96+
var azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
97+
val heading = (azimuth + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
98+
99+
trySend(HeadingState(heading = heading, hasSensor = true, accuracy = event.accuracy))
100+
}
101+
}
102+
103+
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
104+
// No-op
105+
}
106+
}
107+
108+
rotationSensor?.let { sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_UI) }
109+
if (rotationSensor == null) {
110+
accelerometer?.let { sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_UI) }
111+
magnetometer?.let { sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_UI) }
112+
}
113+
114+
awaitClose { sensorManager.unregisterListener(listener) }
115+
}
116+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (c) 2025 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package org.meshtastic.feature.node.compass
19+
20+
import androidx.compose.ui.graphics.Color
21+
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
22+
23+
private const val DEFAULT_TARGET_COLOR_HEX = 0xFFFF9800
24+
25+
enum class CompassWarning {
26+
NO_MAGNETOMETER,
27+
NO_LOCATION_PERMISSION,
28+
LOCATION_DISABLED,
29+
NO_LOCATION_FIX,
30+
}
31+
32+
/** Render-ready state for the compass sheet (heading, bearing, distances, and warnings). */
33+
data class CompassUiState(
34+
val targetName: String = "",
35+
val targetColor: Color = Color(DEFAULT_TARGET_COLOR_HEX),
36+
val heading: Float? = null,
37+
val bearing: Float? = null,
38+
val distanceText: String? = null,
39+
val bearingText: String? = null,
40+
val lastUpdateText: String? = null,
41+
val positionTimeSec: Long? = null, // Epoch seconds for the target position (used for elapsed display)
42+
val warnings: List<CompassWarning> = emptyList(),
43+
val errorRadiusText: String? = null,
44+
val angularErrorDeg: Float? = null,
45+
val isAligned: Boolean = false,
46+
val hasTargetPosition: Boolean = true,
47+
val displayUnits: DisplayUnits = DisplayUnits.METRIC,
48+
)

0 commit comments

Comments
 (0)