Skip to content

Commit

Permalink
Add new way for reflective access to static final fields in unit test…
Browse files Browse the repository at this point in the history
…s 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
  • Loading branch information
mannodermaus committed Apr 29, 2023
1 parent 729d9f1 commit 486d154
Show file tree
Hide file tree
Showing 5 changed files with 96 additions and 50 deletions.
1 change: 1 addition & 0 deletions instrumentation/settings.gradle.kts
Expand Up @@ -5,3 +5,4 @@ include(":compose")
include(":runner")
include(":sample")
include(":testutil")
include(":testutil-reflect")
3 changes: 3 additions & 0 deletions instrumentation/testutil-reflect/build.gradle.kts
@@ -0,0 +1,3 @@
plugins {
kotlin("jvm")
}
@@ -0,0 +1,64 @@
@file:Suppress("removal")

package de.mannodermaus.junit5.testutil.reflect

import sun.misc.Unsafe
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.security.AccessController
import java.security.PrivilegedAction

/**
* Adapted from Paparazzi:
* https://github.com/cashapp/paparazzi/blob/137f5ca5f3a9949336012298a7c2838fc669c01a/paparazzi/paparazzi/src/main/java/app/cash/paparazzi/Reflections.kt
*/
public fun Class<*>.getFieldReflectively(fieldName: String): Field =
try {
this.getDeclaredField(fieldName).also { it.isAccessible = true }
} catch (e: NoSuchFieldException) {
throw RuntimeException("Field '$fieldName' was not found in class $name.")
}

public fun Field.setStaticValue(value: Any?) {
try {
this.isAccessible = true
val isFinalModifierPresent = this.modifiers and Modifier.FINAL == Modifier.FINAL
if (isFinalModifierPresent) {
AccessController.doPrivileged<Any?>(
PrivilegedAction {
try {
val unsafe =
Unsafe::class.java.getFieldReflectively("theUnsafe").get(null) as Unsafe
val offset = unsafe.staticFieldOffset(this)
val base = unsafe.staticFieldBase(this)
unsafe.setFieldValue(this, base, offset, value)
null
} catch (t: Throwable) {
throw RuntimeException(t)
}
}
)
} else {
this.set(null, value)
}
} catch (ex: SecurityException) {
throw RuntimeException(ex)
} catch (ex: IllegalAccessException) {
throw RuntimeException(ex)
} catch (ex: IllegalArgumentException) {
throw RuntimeException(ex)
}
}

private fun Unsafe.setFieldValue(field: Field, base: Any, offset: Long, value: Any?) =
when (field.type) {
Integer.TYPE -> this.putInt(base, offset, (value as Int))
java.lang.Short.TYPE -> this.putShort(base, offset, (value as Short))
java.lang.Long.TYPE -> this.putLong(base, offset, (value as Long))
java.lang.Byte.TYPE -> this.putByte(base, offset, (value as Byte))
java.lang.Boolean.TYPE -> this.putBoolean(base, offset, (value as Boolean))
java.lang.Float.TYPE -> this.putFloat(base, offset, (value as Float))
java.lang.Double.TYPE -> this.putDouble(base, offset, (value as Double))
Character.TYPE -> this.putChar(base, offset, (value as Char))
else -> this.putObject(base, offset, value)
}
2 changes: 2 additions & 0 deletions instrumentation/testutil/build.gradle.kts
Expand Up @@ -55,6 +55,8 @@ tasks.withType<Test> {
}

dependencies {
implementation(project(":testutil-reflect"))

api(libs.androidXTestMonitor)
api(libs.truth)
api(libs.truthJava8Extensions)
Expand Down
Expand Up @@ -4,29 +4,22 @@ import android.os.Build
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import kotlin.reflect.KClass
import de.mannodermaus.junit5.testutil.reflect.getFieldReflectively
import de.mannodermaus.junit5.testutil.reflect.setStaticValue

object AndroidBuildUtils {

fun withApiLevel(api: Int, block: () -> Unit) {
try {
assumeApiLevel(api)
block()
} finally {
resetApiLevel()
}
}
fun withApiLevel(api: Int, block: () -> Unit) = withMockedStaticField<Build.VERSION>(
fieldName = "SDK_INT",
value = api,
block = block,
)

fun withManufacturer(name: String, block: () -> Unit) {
try {
assumeManufacturer(name)
block()
} finally {
resetManufacturer()
}
}
fun withManufacturer(name: String, block: () -> Unit) = withMockedStaticField<Build>(
fieldName = "MANUFACTURER",
value = name,
block = block,
)

fun withMockedInstrumentation(arguments: Bundle = Bundle(), block: () -> Unit) {
val (oldInstrumentation, oldArguments) = try {
Expand All @@ -46,37 +39,20 @@ object AndroidBuildUtils {
}
}

private fun setWithReflection(clazz: KClass<*>, fieldName: String, value: Any?) {
// Adjust the value of the target field statically using reflection
val field = clazz.java.getDeclaredField(fieldName)
field.isAccessible = true

// Temporarily remove the field's "final" modifier
val modifiersField = Field::class.java.getDeclaredField("modifiers")
modifiersField.isAccessible = true
modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv())
private inline fun <reified T : Any> withMockedStaticField(
fieldName: String,
value: Any?,
block: () -> Unit,
) {
val field = T::class.java.getFieldReflectively(fieldName)
val oldValue = field.get(null)

// Apply the value to the field, re-finalize it, then lock it again
field.set(null, value)
modifiersField.setInt(field, field.modifiers or Modifier.FINAL)
field.isAccessible = false
}

private fun assumeApiLevel(apiLevel: Int) {
setWithReflection(Build.VERSION::class, "SDK_INT", apiLevel)
assertThat(Build.VERSION.SDK_INT).isEqualTo(apiLevel)
}

private fun resetApiLevel() {
assumeApiLevel(0)
}

private fun assumeManufacturer(name: String?) {
setWithReflection(Build::class, "MANUFACTURER", name)
assertThat(Build.MANUFACTURER).isEqualTo(name)
}

private fun resetManufacturer() {
assumeManufacturer(null)
try {
field.setStaticValue(value)
assertThat(field.get(null)).isEqualTo(value)
block()
} finally {
field.setStaticValue(oldValue)
}
}
}

0 comments on commit 486d154

Please sign in to comment.