version 1.8 - fixed and updating reading, fixed and updating writing

develop
bvn13 2023-02-13 20:05:01 +03:00
parent 2966a5ea61
commit 3a13dbcd50
8 changed files with 6840 additions and 45 deletions

View File

@ -1,5 +1,7 @@
# Android (Kotlin) SDK for manipulating GPX files
![](https://img.shields.io/maven-central/v/me.bvn13.sdk.android.gpx/GpxAndroidSdk)
## About
This is another one SDK for reading and writing (manipulating) GPX files.
@ -8,6 +10,12 @@ Official GPX format is on [topografix](https://www.topografix.com/GPX/1/1/) site
## Changelog
### 2023-02-13
1) Fixed missed extensions
2) ✅ Tested on reading with self written content
3) ✅ Tested on reading [OsmAnd](https://osmand.net) GPX files
### 2022-12-18
1) implemented GPX format reader

View File

@ -92,10 +92,13 @@ package me.bvn13.sdk.android.gpx
*
* [parameters] Map of key-value pairs
*/
class ExtensionType(val nodeName: String, val value: String? = null, val parameters: Map<String, String>? = null) {
class ExtensionType(val nodeName: String,
val value: String? = null,
val parameters: Map<String, String>? = null,
val nested: List<ExtensionType>? = null) {
init {
require(value != null || parameters != null) {
"value or parameters must be specified"
require(value != null || parameters != null || nested != null) {
"value or parameters or nesting elements must be specified for ${nodeName}"
}
}
@ -108,6 +111,7 @@ class ExtensionType(val nodeName: String, val value: String? = null, val paramet
if (nodeName != other.nodeName) return false
if (value != other.value) return false
if (parameters != other.parameters) return false
if (nested != other.nested) return false
return true
}
@ -116,8 +120,7 @@ class ExtensionType(val nodeName: String, val value: String? = null, val paramet
var result = nodeName.hashCode()
result = 31 * result + (value?.hashCode() ?: 0)
result = 31 * result + (parameters?.hashCode() ?: 0)
result = 31 * result + (nested?.hashCode() ?: 0)
return result
}
}

View File

@ -6,6 +6,7 @@ import java.time.format.DateTimeFormatterBuilder
class GpxConstant {
companion object {
const val HEADER = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
const val HEADER_EXTENDED = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
const val VERSION = "1.1"
val DTF =
DateTimeFormatterBuilder().append(ISO_LOCAL_DATE_TIME) // use the existing formatter for date time

View File

@ -3,6 +3,7 @@ package me.bvn13.sdk.android.gpx
import me.bvn13.sdk.android.gpx.GpxConstant.Companion.DTF
import java.io.InputStream
import java.time.OffsetDateTime
import java.util.stream.Collectors
fun GpxType.Companion.read(dis: InputStream) = GpxReader().read(dis)
@ -28,7 +29,13 @@ class GpxReader {
private fun readSignature(dis: InputStream, buffer: Container): GpxType {
val container = readUntil(dis, buffer, '\n')
if ("${GpxConstant.HEADER}\n" != container.buffer.asString()) {
val signaturePrepared = container.buffer.asString()
.trim()
.replace("'", "\"")
.replace(" ?", "?")
if (GpxConstant.HEADER != signaturePrepared
&& GpxConstant.HEADER_EXTENDED != signaturePrepared
) {
throw IllegalArgumentException("Wrong xml signature!")
}
return readBeginning(dis, Container.empty(container.position))
@ -59,10 +66,12 @@ class GpxReader {
return findObject(container.objects, "gpx").let {
return GpxType(
metadata = assembleMetadataType(it.nested),
creator = findAttributeOrNull(it.attributes, "creator") ?: throw IllegalArgumentException("Gpx.Creator not found"),
wpt = findObjectsOrNull(it.nested ,"wpt")?.map { assembleWptType(it) },
creator = findAttributeOrNull(it.attributes, "creator")
?: throw IllegalArgumentException("Gpx.Creator not found"),
wpt = findObjectsOrNull(it.nested, "wpt")?.map { assembleWptType(it) },
rte = findObjectsOrNull(it.nested, "rte")?.map { assembleRteType(it) },
trk = findObjectsOrNull(it.nested, "trk")?.map { assembleTrkType(it) }
trk = findObjectsOrNull(it.nested, "trk")?.map { assembleTrkType(it) },
extensions = findObjectOrNull(it.nested, "extensions")?.let { assembleExtensionType(it) }
)
}
}
@ -71,10 +80,13 @@ class GpxReader {
findObject(objects, "metadata")
.let {
return MetadataType(
name = findObjectOrNull(it.nested, "name")?.value ?: throw IllegalArgumentException("Gpx.Metadata.Name not found"),
description = findObjectOrNull(it.nested, "desc")?.value ?: throw IllegalArgumentException("Gpx.Metadata.Description not found"),
authorName = findObject(it.nested, "author").let { author ->
findObject(author.nested, "name").value ?: throw IllegalArgumentException("Gpx.Metadata.Author.Name not found")
name = findObjectOrNull(it.nested, "name")?.value
?: throw IllegalArgumentException("Gpx.Metadata.Name not found"),
description = findObjectOrNull(it.nested, "desc")?.value
?: "",
authorName = findObjectOrNull(it.nested, "author").let { author ->
findObjectOrNull(author?.nested, "name")?.value
?: ""
}
)
}
@ -123,7 +135,10 @@ class GpxReader {
cmt = findObjectOrNull(obj.nested, "cmt")?.value,
desc = findObjectOrNull(obj.nested, "desc")?.value,
src = findObjectOrNull(obj.nested, "src")?.value,
link = findObjectsOrNull(obj.nested, "link")?.let { list -> if (list.isNotEmpty()) list.map { assembleLinkType(it) } else null },
link = findObjectsOrNull(
obj.nested,
"link"
)?.let { list -> if (list.isNotEmpty()) list.map { assembleLinkType(it) } else null },
number = findObjectOrNull(obj.nested, "number")?.value?.toInt(),
type = findObjectOrNull(obj.nested, "type")?.value,
extensions = findObjectOrNull(obj.nested, "extensions")?.let { assembleExtensionType(it) },
@ -140,12 +155,17 @@ class GpxReader {
private fun assembleFixType(value: String): FixType =
FixType.valueOf(value.uppercase())
private fun assembleExtensionType(obj: XmlObject): ExtensionType =
ExtensionType(
private fun assembleExtensionType(obj: XmlObject): ExtensionType {
val nested: List<ExtensionType>? = obj.nested?.stream()
?.map { assembleExtensionType(it) }
?.collect(Collectors.toList())
return ExtensionType(
nodeName = obj.type,
value = if (obj.value == "") null else obj.value,
parameters = if (obj.attributes.isEmpty()) null else obj.attributes.toSortedMap()
)
parameters = if (obj.attributes.isEmpty()) null else obj.attributes.toSortedMap(),
nested = nested
)
}
private fun assembleTrksegType(obj: XmlObject): TrksegType =
TrksegType(
@ -186,14 +206,16 @@ class GpxReader {
container = readAttributes(dis, container)
xmlObject.attributes = container.attributes
}
container = readSkipping(dis, container, SKIPPING_SET)
if (container.byte!!.toInt() == '<'.code) {
container = readNestedObjects(dis, container)
xmlObject.nested = container.objects
container = readFinishingTag(dis, container, tagName)
}
if (container.buffer.asString() != "</$tagName>") {
container = readValue(dis, container, tagName)
if (!container.isShortClosing) {
container = readSkipping(dis, container, SKIPPING_SET)
if (container.byte!!.toInt() == '<'.code) {
container = readNestedObjects(dis, container)
xmlObject.nested = container.objects
container = readFinishingTag(dis, container, tagName)
}
if (container.buffer.asString() != "</$tagName>") {
container = readValue(dis, container, tagName)
}
}
xmlObject.value = container.buffer.asString().replace("</$tagName>", "")
container.objects = listOf(xmlObject)
@ -221,8 +243,12 @@ class GpxReader {
return container
}
private fun readFinishingTag(dis: InputStream, buffer: Container, tagName: String): Container =
readExactly(dis, buffer, "</${tagName}>")
private fun readFinishingTag(dis: InputStream, buffer: Container, tagName: String): Container {
if (buffer.isShortClosing) {
return buffer
}
return readExactly(dis, buffer, "</${tagName}>")
}
private fun readValue(dis: InputStream, buffer: Container, tagName: String): Container =
readUntil(dis, buffer, "</$tagName>")
@ -236,8 +262,10 @@ class GpxReader {
do {
attributeContainer = readAttribute(dis, attributeContainer)
attributes.putAll(attributeContainer.attributes)
} while (attributeContainer.attributes.isNotEmpty() && attributeContainer.byte!!.toInt() != '>'.code)
val result = Container.emptyWithAttributes(attributeContainer.position, attributes)
} while (attributeContainer.attributes.isNotEmpty()
&& attributeContainer.byte!!.toInt() != '>'.code
)
val result = Container.emptyWithAttributes(attributeContainer.position, attributes, attributeContainer.isShortClosing)
return result
}
@ -250,8 +278,17 @@ class GpxReader {
val valueAsString = valueContainer.buffer.asString()
val value = valueAsString.substring(0, valueAsString.length - 1)
val result = Container.empty(valueContainer.position)
val closingContainer = readSkipping(dis, result, SKIPPING_SET)
val nextBlockContainer = Container.of(closingContainer.byte!!, closingContainer.position)
var closingContainer = readSkipping(dis, result, SKIPPING_SET)
var isShortClosing = false
if (closingContainer.byte!!.toInt() == '/'.code) {
val endingContainer = readByte(dis, closingContainer)
if (endingContainer.byte!!.toInt() != '>'.code) {
throw IllegalArgumentException("There must be valid closing tag at ${endingContainer.position}")
}
closingContainer = endingContainer
isShortClosing = true
}
val nextBlockContainer = Container.of(closingContainer.byte!!, closingContainer.position, isShortClosing)
nextBlockContainer.attributes = mapOf(name to value)
return nextBlockContainer
}
@ -321,28 +358,42 @@ class GpxReader {
private fun readByte(dis: InputStream, container: Container): Container {
val ba = ByteArray(1);
if (-1 == dis.read(ba, 0, 1)) {
throw InterruptedException("EOF")
throw InterruptedException("EOF at ${container.position}\nUnparsed data: " + String(container.buffer))
} else if (ba.size != 1) {
throw InterruptedException("Reading of 1 byte returns ${ba.size} bytes")
throw InterruptedException("Reading of 1 byte returns ${ba.size} bytes at ${container.position}")
}
return Container(container.position + 1, ba[0], container.buffer.plus(ba))
}
class Container(val position: Long = 0, val byte: Byte?, val buffer: ByteArray) {
class Container(
val position: Long = 0,
val byte: Byte?,
val buffer: ByteArray,
val isShortClosing: Boolean = false
) {
var objects: List<XmlObject>? = null
var attributes: Map<String, String> = HashMap()
var value: String? = null
companion object {
fun empty(): Container = empty(0)
fun empty(position: Long) = Container(position, null, ByteArray(0))
fun emptyWithAttributes(position: Long, attributes: Map<String, String>): Container {
val container = Container.empty(position)
fun empty(position: Long, isShortClosing: Boolean = false) = Container(position, null, ByteArray(0), isShortClosing)
fun emptyWithAttributes(position: Long, attributes: Map<String, String>, isShortClosing: Boolean = false): Container {
val container = Container.empty(position, isShortClosing)
container.attributes = attributes
return container
}
fun of(b: Byte, position: Long) = Container(position, b, ByteArray(1) { _ -> b })
fun of(b: Byte, position: Long, isShortClosing: Boolean = false) =
Container(position, b, ByteArray(1) { _ -> b }, isShortClosing)
fun isShortClosing(c: Container, buffer: ByteArray): Container {
val container = Container(c.position, c.byte, buffer, isShortClosing = true)
container.objects = c.objects
container.attributes = c.attributes
container.value = c.value
return container
}
}
override fun toString(): String = this.buffer.asString()

View File

@ -100,6 +100,7 @@ fun GpxType.toXmlString(clock: Clock?): String = """
${this.wpt?.toXmlString() ?: ""}
${this.rte?.toXmlString() ?: ""}
${this.trk?.toXmlString() ?: ""}
${this.extensions?.toXmlString() ?: ""}
</gpx>
""".trim().removeEmptyStrings()
@ -140,7 +141,7 @@ fun WptType.toXmlString(nodeName: String) = """
${toXmlString(pdop, "pdop")}
${toXmlString(ageofgpsdata, "ageofgpsdata")}
${toXmlString(dgpsid, "dgpsid")}
${extensions?.toXmlString() ?: ""}
${extensions?.toXmlString(true) ?: ""}
</${nodeName}>
""".trim().removeEmptyStrings()
@ -153,7 +154,7 @@ fun RteType.toXmlString() = """
${this.link?.toXmlString() ?: ""}
${toXmlString(this.number, "number")}
${toXmlString(this.type, "type")}
${this.extensions?.toXmlString() ?: ""}
${this.extensions?.toXmlString(true) ?: ""}
${this.rtept?.toXmlString("rtept") ?: ""}
</rte>
""".trim().removeEmptyStrings()
@ -183,13 +184,21 @@ fun FixType.toXmlString() = """
<fix>${this.value}</fix>
""".trim().removeEmptyStrings()
fun ExtensionType.toXmlString() = """
fun ExtensionType.toXmlString() =
if ((this.nested?.size ?: 0) > 0) {
"""
<${this.nodeName}${toXmlString(this.parameters)}>${this.nested?.toXmlString() ?: ""}</${this.nodeName}>
""".trim().removeEmptyStrings()
} else {
"""
<${this.nodeName}${toXmlString(this.parameters)}>${this.value ?: ""}</${this.nodeName}>
""".trim().removeEmptyStrings()
}
fun TrksegType.toXmlString() = """
<trkseg>
${this.trkpt?.toXmlString("trkpt") ?: ""}
${this.extensions?.toXmlString() ?: ""}
</trkseg>
""".trim().removeEmptyStrings()
@ -197,9 +206,17 @@ fun List<WptType>.toXmlString(nodeName: String?) = this.joinToString(prefix = "\
it.toXmlString(nodeName)
}
fun List<ExtensionType>.toXmlString() = this.joinToString(
prefix = "<extensions>\n", postfix = "\n</extensions>", separator = "\n", transform = ExtensionType::toXmlString
)
fun List<ExtensionType>.toXmlString(inGroup: Boolean = false): String {
if (inGroup) {
return this.joinToString(
prefix = "<extensions>\n", postfix = "\n</extensions>", separator = "\n", transform = ExtensionType::toXmlString
)
} else {
return this.joinToString(
prefix = "\n", postfix = "\n", separator = "\n", transform = ExtensionType::toXmlString
)
}
}
@JvmName("toXmlStringWptType")
fun List<WptType>.toXmlString() = this.joinToString(prefix = "", postfix = "", separator = "") {

View File

@ -1,5 +1,6 @@
package me.bvn13.sdk.android.gpx
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.*
@ -409,4 +410,16 @@ class GpxReaderTest {
val gpx = GpxType.read(gpxString.byteInputStream())
assertEquals(gpxType, gpx)
}
@DisplayName("Read test.gpx (generated in OsmAnd Android application")
@Test
fun readTestGpx() {
val gpxType = GpxType.read(javaClass.classLoader.getResource("test.gpx").openStream())
Assertions.assertEquals(1011, gpxType.trk?.get(0)?.trkseg?.get(0)?.trkpt?.size ?: 0)
Assertions.assertEquals(1, gpxType.trk?.get(0)?.trkseg?.get(0)?.trkpt?.get(0)?.extensions?.size ?: 0)
Assertions.assertEquals(2, gpxType.trk?.get(0)?.trkseg?.get(0)?.extensions?.nested?.size ?: 0)
Assertions.assertEquals(223, gpxType.trk?.get(0)?.trkseg?.get(0)?.extensions?.nested?.get(0)?.nested?.size ?: 0)
Assertions.assertEquals(159, gpxType.trk?.get(0)?.trkseg?.get(0)?.extensions?.nested?.get(1)?.nested?.size ?: 0)
Assertions.assertEquals(1, gpxType.extensions?.nested?.size ?: 0)
}
}

View File

@ -0,0 +1,243 @@
package me.bvn13.sdk.android.gpx
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.time.*
class ReadWriteTest {
@DisplayName("Read-Write test")
@Test
fun testReadWrite() {
val clock = Clock.fixed(
LocalDateTime.of(2022, 9, 24, 15, 4, 0, 0).toInstant(ZoneOffset.ofHours(3)), ZoneId.of("Europe/Moscow")
)
val gpxType = GpxType(
MetadataType("test name", description = "test description", authorName = "bvn13"),
wpt = listOf(
WptType(
lat = 14.64736838389662,
lon = 7.93212890625,
ele = 10.toDouble(),
time = OffsetDateTime.now(clock),
magvar = 3.toDouble(),
geoidheight = 45.toDouble(),
name = "test point 1",
cmt = "comment 1",
desc = "description of point 1",
link = listOf(
LinkType(
href = "http://link-to.site.href",
text = "text",
type = "hyperlink"
),
LinkType(
href = "http://link2-to.site.href",
text = "text2",
type = "hyperlink2"
)
),
src = "source 1",
sym = "sym 1",
type = "type 1",
fix = FixType.DGPS,
sat = 1,
hdop = 55.toDouble(),
vdop = 66.toDouble(),
pdop = 77.toDouble(),
ageofgpsdata = 44,
dgpsid = 88,
extensions = listOf(
ExtensionType(
"extension1",
parameters = mapOf(Pair("first", "second"), Pair("third", "fours"))
),
ExtensionType(
"extension2",
parameters = mapOf(Pair("aa", "bb"), Pair("cc", "dd"))
)
)
)
),
rte = listOf(
RteType(
name = "rte name",
cmt = "cmt",
desc = "desc",
src = "src",
link = listOf(
LinkType(
href = "https://new.link.rte",
text = "new text rte",
type = "hyperlink"
)
),
number = 1234,
type = "route",
extensions = listOf(
ExtensionType(
"ext-1",
value = "value1"
)
),
rtept = listOf(
WptType(
lat = 14.64736838389662,
lon = 7.93212890625,
ele = 10.toDouble(),
time = OffsetDateTime.now(clock),
magvar = 3.toDouble(),
geoidheight = 45.toDouble(),
name = "test point 1",
cmt = "comment 1",
desc = "description of point 1",
link = listOf(
LinkType(
href = "http://link-to.site.href",
text = "text",
type = "hyperlink"
),
LinkType(
href = "http://link2-to.site.href",
text = "text2",
type = "hyperlink2"
)
),
src = "source 1",
sym = "sym 1",
type = "type 1",
fix = FixType.DGPS,
sat = 1,
hdop = 55.toDouble(),
vdop = 66.toDouble(),
pdop = 77.toDouble(),
ageofgpsdata = 44,
dgpsid = 88,
extensions = listOf(
ExtensionType(
"extension1",
parameters = mapOf(Pair("first", "second"), Pair("third", "fours"))
),
ExtensionType(
"extension2",
parameters = mapOf(Pair("aa", "bb"), Pair("cc", "dd"))
)
)
)
)
)
),
trk = listOf(
TrkType(
name = "track 1",
cmt = "comment track 1",
desc = "desc track 1",
src = "src track 1",
number = 1234,
type = "type 1",
trkseg = listOf(
TrksegType(
listOf(
WptType(
lat = 14.64736838389662,
lon = 7.93212890625,
ele = 10.toDouble(),
time = OffsetDateTime.now(clock),
magvar = 3.toDouble(),
geoidheight = 45.toDouble(),
name = "test point 1",
cmt = "comment 1",
desc = "description of point 1",
link = listOf(
LinkType(
href = "http://link-to.site.href",
text = "text",
type = "hyperlink"
),
LinkType(
href = "http://link2-to.site.href",
text = "text2",
type = "hyperlink2"
)
),
src = "source 1",
sym = "sym 1",
type = "type 1",
fix = FixType.DGPS,
sat = 1,
hdop = 55.toDouble(),
vdop = 66.toDouble(),
pdop = 77.toDouble(),
ageofgpsdata = 44,
dgpsid = 88,
extensions = listOf(
ExtensionType(
"extension1",
parameters = mapOf(Pair("first", "second"), Pair("third", "fours"))
),
ExtensionType(
"extension2",
parameters = mapOf(Pair("aa", "bb"), Pair("cc", "dd"))
)
)
),
WptType(
lat = 14.64736838389662,
lon = 7.93212890625,
ele = 10.toDouble(),
time = OffsetDateTime.now(clock),
magvar = 3.toDouble(),
geoidheight = 45.toDouble(),
name = "test point 1",
cmt = "comment 1",
desc = "description of point 1",
link = listOf(
LinkType(
href = "http://link-to.site.href",
text = "text",
type = "hyperlink"
),
LinkType(
href = "http://link2-to.site.href",
text = "text2",
type = "hyperlink2"
)
),
src = "source 1",
sym = "sym 1",
type = "type 1",
fix = FixType.DGPS,
sat = 1,
hdop = 55.toDouble(),
vdop = 66.toDouble(),
pdop = 77.toDouble(),
ageofgpsdata = 44,
dgpsid = 88,
extensions = listOf(
ExtensionType(
"extension1",
parameters = mapOf(Pair("first", "second"), Pair("third", "fours"))
),
ExtensionType(
"extension2",
parameters = mapOf(Pair("aa", "bb"), Pair("cc", "dd"))
)
)
)
)
)
)
)
)
)
val gpx = gpxType.toXmlString(clock)
val deserializedGpxType = GpxType.read(ByteArrayInputStream(gpx.toByteArray()))
Assertions.assertEquals(gpxType, deserializedGpxType)
}
}

File diff suppressed because it is too large Load Diff