Skip to content

Commit f36b01c

Browse files
committed
fix(metadata-generator): read Kotlin metadata leniently
Classes compiled by a Kotlin newer than the one the generator jar was built against were silently dropped from the generated metadata, so their members were missing at runtime. Reported against Kotlin 2.4.x, where an app failed to start with "Unable to create application". KotlinClassMetadata.readStrict rejects any metadata version newer than the bundled kotlin-metadata-jvm, and the resulting IllegalArgumentException surfaced lazily inside Builder.build's catch-all, which just logged "Skip <class>" and moved on. readLenient accepts any version from 1.1.0 up; the only thing it gives up is writing metadata back, which this tool never does. A version bump alone cannot fix this, since the jar ships prebuilt while apps pick their own Kotlin version. Unreadable metadata now falls back to bytecode-only parsing instead of dropping the class, and an extension function whose metadata signature has no matching bytecode method is skipped rather than throwing. Kotlin and kotlin-metadata-jvm go to 2.4.10. Both have to move together: kotlin-metadata-jvm pulls in a matching kotlin-stdlib, which an older compiler then refuses to read. Verified that the metadata generated for the test app is unchanged.
1 parent 72fce99 commit f36b01c

5 files changed

Lines changed: 130 additions & 4 deletions

File tree

test-app/build-tools/android-metadata-generator/src/src/com/telerik/metadata/parsing/kotlin/classes/KotlinClassDescriptor.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,19 @@ class KotlinClassDescriptor(nativeClass: JavaClass, private val metadataAnnotati
263263
metadataAnnotation.packageName,
264264
metadataAnnotation.extraInt)
265265

266-
KotlinClassMetadata.readStrict(metadata)
266+
// readStrict rejects any class whose metadata version is newer than the bundled
267+
// kotlin-metadata-jvm, which happens whenever an app or one of its dependencies is
268+
// compiled with a Kotlin release newer than the one this jar was built against.
269+
// Lenient reading only gives up the ability to write metadata back, which nothing here does.
270+
try {
271+
KotlinClassMetadata.readLenient(metadata)
272+
} catch (e: Exception) {
273+
// Bytecode-only parsing still yields usable members, so never let an unreadable
274+
// annotation drop the whole class from the generated metadata.
275+
println("Warning: could not read Kotlin metadata for $className; falling back to bytecode-only parsing")
276+
println("\tError: $e")
277+
null
278+
}
267279
}
268280

269281

test-app/build-tools/android-metadata-generator/src/src/com/telerik/metadata/parsing/kotlin/extensions/bytecode/BytecodeExtensionFunctionsCollector.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ class BytecodeExtensionFunctionsCollector(private val kotlinClassMetadataParser:
2626
val functionName = signature.name
2727
val functionSignature = signature.descriptor
2828

29+
// Metadata written by a newer Kotlin compiler can describe functions this jar's
30+
// bytecode view does not match; skip those rather than dropping the whole class.
2931
val extensionFunctionDescriptor: KotlinMethodDescriptor = Arrays
3032
.stream(kotlinClassDescriptor.methods)
3133
.filter { x -> x.name == functionName && x.signature == functionSignature }
3234
.findFirst()
33-
.get()
35+
.orElse(null) ?: continue
3436

3537
if (extensionFunctionDescriptor.isStatic) {
3638
val receiverType = extensionFunctionDescriptor.argumentTypes[0] // kotlin extension functions' first argument is the receiver type
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package com.telerik.metadata.parsing.kotlin.classes
2+
3+
import com.telerik.metadata.parsing.kotlin.metadata.MetadataAnnotation
4+
import org.apache.bcel.classfile.ClassParser
5+
import org.apache.bcel.classfile.JavaClass
6+
import org.junit.Assert.assertEquals
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Assert.fail
9+
import org.junit.Test
10+
import kotlin.metadata.jvm.KotlinClassMetadata
11+
import kotlin.metadata.jvm.Metadata
12+
13+
class KotlinClassDescriptorMetadataVersionTest {
14+
15+
companion object {
16+
// Newer than any Kotlin release the bundled kotlin-metadata-jvm knows about, so the test
17+
// stays meaningful after the library is upgraded.
18+
private val FUTURE_METADATA_VERSION = intArrayOf(2, 9, 0)
19+
}
20+
21+
private val fixture = KotlinMetadataVersionFixture::class.java
22+
23+
@Test
24+
fun `reads class metadata written by a newer Kotlin compiler`() {
25+
val metadata = descriptorFor(fixture, FUTURE_METADATA_VERSION).kotlinMetadata
26+
27+
assertTrue("Expected readable class metadata, got $metadata", metadata is KotlinClassMetadata.Class)
28+
assertTrue((metadata as KotlinClassMetadata.Class).kmClass.name.endsWith("KotlinMetadataVersionFixture"))
29+
}
30+
31+
@Test
32+
fun `exposes members of a class compiled with a newer Kotlin compiler`() {
33+
val descriptor = descriptorFor(fixture, FUTURE_METADATA_VERSION)
34+
35+
assertTrue("Class visibility should come from Kotlin metadata", descriptor.isPublic)
36+
assertTrue("Expected the 'counter' property", descriptor.properties.any { it.name == "counter" })
37+
assertTrue("Expected the 'greet' method", descriptor.methods.any { it.name == "greet" })
38+
}
39+
40+
@Test
41+
fun `falls back to bytecode parsing when metadata cannot be read at all`() {
42+
val descriptor = KotlinClassDescriptor(bcelClass(fixture), corruptAnnotation(), false)
43+
44+
assertEquals(null, descriptor.kotlinMetadata)
45+
assertTrue("Bytecode-derived visibility should still be available", descriptor.isPublic)
46+
assertTrue("Bytecode-derived methods should still be available", descriptor.methods.any { it.name == "greet" })
47+
}
48+
49+
@Test
50+
fun `strict reading is what rejects newer metadata`() {
51+
val annotation = annotationWithVersion(fixture, FUTURE_METADATA_VERSION)
52+
53+
try {
54+
KotlinClassMetadata.readStrict(toMetadata(annotation))
55+
fail("readStrict was expected to reject metadata version 2.9.0")
56+
} catch (e: IllegalArgumentException) {
57+
assertTrue(e.message!!.contains("version"))
58+
}
59+
}
60+
61+
private fun descriptorFor(clazz: Class<*>, metadataVersion: IntArray) =
62+
KotlinClassDescriptor(bcelClass(clazz), annotationWithVersion(clazz, metadataVersion), false)
63+
64+
private fun bcelClass(clazz: Class<*>): JavaClass {
65+
val resource = clazz.name.replace('.', '/') + ".class"
66+
return clazz.classLoader.getResourceAsStream(resource).use {
67+
ClassParser(it, resource).parse()
68+
}
69+
}
70+
71+
private fun annotationWithVersion(clazz: Class<*>, metadataVersion: IntArray): MetadataAnnotation {
72+
val real = clazz.getAnnotation(kotlin.Metadata::class.java)
73+
return object : MetadataAnnotation {
74+
override val kind = real.kind
75+
override val metadataVersion = metadataVersion
76+
override val bytecodeVersion = intArrayOf()
77+
override val data1 = real.data1
78+
override val data2 = real.data2
79+
override val extraString = real.extraString
80+
override val packageName = real.packageName
81+
override val extraInt = real.extraInt
82+
}
83+
}
84+
85+
private fun corruptAnnotation(): MetadataAnnotation = object : MetadataAnnotation {
86+
override val kind = 1
87+
override val metadataVersion = intArrayOf()
88+
override val bytecodeVersion = intArrayOf()
89+
override val data1 = emptyArray<String>()
90+
override val data2 = emptyArray<String>()
91+
override val extraString = ""
92+
override val packageName = ""
93+
override val extraInt = 0
94+
}
95+
96+
private fun toMetadata(annotation: MetadataAnnotation) = Metadata(
97+
annotation.kind,
98+
annotation.metadataVersion,
99+
annotation.data1,
100+
annotation.data2,
101+
annotation.extraString,
102+
annotation.packageName,
103+
annotation.extraInt)
104+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.telerik.metadata.parsing.kotlin.classes
2+
3+
class KotlinMetadataVersionFixture(val greeting: String) {
4+
5+
var counter: Int = 0
6+
7+
fun greet(name: String): String = "$greeting, $name"
8+
}

test-app/gradle.properties

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ ns_default_google_java_format_version = 1.6
3939
ns_default_gson_version = 2.10.1
4040
ns_default_json_version = 20180813
4141
ns_default_junit_version = 4.13.2
42-
ns_default_kotlin_version = 2.2.20
43-
ns_default_kotlinx_metadata_jvm_version = 2.2.20
42+
ns_default_kotlin_version = 2.4.10
43+
ns_default_kotlinx_metadata_jvm_version = 2.4.10
4444
ns_default_mockito_core_version = 3.0.0
4545
ns_default_spotbugs_version = 3.1.12

0 commit comments

Comments
 (0)