mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-02 16:44:33 -04:00
fix(app): manifest and platform hygiene from the Android audit (#7426)
This commit is contained in:
1 parent
e6cf616bdb
commit
e5291937da
47 files changed
+812
-196
No files matched your search
+1
-1
@@ -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/
|
||||
|
||||
@@ -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)."
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -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"
|
||||
}
|
||||
}
|
||||
+34
@@ -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,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.
|
||||
@@ -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" } }
|
||||
}
|
||||
|
||||
+3
-4
@@ -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>
|
||||
|
||||
+158
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+85
-23
@@ -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"
|
||||
}
|
||||
+4
-4
@@ -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.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
+23
@@ -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)
|
||||
+4
@@ -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
|
||||
}
|
||||
|
||||
+12
-3
@@ -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
|
||||
}
|
||||
+27
-7
@@ -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
|
||||
+28
-8
@@ -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 = "://"
|
||||
+61
@@ -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 })
|
||||
}
|
||||
}
|
||||
+57
-20
@@ -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))
|
||||
}
|
||||
}
|
||||
+19
@@ -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
|
||||
+19
@@ -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
|
||||
@@ -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/.../
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user