fix(app): manifest and platform hygiene from the Android audit (#7426)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-29 16:06:00 +00:00
1 parent e6cf616bdb
commit e5291937da
47 files changed
+812 -196

No files matched your search

+1 -1
View File
@@ -146,7 +146,7 @@ reviews:
instructions: >
New string resources must be alphabetically sorted (scripts/sort-strings.py). Flag out-of-order additions.
- path: baselineprofile/
instructions: Keep baseline profile generation tied to the `google` flavor and connected devices/emulators, and commit the generated profile output to `androidApp/src/googleRelease/generated/baselineProfiles/baseline-prof.txt`.
instructions: Keep baseline profile generation tied to the `google` flavor and connected devices/emulators, and commit the generated profile output to `androidApp/src/main/generated/baselineProfiles/baseline-prof.txt`, which both flavors ship.
- path: docs/
instructions: Treat non-English locale folders as Crowdin-managed output; edit the English sources under `docs/en/` and register new pages through `feature/docs/` instead of hand-editing translated locale directories.
- path: screenshot-tests/
+6 -7
View File
@@ -66,23 +66,22 @@ jobs:
profile: pixel_6
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
# Writes androidApp/src/<variant>/generated/baselineProfiles/ via the androidx.baselineprofile plugin.
# The variant is googleRelease (flavor + buildType), NOT the bare `google` flavor dir.
# Writes androidApp/src/main/generated/baselineProfiles/ via the androidx.baselineprofile plugin:
# :androidApp sets mergeIntoMain, which is what makes the fdroid flavor ship the profile too.
# --no-configuration-cache: the underlying connectedGoogleNonMinifiedReleaseAndroidTest task is not
# config-cache serializable (SeparateTestModuleTestData / ResolutionBackedFileCollection), and the
# project enables org.gradle.configuration-cache by default — same workaround used in reusable-check.yml.
# -Dorg.gradle.isolated-projects=false must accompany it: Isolated Projects implies the configuration cache, and
# Gradle 9.7+ hard-errors when the cache is disabled while Isolated Projects is on.
script: ./gradlew :androidApp:generateGoogleReleaseBaselineProfile -Pci=true -Dorg.gradle.isolated-projects=false --no-configuration-cache
script: ./gradlew :androidApp:generateBaselineProfile -Pci=true -Dorg.gradle.isolated-projects=false --no-configuration-cache
- name: Detect baseline profile changes
id: baseline
run: |
outcome="${{ steps.generate_baseline.outcome }}"
# Pin the variant the Gradle task above targets: googleRelease (flavor + buildType), NOT the
# bare `google` flavor dir. Searching for any baseline-prof.txt would happily validate another
# variant's file — or, once this profile is committed, the stale one already in the checkout.
profile_dir="androidApp/src/googleRelease/generated/baselineProfiles"
# Pin the directory mergeIntoMain writes. Searching for any baseline-prof.txt would happily
# validate a file from some other source set, or the stale one already in the checkout.
profile_dir="androidApp/src/main/generated/baselineProfiles"
profile="$profile_dir/baseline-prof.txt"
if [ "$outcome" != "success" ]; then
echo "::error::Baseline profile generation failed (outcome: $outcome)."
+1
View File
@@ -1852,6 +1852,7 @@ uptime
### URL ###
url
url_cannot_be_empty
url_http_localhost_only
url_must_be_http
url_must_contain_placeholders
url_template
+9 -4
View File
@@ -198,6 +198,15 @@ secrets {
// AppSearch without dynamic-schema support indexes only the v1 XML named by the `android.app.appfunctions` property.
ksp { arg("appfunctions:generateV1Xml", "true") }
// Merging into src/main is what ships the profile in fdroid too.
baselineProfile { mergeIntoMain = true }
// The producer only has the google flavor, so only googleRelease may depend on it: fdroidRelease would fail to resolve
// it. The plugin creates this configuration per variant, after this script runs.
configurations
.matching { it.name == "googleReleaseBaselineProfile" }
.configureEach { dependencies.add(projects.baselineprofile) }
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.flavorName?.let { flavor -> variant.applicationId.set("com.geeksville.mesh.$flavor.debug") }
@@ -347,8 +356,4 @@ dependencies {
testImplementation(libs.androidx.glance.appwidget)
// JVM variant provides the host-platform native library for BundledSQLiteDriver under Robolectric
testRuntimeOnly(libs.androidx.sqlite.bundled.jvm)
// Producer of the baseline profile consumed by the release build. The androidx.baselineprofile
// plugin merges the generated rules into src/<variant>/generated/baselineProfiles at build time.
baselineProfile(projects.baselineprofile)
}
@@ -19,15 +19,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
Required for writing TAK route data packages to ATAK's auto-import directory.
Only declared for the F-Droid flavor — the Google Play flavor uses scoped
storage (SAF / app-scoped cache) so this permission is not needed there
and would violate Play policy.
-->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application>
<!--
Register as an "Open in / Send to Meshtastic" target for GeoJSON/KML map files (e.g. the Meshtastic Site
@@ -124,6 +124,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -186,6 +187,7 @@ import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.MapBounds
import org.meshtastic.feature.map.MapNodePolicy
@@ -330,6 +332,9 @@ fun MapView(
val coroutineScope = rememberCoroutineScope()
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
// Collected here, not in a sheet: basemap selection and network layers report errors while no sheet is open.
LaunchedEffect(mapViewModel) { mapViewModel.errorFlow.collectLatest { context.showToast(it) } }
// --- Location permissions ---
val locationPermission = rememberLocationPermissionState()
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
@@ -1627,20 +1632,19 @@ private fun offsetPolyline(
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
val headings =
headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
val headings = headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
headingPoints.lastIndex ->
SphericalUtil.computeHeading(
headingPoints[headingPoints.lastIndex - 1],
headingPoints[headingPoints.lastIndex],
)
headingPoints.lastIndex ->
SphericalUtil.computeHeading(
headingPoints[headingPoints.lastIndex - 1],
headingPoints[headingPoints.lastIndex],
)
else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
}
else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
}
}
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
@@ -71,6 +71,9 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.url_http_localhost_only
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.feature.map.layers.LayerOpacityStore
@@ -88,6 +91,8 @@ import org.meshtastic.feature.map.tiles.CustomTileProviderSaveResult
import org.meshtastic.feature.map.tiles.MapTileCatalogue
import org.meshtastic.feature.map.tiles.RasterOverlaySource
import org.meshtastic.feature.map.tiles.RasterTileSpec
import org.meshtastic.feature.map.tiles.isCleartextPermitted
import org.meshtastic.feature.map.tiles.isRefusedCleartextTileUrl
import org.meshtastic.feature.map.tiles.isValidTileUrlTemplate
import java.io.File
import java.io.FileOutputStream
@@ -331,6 +336,9 @@ class MapViewModel(
if (config != null) {
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
Logger.withTag("MapViewModel").w("Attempted to select an invalid custom tile URL template")
if (config.urlTemplate.isRefusedCleartextTileUrl()) {
viewModelScope.launch { _errorFlow.emit(getStringSuspend(Res.string.url_http_localhost_only)) }
}
clearCurrentTileProvider()
_selectedRasterBasemapId.value = null
_selectedGoogleMapType.value = MapType.NORMAL
@@ -429,8 +437,7 @@ class MapViewModel(
}
}
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean =
urlTemplate.isValidTileUrlTemplate(requireHttps = false)
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.isValidTileUrlTemplate()
/** What to restore once the network returns from an auto-switch; null when nothing has been auto-switched. */
private data class OfflineAutoSwitchState(val rasterBasemapId: String?, val googleMapType: MapType)
@@ -750,6 +757,7 @@ class MapViewModel(
if (selection.customTileUrl != null) googleMapsPrefs.setSelectedCustomTileUrl(null)
} else {
_selectedRasterBasemapId.value = null
if (resolvedSelection.refusedCleartextSource) reportRefusedCleartextSource()
if (resolvedSelection.canDiscardMissingSelection) {
if (selectedProviderId != null) mapTileProviderPrefs.setSelectedCustomTileProviderId(null)
if (selection.customTileUrl != null) googleMapsPrefs.setSelectedCustomTileUrl(null)
@@ -768,6 +776,14 @@ class MapViewModel(
}
}
/** Waits for a collector: this runs from init, before the map collects, and the selection is cleared next. */
private fun reportRefusedCleartextSource() {
viewModelScope.launch {
_errorFlow.subscriptionCount.first { it > 0 }
_errorFlow.emit(getStringSuspend(Res.string.url_http_localhost_only))
}
}
fun addMapLayer(picked: PickedMapFile) = mapLayersManager.addMapLayer(picked)
fun addNetworkMapLayer(name: String, url: String) {
@@ -838,24 +854,30 @@ internal fun List<CustomTileProviderConfig>.findLegacyCustomTileProvider(
internal data class PersistedCustomTileSelection(
val provider: CustomTileProviderConfig?,
val canDiscardMissingSelection: Boolean,
val refusedCleartextSource: Boolean = false,
)
internal fun List<CustomTileProviderConfig>.resolvePersistedCustomTileSelection(
selectedProviderId: String?,
legacySource: String?,
providerLoadSuccessful: Boolean,
cleartextPermitted: (host: String) -> Boolean = ::isCleartextPermitted,
): PersistedCustomTileSelection {
val provider =
val candidates =
listOfNotNull(findSelectedCustomTileProvider(selectedProviderId), findLegacyCustomTileProvider(legacySource))
.firstOrNull { it.hasValidGoogleTileSource() }
val provider = candidates.firstOrNull { it.hasValidGoogleTileSource(cleartextPermitted) }
return PersistedCustomTileSelection(
provider = provider,
canDiscardMissingSelection = provider == null && providerLoadSuccessful,
refusedCleartextSource =
provider == null &&
candidates.any { !it.isLocal && it.urlTemplate.isRefusedCleartextTileUrl(cleartextPermitted) },
)
}
internal fun CustomTileProviderConfig.hasValidGoogleTileSource(): Boolean =
isLocal || urlTemplate.isValidTileUrlTemplate(requireHttps = false)
internal fun CustomTileProviderConfig.hasValidGoogleTileSource(
cleartextPermitted: (host: String) -> Boolean = ::isCleartextPermitted,
): Boolean = isLocal || urlTemplate.isValidTileUrlTemplate(cleartextPermitted)
private fun GoogleCameraPosition.toCameraPosition() = CameraPosition(LatLng(targetLat, targetLng), zoom, tilt, bearing)
@@ -22,16 +22,13 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.importMbTiles
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.component.CustomTileProviderManager
import org.meshtastic.feature.map.layers.getFileName
import java.io.File
@@ -65,8 +62,6 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
}
}
LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { context.showToast(it) } }
CustomTileProviderManager(
providers = providers,
onAdd = mapViewModel::addCustomTileProvider,
@@ -47,7 +47,7 @@ internal fun CustomTileProviderConfig.toRasterBasemap(): RasterBasemap? {
return when {
archive != null -> RasterBasemap.Local(id = id, uri = archive)
urlTemplate.isValidTileUrlTemplate(requireHttps = false) ->
urlTemplate.isValidTileUrlTemplate() ->
RasterBasemap.Remote(id = id, spec = RasterTileSpec(tiles = listOf(urlTemplate)))
else -> null
+8 -5
View File
@@ -159,11 +159,6 @@
the flagship test device.
-->
<uses-library
android:name="org.apache.http.legacy"
android:required="false" />
<!-- Default crash collection and analytics off until we (possibly) turn it on in application.onCreate -->
<meta-data
android:name="firebase_crashlytics_collection_enabled"
@@ -386,6 +381,14 @@
android:name="androidx.glance.appwidget.action.ActionTrampolineActivity"
tools:node="remove" />
<!--
The JetBrains ui-tooling facade AAR declares this exported activity but ships no class for it, so any
launch crashes the app on instantiation. Android Studio previews use androidx.compose.ui.tooling's own.
-->
<activity
android:name="org.jetbrains.androidx.compose.ui.tooling.PreviewActivity"
tools:node="remove" />
<!--
ATAK plugin-discovery marker. The action is what ATAK looks for, so the filter stays exported; it now
resolves to a real no-op activity because the name used to be com.atakmap.app.component, a class not
@@ -17,12 +17,22 @@
package org.meshtastic.app
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -59,6 +69,11 @@ class BubbleActivity : AppCompatActivity() {
}
messageViewModel.setContactKey(contactKey)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
setContent {
val theme by model.theme.collectAsStateWithLifecycle()
val dark =
@@ -67,19 +82,28 @@ class BubbleActivity : AppCompatActivity() {
AppCompatDelegate.MODE_NIGHT_NO -> false
else -> isSystemInDarkTheme()
}
AppTheme(dynamicColor = theme == MODE_DYNAMIC, darkTheme = dark) {
MessageScreen(
contactKey = contactKey,
message = "",
viewModel = messageViewModel,
navigateToNodeDetails = { nodeNum -> openInApp("nodes/$nodeNum") },
// Quick chat and message filters have no deep link of their own, so the full app opens on this
// conversation — the screen those menu items live on.
navigateToQuickChatOptions = { openInApp("messages/$contactKey") },
navigateToFilterSettings = { openInApp("messages/$contactKey") },
onNavigateBack = { finish() },
SideEffect {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
)
}
AppTheme(dynamicColor = theme == MODE_DYNAMIC, darkTheme = dark) {
// Edge to edge, only this padding keeps the composer clear of the keyboard and the navigation bar.
Box(Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding()) {
MessageScreen(
contactKey = contactKey,
message = "",
viewModel = messageViewModel,
navigateToNodeDetails = { nodeNum -> openInApp("nodes/$nodeNum") },
// Quick chat and message filters have no deep link of their own, so the full app opens on
// this conversation, the screen those menu items live on.
navigateToQuickChatOptions = { openInApp("messages/$contactKey") },
navigateToFilterSettings = { openInApp("messages/$contactKey") },
onNavigateBack = { finish() },
)
}
}
}
}
@@ -21,6 +21,7 @@ import android.app.Application
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.os.Build
import android.os.StrictMode
import androidx.annotation.RequiresApi
import androidx.collection.intSetOf
import androidx.glance.appwidget.GlanceAppWidgetManager
@@ -95,6 +96,7 @@ open class MeshUtilApplication :
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) enableDebugVmPolicy()
ContextServices.app = this
configureFlavorApplication(BuildConfig.APPLICATION_ID)
@@ -255,3 +257,11 @@ open class MeshUtilApplication :
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(KoinWorkerFactory()).build()
}
/** Logs hidden-API reflection, such as the GATT cache refresh, and implicit URI permission grants. */
private fun enableDebugVmPolicy() {
val policy = StrictMode.VmPolicy.Builder()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) policy.detectNonSdkApiUsage()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) policy.detectImplicitUriPermissionGrant()
StrictMode.setVmPolicy(policy.penaltyLog().build())
}
@@ -0,0 +1,80 @@
/*
* 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
import android.app.Application
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import androidx.test.core.app.ApplicationProvider
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Runs against the merged manifest of the variant under test, library components included, so it catches an AAR that
* declares an exported activity whose class the app does not ship. Another app can launch such a component, and the
* launch crashes the process on instantiation.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class, sdk = [34])
class ExportedActivityManifestTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun `every exported activity resolves to a class on the classpath`() {
val exported =
context.packageManager
.getPackageInfo(context.packageName, PackageManager.GET_ACTIVITIES)
.activities
.orEmpty()
.filter { it.exported }
.map { it.targetActivity ?: it.name }
assertTrue(MAIN_ACTIVITY in exported, "Merged manifest under test has no exported MainActivity: $exported")
val missing = exported.filterNot { it.isLoadable() }
assertEquals(emptyList(), missing, "Exported activities with no class in the app: $missing")
}
@Test
fun `jetbrains ui-tooling preview activity is absent from the merged manifest`() {
assertFailsWith<PackageManager.NameNotFoundException>(
"$JETBRAINS_PREVIEW_ACTIVITY is declared in the merged manifest. Restore its tools:node=\"remove\" " +
"entry in androidApp/src/main/AndroidManifest.xml.",
) {
context.packageManager.getActivityInfo(ComponentName(context, JETBRAINS_PREVIEW_ACTIVITY), 0)
}
}
private fun String.isLoadable(): Boolean = try {
Class.forName(this, false, context.classLoader)
true
} catch (_: ClassNotFoundException) {
false
}
private companion object {
const val MAIN_ACTIVITY = "org.meshtastic.app.MainActivity"
const val JETBRAINS_PREVIEW_ACTIVITY = "org.jetbrains.androidx.compose.ui.tooling.PreviewActivity"
}
}
@@ -366,6 +366,40 @@ class GoogleCustomTileSelectionTest {
assertNull(resolved.provider)
assertTrue(resolved.canDiscardMissingSelection)
assertFalse(resolved.refusedCleartextSource)
}
@Test
fun `a saved http source is dropped and reported only where the platform refuses plain http`() {
val http =
CustomTileProviderConfig(
id = "http",
name = "Http",
urlTemplate = "http://tiles.example.org/{z}/{x}/{y}.png",
)
val refused =
listOf(http)
.resolvePersistedCustomTileSelection(
selectedProviderId = http.id,
legacySource = null,
providerLoadSuccessful = true,
cleartextPermitted = { false },
)
val permitted =
listOf(http)
.resolvePersistedCustomTileSelection(
selectedProviderId = http.id,
legacySource = null,
providerLoadSuccessful = true,
cleartextPermitted = { true },
)
assertNull(refused.provider)
assertTrue(refused.canDiscardMissingSelection)
assertTrue(refused.refusedCleartextSource)
assertEquals(http, permitted.provider)
assertFalse(permitted.refusedCleartextSource)
}
@Test
+7 -6
View File
@@ -7,16 +7,17 @@ JIT cost on first launch. Targets the **google** flavor (the variant most users
## Generate the profile (run on a device/emulator)
```bash
./gradlew :androidApp:generateGoogleReleaseBaselineProfile
./gradlew :androidApp:generateBaselineProfile
```
Output is merged into `androidApp/src/googleRelease/generated/baselineProfiles/baseline-prof.txt`.
**Commit that file** — release builds package it via `androidx.profileinstaller`.
Output is merged into `androidApp/src/main/generated/baselineProfiles/baseline-prof.txt` (`mergeIntoMain`
in `androidApp/build.gradle.kts`). **Commit that file**: release builds of both flavors package it via
`androidx.profileinstaller`.
## Quantify the win
```bash
./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile
./gradlew :baselineprofile:connectedGoogleBenchmarkReleaseAndroidTest
```
Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the output.
@@ -28,5 +29,5 @@ Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the
connected device is wired into the harness — a more representative journey yields a better profile.
- For hermetic CI generation, swap `useConnectedDevices = true` in `build.gradle.kts` for a
[Gradle Managed Device](https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile#gradle-managed).
- f-droid currently inherits no profile (only `google` is produced). Add a second flavor here if
the f-droid startup path ever diverges enough to matter.
- Only `google` is produced, and f-droid ships that same profile from `src/main`. Add a second flavor
here if the f-droid startup path ever diverges enough to matter.
+2 -1
View File
@@ -34,7 +34,8 @@ android {
// The app declares a `marketplace` flavor dimension (google / fdroid). A test module must
// match it. We pin to `google` — the variant the vast majority of users run (and the one with
// Maps). f-droid can reuse the same profile; wire a second flavor here if it ever diverges.
// Maps). :androidApp merges the profile into src/main, so f-droid ships the same one; wire a
// second flavor here if its startup path ever diverges.
flavorDimensions += "marketplace"
productFlavors { create("google") { dimension = "marketplace" } }
}
@@ -29,13 +29,12 @@ import org.junit.runner.RunWith
*
* Run it with:
* ```
* ./gradlew :androidApp:generateGoogleReleaseBaselineProfile
* ./gradlew :androidApp:generateBaselineProfile
* ```
*
* The [androidx.baselineprofile] plugin on `:androidApp` drives this against the auto-created
* `nonMinifiedRelease` variant and merges the result into
* `androidApp/src/googleRelease/generated/baselineProfiles/`. Commit that output so release builds ship
* it.
* `nonMinifiedRelease` variant and, with `mergeIntoMain`, merges the result into
* `androidApp/src/main/generated/baselineProfiles/`. Commit that output so release builds of both flavors ship it.
*
* The journey is intentionally minimal (cold start → first frame) because CI has no paired radio.
* Extend it with post-connection screens (node list, map, message thread) once a fake transport or
@@ -33,7 +33,7 @@ import org.junit.runner.RunWith
*
* Run it with:
* ```
* ./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile
* ./gradlew :baselineprofile:connectedGoogleBenchmarkReleaseAndroidTest
* ```
*
* Compare `startupCompilationNone` vs `startupCompilationBaselineProfiles` in the output: the delta
@@ -41,6 +41,11 @@ class KmpLibraryComposeConventionPlugin : Plugin<Project> {
}
}
}
// Android Studio renders previews from this classpath. It is resolvable only, so ui-tooling reaches neither
// consumers nor the app, whose release manifest would otherwise export its PreviewActivity.
configurations
.matching { it.name == "androidRuntimeClasspath" }
.configureEach { dependencies.addLater(libs.library("compose-multiplatform-ui-tooling")) }
configureComposeCompiler()
}
}
@@ -59,7 +59,9 @@ internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) {
dependencies {
"debugImplementation"(libs.library("compose-multiplatform-ui-tooling"))
"implementation"(libs.library("compose-multiplatform-runtime"))
"runtimeOnly"(libs.library("androidx-compose-runtime-tracing"))
// Debug only: it registers a startup initializer and an exported receiver, and nothing profiles release builds
// with composition tracing.
"debugRuntimeOnly"(libs.library("androidx-compose-runtime-tracing"))
"implementation"(libs.library("compose-multiplatform-resources"))
@@ -38,7 +38,6 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.PROCESS_LIFECYCLE
import org.meshtastic.core.common.hasBluetoothLe
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.anonymize
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -132,27 +131,6 @@ class AndroidBluetoothRepository(
}
}
@Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount")
@SuppressLint("MissingPermission")
override suspend fun removeBond(address: String): Boolean {
val remoteDevice = bluetoothAdapter?.getRemoteDevice(address)
if (remoteDevice == null || remoteDevice.bondState == android.bluetooth.BluetoothDevice.BOND_NONE) {
return false
}
return try {
// removeBond() is a public-but-hidden BluetoothDevice API (no SDK stub); reflection is the standard access
// path used across the Android BLE/DFU ecosystem (incl. Nordic's DFU library).
val removed = remoteDevice.javaClass.getMethod("removeBond").invoke(remoteDevice) as? Boolean ?: false
Logger.i { "removeBond(${address.anonymize()}) -> $removed" }
removed
} catch (e: Exception) {
Logger.w(e) { "removeBond(${address.anonymize()}) reflection failed" }
false
} finally {
updateBluetoothState()
}
}
@Suppress("TooGenericExceptionCaught")
@SuppressLint("MissingPermission")
private fun startOrObserveBond(
@@ -17,6 +17,7 @@
package org.meshtastic.core.ble
import android.bluetooth.BluetoothGatt
import android.os.Build
import co.touchlab.kermit.Logger
import com.juul.kable.Peripheral
import kotlinx.coroutines.flow.StateFlow
@@ -30,24 +31,37 @@ internal actual fun Peripheral.refreshGattCache(): Boolean {
try {
extractBluetoothGatt()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "refreshGattCache: BluetoothGatt extraction failed (${this.javaClass.name})" }
null
logRefreshFailure("gatt-unreachable peripheral=${javaClass.name}", e)
return false
}
?: run {
Logger.w { "refreshGattCache: BluetoothGatt unreachable via Kable internals (${this.javaClass.name})" }
logRefreshFailure("gatt-unreachable peripheral=${javaClass.name}")
return false
}
return try {
val refreshMethod = gatt.javaClass.getDeclaredMethod("refresh").apply { isAccessible = true }
val result = refreshMethod.invoke(gatt) as? Boolean ?: false
Logger.i { "refreshGattCache: refresh() returned $result" }
result
val refreshed = refreshMethod.invoke(gatt) as? Boolean ?: false
Logger.i {
"$REFRESH_LOG_LABEL outcome=${if (refreshed) "refreshed" else "refused"} sdk=${Build.VERSION.SDK_INT}"
}
refreshed
} catch (e: NoSuchMethodException) {
// The hidden-API blocklist hides the method rather than throwing on access, so this is how a block shows up.
logRefreshFailure("hidden-api-blocked", e)
false
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "refreshGattCache: refresh() invocation failed" }
logRefreshFailure("invoke-failed", e)
false
}
}
/** A stable label, so the field logs show when refresh() stops working and on which API level. */
private const val REFRESH_LOG_LABEL = "gatt-cache-refresh"
private fun logRefreshFailure(outcome: String, cause: Throwable? = null) {
Logger.w(cause) { "$REFRESH_LOG_LABEL outcome=$outcome sdk=${Build.VERSION.SDK_INT}" }
}
/**
* Extracts the [BluetoothGatt] from Kable's internal object graph via a 2-hop field lookup.
*
@@ -38,15 +38,6 @@ interface BluetoothRepository {
/** Initiates bonding with the given device. */
suspend fun bond(device: BleDevice)
/**
* Removes any existing bond for [address]. Returns true if a bond was present and removal was initiated.
*
* Needed before connecting to a nRF Legacy-DFU bootloader that re-advertises at the *same* address as the app (e.g.
* AdaDFU): a leftover bond makes the OS force stale link encryption the fresh bootloader can't satisfy, so it drops
* the link on the first DFU command. Default no-op for platforms/impls that don't manage bonds.
*/
suspend fun removeBond(address: String): Boolean = false
}
/** Represents the state of Bluetooth on the device. */
@@ -1912,6 +1912,7 @@
<!-- URL -->
<string name="url">URL</string>
<string name="url_cannot_be_empty">URL cannot be empty.</string>
<string name="url_http_localhost_only">Use https:// here. Plain http:// only works for localhost.</string>
<string name="url_must_be_http">URL must start with http:// or https://.</string>
<string name="url_must_contain_placeholders">URL must contain placeholders.</string>
<string name="url_template">URL Template</string>
@@ -0,0 +1,158 @@
/*
* 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.takserver
import android.app.Application
import android.content.ContentProvider
import android.content.ContentUris
import android.content.ContentValues
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.provider.BaseColumns
import android.provider.MediaStore
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.common.ContextServices
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@RunWith(RobolectricTestRunner::class)
class AtakFileWriterTest {
private val app: Application = RuntimeEnvironment.getApplication()
@Before
fun setUp() {
ContextServices.app = app
}
@Test
@Config(sdk = [34])
fun `saves to the shared Downloads folder through MediaStore`() {
val mediaStore = Robolectric.setupContentProvider(FakeMediaStore::class.java, MediaStore.AUTHORITY)
assertTrue(AtakFileWriter.writeToImportDir("route-1.zip", byteArrayOf(1, 2, 3)))
val row = mediaStore.rows.values.single()
assertEquals("route-1.zip", row.values.getAsString(MediaStore.MediaColumns.DISPLAY_NAME))
assertEquals("Download/", row.values.getAsString(MediaStore.MediaColumns.RELATIVE_PATH))
assertEquals(0, row.values.getAsInteger(MediaStore.MediaColumns.IS_PENDING))
assertContentEquals(byteArrayOf(1, 2, 3), row.file.readBytes())
}
@Test
@Config(sdk = [34])
fun `saving the same route again replaces the earlier file`() {
val mediaStore = Robolectric.setupContentProvider(FakeMediaStore::class.java, MediaStore.AUTHORITY)
assertTrue(AtakFileWriter.writeToImportDir("route-1.zip", byteArrayOf(1, 2, 3)))
assertTrue(AtakFileWriter.writeToImportDir("route-1.zip", byteArrayOf(9)))
val row = mediaStore.rows.values.single()
assertContentEquals(byteArrayOf(9), row.file.readBytes())
}
@Test
@Config(sdk = [34])
fun `a failed save removes its pending row`() {
val mediaStore = Robolectric.setupContentProvider(FakeMediaStore::class.java, MediaStore.AUTHORITY)
mediaStore.failUpdatesWith = IllegalStateException("provider refused the update")
assertFalse(AtakFileWriter.writeToImportDir("route-1.zip", byteArrayOf(1, 2, 3)))
assertTrue(mediaStore.rows.isEmpty(), "pending rows left behind: ${mediaStore.rows.keys}")
}
@Test
@Config(sdk = [28])
fun `saves to the app external Downloads folder below API 29`() {
assertTrue(AtakFileWriter.writeToImportDir("route/../1.zip", byteArrayOf(5)))
val dir = checkNotNull(app.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS))
assertContentEquals(byteArrayOf(5), File(dir, "route_.._1.zip").readBytes())
}
/** Just enough of MediaStore for the writer: rows keyed by id, each backed by a real file. */
class FakeMediaStore : ContentProvider() {
class Row(val values: ContentValues, val file: File)
val rows = linkedMapOf<Long, Row>()
var failUpdatesWith: RuntimeException? = null
private var nextId = 1L
override fun onCreate(): Boolean = true
override fun getType(uri: Uri): String? = null
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
val (name, relativePath) = checkNotNull(selectionArgs)
val cursor = MatrixCursor(arrayOf(BaseColumns._ID))
rows
.filterValues {
it.values.getAsString(MediaStore.MediaColumns.DISPLAY_NAME) == name &&
it.values.getAsString(MediaStore.MediaColumns.RELATIVE_PATH) == relativePath
}
.keys
.forEach { cursor.addRow(arrayOf(it)) }
return cursor
}
override fun insert(uri: Uri, values: ContentValues?): Uri {
val id = nextId++
val file = File.createTempFile("media", ".bin", checkNotNull(context).cacheDir)
rows[id] = Row(ContentValues(values), file)
return ContentUris.withAppendedId(uri, id)
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int {
failUpdatesWith?.let { throw it }
val row = rows[ContentUris.parseId(uri)] ?: return 0
row.values.putAll(values)
return 1
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int =
if (rows.remove(ContentUris.parseId(uri)) != null) 1 else 0
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor = ParcelFileDescriptor.open(
rows.getValue(ContentUris.parseId(uri)).file,
ParcelFileDescriptor.parseMode(mode),
)
}
}
@@ -16,38 +16,100 @@
*/
package org.meshtastic.core.takserver
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.ContextServices
import java.io.File
import java.io.IOException
/**
* Android implementation — writes route data packages to ATAK's monitored auto-import directory. Tries multiple
* locations in order of preference:
* 1. `/sdcard/atak/tools/datapackage/` (ATAK monitors this)
* 2. `/sdcard/Download/` (user can manually import from here)
*/
@Suppress("TooGenericExceptionCaught")
internal actual object AtakFileWriter {
@Suppress("TooGenericExceptionCaught")
actual fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean {
// Sanitize: fileName originates from untrusted mesh CoT uid attributes.
val safeName = fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
// Use hardcoded paths — on Android /sdcard/ maps to external storage.
// On JVM desktop these paths don't exist and the fallback returns false.
val targets = listOf(File("/sdcard/atak/tools/datapackage"), File("/sdcard/Download"))
val safeName = fileName.replace(UNSAFE_FILE_NAME_CHARS, "_")
return try {
val context = ContextServices.app
val location =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
writeToSharedDownloads(context, safeName, zipBytes).toString()
} else {
// Shared storage needs WRITE_EXTERNAL_STORAGE below API 29; the app's own external dir needs none.
writeToAppExternalDownloads(context, safeName, zipBytes)
}
Logger.i { "Route data package written: $safeName (${zipBytes.size} bytes) to $location" }
true
} catch (e: Exception) {
Logger.w(e) { "Failed to save route data package $safeName" }
false
}
}
for (dir in targets) {
try {
if (!dir.exists()) dir.mkdirs()
val target = File(dir, safeName)
target.writeBytes(zipBytes)
Logger.i { "Route data package written: $fileName (${zipBytes.size} bytes) → ${target.absolutePath}" }
return true
} catch (e: Exception) {
Logger.d { "Cannot write to ${dir.absolutePath}: ${e.message}" }
}
/** Route updates reuse the route's file name, so an existing row is overwritten rather than duplicated. */
@RequiresApi(Build.VERSION_CODES.Q)
private fun writeToSharedDownloads(context: Context, name: String, bytes: ByteArray): Uri {
val resolver = context.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val existing =
resolver
.query(
collection,
arrayOf(MediaStore.Downloads._ID),
"${MediaStore.Downloads.DISPLAY_NAME} = ? AND ${MediaStore.Downloads.RELATIVE_PATH} = ?",
arrayOf(name, DOWNLOADS_RELATIVE_PATH),
null,
)
?.use { cursor ->
if (cursor.moveToFirst()) ContentUris.withAppendedId(collection, cursor.getLong(0)) else null
}
if (existing != null) {
resolver.writeBytes(existing, "wt", bytes)
return existing
}
Logger.w { "Failed to write route data package to any ATAK import directory" }
return false
val pending =
ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
put(MediaStore.Downloads.MIME_TYPE, ZIP_MIME_TYPE)
put(MediaStore.Downloads.RELATIVE_PATH, DOWNLOADS_RELATIVE_PATH)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val inserted = resolver.insert(collection, pending) ?: throw IOException("MediaStore refused to create $name")
// A pending row left behind hides this name from the lookup above, so a retry would get a renamed copy.
var published = false
try {
resolver.writeBytes(inserted, "w", bytes)
resolver.update(inserted, ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }, null, null)
published = true
} finally {
if (!published) resolver.delete(inserted, null, null)
}
return inserted
}
private fun writeToAppExternalDownloads(context: Context, name: String, bytes: ByteArray): String {
val dir =
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?: throw IOException("External storage is not available")
val target = File(dir, name)
target.writeBytes(bytes)
return target.absolutePath
}
private fun ContentResolver.writeBytes(uri: Uri, mode: String, bytes: ByteArray) {
val stream = openOutputStream(uri, mode) ?: throw IOException("No output stream for $uri")
stream.use { it.write(bytes) }
}
private val UNSAFE_FILE_NAME_CHARS = Regex("[^a-zA-Z0-9._-]")
private val DOWNLOADS_RELATIVE_PATH = "${Environment.DIRECTORY_DOWNLOADS}/"
private const val ZIP_MIME_TYPE = "application/zip"
}
@@ -17,14 +17,14 @@
package org.meshtastic.core.takserver
/**
* Writes data package files to ATAK's auto-import directory.
* Saves data package files where the user can import them into ATAK.
*
* On Android, the actual implementation writes to `/sdcard/atak/tools/datapackage/` which ATAK monitors for new zip
* files. On other platforms this is a no-op.
* On Android the package goes to the shared Downloads folder (the app's own external Downloads folder below API 29),
* without any storage permission. On other platforms this is a no-op.
*/
internal expect object AtakFileWriter {
/**
* Write a data package zip to ATAK's monitored import directory.
* Save a data package zip, replacing an earlier one with the same name.
*
* @return true if the file was written successfully, false otherwise.
*/
@@ -410,7 +410,7 @@ class TAKMeshIntegration(
}
// Logger.d { "RAW CoT IN (mesh): $xml" }
// Routes: ATAK ignores b-m-r CoT events over TCP streaming.
// Convert to a KML data package and write to ATAK's auto-import dir.
// Convert to a KML data package and save it to Downloads for import into ATAK.
if (xml.contains("""type="b-m-r"""")) {
try {
val pkg = RouteDataPackageGenerator.generateDataPackage(xml)
@@ -17,8 +17,8 @@
package org.meshtastic.core.takserver
/**
* Desktop JVM no-op — writing data packages to ATAK's monitored directory is Android-only behaviour. On desktop, data
* packages are shared via the export launcher (file chooser) instead.
* Desktop JVM no-op: saving route data packages to Downloads is Android-only behaviour. On desktop, data packages are
* shared via the export launcher (file chooser) instead.
*/
internal actual object AtakFileWriter {
actual fun writeToImportDir(fileName: String, zipBytes: ByteArray): Boolean = false
-2
View File
@@ -70,8 +70,6 @@ kotlin {
implementation(libs.jetbrains.lifecycle.runtime.compose)
}
getByName("jvmAndroidMain") { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }
androidMain.dependencies { implementation(libs.androidx.activity.compose) }
commonTest.dependencies {
+4 -4
View File
@@ -97,16 +97,16 @@ adb pull /data/local/tmp/store-screenshots/fdroid/. fastlane/metadata/android/en
### Baseline Profile / startup performance
The `:baselineprofile` module (#5735) generates a [Baseline Profile](https://developer.android.com/topic/performance/baselineprofiles/overview) for `:androidApp`, AOT-compiling the hot startup paths so ART doesn't pay the JIT cost on first launch. It targets the `google` flavor (the variant most users run).
The `:baselineprofile` module (#5735) generates a [Baseline Profile](https://developer.android.com/topic/performance/baselineprofiles/overview) for `:androidApp`, AOT-compiling the hot startup paths so ART doesn't pay the JIT cost on first launch. It profiles the `google` flavor (the variant most users run), and both flavors ship the result.
The Macrobenchmark generator (`BaselineProfileGenerator`) and the before/after benchmark (`StartupBenchmark`) live in `baselineprofile/src/main/kotlin/org/meshtastic/baselineprofile/`. Both run on a device/emulator:
```shell
./gradlew :androidApp:generateGoogleReleaseBaselineProfile # Generate the profile (commit the output)
./gradlew :androidApp:benchmarkGoogleReleaseBaselineProfile # Quantify the cold-start win
./gradlew :androidApp:generateBaselineProfile # Generate the profile (commit the output)
./gradlew :baselineprofile:connectedGoogleBenchmarkReleaseAndroidTest # Quantify the cold-start win
```
The generated profile is merged into `androidApp/src/googleRelease/generated/baselineProfiles/` and packaged into release builds via `androidx.profileinstaller`.
The generated profile is merged into `androidApp/src/main/generated/baselineProfiles/` (`mergeIntoMain` in `androidApp/build.gradle.kts`), so the fdroid and google release builds both package it via `androidx.profileinstaller`.
> ℹ️ **Note:** The journey covers cold start only (launch → first frame), because CI has no paired node. Post-connection screens (node list, map, message thread) aren't yet AOT-compiled.
+4 -2
View File
@@ -2,7 +2,7 @@
title: Map & Waypoints
parent: User Guide
nav_order: 6
last_updated: 2026-09-11
last_updated: 2026-09-28
description: View node positions on the map, create and share waypoints, manage map layers and Site Planner, and control position sharing and privacy.
aliases:
- map
@@ -100,7 +100,7 @@ Since waypoints (and their geofences) are broadcast to the whole mesh, only the
## Map Layers
Tap the layers icon on the map to open **Manage Map Layers**. It imports your own overlays in `.kml`, `.kmz`, or GeoJSON format — including KMZ ground overlays (georeferenced images, such as exported topo or aerial tiles), which drape at their stated bounds. Add one by picking a file with **Add Layer**, opening a file with Meshtastic, or sharing it into the app from another app. **Add Network Layer** instead takes a name and an `http://` or `https://` URL pointing at a KML or GeoJSON file; that layer then carries its own refresh button in the sheet. On **Google Play** builds the toolbar's refresh button re-fetches every visible network layer at once.
Tap the layers icon on the map to open **Manage Map Layers**. It imports your own overlays in `.kml`, `.kmz`, or GeoJSON format, including KMZ ground overlays (georeferenced images, such as exported topo or aerial tiles), which drape at their stated bounds. Add one by picking a file with **Add Layer**, opening a file with Meshtastic, or sharing it into the app from another app. **Add Network Layer** instead takes a name and an `https://` URL pointing at a KML or GeoJSON file (`http://` also works on Desktop, but on Android only for `localhost`); that layer then carries its own refresh button in the sheet. On **Google Play** builds the toolbar's refresh button re-fetches every visible network layer at once.
Imported layers are listed with a toggle to show/hide each one and an option to remove it. Each layer — imported or built-in overlay — carries its own opacity slider while it is switched on, so an overlay can be faded back rather than only switched off. This works on the Google Play build, the F-Droid build, and **Desktop**, which shares the same layer store and file picker.
@@ -158,6 +158,8 @@ Tile Sources** at the foot of the base map picker and paste a URL template using
https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg
```
On **Android** the template must use `https://`; plain `http://` works only for `localhost`.
Tiles are cached on disk, so panning does not re-download what you were just looking at.
On **Android**, the same screen also imports a local `.mbtiles` archive for fully offline use.
+2 -1
View File
@@ -2,7 +2,7 @@
title: TAK Integration
parent: User Guide
nav_order: 10
last_updated: 2026-09-11
last_updated: 2026-09-28
description: Interoperate with ATAK and WinTAK — CoT position sharing, TAK roles, and plugin setup.
aliases:
- tak
@@ -103,6 +103,7 @@ Once configured:
- Chat messages can bridge between mesh and TAK networks
- Position updates flow bidirectionally between Meshtastic and TAK
- TAK Tracker nodes broadcast PLI automatically — their positions appear on ATAK maps without any ATAK-side configuration
- Routes received from the mesh are also saved as a data package (`.zip`) in **Downloads**; import it in ATAK to add the route. On Android 9 and older the file goes to the app's own folder under `Android/data` instead
> ℹ️ **Note:** TAK integration requires specific node roles. Standard client nodes don't automatically participate in TAK operations — though with **Mesh to CoT Converter** enabled they still appear on the ATAK map as contacts.
@@ -0,0 +1,23 @@
/*
* 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.feature.map.tiles
import android.security.NetworkSecurityPolicy
/** Answers from the app's network security config, the same check the HTTP stacks apply when they connect. */
actual fun isCleartextPermitted(host: String): Boolean =
NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(host)
@@ -69,6 +69,7 @@ import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.show_layer
import org.meshtastic.core.resources.url
import org.meshtastic.core.resources.url_cannot_be_empty
import org.meshtastic.core.resources.url_http_localhost_only
import org.meshtastic.core.resources.url_must_be_http
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.CellTower
@@ -80,6 +81,7 @@ import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
import org.meshtastic.feature.map.layers.LayerType
import org.meshtastic.feature.map.layers.MapLayerItem
import org.meshtastic.feature.map.layers.isRefusedCleartextLayerUrl
import org.meshtastic.feature.map.layers.isValidNetworkLayerUrl
import org.meshtastic.feature.map.layers.opacityOf
@@ -271,6 +273,7 @@ fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) ->
val emptyNameError = stringResource(Res.string.name_cannot_be_empty)
val emptyUrlError = stringResource(Res.string.url_cannot_be_empty)
val invalidUrlError = stringResource(Res.string.url_must_be_http)
val httpLocalhostOnlyError = stringResource(Res.string.url_http_localhost_only)
// Validated here, not just in the store: the store's error return is dropped by two of its three callers,
// so this dialog is the one place the user can be told. Same rules as [isValidNetworkLayerUrl].
@@ -279,6 +282,7 @@ fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) ->
urlError =
when {
url.isBlank() -> emptyUrlError
isRefusedCleartextLayerUrl(url.trim()) -> httpLocalhostOnlyError
!isValidNetworkLayerUrl(url.trim()) -> invalidUrlError
else -> null
}
@@ -55,6 +55,7 @@ import org.meshtastic.core.resources.no_custom_tile_sources_found
import org.meshtastic.core.resources.provider_name_exists
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.url_cannot_be_empty
import org.meshtastic.core.resources.url_http_localhost_only
import org.meshtastic.core.resources.url_must_contain_placeholders
import org.meshtastic.core.resources.url_template
import org.meshtastic.core.resources.url_template_hint
@@ -63,6 +64,7 @@ import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.Edit
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.map.tiles.CustomTileProviderConfig
import org.meshtastic.feature.map.tiles.isRefusedCleartextTileUrl
import org.meshtastic.feature.map.tiles.isValidTileUrlTemplate
@Suppress("LongMethod", "LongParameterList")
@@ -188,10 +190,11 @@ private fun AddEditCustomTileProviderDialog(
val providerNameExistsError = stringResource(Res.string.provider_name_exists)
val urlCannotBeEmptyError = stringResource(Res.string.url_cannot_be_empty)
val urlMustContainPlaceholdersError = stringResource(Res.string.url_must_contain_placeholders)
val httpLocalhostOnlyError = stringResource(Res.string.url_http_localhost_only)
fun validateAndSave() {
nameError = validateName(name, providers, config?.id, emptyNameError, providerNameExistsError)
urlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError)
urlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError, httpLocalhostOnlyError)
if (nameError == null && urlError == null) {
onSave(
(config ?: CustomTileProviderConfig(name = name, urlTemplate = url))
@@ -252,8 +255,14 @@ private fun validateName(
else -> null
}
private fun validateUrl(url: String, emptyUrlError: String, missingPlaceholdersError: String): String? = when {
private fun validateUrl(
url: String,
emptyUrlError: String,
missingPlaceholdersError: String,
httpLocalhostOnlyError: String,
): String? = when {
url.isBlank() -> emptyUrlError
!url.isValidTileUrlTemplate(requireHttps = false) -> missingPlaceholdersError
url.isRefusedCleartextTileUrl() -> httpLocalhostOnlyError
!url.isValidTileUrlTemplate() -> missingPlaceholdersError
else -> null
}
@@ -37,6 +37,7 @@ import okio.Path
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.feature.map.tiles.isCleartextPermitted
/**
* Owner of the imported map-layer list, its on-disk persistence, and the import plumbing.
@@ -292,16 +293,35 @@ internal const val LAYERS_DIR = "map_layers"
*
* The scheme check is on the string, not the parsed protocol — Ktor's [Url] defaults a missing scheme to `http`, so
* `example.com/map.kml` would parse as valid and then be stored as a string nothing can fetch. Shared with the
* add-layer dialog so the form and the store cannot disagree about what is acceptable.
* add-layer dialog so the form and the store cannot disagree about what is acceptable. Plain http is accepted only for
* a host the platform allows it to.
*/
fun isValidNetworkLayerUrl(url: String): Boolean {
val hasScheme = url.startsWith("http://", ignoreCase = true) || url.startsWith("https://", ignoreCase = true)
if (!hasScheme) return false
fun isValidNetworkLayerUrl(
url: String,
cleartextPermitted: (host: String) -> Boolean = ::isCleartextPermitted,
): Boolean {
val parsed = parseNetworkLayerUrl(url) ?: return false
return !parsed.isHttp || cleartextPermitted(parsed.url.host)
}
/** Whether [url] is a parseable http URL whose host the platform refuses plain http to. */
fun isRefusedCleartextLayerUrl(
url: String,
cleartextPermitted: (host: String) -> Boolean = ::isCleartextPermitted,
): Boolean {
val parsed = parseNetworkLayerUrl(url) ?: return false
return parsed.isHttp && !cleartextPermitted(parsed.url.host)
}
private class ParsedLayerUrl(val url: Url, val isHttp: Boolean)
private fun parseNetworkLayerUrl(url: String): ParsedLayerUrl? {
val isHttp = url.startsWith("http://", ignoreCase = true)
if (!isHttp && !url.startsWith("https://", ignoreCase = true)) return null
return try {
Url(url)
true
ParsedLayerUrl(Url(url), isHttp)
} catch (@Suppress("SwallowedException", "TooGenericExceptionCaught") e: Exception) {
false
null
}
}
@@ -0,0 +1,23 @@
/*
* 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.feature.map.tiles
/**
* Whether this platform will open a plain http connection to [host]. A URL validator that accepts http where this is
* false saves a tile source or layer that can never load.
*/
expect fun isCleartextPermitted(host: String): Boolean
@@ -43,9 +43,15 @@ data class CustomTileProviderConfig(
* A private/link-local host blocklist is intentionally omitted: the user supplies the tile endpoint, requests carry no
* Meshtastic-held credentials, and client-side tile GETs make that SSRF shape an accepted low-risk case.
*/
fun String.isValidTileUrlTemplate(requireHttps: Boolean): Boolean {
fun String.isValidTileUrlTemplate(cleartextPermitted: (host: String) -> Boolean = ::isCleartextPermitted): Boolean {
val resolved = resolvedForValidation() ?: return false
return resolved.hasAcceptedScheme(requireHttps) && resolved.hasUsableAuthority()
return resolved.hasUsableAuthority() && resolved.hasAcceptedScheme(cleartextPermitted)
}
/** Whether this is an otherwise usable http template whose host the platform refuses plain http to. */
fun String.isRefusedCleartextTileUrl(cleartextPermitted: (host: String) -> Boolean = ::isCleartextPermitted): Boolean {
val resolved = resolvedForValidation() ?: return false
return resolved.scheme() == "http" && resolved.hasUsableAuthority() && !cleartextPermitted(resolved.host())
}
/**
@@ -66,16 +72,30 @@ private fun String.resolvedForValidation(): String? {
return resolved.takeIf { hasPlaceholders && '{' !in it && '}' !in it && it.none(Char::isWhitespace) }
}
private fun String.hasAcceptedScheme(requireHttps: Boolean): Boolean {
val scheme = substringBefore(SCHEME_SEPARATOR, missingDelimiterValue = "").lowercase()
return if (requireHttps) scheme == "https" else scheme == "http" || scheme == "https"
private fun String.hasAcceptedScheme(cleartextPermitted: (host: String) -> Boolean): Boolean = when (scheme()) {
"https" -> true
"http" -> cleartextPermitted(host())
else -> false
}
/** A host, no fragment, and no credentials — those would be persisted in the clear and sent with every tile. */
private fun String.hasUsableAuthority(): Boolean {
val afterScheme = substringAfter(SCHEME_SEPARATOR)
val authority = afterScheme.substringBefore('/').substringBefore('?')
return '#' !in afterScheme && '@' !in authority && authority.substringBefore(':').isNotBlank()
val authority = authority()
return '#' !in substringAfter(SCHEME_SEPARATOR) && '@' !in authority && host().isNotBlank()
}
private fun String.scheme(): String = substringBefore(SCHEME_SEPARATOR, missingDelimiterValue = "").lowercase()
private fun String.authority(): String = substringAfter(SCHEME_SEPARATOR).substringBefore('/').substringBefore('?')
/** The authority without its port; an IPv6 literal loses its brackets, and an unterminated one has no host. */
private fun String.host(): String {
val authority = authority()
return if (authority.startsWith('[')) {
if (']' !in authority) "" else authority.substringAfter('[').substringBefore(']')
} else {
authority.substringBefore(':')
}
}
private const val SCHEME_SEPARATOR = "://"
@@ -0,0 +1,61 @@
/*
* 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.feature.map.layers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NetworkLayerUrlTest {
private val loopbackOnly: (String) -> Boolean = { it == "localhost" || it == "127.0.0.1" }
@Test
fun `plain http to a host the platform refuses is invalid and reported as refused cleartext`() {
val url = "http://example.org/map.kml"
assertFalse(isValidNetworkLayerUrl(url, loopbackOnly))
assertTrue(isRefusedCleartextLayerUrl(url, loopbackOnly))
}
@Test
fun `plain http to a host the platform allows is valid`() {
assertTrue(isValidNetworkLayerUrl("http://localhost:8080/map.geojson", loopbackOnly))
assertTrue(isValidNetworkLayerUrl("http://127.0.0.1/map.kml", loopbackOnly))
assertFalse(isRefusedCleartextLayerUrl("http://localhost:8080/map.geojson", loopbackOnly))
}
@Test
fun `https never consults the cleartext policy`() {
val failIfAsked: (String) -> Boolean = { error("asked about $it") }
assertTrue(isValidNetworkLayerUrl("https://example.org/map.kml", failIfAsked))
assertFalse(isRefusedCleartextLayerUrl("https://example.org/map.kml", failIfAsked))
}
@Test
fun `the cleartext policy is asked about the host alone`() {
val asked = mutableListOf<String>()
isValidNetworkLayerUrl("HTTP://Example.org:8080/map.kml?x=1") { host -> false.also { asked += host } }
assertEquals(1, asked.size)
assertEquals("example.org", asked.single().lowercase())
}
@Test
fun `a url without an explicit scheme is invalid but not reported as refused cleartext`() {
assertFalse(isValidNetworkLayerUrl("example.org/map.kml") { true })
assertFalse(isRefusedCleartextLayerUrl("example.org/map.kml") { false })
}
}
@@ -17,50 +17,87 @@
package org.meshtastic.feature.map.tiles
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CustomTileProviderConfigTest {
// Every http case passes its policy explicitly: the Android actual needs a real framework and these also run as
// host tests.
private val cleartextAllowed: (String) -> Boolean = { true }
private val cleartextRefused: (String) -> Boolean = { false }
@Test
fun `Google-compatible validation retains HTTP support`() {
assertTrue("http://tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = false))
assertTrue("https://{s}.example.org/{Z}/{X}/{Y}.jpg".isValidTileUrlTemplate(requireHttps = false))
fun `http is accepted where the platform permits plain http to the host`() {
assertTrue("http://tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(cleartextAllowed))
assertFalse("http://tiles.example.org/{z}/{x}/{y}.png".isRefusedCleartextTileUrl(cleartextAllowed))
}
@Test
fun `HTTPS can be required`() {
assertTrue("https://tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = true))
assertFalse("http://tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = true))
fun `http is refused where the platform refuses plain http to the host`() {
assertFalse("http://tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(cleartextRefused))
assertTrue("http://tiles.example.org/{z}/{x}/{y}.png".isRefusedCleartextTileUrl(cleartextRefused))
}
@Test
fun `https never consults the cleartext policy`() {
val failIfAsked: (String) -> Boolean = { error("asked about $it") }
assertTrue("https://{s}.example.org/{Z}/{X}/{Y}.jpg".isValidTileUrlTemplate(failIfAsked))
assertFalse("https://tiles.example.org/{z}/{x}/{y}.png".isRefusedCleartextTileUrl(failIfAsked))
}
@Test
fun `the cleartext policy is asked about the bare host`() {
val asked = mutableListOf<String>()
val recordAndAllow: (String) -> Boolean = { host -> true.also { asked += host } }
assertTrue("http://127.0.0.1:8080/{z}/{x}/{y}.png".isValidTileUrlTemplate(recordAndAllow))
assertTrue("http://[::1]:8080/{z}/{x}/{y}.png".isValidTileUrlTemplate(recordAndAllow))
assertTrue("HTTP://localhost/{z}/{x}/{y}.png?v=1".isValidTileUrlTemplate(recordAndAllow))
assertEquals(listOf("127.0.0.1", "::1", "localhost"), asked)
}
@Test
fun `an unterminated IPv6 literal has no host`() {
assertFalse("http://[::1/{z}/{x}/{y}.png".isValidTileUrlTemplate(cleartextAllowed))
assertFalse("https://[::1/{z}/{x}/{y}.png".isValidTileUrlTemplate(cleartextAllowed))
assertFalse("http://[::1/{z}/{x}/{y}.png".isRefusedCleartextTileUrl(cleartextRefused))
}
@Test
fun `a malformed http template is not reported as refused cleartext`() {
// The form reports these as malformed instead, which is the fix the user actually needs.
assertFalse("http://tiles.example.org/{z}/{x}.png".isRefusedCleartextTileUrl(cleartextRefused))
assertFalse("http://token@tiles.example.org/{z}/{x}/{y}.png".isRefusedCleartextTileUrl(cleartextRefused))
}
@Test
fun `a template missing any of the three coordinates is rejected`() {
assertFalse("https://tiles.example.org/{z}/{x}.png".isValidTileUrlTemplate(requireHttps = false))
assertFalse("https://tiles.example.org/static.png".isValidTileUrlTemplate(requireHttps = false))
assertFalse("https://tiles.example.org/{z}/{x}.png".isValidTileUrlTemplate())
assertFalse("https://tiles.example.org/static.png".isValidTileUrlTemplate())
}
@Test
fun `validation refuses what is not an http url at all`() {
// These are the shapes a hand-written parser gets wrong: no scheme, a scheme we do not fetch, and whitespace
// that a URL type would have thrown on.
assertFalse("tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = false))
assertFalse("file:///tiles/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = false))
assertFalse("javascript:alert('{z}{x}{y}')".isValidTileUrlTemplate(requireHttps = false))
assertFalse("https://tiles example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = false))
assertFalse("tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(cleartextAllowed))
assertFalse("file:///tiles/{z}/{x}/{y}.png".isValidTileUrlTemplate(cleartextAllowed))
assertFalse("javascript:alert('{z}{x}{y}')".isValidTileUrlTemplate(cleartextAllowed))
assertFalse("https://tiles example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(cleartextAllowed))
}
@Test
fun `a port and a query string are both fine`() {
assertTrue("https://tiles.example.org:8443/{z}/{x}/{y}.png?v=2".isValidTileUrlTemplate(requireHttps = false))
assertTrue("https://tiles.example.org:8443/{z}/{x}/{y}.png?v=2".isValidTileUrlTemplate())
}
@Test
fun `Google-compatible validation rejects unsafe and unresolved templates`() {
assertFalse("http://token@tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = false))
assertFalse("http:///tiles/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = false))
assertFalse("http://tiles.example.org/static#{z}/{x}/{y}".isValidTileUrlTemplate(requireHttps = false))
assertFalse(
"http://tiles.example.org/{z}/{x}/{y}.png?token={apiKey}".isValidTileUrlTemplate(requireHttps = false),
)
fun `unsafe and unresolved templates are rejected even where plain http is allowed`() {
assertFalse("http://token@tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(cleartextAllowed))
assertFalse("http:///tiles/{z}/{x}/{y}.png".isValidTileUrlTemplate(cleartextAllowed))
assertFalse("http://tiles.example.org/static#{z}/{x}/{y}".isValidTileUrlTemplate(cleartextAllowed))
assertFalse("http://tiles.example.org/{z}/{x}/{y}.png?token={apiKey}".isValidTileUrlTemplate(cleartextAllowed))
}
}
@@ -0,0 +1,19 @@
/*
* 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.feature.map.tiles
actual fun isCleartextPermitted(host: String): Boolean = true
@@ -0,0 +1,19 @@
/*
* 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.feature.map.tiles
actual fun isCleartextPermitted(host: String): Boolean = true
+3 -3
View File
@@ -12,7 +12,7 @@ Upgrades Meshtastic Android's TAK integration from legacy v1 (port 72, PLI + Geo
**Language/Version**: Kotlin 2.3+ targeting JDK 21 (KMP multi-target)
**Primary Dependencies**: TAKPacket-SDK v0.1.3 (zstd compression), xmlutil (CoT XML parsing), Ktor Network (TCP), zstd-jni 1.5.7-7, Okio (I/O), Koin 4.2+ (DI), Kermit (logging)
**Storage**: App-private filesystem for route KML data packages; bundled .p12/.pem certificates for TLS
**Storage**: Downloads (MediaStore) for route KML data packages; bundled .p12/.pem certificates for TLS
**Testing**: `commonTest` (9 test classes, 65+ test methods), 40 XML fixture files in `jvmAndroidMain/resources/tak_test_fixtures/`
**Target Platform**: Android (primary), JVM Desktop (secondary), iOS (stubs only)
**Project Type**: Mobile app — KMP module (`core:takserver`) + UI integration (`feature:settings`)
@@ -26,7 +26,7 @@ Upgrades Meshtastic Android's TAK integration from legacy v1 (port 72, PLI + Geo
- **I. Kotlin Multiplatform Core**: ✅ All business logic (TAKMeshIntegration, conversions, type mapper, CoT parser, detail stripper, server manager, models, DI module) resides in `commonMain`. Platform-specific code isolated to:
- `jvmAndroidMain`: TAKServerJvm (JSSE TLS), TakV2Compressor (zstd-jni via SDK), TakCertLoader, TAKClientConnection
- `androidMain`: AtakFileWriter (SAF/private dirs), TakPermissionUtil (runtime permissions)
- `androidMain`: AtakFileWriter (MediaStore Downloads), TakPermissionUtil (runtime permissions)
- `jvmMain`: AtakFileWriter (desktop filesystem), TakPermissionUtil (no-op)
- `iosMain`: TAKServerIos (no-op), TakV2Compressor (uncompressed stub), AtakFileWriter (stub)
@@ -115,7 +115,7 @@ core/takserver/
│ ├── tak_certs/ # Bundled mTLS certificates
│ └── tak_test_fixtures/ # 40 CoT XML fixtures
├── androidMain/kotlin/.../
│ └── AtakFileWriter.kt # SAF/private directory writer
│ └── AtakFileWriter.kt # MediaStore Downloads writer
├── jvmMain/kotlin/.../
│ └── AtakFileWriter.kt # Desktop filesystem writer
└── iosMain/kotlin/.../
+5 -5
View File
@@ -16,7 +16,7 @@ This feature upgrades the Meshtastic Android app's TAK (Team Awareness Kit) inte
2. **Efficient wire encoding**: Use zstd dictionary compression and CoT detail stripping to fit rich CoT payloads within the LoRa MTU constraint (237 bytes raw, ~225 bytes usable after protobuf framing overhead)
3. **Backward compatibility**: Auto-detect firmware version and gracefully fall back to legacy TAKPacket (v1) for radios running firmware < 2.8.0
4. **Reliable TAK server operation**: Maintain a local TLS/mTLS TAK server that ATAK and iTAK clients can connect to, with wake lock protection against Android battery optimization
5. **Route interoperability**: Bridge ATAK's route CoT limitation by generating KML data packages for auto-import into ATAK's monitored directory
5. **Route interoperability**: Bridge ATAK's route CoT limitation by generating KML data packages saved to Downloads for the user to import into ATAK
## Non-Goals
@@ -136,7 +136,7 @@ A v2-capable node receives packets from both v1 (port 72) and v2 (port 78) mesh
| RouteDataPackageGenerator | `core/takserver/…/RouteDataPackageGenerator.kt` (commonMain) | Converts route CoT to ATAK-importable KML data packages |
| CoTXmlParser | `core/takserver/…/CoTXmlParser.kt` (commonMain) | Streaming XML parser for inbound CoT from ATAK clients |
| XmlUtils | `core/takserver/…/XmlUtils.kt` (commonMain) | XML escaping/sanitization utilities (5 special characters) |
| AtakFileWriter | `core/takserver/…/AtakFileWriter.kt` (expect/actual) | Platform filesystem access: androidMain (SAF/private dirs), jvmMain (desktop filesystem), iosMain (stub) |
| AtakFileWriter | `core/takserver/…/AtakFileWriter.kt` (expect/actual) | Saves route data packages: androidMain (Downloads via MediaStore from API 29, the app's external Downloads folder below it), jvmMain and iosMain (no-op) |
| TAKConfigItemList | `feature/settings/…/TAKConfigItemList.kt` (commonMain) | Compose UI for TAK module configuration |
| TakPermissionUtil | `feature/settings/…/TakPermissionUtil.kt` (expect/actual) | Platform-specific permission handling (Android, iOS, JVM) |
| MeshService (wake lock) | `core/service/MeshService.kt` (androidMain) | Partial wake lock for reliable TAK server operation |
@@ -168,14 +168,14 @@ A v2-capable node receives packets from both v1 (port 72) and v2 (port 78) mesh
- **NFR-001**: Compressed TAKPacketV2 payloads MUST fit within the usable mesh payload (~225 bytes after protobuf framing within the 237-byte raw LoRa MTU) for single-packet transmission
- **NFR-002**: TAK server connection MUST survive screen-off and Doze mode for at least 30 minutes without disconnection
- **NFR-003**: CoT message round-trip (ATAK → mesh → remote ATAK) MUST complete within the mesh network's standard transmission latency (no added processing delay > 100ms)
- **NFR-004**: Route data packages MUST be written to app-private or cache directories (no MANAGE_EXTERNAL_STORAGE required); ATAK integration relies on content sharing or documented import paths
- **NFR-004**: Route data packages MUST be saved without any storage permission: to Downloads through MediaStore from API 29, and to the app's own external Downloads folder below it. The user imports them into ATAK by hand; the app writes nothing into ATAK's own directories
## Source-Set Impact
| Source Set | Impact | Justification |
|-----------|--------|---------------|
| `commonMain` | All business logic: TAKMeshIntegration, conversions, models, parser, server manager, detail stripper, XML utils, config UI | All business logic and UI per Constitution §I, §III |
| `androidMain` | MeshService wake lock, AtakFileWriter (Android filesystem/SAF), TakPermissionUtil (runtime permissions) | Platform-specific Android APIs |
| `androidMain` | MeshService wake lock, AtakFileWriter (MediaStore Downloads), TakPermissionUtil (runtime permissions) | Platform-specific Android APIs |
| `jvmAndroidMain` | TAKServerJvm TLS implementation, TakV2Compressor (zstd via TAKPacket-SDK), TakCertLoader, TakFixtureLoader | Shared JVM/Android TLS, compression, and I/O |
| `jvmMain` | AtakFileWriter (desktop filesystem), TakPermissionUtil (no-op) | Desktop platform support for file operations |
| `iosMain` | TAKServerIos, TakV2Compressor (stub — uncompressed TAK_TRACKER mode only), AtakFileWriter (stub), TakFixtureLoader | Platform stubs pending Swift SDK integration |
@@ -214,7 +214,7 @@ A v2-capable node receives packets from both v1 (port 72) and v2 (port 78) mesh
- ATAK clients support standard TAK Server protocol (TLS on port 8089, data package import)
- Zstd dictionaries are pre-trained and bundled as binary resources (not trained at runtime)
- The 237-byte raw LoRa MTU is a hard limit imposed by the radio hardware; usable payload is ~225 bytes after protobuf framing
- Route data packages are written to app-private/cache directories (no broad filesystem permissions required)
- Route data packages are saved to Downloads for manual import into ATAK (no storage permission required)
- iOS implementation uses uncompressed TAK_TRACKER mode (flags=0xFF) pending platform-specific zstd library integration via Swift SDK interop
- Desktop (JVM) has partial TAK support: filesystem operations via `jvmMain` AtakFileWriter, TLS server via `jvmAndroidMain`
- Android 17+ (API 37) requires ACCESS_LOCAL_NETWORK permission for TAK server localhost binding