Skip to content

Commit 486d154

Browse files
authored
Add new way for reflective access to static final fields in unit tests of instrumentation projects (#297)
The Field class no longer has a `modifiers` property, so removing the FINAL modifier doesn't work like it used to with Java 8. Utilize a solution from other libraries to work around the issue with `com.misc.Unsafe`, encapsulated in a non-Android reflection module
1 parent 729d9f1 commit 486d154

File tree

5 files changed

+96
-50
lines changed

5 files changed

+96
-50
lines changed

instrumentation/settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ include(":compose")
55
include(":runner")
66
include(":sample")
77
include(":testutil")
8+
include(":testutil-reflect")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
plugins {
2+
kotlin("jvm")
3+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
@file:Suppress("removal")
2+
3+
package de.mannodermaus.junit5.testutil.reflect
4+
5+
import sun.misc.Unsafe
6+
import java.lang.reflect.Field
7+
import java.lang.reflect.Modifier
8+
import java.security.AccessController
9+
import java.security.PrivilegedAction
10+
11+
/**
12+
* Adapted from Paparazzi:
13+
* https://github.com/cashapp/paparazzi/blob/137f5ca5f3a9949336012298a7c2838fc669c01a/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Reflections.kt
14+
*/
15+
public fun Class<*>.getFieldReflectively(fieldName: String): Field =
16+
try {
17+
this.getDeclaredField(fieldName).also { it.isAccessible = true }
18+
} catch (e: NoSuchFieldException) {
19+
throw RuntimeException("Field '$fieldName' was not found in class $name.")
20+
}
21+
22+
public fun Field.setStaticValue(value: Any?) {
23+
try {
24+
this.isAccessible = true
25+
val isFinalModifierPresent = this.modifiers and Modifier.FINAL == Modifier.FINAL
26+
if (isFinalModifierPresent) {
27+
AccessController.doPrivileged<Any?>(
28+
PrivilegedAction {
29+
try {
30+
val unsafe =
31+
Unsafe::class.java.getFieldReflectively("theUnsafe").get(null) as Unsafe
32+
val offset = unsafe.staticFieldOffset(this)
33+
val base = unsafe.staticFieldBase(this)
34+
unsafe.setFieldValue(this, base, offset, value)
35+
null
36+
} catch (t: Throwable) {
37+
throw RuntimeException(t)
38+
}
39+
}
40+
)
41+
} else {
42+
this.set(null, value)
43+
}
44+
} catch (ex: SecurityException) {
45+
throw RuntimeException(ex)
46+
} catch (ex: IllegalAccessException) {
47+
throw RuntimeException(ex)
48+
} catch (ex: IllegalArgumentException) {
49+
throw RuntimeException(ex)
50+
}
51+
}
52+
53+
private fun Unsafe.setFieldValue(field: Field, base: Any, offset: Long, value: Any?) =
54+
when (field.type) {
55+
Integer.TYPE -> this.putInt(base, offset, (value as Int))
56+
java.lang.Short.TYPE -> this.putShort(base, offset, (value as Short))
57+
java.lang.Long.TYPE -> this.putLong(base, offset, (value as Long))
58+
java.lang.Byte.TYPE -> this.putByte(base, offset, (value as Byte))
59+
java.lang.Boolean.TYPE -> this.putBoolean(base, offset, (value as Boolean))
60+
java.lang.Float.TYPE -> this.putFloat(base, offset, (value as Float))
61+
java.lang.Double.TYPE -> this.putDouble(base, offset, (value as Double))
62+
Character.TYPE -> this.putChar(base, offset, (value as Char))
63+
else -> this.putObject(base, offset, value)
64+
}

instrumentation/testutil/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ tasks.withType<Test> {
5555
}
5656

5757
dependencies {
58+
implementation(project(":testutil-reflect"))
59+
5860
api(libs.androidXTestMonitor)
5961
api(libs.truth)
6062
api(libs.truthJava8Extensions)

instrumentation/testutil/src/main/kotlin/de/mannodermaus/junit5/testutil/AndroidBuildUtils.kt

Lines changed: 26 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,22 @@ import android.os.Build
44
import android.os.Bundle
55
import androidx.test.platform.app.InstrumentationRegistry
66
import com.google.common.truth.Truth.assertThat
7-
import java.lang.reflect.Field
8-
import java.lang.reflect.Modifier
9-
import kotlin.reflect.KClass
7+
import de.mannodermaus.junit5.testutil.reflect.getFieldReflectively
8+
import de.mannodermaus.junit5.testutil.reflect.setStaticValue
109

1110
object AndroidBuildUtils {
1211

13-
fun withApiLevel(api: Int, block: () -> Unit) {
14-
try {
15-
assumeApiLevel(api)
16-
block()
17-
} finally {
18-
resetApiLevel()
19-
}
20-
}
12+
fun withApiLevel(api: Int, block: () -> Unit) = withMockedStaticField<Build.VERSION>(
13+
fieldName = "SDK_INT",
14+
value = api,
15+
block = block,
16+
)
2117

22-
fun withManufacturer(name: String, block: () -> Unit) {
23-
try {
24-
assumeManufacturer(name)
25-
block()
26-
} finally {
27-
resetManufacturer()
28-
}
29-
}
18+
fun withManufacturer(name: String, block: () -> Unit) = withMockedStaticField<Build>(
19+
fieldName = "MANUFACTURER",
20+
value = name,
21+
block = block,
22+
)
3023

3124
fun withMockedInstrumentation(arguments: Bundle = Bundle(), block: () -> Unit) {
3225
val (oldInstrumentation, oldArguments) = try {
@@ -46,37 +39,20 @@ object AndroidBuildUtils {
4639
}
4740
}
4841

49-
private fun setWithReflection(clazz: KClass<*>, fieldName: String, value: Any?) {
50-
// Adjust the value of the target field statically using reflection
51-
val field = clazz.java.getDeclaredField(fieldName)
52-
field.isAccessible = true
53-
54-
// Temporarily remove the field's "final" modifier
55-
val modifiersField = Field::class.java.getDeclaredField("modifiers")
56-
modifiersField.isAccessible = true
57-
modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv())
42+
private inline fun <reified T : Any> withMockedStaticField(
43+
fieldName: String,
44+
value: Any?,
45+
block: () -> Unit,
46+
) {
47+
val field = T::class.java.getFieldReflectively(fieldName)
48+
val oldValue = field.get(null)
5849

59-
// Apply the value to the field, re-finalize it, then lock it again
60-
field.set(null, value)
61-
modifiersField.setInt(field, field.modifiers or Modifier.FINAL)
62-
field.isAccessible = false
63-
}
64-
65-
private fun assumeApiLevel(apiLevel: Int) {
66-
setWithReflection(Build.VERSION::class, "SDK_INT", apiLevel)
67-
assertThat(Build.VERSION.SDK_INT).isEqualTo(apiLevel)
68-
}
69-
70-
private fun resetApiLevel() {
71-
assumeApiLevel(0)
72-
}
73-
74-
private fun assumeManufacturer(name: String?) {
75-
setWithReflection(Build::class, "MANUFACTURER", name)
76-
assertThat(Build.MANUFACTURER).isEqualTo(name)
77-
}
78-
79-
private fun resetManufacturer() {
80-
assumeManufacturer(null)
50+
try {
51+
field.setStaticValue(value)
52+
assertThat(field.get(null)).isEqualTo(value)
53+
block()
54+
} finally {
55+
field.setStaticValue(oldValue)
56+
}
8157
}
8258
}

0 commit comments

Comments
 (0)