diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 6889357ee8..6658e99391 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -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/ diff --git a/.github/workflows/scheduled-baseline.yml b/.github/workflows/scheduled-baseline.yml index 5d558b93d6..cd58cd2b8a 100644 --- a/.github/workflows/scheduled-baseline.yml +++ b/.github/workflows/scheduled-baseline.yml @@ -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//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)." diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index bd7925e71d..f13c1a6c12 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index c29c3c2429..451b3fda22 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -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//generated/baselineProfiles at build time. - baselineProfile(projects.baselineprofile) } diff --git a/androidApp/src/fdroid/AndroidManifest.xml b/androidApp/src/fdroid/AndroidManifest.xml index adb8c226e3..d8d96da9ac 100644 --- a/androidApp/src/fdroid/AndroidManifest.xml +++ b/androidApp/src/fdroid/AndroidManifest.xml @@ -19,15 +19,6 @@ - - - - - - + + + URL URL cannot be empty. + Use https:// here. Plain http:// only works for localhost. URL must start with http:// or https://. URL must contain placeholders. URL Template diff --git a/core/takserver/src/androidHostTest/kotlin/org/meshtastic/core/takserver/AtakFileWriterTest.kt b/core/takserver/src/androidHostTest/kotlin/org/meshtastic/core/takserver/AtakFileWriterTest.kt new file mode 100644 index 0000000000..da6226fc18 --- /dev/null +++ b/core/takserver/src/androidHostTest/kotlin/org/meshtastic/core/takserver/AtakFileWriterTest.kt @@ -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 . + */ +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() + 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?, + selection: String?, + selectionArgs: Array?, + 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?, + ): 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?): 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), + ) + } +} diff --git a/core/takserver/src/androidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt b/core/takserver/src/androidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt index 9abd1017ed..8d4bd35d17 100644 --- a/core/takserver/src/androidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt +++ b/core/takserver/src/androidMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt @@ -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" } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt index 4d863e14fe..4631127b0c 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt @@ -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. */ diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt index 931ba337b3..90b95a257e 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt @@ -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) diff --git a/core/takserver/src/jvmMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt b/core/takserver/src/jvmMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt index ec7f7727e1..6cc3faa52a 100644 --- a/core/takserver/src/jvmMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt +++ b/core/takserver/src/jvmMain/kotlin/org/meshtastic/core/takserver/AtakFileWriter.kt @@ -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 diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 98667fbeef..069d98002e 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -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 { diff --git a/docs/en/developer/testing.md b/docs/en/developer/testing.md index ffed48873b..028ea9b856 100644 --- a/docs/en/developer/testing.md +++ b/docs/en/developer/testing.md @@ -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. diff --git a/docs/en/user/map-and-waypoints.md b/docs/en/user/map-and-waypoints.md index b777873679..f13f0cfe5c 100644 --- a/docs/en/user/map-and-waypoints.md +++ b/docs/en/user/map-and-waypoints.md @@ -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. diff --git a/docs/en/user/tak.md b/docs/en/user/tak.md index 543c29764d..8eac88dba0 100644 --- a/docs/en/user/tak.md +++ b/docs/en/user/tak.md @@ -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. diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.android.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.android.kt new file mode 100644 index 0000000000..12671efeec --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.android.kt @@ -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 . + */ +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) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt index 2b3909ac3e..37e9d022e7 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt @@ -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 } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManager.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManager.kt index 9e7fbaa018..ad709573ed 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManager.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManager.kt @@ -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 } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/layers/MapLayersManager.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/layers/MapLayersManager.kt index d33c925a79..5e7001edc0 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/layers/MapLayersManager.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/layers/MapLayersManager.kt @@ -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 } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.kt new file mode 100644 index 0000000000..3f18a81d49 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.kt @@ -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 . + */ +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 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/tiles/CustomTileProviderConfig.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/tiles/CustomTileProviderConfig.kt index f63ac37325..d3e407e89c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/tiles/CustomTileProviderConfig.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/tiles/CustomTileProviderConfig.kt @@ -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 = "://" diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/layers/NetworkLayerUrlTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/layers/NetworkLayerUrlTest.kt new file mode 100644 index 0000000000..2a1ca739ea --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/layers/NetworkLayerUrlTest.kt @@ -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 . + */ +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() + 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 }) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/tiles/CustomTileProviderConfigTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/tiles/CustomTileProviderConfigTest.kt index be11e6c06c..60ce5f8dff 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/tiles/CustomTileProviderConfigTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/tiles/CustomTileProviderConfigTest.kt @@ -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() + 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)) } } diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.ios.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.ios.kt new file mode 100644 index 0000000000..a0fd985a4a --- /dev/null +++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.ios.kt @@ -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 . + */ +package org.meshtastic.feature.map.tiles + +actual fun isCleartextPermitted(host: String): Boolean = true diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.jvm.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.jvm.kt new file mode 100644 index 0000000000..a0fd985a4a --- /dev/null +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/tiles/CleartextPolicy.jvm.kt @@ -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 . + */ +package org.meshtastic.feature.map.tiles + +actual fun isCleartextPermitted(host: String): Boolean = true diff --git a/specs/005-tak-v2-protocol/plan.md b/specs/005-tak-v2-protocol/plan.md index 9f1c890d7a..dd54ff65b2 100644 --- a/specs/005-tak-v2-protocol/plan.md +++ b/specs/005-tak-v2-protocol/plan.md @@ -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/.../ diff --git a/specs/005-tak-v2-protocol/spec.md b/specs/005-tak-v2-protocol/spec.md index 4946edcc26..868bb05f94 100644 --- a/specs/005-tak-v2-protocol/spec.md +++ b/specs/005-tak-v2-protocol/spec.md @@ -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