Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 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.

40 changes: 21 additions & 19 deletions androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ import org.meshtastic.feature.coverage.rememberCoverageEstimate
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.MapBounds
import org.meshtastic.feature.map.MapNodePolicy
import org.meshtastic.feature.map.TRACK_FADE_LEVELS
import org.meshtastic.feature.map.TRACK_STOP_RADIUS_METERS
import org.meshtastic.feature.map.component.ClusterMemberEntry
import org.meshtastic.feature.map.component.ClusterMembersDialog
Expand Down Expand Up @@ -227,6 +228,10 @@ import org.meshtastic.feature.map.mergeStationaryRuns
import org.meshtastic.feature.map.terrain.MapterhornEndpoints
import org.meshtastic.feature.map.tiles.mapAttributionText
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.feature.map.trackFadeAlpha
import org.meshtastic.feature.map.trackFadeLevel
import org.meshtastic.feature.map.trackFadeLevelAlpha
import org.meshtastic.feature.map.trackRecency
import org.meshtastic.proto.BoundingBox
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
Expand Down Expand Up @@ -316,10 +321,6 @@ private const val TERRAIN_HILLSHADE_Z_INDEX = -0.9f
/** Clears Google's own logo and the zoom controls, both of which sit along the bottom edge. */
private val ATTRIBUTION_BOTTOM_PADDING = 4.dp
private const val ATTRIBUTION_SCRIM_ALPHA = 0.7f

/** Faded rather than transparent, so the oldest point stays visible and tappable, as on the MapLibre track. */
private const val OLDEST_TRACK_ALPHA = 0.25f
private const val TRACK_FADE_LEVELS = 8
private const val MAX_TRACK_MARKERS = 500
private const val TRACK_POINT_SIZE_DP = 24f
private const val SELECTED_TRACK_POINT_SIZE_DP = 32f
Expand Down Expand Up @@ -1457,14 +1458,15 @@ private fun NodeTrackOverlay(
val pointIcons =
remember(trackColor, density) {
List(TRACK_FADE_LEVELS) { level ->
trackPointIcon(trackColor.copy(alpha = trackFadeAlpha(level)), TRACK_POINT_SIZE_DP, density)
trackPointIcon(trackColor.copy(alpha = trackFadeLevelAlpha(level)), TRACK_POINT_SIZE_DP, density)
}
}
val selectedIcon =
remember(selectedColor, density) { trackPointIcon(selectedColor, SELECTED_TRACK_POINT_SIZE_DP, density) }
val pointTitle = stringResource(Res.string.position)
val pointDescription = stringResource(Res.string.track_point)
val runs = remember(sortedPositions) { mergeStationaryRuns(sortedPositions) }
val recency = remember(runs) { trackRecency(runs.map { it.position.time.toLong() }) }
// Every marker is added on the main thread, so only the newest points and the selected one get one.
val firstMarkerIndex = (runs.size - MAX_TRACK_MARKERS).coerceAtLeast(0)

Expand All @@ -1489,14 +1491,15 @@ private fun NodeTrackOverlay(
NodeChip(node = focusedNode)
}
} else {
val level = trackFadeLevel(index, runs.lastIndex)
// Bucketed, so a new fix changes the icon of only the markers that cross a level.
val level = trackFadeLevel(recency[index])
MarkerInfoWindow(
state = markerState,
contentDescription = pointDescription,
icon = if (isSelected) selectedIcon else pointIcons[level],
title = pointTitle,
snippet = formatAgo(position.time),
zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + trackFadeAlpha(level),
zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + trackFadeLevelAlpha(level),
onClick = {
onPositionSelect?.invoke(position.time)
false // Allow default info window behavior
Expand All @@ -1510,24 +1513,23 @@ private fun NodeTrackOverlay(

if (runs.size > 1) {
val points = remember(runs) { runs.map { it.position.toLatLng() } }
// A span without a segment count covers only the first segment.
// One gradient span per segment, between its two ends' fade, so the line follows time rather than order.
val spans =
remember(trackColor, points.size) {
val oldest = trackColor.copy(alpha = OLDEST_TRACK_ALPHA).toArgb()
val gradient = StrokeStyle.gradientBuilder(oldest, trackColor.toArgb()).build()
listOf(StyleSpan(gradient, (points.size - 1).toDouble()))
remember(trackColor, recency) {
recency.zipWithNext { from, to ->
val gradient =
StrokeStyle.gradientBuilder(
trackColor.copy(alpha = trackFadeAlpha(from)).toArgb(),
trackColor.copy(alpha = trackFadeAlpha(to)).toArgb(),
)
.build()
StyleSpan(gradient, 1.0)
}
}
Polyline(points = points, spans = spans, jointType = JointType.ROUND, width = 8f, zIndex = 0.6f)
}
}

/** Buckets a point's position along the track so that appending a point changes the icon of only a few markers. */
private fun trackFadeLevel(index: Int, lastIndex: Int): Int =
if (lastIndex <= 0) TRACK_FADE_LEVELS - 1 else index * (TRACK_FADE_LEVELS - 1) / lastIndex

private fun trackFadeAlpha(level: Int): Float =
OLDEST_TRACK_ALPHA + (1f - OLDEST_TRACK_ALPHA) * level / (TRACK_FADE_LEVELS - 1)

/** A ring, drawn straight to a bitmap so building it never composes. */
private fun trackPointIcon(color: Color, sizeDp: Float, density: Float): BitmapDescriptor {
val sizePx = (sizeDp * density).roundToInt().coerceAtLeast(1)
Expand Down
39 changes: 2 additions & 37 deletions androidApp/src/google/kotlin/org/meshtastic/app/node/ArCapture.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,11 @@
*/
package org.meshtastic.app.node

import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.view.PixelCopy
import android.view.SurfaceView
import android.view.View
Expand All @@ -33,14 +30,9 @@ import androidx.core.view.children
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.meshtastic.core.ui.util.saveJpegToPictures
import kotlin.coroutines.resume

private const val PICTURES_RELATIVE_PATH = "Pictures/Meshtastic"
private const val JPEG_QUALITY = 92

/**
* Copies the AR view's GL surface into Pictures/Meshtastic: the camera feed and the model, none of the Compose controls
* drawn over it. The scene renders every camera frame, so the surface is never stale. Null when nothing was saved.
Expand All @@ -57,38 +49,11 @@ internal suspend fun captureArScene(root: View, title: String, io: CoroutineDisp
Handler(Looper.getMainLooper()),
)
}
return if (copied) withContext(io) { saveToPictures(root.context, bitmap, title) } else null
return if (copied) withContext(io) { saveJpegToPictures(root.context, bitmap, title) } else null
}

private fun View.findSurfaceView(): SurfaceView? = when (this) {
is SurfaceView -> this
is ViewGroup -> children.firstNotNullOfOrNull { it.findSurfaceView() }
else -> null
}

@RequiresApi(Build.VERSION_CODES.Q)
private fun saveToPictures(context: Context, bitmap: Bitmap, title: String): Uri? {
val resolver = context.contentResolver
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val name = "${title.replace(Regex("[^A-Za-z0-9]+"), "-").trim('-')}-$stamp.jpg"
val pending =
ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, PICTURES_RELATIVE_PATH)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri =
runCatching {
resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), pending)
}
.getOrNull() ?: return null
val written = runCatching {
resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) } == true &&
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) > 0
}
.getOrDefault(false)
// A row left pending would sit hidden in the gallery, so a failed write removes it.
if (!written) runCatching { resolver.delete(uri, null, null) }
return uri.takeIf { written }
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ private typealias DeepLinkArgs = Map<String, List<String>>
* - `/nodes` -> List of all nodes
* - `/nodes/{destNum}` -> Node details
* - `/nodes/{destNum}/{metric}` -> Specific node metric (e.g., `/nodes/1234/device-metrics`)
* - `/nodes/{destNum}/mesh-view` -> Camera mesh view finding that node
* - `/nodes/{destNum}/position/mesh-view` -> Camera mesh view of that node's position track
* - `/map/mesh-view` -> Camera mesh view of the whole mesh
* - `/messages` -> Conversation list
* - `/messages/{contactKey}` -> Specific conversation
* - `/settings` -> Settings root
Expand Down Expand Up @@ -144,10 +147,18 @@ object DeepLinkRouter {
listOf(key)
},
pattern("discovery", DiscoveryRoute.DiscoveryGraph.serializer()) { key, _ -> listOf(key) },
pattern("map/mesh-view", MapRoute.Map.serializer()) { key, _ -> listOf(key, MapRoute.MeshHud()) },
pattern("map/{waypointId}", MapRoute.Map.serializer()) { key, _ -> listOf(key) },
pattern("map?waypointId={waypointId}", MapRoute.Map.serializer()) { key, _ -> listOf(key) },
// A non-numeric waypointId fails to decode above and opens the plain map.
pattern("map/.*", MapRoute.Map.serializer()) { key, _ -> listOf(key) },
pattern("nodes/{destNum}/position/mesh-view", NodesRoute.NodeDetail.serializer()) { key, _ ->
listOf(NodesRoute.Nodes, key) +
listOfNotNull(
key.destNum?.let { NodeDetailRoute.PositionLog(it) },
key.destNum?.let { MapRoute.MeshHud(focusNodeNum = it, trackNodeNum = it) },
)
},
pattern("nodes/{destNum}/{metric}", NodesRoute.NodeDetail.serializer()) { key, args ->
val metric = key.destNum?.let { nodeDetailSubRoutes[args.value("metric").lowercase()]?.invoke(it) }
listOf(NodesRoute.Nodes, key) + listOfNotNull(metric)
Expand Down Expand Up @@ -269,5 +280,6 @@ object DeepLinkRouter {
"host-metrics" to { destNum -> NodeDetailRoute.HostMetricsLog(destNum) },
"pax" to { destNum -> NodeDetailRoute.PaxMetrics(destNum) },
"neighbors" to { destNum -> NodeDetailRoute.NeighborInfoLog(destNum) },
"mesh-view" to { destNum -> MapRoute.MeshHud(focusNodeNum = destNum) },
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ class DeepLinkRouterTest {
assertEquals(listOf(MapRoute.Map(waypointId = null)), route("/map/not-a-number"))
}

@Test
fun `map mesh-view opens the mesh view over the map`() {
assertEquals(listOf(MapRoute.Map(waypointId = null), MapRoute.MeshHud()), route("/map/mesh-view"))
}

// endregion

// region nodes
Expand Down Expand Up @@ -211,6 +216,32 @@ class DeepLinkRouterTest {
)
}

@Test
fun `nodes mesh-view finds the node over its detail`() {
assertEquals(
listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 100), MapRoute.MeshHud(focusNodeNum = 100)),
route("/nodes/100/mesh-view"),
)
}

@Test
fun `nodes position mesh-view shows the track over its position log`() {
assertEquals(
listOf(
NodesRoute.Nodes,
NodesRoute.NodeDetail(destNum = 100),
NodeDetailRoute.PositionLog(destNum = 100),
MapRoute.MeshHud(focusNodeNum = 100, trackNodeNum = 100),
),
route("/nodes/100/position/mesh-view"),
)
}

@Test
fun `nodes position mesh-view with a bad destNum opens the node list`() {
assertEquals(listOf(NodesRoute.Nodes), route("/nodes/abc/position/mesh-view"))
}

@Test
fun `nodes with destNum and environment sub-route`() {
assertEquals(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1191,6 +1191,13 @@
<string name="mesh_hud_thermal_hot">Phone is hot. Updating less often.</string>
<string name="mesh_hud_tilt_down">Tilt down %1$d°</string>
<string name="mesh_hud_tilt_up">Tilt up %1$d°</string>
<string name="mesh_hud_track_empty">No positions in the track window</string>
<string name="mesh_hud_track_newest">%1$s · newest %2$s</string>
<plurals name="mesh_hud_track_points">
<item quantity="one">%1$d point</item>
<item quantity="other">%1$d points</item>
</plurals>
<string name="mesh_hud_track_summary">%1$s over %2$s · newest %3$s</string>
<string name="mesh_hud_turn_left">Turn left %1$d°</string>
<string name="mesh_hud_turn_right">Turn right %1$d°</string>
<string name="mesh_hud_waiting_sensor">Waiting for the orientation sensor</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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.ui.util

import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

private const val PICTURES_RELATIVE_PATH = "Pictures/Meshtastic"
private const val JPEG_QUALITY = 92

/**
* Saves [bitmap] as a JPEG in Pictures/Meshtastic, named after [title] and the time, and returns it, or null when
* nothing was saved. Android 10 and later only, where writing there needs no storage permission. Blocks on disk, so
* call it off the main thread.
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun saveJpegToPictures(context: Context, bitmap: Bitmap, title: String): Uri? {
val resolver = context.contentResolver
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val name = "${title.replace(Regex("[^A-Za-z0-9]+"), "-").trim('-')}-$stamp.jpg"
val pending =
ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, name)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, PICTURES_RELATIVE_PATH)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri =
runCatching {
resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), pending)
}
.getOrNull() ?: return null
val written = runCatching {
resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) } == true &&
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) > 0
}
.getOrDefault(false)
// A row left pending would sit hidden in the gallery, so a failed write removes it.
if (!written) runCatching { resolver.delete(uri, null, null) }
return uri.takeIf { written }
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ import org.meshtastic.desktop.stub.NoopMagneticFieldProvider
import org.meshtastic.desktop.stub.NoopMeshLocationManager
import org.meshtastic.desktop.stub.NoopMeshWorkerManager
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
import org.meshtastic.desktop.stub.NoopPhonePressureProvider
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
import org.meshtastic.feature.map.hud.CameraPoseProvider
import org.meshtastic.feature.map.hud.PhonePressureProvider
import org.meshtastic.feature.node.compass.CompassHeadingProvider

/**
Expand All @@ -61,4 +63,6 @@ class DesktopStubsModule {
@Single fun magneticFieldProvider(): MagneticFieldProvider = NoopMagneticFieldProvider()

@Single fun cameraPoseProvider(): CameraPoseProvider = NoopCameraPoseProvider()

@Single fun phonePressureProvider(): PhonePressureProvider = NoopPhonePressureProvider()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
package org.meshtastic.desktop.stub

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import org.meshtastic.core.repository.MagneticFieldProvider
import org.meshtastic.core.repository.PhoneLocationProvider
import org.meshtastic.core.repository.PhoneLocationState
import org.meshtastic.feature.map.hud.CameraPoseProvider
import org.meshtastic.feature.map.hud.CameraPoseState
import org.meshtastic.feature.map.hud.PhonePressureProvider
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.HeadingState

Expand All @@ -42,6 +44,11 @@ class NoopCameraPoseProvider : CameraPoseProvider {
override fun poseUpdates(): Flow<CameraPoseState> = flowOf(CameraPoseState(hasSensor = false))
}

/** No-op [PhonePressureProvider]: desktop has no barometer. */
class NoopPhonePressureProvider : PhonePressureProvider {
override fun pressureUpdates(): Flow<Float> = emptyFlow()
}

/** No-op [MagneticFieldProvider] — always returns zero declination. */
class NoopMagneticFieldProvider : MagneticFieldProvider {
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f
Expand Down
Loading
Loading