fix(app): check a shared map file before importing it (#7463)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-29 23:47:49 +00:00
1 parent 43b64f2ebe
commit fda9140720
5 files changed
+484 -5

No files matched your search

+2
View File
@@ -980,6 +980,8 @@ map_filter_show_ignored
map_filter_title
map_layer_formats
map_layer_opacity
map_layer_open_failed
map_layer_too_large
map_node_popup_details
map_offline_banner
map_offline_download_failed_build
@@ -49,6 +49,7 @@ import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import com.eygraber.uri.toKmpUri
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.compose.koinInject
@@ -60,6 +61,7 @@ import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.common.state.LaunchOptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceAddress
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.network.repository.UsbRepository
@@ -68,6 +70,9 @@ import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.nfc.NfcWriterEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.map_layer_formats
import org.meshtastic.core.resources.map_layer_open_failed
import org.meshtastic.core.resources.map_layer_too_large
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.service.ServiceStartTrigger
import org.meshtastic.core.service.startService
@@ -105,7 +110,7 @@ import org.meshtastic.feature.intro.IntroViewModel
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.layers.MapLayersManager
import org.meshtastic.feature.map.layers.toPickedMapFile
import org.meshtastic.feature.map.layers.PickedMapFile
class MainActivity : AppCompatActivity() {
private val model: UIViewModel by viewModel()
@@ -113,6 +118,7 @@ class MainActivity : AppCompatActivity() {
private val usbRepository: UsbRepository by inject()
private val mapLayersManager: MapLayersManager by inject()
private val launchOptions: LaunchOptions by inject()
private val dispatchers: CoroutineDispatchers by inject()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
@@ -372,13 +378,39 @@ class MainActivity : AppCompatActivity() {
*
* Handed to the layer store directly rather than through a one-slot bus for the map to drain. The store is common
* code now, so both flavours import a shared file; the bus only ever reached the Google map, which meant the same
* share silently did nothing on F-Droid. The read grant on [uri] lives as long as this activity, which is long
* enough for the store to copy the file in.
* share silently did nothing on F-Droid. The file is read here, in this activity's scope, because the read grant on
* [uri] lives only as long as the activity; the Map tab opens once the read succeeds.
*/
private fun importMapFile(uri: Uri) {
Logger.d { "Importing shared map file: $uri" }
mapLayersManager.addMapLayer(uri.toPickedMapFile(this))
handleMeshtasticUri("$DEEP_LINK_BASE_URI/map".toUri())
lifecycleScope.launch {
when (val load = withContext(dispatchers.io) { contentResolver.loadSharedMapFile(uri) }) {
is SharedMapFileLoad.Refused -> {
Logger.w { "Refusing shared map file: ${load.reason}" }
when (load.reason) {
SharedMapFileRejection.UNSUPPORTED_TYPE -> showToast(Res.string.map_layer_formats)
SharedMapFileRejection.TOO_LARGE ->
showToast(Res.string.map_layer_too_large, MAX_SHARED_MAP_FILE_MB)
SharedMapFileRejection.NOT_CONTENT_URI,
SharedMapFileRejection.UNREADABLE,
-> showToast(Res.string.map_layer_open_failed)
}
}
is SharedMapFileLoad.Loaded -> {
mapLayersManager.addMapLayer(
PickedMapFile(
displayName = load.file.displayName,
extensionOrMime = load.file.extensionOrMime,
read = { load.bytes },
),
)
handleMeshtasticUri("$DEEP_LINK_BASE_URI/map".toUri())
}
}
}
}
private fun createShareIntent(message: String): PendingIntent {
@@ -0,0 +1,144 @@
/*
* 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.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import co.touchlab.kermit.Logger
import org.meshtastic.feature.map.layers.LayerType
import org.meshtastic.feature.map.layers.MAX_KMZ_INFLATED_BYTES
import org.meshtastic.feature.map.layers.resolveLayerType
import java.io.ByteArrayOutputStream
import java.io.InputStream
/** What another app's VIEW or SEND says about a map file, read from its provider before any of the file is. */
internal data class SharedMapFile(
val scheme: String?,
val displayName: String,
val mimeType: String?,
val size: Long?,
) {
/** Same precedence as the in-app picker: the name's extension, then the MIME subtype. */
val extensionOrMime: String?
get() = displayName.substringAfterLast('.', "").ifBlank { mimeType?.substringAfterLast('/') }
fun rejection(maxBytes: Long = MAX_SHARED_MAP_FILE_BYTES): SharedMapFileRejection? = when {
// The app holds no storage permission, so any file:// it can open is its own private data or a system path.
scheme != ContentResolver.SCHEME_CONTENT -> SharedMapFileRejection.NOT_CONTENT_URI
resolveLayerType(extensionOrMime) !in SHAREABLE_LAYER_TYPES -> SharedMapFileRejection.UNSUPPORTED_TYPE
size != null && size > maxBytes -> SharedMapFileRejection.TOO_LARGE
else -> null
}
}
internal enum class SharedMapFileRejection {
NOT_CONTENT_URI,
UNSUPPORTED_TYPE,
TOO_LARGE,
UNREADABLE,
}
/** No bigger than the most the KMZ reader will inflate, so a larger file could never be read whole anyway. */
internal const val MAX_SHARED_MAP_FILE_BYTES: Long = MAX_KMZ_INFLATED_BYTES
internal const val MAX_SHARED_MAP_FILE_MB = (MAX_SHARED_MAP_FILE_BYTES / (1024L * 1024L)).toInt()
private val SHAREABLE_LAYER_TYPES = setOf(LayerType.KML, LayerType.GEOJSON)
private const val TAG = "SharedMapFile"
/** A shared map file read whole, or why it wasn't. */
internal sealed interface SharedMapFileLoad {
class Loaded(val file: SharedMapFile, val bytes: ByteArray) : SharedMapFileLoad
data class Refused(val reason: SharedMapFileRejection) : SharedMapFileLoad
}
/**
* Checks [uri] and reads it at most once, never past [maxBytes]. Blocking provider IPC, so never on the main thread.
* Nothing is opened when the metadata alone refuses the file.
*/
internal fun ContentResolver.loadSharedMapFile(
uri: Uri,
maxBytes: Long = MAX_SHARED_MAP_FILE_BYTES,
): SharedMapFileLoad {
val file = sharedMapFile(uri) ?: return SharedMapFileLoad.Refused(SharedMapFileRejection.UNREADABLE)
val refusal = file.rejection(maxBytes)
return if (refusal != null) SharedMapFileLoad.Refused(refusal) else readSharedMapFile(uri, file, maxBytes)
}
/**
* Metadata only, null if the provider throws. Nothing is queried for a non-`content://` [uri], which
* [SharedMapFile.rejection] refuses anyway.
*/
internal fun ContentResolver.sharedMapFile(uri: Uri): SharedMapFile? {
val fallbackName = uri.lastPathSegment.orEmpty()
if (uri.scheme != ContentResolver.SCHEME_CONTENT) return SharedMapFile(uri.scheme, fallbackName, null, null)
return try {
val mimeType = getType(uri)
val nameAndSize = queryNameAndSize(uri)
SharedMapFile(uri.scheme, nameAndSize?.first ?: fallbackName, mimeType, nameAndSize?.second)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
// Another app's provider: binder rethrows whatever it throws.
Logger.withTag(TAG).w(e) { "Shared map file provider failed its metadata query" }
null
}
}
private fun ContentResolver.queryNameAndSize(uri: Uri): Pair<String?, Long?>? =
query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
val name = if (nameIndex >= 0 && !cursor.isNull(nameIndex)) cursor.getString(nameIndex) else null
val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else null
name to size
}
/** A reported size is only a claim, so the cap holds on the stream too. */
private fun ContentResolver.readSharedMapFile(uri: Uri, file: SharedMapFile, maxBytes: Long): SharedMapFileLoad = try {
val stream = openInputStream(uri)
val bytes = stream?.use { it.readAtMost(maxBytes) }
when {
stream == null -> SharedMapFileLoad.Refused(SharedMapFileRejection.UNREADABLE)
bytes == null -> SharedMapFileLoad.Refused(SharedMapFileRejection.TOO_LARGE)
else -> SharedMapFileLoad.Loaded(file, bytes)
}
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.withTag(TAG).w(e) { "Could not read the shared map file" }
SharedMapFileLoad.Refused(SharedMapFileRejection.UNREADABLE)
}
/** Every byte of the stream, or null once it runs past [maxBytes]. */
internal fun InputStream.readAtMost(maxBytes: Long): ByteArray? {
val out = ByteArrayOutputStream()
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0L
while (true) {
val count = read(buffer)
if (count < 0) return out.toByteArray()
total += count
if (total > maxBytes) {
Logger.withTag(TAG).w { "Refusing a shared map file past the ${maxBytes}B cap" }
return null
}
out.write(buffer, 0, count)
}
}
@@ -0,0 +1,299 @@
/*
* 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.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns
import androidx.test.core.app.ApplicationProvider
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import java.io.ByteArrayInputStream
import java.io.File
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@RunWith(RobolectricTestRunner::class)
class SharedMapFileTest {
@get:Rule val temp = TemporaryFolder()
private fun shared(
displayName: String,
mimeType: String? = null,
size: Long? = null,
scheme: String? = ContentResolver.SCHEME_CONTENT,
) = SharedMapFile(scheme = scheme, displayName = displayName, mimeType = mimeType, size = size)
@Test
fun `kml kmz geojson and json files are accepted by their extension`() {
listOf("route.kml", "route.kmz", "coverage.geojson", "area.json", "ROUTE.KML").forEach { name ->
assertNull(shared(name, mimeType = "application/octet-stream").rejection(), name)
}
}
@Test
fun `a file with no extension is accepted by a map MIME type`() {
listOf(
"application/vnd.google-earth.kml+xml",
"application/vnd.google-earth.kmz",
"application/geo+json",
"application/vnd.geo+json",
"application/json",
)
.forEach { mime -> assertNull(shared("export", mimeType = mime).rejection(), mime) }
}
@Test
fun `a file whose extension is not a map format is refused`() {
listOf("photo.jpg", "track.gpx", "notes.txt", "route.kml.zip").forEach { name ->
assertEquals(
SharedMapFileRejection.UNSUPPORTED_TYPE,
shared(name, mimeType = "application/json").rejection(),
name,
)
}
}
@Test
fun `a file with no extension and a non-map MIME type is refused`() {
listOf("image/png", "text/plain", "application/octet-stream").forEach { mime ->
assertEquals(SharedMapFileRejection.UNSUPPORTED_TYPE, shared("export", mimeType = mime).rejection(), mime)
}
assertEquals(SharedMapFileRejection.UNSUPPORTED_TYPE, shared("export", mimeType = null).rejection())
}
@Test
fun `a coverage file from another app is refused`() {
assertEquals(SharedMapFileRejection.UNSUPPORTED_TYPE, shared("estimate.coverage").rejection())
}
@Test
fun `a file uri is refused whatever it names`() {
listOf("route.kml", "coverage.geojson").forEach { name ->
assertEquals(
SharedMapFileRejection.NOT_CONTENT_URI,
shared(name, scheme = ContentResolver.SCHEME_FILE, size = 10).rejection(),
name,
)
}
assertEquals(SharedMapFileRejection.NOT_CONTENT_URI, shared("route.kml", scheme = null).rejection())
}
@Test
fun `a file reported over the cap is refused before it is read`() {
assertEquals(SharedMapFileRejection.TOO_LARGE, shared("route.kml", size = 101).rejection(maxBytes = 100))
assertNull(shared("route.kml", size = 100).rejection(maxBytes = 100))
assertNull(shared("route.kml", size = 0).rejection(maxBytes = 100))
}
@Test
fun `a file of unknown size is left to the capped read`() {
assertNull(shared("route.kml", size = null).rejection(maxBytes = 100))
}
@Test
fun `a read that runs past the cap returns nothing`() {
assertNull(ByteArrayInputStream(ByteArray(DEFAULT_BUFFER_SIZE * 3 + 1)).readAtMost(DEFAULT_BUFFER_SIZE * 3L))
assertNull(ByteArrayInputStream(ByteArray(101)).readAtMost(100))
}
@Test
fun `a read at the cap returns every byte`() {
val bytes = ByteArray(DEFAULT_BUFFER_SIZE * 2 + 7) { it.toByte() }
assertContentEquals(bytes, ByteArrayInputStream(bytes).readAtMost(bytes.size.toLong()))
assertContentEquals(ByteArray(0), ByteArrayInputStream(ByteArray(0)).readAtMost(100))
}
@Test
fun `the provider's name size and type are what the check sees`() {
val provider = registerProvider()
provider.displayName = "Ridge Trail.kml"
provider.size = 42L
provider.mimeType = "application/vnd.google-earth.kml+xml"
assertEquals(
SharedMapFile(
scheme = ContentResolver.SCHEME_CONTENT,
displayName = "Ridge Trail.kml",
mimeType = "application/vnd.google-earth.kml+xml",
size = 42L,
),
resolver.sharedMapFile(uri),
)
}
@Test
fun `a provider with no name or size falls back to the last path segment`() {
val provider = registerProvider()
provider.displayName = null
provider.size = null
provider.mimeType = "application/geo+json"
val shared = assertNotNull(resolver.sharedMapFile(uri))
assertEquals("shared-layer", shared.displayName)
assertNull(shared.size)
assertEquals("geo+json", shared.extensionOrMime)
}
@Test
fun `a provider that throws on the metadata query is refused unopened`() {
val provider = registerProvider()
provider.mimeType = "application/geo+json"
provider.refuse = true
assertEquals(SharedMapFileLoad.Refused(SharedMapFileRejection.UNREADABLE), resolver.loadSharedMapFile(uri))
assertEquals(0, provider.opens)
}
@Test
fun `a file uri is not queried`() {
val fileUri = Uri.fromFile(File("/data/data/com.geeksville.mesh/databases/meshtastic.db"))
assertEquals(
SharedMapFile(ContentResolver.SCHEME_FILE, "meshtastic.db", null, null),
resolver.sharedMapFile(fileUri),
)
}
@Test
fun `a map file of unknown size is read whole up to the cap and refused past it`() {
val provider = registerProvider()
provider.displayName = "layer.geojson"
val bytes = """{"type":"FeatureCollection","features":[]}""".encodeToByteArray()
provider.file = temp.newFile("layer.geojson").apply { writeBytes(bytes) }
val loaded = assertIs<SharedMapFileLoad.Loaded>(resolver.loadSharedMapFile(uri, maxBytes = bytes.size.toLong()))
assertContentEquals(bytes, loaded.bytes)
assertEquals("layer.geojson", loaded.file.displayName)
assertEquals(
SharedMapFileLoad.Refused(SharedMapFileRejection.TOO_LARGE),
resolver.loadSharedMapFile(uri, maxBytes = bytes.size - 1L),
)
}
@Test
fun `a file the metadata refuses is never opened`() {
val provider = registerProvider()
provider.file = temp.newFile("photo.jpg")
provider.displayName = "photo.jpg"
assertEquals(
SharedMapFileLoad.Refused(SharedMapFileRejection.UNSUPPORTED_TYPE),
resolver.loadSharedMapFile(uri),
)
provider.displayName = "route.kml"
provider.size = 101L
assertEquals(
SharedMapFileLoad.Refused(SharedMapFileRejection.TOO_LARGE),
resolver.loadSharedMapFile(uri, maxBytes = 100),
)
assertEquals(0, provider.opens)
}
@Test
fun `a provider that throws on open is refused instead of crashing`() {
val provider = registerProvider()
provider.displayName = "route.kml"
listOf(SecurityException("no grant"), IllegalStateException("provider bug")).forEach { error ->
provider.openError = error
assertEquals(
SharedMapFileLoad.Refused(SharedMapFileRejection.UNREADABLE),
resolver.loadSharedMapFile(uri),
error.toString(),
)
}
}
@Test
fun `a file uri is refused without being opened`() {
val fileUri = Uri.fromFile(temp.newFile("route.kml"))
assertEquals(
SharedMapFileLoad.Refused(SharedMapFileRejection.NOT_CONTENT_URI),
resolver.loadSharedMapFile(fileUri),
)
}
private val resolver: ContentResolver
get() = ApplicationProvider.getApplicationContext<Context>().contentResolver
private val uri = Uri.parse("content://$AUTHORITY/shared-layer")
private fun registerProvider(): FakeMapFileProvider =
Robolectric.buildContentProvider(FakeMapFileProvider::class.java).create(AUTHORITY).get()
class FakeMapFileProvider : ContentProvider() {
var displayName: String? = null
var size: Long? = null
var mimeType: String? = null
var file: File? = null
var refuse = false
var openError: RuntimeException? = null
var opens = 0
override fun onCreate() = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
if (refuse) throw SecurityException("no grant")
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply {
addRow(arrayOf<Any?>(displayName, size))
}
}
override fun getType(uri: Uri): String? = mimeType
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
opens++
openError?.let { throw it }
return ParcelFileDescriptor.open(checkNotNull(file), ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
}
private companion object {
const val AUTHORITY = "org.meshtastic.test.sharedmapfile"
}
}
@@ -1022,6 +1022,8 @@
<string name="map_filter_title">Filter map</string>
<string name="map_layer_formats">Map layers support .kml, .kmz, or GeoJSON formats.</string>
<string name="map_layer_opacity">Opacity: %1$d%</string>
<string name="map_layer_open_failed">Couldn't open that map file.</string>
<string name="map_layer_too_large">Map files over %1$d MB can't be imported.</string>
<string name="map_node_popup_details">%1$s&lt;br&gt;Last heard: %2$s&lt;br&gt;Last position: %3$s&lt;br&gt;Battery: %4$s</string>
<string name="map_offline_banner">Offline — showing cached map data</string>
<string name="map_offline_download_failed_build">No offline map data available right now — try again later</string>