Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4c597f5
refactor(map): move the node map filter rules to core:model
jamesarich Oct 17, 2026
a4c06e0
refactor(core): move the phone location and magnetic field providers …
jamesarich Oct 17, 2026
1fc0189
feat(map): AR mesh view
jamesarich Oct 17, 2026
3b2110f
fix(map): mesh view opened for a purpose shows only that
jamesarich Oct 17, 2026
670b6bb
fix(map): mesh view draws routes and tracks on the radar
jamesarich Oct 17, 2026
2fdbe94
refactor(map): mesh view drops drag-to-align and takes true-north poses
jamesarich Oct 19, 2026
95e4d50
feat(map): mesh view entry points use the center_focus_strong icon
jamesarich Oct 19, 2026
f537624
feat(map): mesh view pose carries the phone's thermal state
jamesarich Oct 19, 2026
97d619c
feat(node): node list menu opens the mesh view, and the mesh view nev…
jamesarich Oct 19, 2026
1214cf0
feat(map): mesh view takes the fused orientation provider on google, …
jamesarich Oct 19, 2026
83bcaf0
feat(map): mesh view coaching, recovery states, contrast, TalkBack an…
jamesarich Oct 19, 2026
0697065
feat(node): node list menu and node detail route open the compass
jamesarich Oct 19, 2026
d7ff965
test(map): mesh view scoping, edge pinning, clock positions and fix a…
jamesarich Oct 19, 2026
3e9549a
style(map): mesh view lint and formatting
jamesarich Oct 19, 2026
68b1a9b
fix(map): mesh view keeps 0 coordinates and the user's focus across r…
jamesarich Oct 20, 2026
91ce3ed
docs(map): describe the AR mesh view and the node list's new menu ent…
jamesarich Oct 20, 2026
0cda7ea
fix(map): radar waypoints open their info; SNR tag through NumberForm…
jamesarich Oct 20, 2026
468a71c
fix(map): mesh view speaks an unnamed node by its id
jamesarich Oct 20, 2026
0b4f201
fix(node): compass estimated area shows one ±
jamesarich Oct 20, 2026
c9cd291
fix(map): mesh view places a traceroute's hops where it recorded them
jamesarich Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.GOOGLE_SERVICES_AVAILABLE
import org.meshtastic.core.ui.theme.EventFontResolver
import org.meshtastic.feature.map.hud.FusedOrientationSource

@Module(includes = [FdroidAiModule::class])
class FlavorModule {
Expand All @@ -32,6 +33,9 @@ class FlavorModule {
/** No Play Services font provider on F-Droid — event fonts stay off; UI falls back to the app typeface. */
@Single fun eventFontResolver(): EventFontResolver = EventFontResolver { null }

/** F-Droid has no Play services orientation fusion, so the camera mesh view uses the platform rotation vector. */
@Single fun fusedOrientationSource(): FusedOrientationSource = FusedOrientationSource.NONE

/** No analytics on F-Droid — the shared Ktor `HttpClient` gets no Datadog interceptor or event listener. */
@Single fun okHttpNetworkInstrumentation(): OkHttpNetworkInstrumentation = OkHttpNetworkInstrumentation.NONE
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,27 @@

package org.meshtastic.app.di

import android.content.Context
import com.datadog.android.okhttp.DatadogEventListener
import com.datadog.android.okhttp.DatadogInterceptor
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.app.map.hud.GoogleFusedOrientationSource
import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
import org.meshtastic.app.theme.GoogleFontsEventFontResolver
import org.meshtastic.core.ui.theme.EventFontResolver
import org.meshtastic.feature.map.hud.FusedOrientationSource

@Module(includes = [GoogleMapsKoinModule::class, GoogleAiModule::class, AppFunctionsModule::class])
class FlavorModule {
/** Downloadable Google Fonts for event branding — Google flavor only. */
@Single fun eventFontResolver(): EventFontResolver = GoogleFontsEventFontResolver()

/**
* Play services' fused orientation for the camera mesh view, which falls back to the rotation vector without it.
*/
@Single fun fusedOrientationSource(context: Context): FusedOrientationSource = GoogleFusedOrientationSource(context)

/**
* Datadog network instrumentation for the shared Ktor `HttpClient`. The interceptor emits RUM Resource spans with
* full DNS/connect/SSL/download timing for first-party (`meshtastic.org`) requests, and the event listener supplies
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.hud

import android.content.Context
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailabilityLight
import com.google.android.gms.location.DeviceOrientationListener
import com.google.android.gms.location.DeviceOrientationRequest
import com.google.android.gms.location.LocationServices
import org.meshtastic.feature.map.hud.FusedOrientationSource
import java.util.concurrent.Executor

/** The platform's SENSOR_DELAY_UI period, which the rotation-vector path drops to when the phone runs hot. */
private const val REDUCED_PERIOD_MICROS = 66_667L

/** Play services' Fused Orientation Provider, the same fusion Google Maps draws its heading from. */
class GoogleFusedOrientationSource(private val context: Context) : FusedOrientationSource {

override fun start(
reduced: Boolean,
executor: Executor,
listener: FusedOrientationSource.Listener,
onFailure: () -> Unit,
): AutoCloseable? {
val availability = GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context)
if (availability != ConnectionResult.SUCCESS) return null
val client = LocationServices.getFusedOrientationProviderClient(context)
val callback = DeviceOrientationListener { orientation ->
val headingError =
if (orientation.hasConservativeHeadingErrorDegrees()) {
orientation.conservativeHeadingErrorDegrees
} else {
orientation.headingErrorDegrees
}
// FOP has no per-sample flag: it applies the declination whenever Play services knows the location,
// which it does whenever the view has the fix it needs to place anything.
listener.onSample(
attitude = orientation.attitude,
headingErrorDeg = headingError,
trueNorth = true,
timestampNs = orientation.elapsedRealtimeNs,
)
}
val period = if (reduced) REDUCED_PERIOD_MICROS else DeviceOrientationRequest.OUTPUT_PERIOD_DEFAULT
client
.requestOrientationUpdates(DeviceOrientationRequest.Builder(period).build(), executor, callback)
.addOnFailureListener(executor) { onFailure() }
return AutoCloseable { client.removeOrientationUpdates(callback) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.accentColorOrNull
import org.meshtastic.core.ui.util.brandHighlightOrNull
import org.meshtastic.core.ui.util.brandPalette
import org.meshtastic.core.ui.util.rememberMeshHudSupported
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
Expand Down Expand Up @@ -296,14 +297,15 @@ class MainActivity : AppCompatActivity() {
org.meshtastic.app.map.discovery.DiscoveryMap(userLat, userLon, nodes, modifier)
},
LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum ->
{ onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum, onOpenMeshHud ->
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
sitePlannerNodeNum = sitePlannerNodeNum,
onOpenMeshHud = onOpenMeshHud.takeIf { rememberMeshHudSupported() },
)
},
content = content,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class NavigationAssemblyTest {
val backStack = rememberNavBackStack(route)
CompositionLocalProvider(
LocalMapMainScreenProvider provides
{ _, _, waypointId, sitePlannerNodeNum ->
{ _, _, waypointId, sitePlannerNodeNum, _ ->
SideEffect {
receivedWaypointId = waypointId
receivedSitePlannerNodeNum = sitePlannerNodeNum
Expand Down Expand Up @@ -113,7 +113,7 @@ class NavigationAssemblyTest {
backStack = rememberNavBackStack(route)
CompositionLocalProvider(
LocalMapMainScreenProvider provides
{ _, _, _, sitePlannerNodeNum ->
{ _, _, _, sitePlannerNodeNum, _ ->
LaunchedEffect(sitePlannerNodeNum) { effectStarts += 1 }
DisposableEffect(Unit) { onDispose { disposals += 1 } }
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.compass
package org.meshtastic.core.data.location

import android.hardware.GeomagneticField
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.MagneticFieldProvider

@Single
class AndroidMagneticFieldProvider : MagneticFieldProvider {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,30 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.compass
package org.meshtastic.core.data.location

import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.Looper
import androidx.core.location.LocationCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import androidx.core.location.altitude.AltitudeConverterCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.PhoneLocation
import org.meshtastic.core.repository.PhoneLocationProvider
import org.meshtastic.core.repository.PhoneLocationState

@Single
class AndroidPhoneLocationProvider(private val context: Context, private val dispatchers: CoroutineDispatchers) :
Expand Down Expand Up @@ -121,10 +128,46 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis

awaitClose { LocationManagerCompat.removeUpdates(locationManager, listener) }
}
.map { it.withMslAltitude() }
.flowOn(dispatchers.io)

private fun Location.toPhoneLocation() =
PhoneLocation(latitude = latitude, longitude = longitude, altitude = altitude, timeMillis = time)
/**
* Fills the sea-level height a provider left out, from the geoid model, on every API level through the compat
* converter (as `LocationRepositoryImpl` does). It loads model data on first use and may block, which is why it
* runs here, upstream of the IO dispatcher, rather than in the callback.
*/
@Suppress("ReturnCount")
private fun PhoneLocationState.withMslAltitude(): PhoneLocationState {
val fix = location ?: return this
val ellipsoid = fix.altitude
if (fix.mslAltitude != null || ellipsoid == null) return this
val probe =
Location("mesh-view").apply {
latitude = fix.latitude
longitude = fix.longitude
altitude = ellipsoid
}
@Suppress("TooGenericExceptionCaught")
return try {
AltitudeConverterCompat.addMslAltitudeToLocation(context, probe)
val msl = if (LocationCompat.hasMslAltitude(probe)) LocationCompat.getMslAltitudeMeters(probe) else null
copy(location = fix.copy(mslAltitude = msl))
} catch (e: Exception) {
Logger.w(e) { "MSL altitude conversion failed" }
this
}
}

private fun Location.toPhoneLocation() = PhoneLocation(
latitude = latitude,
longitude = longitude,
altitude = if (hasAltitude()) altitude else null,
timeMillis = time,
mslAltitude = if (LocationCompat.hasMslAltitude(this)) LocationCompat.getMslAltitudeMeters(this) else null,
accuracyM = if (hasAccuracy()) accuracy else null,
verticalAccuracyM = if (hasVerticalAccuracy()) verticalAccuracyMeters else null,
speedMps = if (hasSpeed()) speed else null,
)

companion object {
private const val MIN_UPDATE_INTERVAL_MS = 1_000L
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model

import org.meshtastic.core.model.util.ONLINE_WINDOW_SECONDS
import org.meshtastic.proto.Config

/**
* The node-level map filters, shared by every view that draws nodes over a place: both map renderers and the camera
* mesh view. [lastHeardSeconds] of 0 means no limit.
*/
data class NodeMapFilter(
val onlyFavorites: Boolean = false,
val showIgnored: Boolean = false,
/** Roles the user has switched off. Excluded rather than included so a role added by future firmware shows. */
val excludedRoles: Set<Config.DeviceConfig.Role> = emptySet(),
val includeUnknown: Boolean = true,
val onlyOnline: Boolean = false,
val onlyDirect: Boolean = false,
val excludeMqtt: Boolean = false,
val lastHeardSeconds: Long = 0,
val onlySigned: Boolean = false,
val onlyEncrypted: Boolean = false,
)

/**
* One node against every filter.
*
* The node-level rules match the node list's query exactly, because they carry its labels: direct means `hopsAway == 0`
* (a node whose distance was never measured, -1, is not direct) and unknown means a node that has not sent a short
* name.
*
* [NodeMapFilter.showIgnored] is the one place the map deliberately differs. The node list segregates (`isIgnored ==
* showIgnored`, so switching it on shows *only* ignored nodes); here it reads literally and adds them, because a map of
* nothing but ignored nodes is not a view anyone asked for.
*/
fun Node.passesMapFilter(filter: NodeMapFilter, nowSeconds: Long): Boolean {
val secondsSinceHeard = nowSeconds - lastHeard
return passesIdentityFilters(filter) &&
passesSecurityFilters(filter) &&
passesReachabilityFilters(filter, secondsSinceHeard)
}

/**
* Resolves persisted role names back to roles, dropping any the current protobufs no longer define.
*
* Names rather than ordinals, and forgiving of names it does not know: a set written by a newer build (or a role
* removed from the protos) must not throw on the way back in.
*/
fun decodeExcludedRoles(names: Set<String>): Set<Config.DeviceConfig.Role> =
names.mapNotNullTo(mutableSetOf()) { name -> Config.DeviceConfig.Role.entries.firstOrNull { it.name == name } }

/** Who the node is: favourited, ignored, its role, and whether its name has arrived yet. */
private fun Node.passesIdentityFilters(filter: NodeMapFilter): Boolean = (!filter.onlyFavorites || isFavorite) &&
(!isIgnored || filter.showIgnored) &&
user.role !in filter.excludedRoles &&
(filter.includeUnknown || user.short_name.isNotEmpty())

/** How the node is reachable: online, hop count, bearer, and how recently it was heard. */
private fun Node.passesReachabilityFilters(filter: NodeMapFilter, secondsSinceHeard: Long): Boolean =
(!filter.onlyOnline || secondsSinceHeard <= ONLINE_WINDOW_SECONDS) &&
(!filter.onlyDirect || (hopsAway == 0 && !viaMqtt)) &&
(!filter.excludeMqtt || !viaMqtt) &&
(filter.lastHeardSeconds == 0L || secondsSinceHeard <= filter.lastHeardSeconds)

/**
* The two security filters (design#149).
*
* Signing reads the raw flag rather than the version-gated indicator: the filter answers "has the radio verified this
* node's signature", which is what the badge beside it claims. Encrypted excludes a stored key that stopped matching,
* since a mismatch is not a key you can safely encrypt to.
*/
private fun Node.passesSecurityFilters(filter: NodeMapFilter): Boolean =
(!filter.onlySigned || signsPackets) && (!filter.onlyEncrypted || (hasPKC && !mismatchKey))
Loading