Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0d67f15
refactor(map): move the node map filter rules to core:model
jamesarich Oct 17, 2026
063cc61
refactor(core): move the phone location and magnetic field providers …
jamesarich Oct 17, 2026
0d17dc5
feat(map): AR mesh view
jamesarich Oct 17, 2026
548c468
fix(map): mesh view opened for a purpose shows only that
jamesarich Oct 17, 2026
d03d532
fix(map): mesh view draws routes and tracks on the radar
jamesarich Oct 17, 2026
51dc453
refactor(map): mesh view drops drag-to-align and takes true-north poses
jamesarich Oct 19, 2026
6b9db47
feat(map): mesh view entry points use the center_focus_strong icon
jamesarich Oct 19, 2026
a61562b
feat(map): mesh view pose carries the phone's thermal state
jamesarich Oct 19, 2026
e8d55a7
feat(node): node list menu opens the mesh view, and the mesh view nev…
jamesarich Oct 19, 2026
bc059d5
feat(map): mesh view takes the fused orientation provider on google, …
jamesarich Oct 19, 2026
a2186f3
feat(map): mesh view coaching, recovery states, contrast, TalkBack an…
jamesarich Oct 19, 2026
74062e2
feat(node): node list menu and node detail route open the compass
jamesarich Oct 19, 2026
3d6652b
test(map): mesh view scoping, edge pinning, clock positions and fix a…
jamesarich Oct 19, 2026
e7ba2d9
style(map): mesh view lint and formatting
jamesarich Oct 19, 2026
bb508de
fix(map): mesh view keeps 0 coordinates and the user's focus across r…
jamesarich Oct 20, 2026
be61483
docs(map): describe the AR mesh view and the node list's new menu ent…
jamesarich Oct 20, 2026
3668da9
fix(map): radar waypoints open their info; SNR tag through NumberForm…
jamesarich Oct 20, 2026
72e0003
fix(map): mesh view speaks an unnamed node by its id
jamesarich Oct 20, 2026
1939c79
fix(node): compass estimated area shows one ±
jamesarich Oct 20, 2026
bd6f994
fix(map): mesh view places a traceroute's hops where it recorded them
jamesarich Oct 20, 2026
6cb0028
fix(map): mesh view draws a traceroute's legs to the connected radio …
jamesarich Oct 20, 2026
234ffa6
feat(map): mesh view tags the legs to the connected radio with their SNR
jamesarich Oct 20, 2026
162769f
fix(map): mesh view stem tags sit at different heights per leg
jamesarich Oct 20, 2026
9f286a9
fix(map): mesh view thins the pose itself when the phone runs hot
jamesarich Oct 20, 2026
3a0c1b3
fix(map): mesh view shows a short age on the focused chip only
jamesarich Oct 20, 2026
96ad723
feat(map): mesh view coaching carries Chirpy's battery and heat warning
jamesarich Oct 20, 2026
90bbcb6
feat(map): mesh view chips lead to their point with a callout, and th…
jamesarich Oct 20, 2026
54964eb
fix(map): mesh view chips step aside toward the inside of the screen
jamesarich Oct 20, 2026
6f73dcf
refactor(map): mesh view drops its range limit and leaves distance to…
jamesarich Oct 20, 2026
268d485
feat(map): mesh view tightens its top bar and lightens the compass tape
jamesarich Oct 20, 2026
5ccad53
fix(map): mesh view leaves room for the arrows of chips stacked on th…
jamesarich Oct 20, 2026
8b6a9a9
fix(map): mesh view counts the connected radio as mapped and keeps Ch…
jamesarich Oct 20, 2026
f1a92d5
fix(map): mesh view review follow-ups: stale range docs, dead code, w…
jamesarich Oct 20, 2026
eeb9da4
fix(map): radar dots keep TalkBack's click action, and the tracked-no…
jamesarich Oct 20, 2026
6945de3
style: mesh view detekt fixes after the rebase
jamesarich Oct 21, 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
46 changes: 46 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 @@ -105,6 +105,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 @@ -299,14 +300,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: favorited, 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
Loading