diff --git a/MIGRATION.md b/MIGRATION.md index 5493c6f43..1fbc6db01 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -133,3 +133,19 @@ uses-sdk:minSdkVersion 23 cannot be smaller than version 24 declared in library ``` Raise your app's `minSdk` to `24`. Overriding the check with `tools:overrideLibrary` is not supported and may crash on API 23 devices. + +--- + +## 6. Data Parsers Declare `IOException` for Java Callers + +Starting with `v7.0.0`, `KmzParser.parse`, `KmlParser.parse`, `KmlParser.parseAsKml`, `GpxParser.parse` and `GeoJsonParser.parse` are annotated with `@Throws(IOException::class)`. These methods always propagated `IOException` at runtime (stream failures and the KMZ entry count and size limits), but the Java signatures did not declare it, so Java callers could not write `catch (IOException e)`. + +Kotlin callers are unaffected, and so is binary compatibility. Java callers that recompile must now catch or declare `IOException`: + +```java +try { + Kml kml = new KmzParser().parse(inputStream); +} catch (IOException e) { + // Handle the read failure or rejected KMZ +} +``` diff --git a/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt b/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt index c0e9f4392..4a550628d 100644 --- a/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt +++ b/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt @@ -24,6 +24,7 @@ import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import java.io.IOException import java.io.InputStream public class GeoJsonParser @JvmOverloads public constructor( @@ -35,6 +36,15 @@ public class GeoJsonParser @JvmOverloads public constructor( @OptIn(ExperimentalSerializationApi::class) private val jsonParser = Json { maxNestingDepth = maxStructuralDepth } + /** + * Parses a GeoJSON document from an InputStream. + * + * @param inputStream The InputStream of the GeoJSON document, read as UTF-8. + * @return The parsed GeoJSON object, or `null` if the top-level `type` is not recognized. + * @throws IOException If the stream cannot be read. + * @throws IllegalArgumentException If the document exceeds the size or nesting limits. + */ + @Throws(IOException::class) public fun parse(inputStream: InputStream): GeoJsonObject? { // Bound the untrusted input *before* materialising it. [Json.parseToJsonElement] reads the // whole document into an in-memory tree, so without these guards a hostile document can diff --git a/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxParser.kt b/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxParser.kt index e3ff1bf87..f4f5d75d1 100644 --- a/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxParser.kt +++ b/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxParser.kt @@ -17,6 +17,7 @@ package com.google.maps.android.data.parser.gpx import kotlinx.serialization.decodeFromString import nl.adaptivity.xmlutil.serialization.XML +import java.io.IOException import java.io.InputStream import java.nio.charset.StandardCharsets @@ -35,6 +36,14 @@ public class GpxParser public constructor() { isCollectingNSAttributes = true } + /** + * Parses a GPX document from an InputStream. + * + * @param inputStream The InputStream of the GPX document, read as UTF-8. + * @return The parsed GPX object. + * @throws IOException If the stream cannot be read. + */ + @Throws(IOException::class) public fun parse(inputStream: InputStream): Gpx { val xmlContent = inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } // Normalize GPX 1.0 namespace to 1.1 to allow parsing with the same model diff --git a/data/src/main/java/com/google/maps/android/data/parser/kml/KmlParser.kt b/data/src/main/java/com/google/maps/android/data/parser/kml/KmlParser.kt index fc4173069..3a1ad966a 100644 --- a/data/src/main/java/com/google/maps/android/data/parser/kml/KmlParser.kt +++ b/data/src/main/java/com/google/maps/android/data/parser/kml/KmlParser.kt @@ -21,6 +21,7 @@ import nl.adaptivity.xmlutil.XmlException import nl.adaptivity.xmlutil.XmlReader import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.xmlStreaming +import java.io.IOException import java.io.InputStream import java.nio.charset.StandardCharsets @@ -33,6 +34,14 @@ public class KmlParser public constructor() { isCollectingNSAttributes = true } + /** + * Parses a KML document from an InputStream. + * + * @param inputStream The InputStream of the KML document, read as UTF-8. + * @return The parsed KML object. + * @throws IOException If the stream cannot be read. + */ + @Throws(IOException::class) public fun parseAsKml(inputStream: InputStream): Kml { val xmlContent = inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } return DepthLimitingReader(xmlStreaming.newReader(xmlContent)).use { reader -> @@ -40,6 +49,14 @@ public class KmlParser public constructor() { } } + /** + * Parses a KML document from an InputStream. Equivalent to [parseAsKml]. + * + * @param inputStream The InputStream of the KML document, read as UTF-8. + * @return The parsed KML object. + * @throws IOException If the stream cannot be read. + */ + @Throws(IOException::class) public fun parse(inputStream: InputStream): Kml = parseAsKml(inputStream) public companion object { diff --git a/data/src/main/java/com/google/maps/android/data/parser/kml/KmzParser.kt b/data/src/main/java/com/google/maps/android/data/parser/kml/KmzParser.kt index ac1d95d63..395375708 100644 --- a/data/src/main/java/com/google/maps/android/data/parser/kml/KmzParser.kt +++ b/data/src/main/java/com/google/maps/android/data/parser/kml/KmzParser.kt @@ -98,7 +98,11 @@ public class KmzParser @JvmOverloads public constructor( * * @param inputStream The InputStream of the KMZ file. * @return The parsed KML object, including extracted images. + * @throws IOException If the stream cannot be read, or the KMZ exceeds the entry count or + * uncompressed size limits. + * @throws IllegalArgumentException If the KMZ does not contain a KML file. */ + @Throws(IOException::class) public fun parse(inputStream: InputStream): Kml { val images = mutableMapOf() var kml: Kml? = null diff --git a/data/src/test/java/com/google/maps/android/data/parser/ParserCheckedExceptionTest.java b/data/src/test/java/com/google/maps/android/data/parser/ParserCheckedExceptionTest.java new file mode 100644 index 000000000..17b695a41 --- /dev/null +++ b/data/src/test/java/com/google/maps/android/data/parser/ParserCheckedExceptionTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.data.parser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import com.google.maps.android.data.parser.geojson.GeoJsonParser; +import com.google.maps.android.data.parser.gpx.GpxParser; +import com.google.maps.android.data.parser.kml.KmlParser; +import com.google.maps.android.data.parser.kml.KmzParser; +import java.io.IOException; +import java.io.InputStream; +import org.junit.Test; + +/** + * Verifies from Java that the parsers declare {@link IOException}. Each {@code catch (IOException)} + * below only compiles if the called method declares it, so this test also guards the Java + * signature. + */ +public class ParserCheckedExceptionTest { + private static final String MESSAGE = "stream failure"; + + private static InputStream failingStream() { + return new InputStream() { + @Override + public int read() throws IOException { + throw new IOException(MESSAGE); + } + }; + } + + @Test + public void kmzParserDeclaresIOException() { + try { + new KmzParser().parse(failingStream()); + fail("Expected IOException"); + } catch (IOException e) { + assertEquals(MESSAGE, e.getMessage()); + } + } + + @Test + public void kmlParserDeclaresIOException() { + try { + new KmlParser().parse(failingStream()); + fail("Expected IOException"); + } catch (IOException e) { + assertEquals(MESSAGE, e.getMessage()); + } + } + + @Test + public void kmlParserParseAsKmlDeclaresIOException() { + try { + new KmlParser().parseAsKml(failingStream()); + fail("Expected IOException"); + } catch (IOException e) { + assertEquals(MESSAGE, e.getMessage()); + } + } + + @Test + public void gpxParserDeclaresIOException() { + try { + new GpxParser().parse(failingStream()); + fail("Expected IOException"); + } catch (IOException e) { + assertEquals(MESSAGE, e.getMessage()); + } + } + + @Test + public void geoJsonParserDeclaresIOException() { + try { + new GeoJsonParser().parse(failingStream()); + fail("Expected IOException"); + } catch (IOException e) { + assertEquals(MESSAGE, e.getMessage()); + } + } +}