diff --git a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeUiTest.kt b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeUiTest.kt index df4342d..fcb8b42 100644 --- a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeUiTest.kt +++ b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeUiTest.kt @@ -46,7 +46,7 @@ import androidx.compose.ui.semantics.SemanticsProperties.ToggleableState import androidx.compose.ui.semantics.SemanticsProperties.VerticalScrollAxisRange import androidx.compose.ui.semantics.semantics import androidx.compose.ui.state.ToggleableState.On -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction.Companion.Send import androidx.compose.ui.viewinterop.AndroidView @@ -417,7 +417,7 @@ class ComposeUiTest { |Box: |${BLANK}Box { test-tag:"parent" } |${BLANK}╰─Dialog - |${BLANK} ╰─CompositionLocalProvider { DIALOG } + |${BLANK} ╰─ProvideCompositionLocals { DIALOG } |${BLANK} ╰─Box { test-tag:"child" } | """.trimMargin() @@ -448,7 +448,7 @@ class ComposeUiTest { |Box: |${BLANK}Box { test-tag:"parent" } |${BLANK}╰─CustomTestDialog - |${BLANK} ╰─CompositionLocalProvider { DIALOG } + |${BLANK} ╰─ProvideCompositionLocals { DIALOG } |${BLANK} ╰─Box { test-tag:"child" } | """.trimMargin() diff --git a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt index 6215ad1..32a7f77 100644 --- a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt +++ b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.tooling.data.UiToolingDataApi import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density diff --git a/compose-unsupported-tests/src/androidTest/java/radiography/test/compose/ComposeUnsupportedTest.kt b/compose-unsupported-tests/src/androidTest/java/radiography/test/compose/ComposeUnsupportedTest.kt index 5808865..79b6c28 100644 --- a/compose-unsupported-tests/src/androidTest/java/radiography/test/compose/ComposeUnsupportedTest.kt +++ b/compose-unsupported-tests/src/androidTest/java/radiography/test/compose/ComposeUnsupportedTest.kt @@ -1,7 +1,7 @@ package radiography.test.compose import androidx.compose.foundation.text.BasicText -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e5a2a7..4e3e029 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidx-test = "1.5.0" # We compile against an older version of Compose intentionally to support consumers that aren't on # the latest. #noinspection GradleDependency -compose = "1.10.1" +compose = "1.11.1" # Allows using a different version of Compose to validate that we degrade gracefully on apps # built with unsupported Compose versions. #noinspection GradleDependency diff --git a/radiography/src/main/java/radiography/internal/ComposeLayoutInfo.kt b/radiography/src/main/java/radiography/internal/ComposeLayoutInfo.kt index 001ba28..9deff8c 100644 --- a/radiography/src/main/java/radiography/internal/ComposeLayoutInfo.kt +++ b/radiography/src/main/java/radiography/internal/ComposeLayoutInfo.kt @@ -21,6 +21,8 @@ import radiography.ScannableView.CallGroupInfo import radiography.internal.ComposeLayoutInfo.AndroidViewInfo import radiography.internal.ComposeLayoutInfo.LayoutNodeInfo import radiography.internal.ComposeLayoutInfo.SubcompositionInfo +import java.lang.reflect.Method +import java.util.IdentityHashMap /** * Information about a Compose `LayoutNode`, extracted from a [Group] tree via [Group.computeLayoutInfos]. @@ -34,12 +36,14 @@ import radiography.internal.ComposeLayoutInfo.SubcompositionInfo */ internal sealed class ComposeLayoutInfo { data class LayoutNodeInfo( - val name: String, - val callChain: List, - val bounds: IntRect, - val modifiers: List, - val children: Sequence, - val semanticsNodes: List, + val name: String, + val callChain: List, + val bounds: IntRect, + val modifiers: List, + val children: Sequence, + val semanticsNodes: List, + // Node reference used to sort Subcompose Layouts deterministically across runs + val layoutNode: Any? = null, ) : ComposeLayoutInfo() data class SubcompositionInfo( @@ -114,6 +118,7 @@ internal fun Group.computeLayoutInfos( modifiers = modifierInfo.map { it.modifier }, semanticsNodes = semanticsNodes, children = children + irregularChildren, + layoutNode = this.node, ) return sequenceOf(layoutInfo) } @@ -195,10 +200,13 @@ private fun Group.tryParseSubcomposition( // We can be pretty confident at this point that this is an actual SubcomposeLayout, so // expose its layout node as the parent of all its subcompositions. + // Sort subcompositions by the placement order of their children within the parent LayoutNode, + // since the set backing composers may not preserve insertion order. val subcompositionName = "" return sequenceOf( - mainNode.copy(children = subcompositions.asSequence() - .map { it.copy(name = subcompositionName) } + mainNode.copy( + children = sortSubcompositionsByChildOrder(this, subcompositions).asSequence() + .map { it.copy(name = subcompositionName) } ) ) } @@ -253,4 +261,50 @@ private fun Group.tryParseAndroidView( return sequenceOf(mainNode.copy(children = mainNode.children + androidViews)) } +/** + * Sorts subcompositions by the placement order of their first child LayoutNode within the parent + * LayoutNode. Uses object identity to match layout nodes, which is reliable regardless of whether + * the nodes have semantics. Falls back to the original order if reflection fails. + */ +private fun sortSubcompositionsByChildOrder( + parentGroup: Group, + subcompositions: List +): List { + if (subcompositions.size <= 1) return subcompositions + val childOrder = getChildLayoutNodeOrder(parentGroup) ?: return subcompositions + val materialized = subcompositions.map { + it.copy(children = it.children.toList().asSequence()) + } + + return materialized.sortedBy { sub -> + sub.children + .filterIsInstance() + .firstOrNull { it.layoutNode != null && it.layoutNode in childOrder } + ?.let { childOrder[it.layoutNode] } + ?: Int.MAX_VALUE + } +} + +private val GET_CHILDREN_METHOD: Method by lazy { + Class.forName("androidx.compose.ui.node.LayoutNode").getMethod($$"getChildren$ui") +} + +@Suppress("UNCHECKED_CAST") +private fun getChildLayoutNodeOrder(parentGroup: Group): IdentityHashMap? { + val children = GET_CHILDREN_METHOD(checkNotNull(findNodeGroup(parentGroup)).node) as List<*> + val order = IdentityHashMap(children.size) + children.forEachIndexed { index, child -> + if (child != null) order[child] = index + } + return order +} + +private fun findNodeGroup(group: Group): NodeGroup? { + if (group is NodeGroup) return group + for (child in group.children) { + findNodeGroup(child)?.let { return it } + } + return null +} + private fun Sequence<*>.isEmpty(): Boolean = !iterator().hasNext() diff --git a/radiography/src/main/java/radiography/internal/CompositionContexts.kt b/radiography/src/main/java/radiography/internal/CompositionContexts.kt index c12ef68..289cf83 100644 --- a/radiography/src/main/java/radiography/internal/CompositionContexts.kt +++ b/radiography/src/main/java/radiography/internal/CompositionContexts.kt @@ -4,51 +4,110 @@ import androidx.compose.runtime.Composer import androidx.compose.runtime.CompositionContext import androidx.compose.ui.tooling.data.Group import androidx.compose.ui.tooling.data.UiToolingDataApi +import java.lang.reflect.Field +import java.lang.reflect.Method import kotlin.LazyThreadSafetyMode.PUBLICATION -private val REFLECTION_CONSTANTS by lazy(PUBLICATION) { - try { - object { - val CompositionContextHolderClass = - Class.forName("androidx.compose.runtime.ComposerImpl\$CompositionContextHolder") - val CompositionContextImplClass = - Class.forName("androidx.compose.runtime.ComposerImpl\$CompositionContextImpl") - val ReusableRememberObserverHolderClass = - Class.forName("androidx.compose.runtime.ReusableRememberObserverHolder") - val CompositionContextHolderRefField = - CompositionContextHolderClass.getDeclaredField("ref") - .apply { isAccessible = true } - val CompositionContextImplComposersField = - CompositionContextImplClass.getDeclaredField("composers") - .apply { isAccessible = true } +private class ComposerVariantConstants( + val contextHolderClass: Class<*>, + val contextImplClass: Class<*>, + val refField: Field, + val composersField: Field, + val composersToIterable: (Any) -> Iterable<*>, +) + +private fun tryLoadVariant(composerClassName: String): ComposerVariantConstants? { + return try { + val contextHolderClass = + Class.forName($$"$$composerClassName$CompositionContextHolder") + val contextImplClass = + Class.forName($$"$$composerClassName$CompositionContextImpl") + val refField = contextHolderClass.getDeclaredField("ref") + .apply { isAccessible = true } + val composersField = contextImplClass.getDeclaredField("composers") + .apply { isAccessible = true } + + val composersToIterable: (Any) -> Iterable<*> = run { + val fieldType = composersField.type + when { + Iterable::class.java.isAssignableFrom(fieldType) -> { obj -> obj as Iterable<*> } + else -> { + val asSetMethod: Method? = try { + fieldType.getMethod("asSet") + } catch (_: NoSuchMethodException) { + try { + fieldType.getMethod("asMutableSet") + } catch (_: NoSuchMethodException) { + null + } + } + if (asSetMethod != null) { + { obj -> asSetMethod(obj) as Iterable<*> } + } else { + return null + } + } + } } - } catch (e: Throwable) { + + ComposerVariantConstants( + contextHolderClass, contextImplClass, refField, composersField, composersToIterable + ) + } catch (_: Throwable) { null } } +private val COMPOSER_VARIANTS: List by lazy(PUBLICATION) { + listOf( + "androidx.compose.runtime.GapComposer", + "androidx.compose.runtime.LinkComposer", + "androidx.compose.runtime.ComposerImpl", + ).mapNotNull { tryLoadVariant(it) } +} + +private val REMEMBER_OBSERVER_HOLDER_CLASS: Class<*> by lazy(PUBLICATION) { + Class.forName("androidx.compose.runtime.RememberObserverHolder") +} + +private val GET_WRAPPED_METHOD: Method by lazy(PUBLICATION) { + REMEMBER_OBSERVER_HOLDER_CLASS.getMethod("getWrapped") +} + @OptIn(UiToolingDataApi::class) internal fun Group.getCompositionContexts(): Sequence { - return REFLECTION_CONSTANTS?.run { + return if (COMPOSER_VARIANTS.isEmpty()) { + emptySequence() + } else { data.asSequence() - .filter { it != null && it::class.java == ReusableRememberObserverHolderClass } - .mapNotNull { holder -> - holder - ?.let { holder::class.java.getMethod("getWrapped") } - ?.invoke(holder) - ?.tryGetCompositionContext() + .mapNotNull { datum -> + if (datum == null) return@mapNotNull null + // Try direct match (Compose <= 1.10: data contains CompositionContextHolder directly) + for (variant in COMPOSER_VARIANTS) { + if (variant.contextHolderClass.isInstance(datum)) { + return@mapNotNull variant.refField.get(datum) as? CompositionContext + } + } + // Compose >= 1.11: data contains RememberObserverHolder wrapping CompositionContextHolder + if (!REMEMBER_OBSERVER_HOLDER_CLASS.isInstance(datum)) return@mapNotNull null + val wrapped = GET_WRAPPED_METHOD(datum) ?: return@mapNotNull null + for (variant in COMPOSER_VARIANTS) { + if (variant.contextHolderClass.isInstance(wrapped)) { + return@mapNotNull variant.refField.get(wrapped) as? CompositionContext + } + } + null } - } ?: emptySequence() + } } @Suppress("UNCHECKED_CAST") internal fun CompositionContext.tryGetComposers(): Iterable { - return REFLECTION_CONSTANTS?.let { - if (!it.CompositionContextImplClass.isInstance(this)) return emptyList() - it.CompositionContextImplComposersField.get(this) as? Iterable - } ?: emptyList() -} - -private fun Any?.tryGetCompositionContext() = REFLECTION_CONSTANTS?.let { - it.CompositionContextHolderRefField.get(this) as? CompositionContext + for (variant in COMPOSER_VARIANTS) { + if (variant.contextImplClass.isInstance(this)) { + val composersObj = variant.composersField.get(this) ?: return emptyList() + return variant.composersToIterable(composersObj) as Iterable + } + } + return emptyList() }