diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/README.facebook b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/README.facebook new file mode 100644 index 000000000..2c093fe1d --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/README.facebook @@ -0,0 +1,5 @@ +This is a check-in of https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-main/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection + +The classes are currently not exported but we rely on them for debug information. + +Tree: 59746be8ea17d5753471bd285b3fbc9cf8ea7c31 diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/InlineClassConverter.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/InlineClassConverter.kt new file mode 100644 index 000000000..6c2152379 --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/InlineClassConverter.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.inspector + +/** + * Converter for casting a parameter represented by its primitive value to its inline class type. + * + * For example: an androidx.compose.ui.graphics.Color instance is often represented by a long + */ +internal class InlineClassConverter { + // Map from inline type name to inline class and conversion lambda + private val typeMap = mutableMapOf Any>() + // Return value used in functions + private val notInlineType: (Any) -> Any = { it } + + /** Clear any cached data. */ + fun clear() { + typeMap.clear() + } + + /** + * Cast the specified [value] to a value of type [inlineClassName] if possible. + * + * @param inlineClassName the fully qualified name of the inline class. + * @param value the value to convert to an instance of [inlineClassName]. + */ + fun castParameterValue(inlineClassName: String?, value: Any?): Any? = + if (value != null && inlineClassName != null) typeMapperFor(inlineClassName)(value) else value + + private fun typeMapperFor(typeName: String): (Any) -> (Any) = + typeMap.getOrPut(typeName) { loadTypeMapper(typeName.replace('.', '/')) } + + private fun loadTypeMapper(className: String): (Any) -> Any { + val javaClass = loadClassOrNull(className) ?: return notInlineType + val create = javaClass.declaredConstructors.singleOrNull() ?: return notInlineType + create.isAccessible = true + return { value -> create.newInstance(value) } + } + + private fun loadClassOrNull(className: String): Class<*>? = + try { + javaClass.classLoader!!.loadClass(className) + } catch (ex: Exception) { + null + } +} diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/InspectorNode.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/InspectorNode.kt new file mode 100644 index 000000000..63eb4e4e6 --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/InspectorNode.kt @@ -0,0 +1,237 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.inspector + +import androidx.compose.ui.layout.LayoutInfo +import androidx.compose.ui.unit.IntRect + +internal const val UNDEFINED_ID = 0L + +internal val emptyBox = IntRect(0, 0, 0, 0) +internal val outsideBox = IntRect(Int.MAX_VALUE, Int.MIN_VALUE, Int.MAX_VALUE, Int.MIN_VALUE) + +/** Node representing a Composable for the Layout Inspector. */ +class InspectorNode +internal constructor( + /** The associated render node id or 0. */ + val id: Long, + + /** The associated key for tracking recomposition counts. */ + val key: Int, + + /** + * The id of the associated anchor for tracking recomposition counts. + * + * An Anchor is a mechanism in the compose runtime that can identify a Group in the SlotTable + * that is invariant to SlotTable updates. See [androidx.compose.runtime.Anchor] for more + * information. + */ + val anchorId: Int, + + /** The name of the Composable. */ + val name: String, + + /** The fileName where the Composable was called. */ + val fileName: String, + + /** + * A hash of the package name to help disambiguate duplicate [fileName] values. + * + * This hash is calculated by, + * + * `packageName.fold(0) { hash, current -> hash * 31 + current.toInt() }?.absoluteValue` + * + * where the package name is the dotted name of the package. This can be used to disambiguate + * which file is referenced by [fileName]. This number is -1 if there was no package hash + * information generated such as when the file does not contain a package declaration. + */ + val packageHash: Int, + + /** The line number where the Composable was called. */ + val lineNumber: Int, + + /** The UTF-16 offset in the file where the Composable was called */ + val offset: Int, + + /** The number of UTF-16 code point comprise the Composable call */ + val length: Int, + + /** The bounding box of the Composable. */ + internal val box: IntRect, + + /** The 4 corners of the polygon after transformations of the original rectangle. */ + val bounds: QuadBounds? = null, + + /** True if the code for the Composable was inlined */ + val inlined: Boolean = false, + + /** The parameters of this Composable. */ + val parameters: List, + + /** The id of a android View embedded under this node. */ + val viewId: Long, + + /** The merged semantics information of this Composable. */ + val mergedSemantics: List, + + /** The un-merged semantics information of this Composable. */ + val unmergedSemantics: List, + + /** The children nodes of this Composable. */ + val children: List +) { + /** Left side of the Composable in pixels. */ + val left: Int + get() = box.left + + /** Top of the Composable in pixels. */ + val top: Int + get() = box.top + + /** Width of the Composable in pixels. */ + val width: Int + get() = box.width + + /** Width of the Composable in pixels. */ + val height: Int + get() = box.height + + fun parametersByKind(kind: ParameterKind): List = + when (kind) { + ParameterKind.Normal -> parameters + ParameterKind.MergedSemantics -> mergedSemantics + ParameterKind.UnmergedSemantics -> unmergedSemantics + } +} + +data class QuadBounds( + val x0: Int, + val y0: Int, + val x1: Int, + val y1: Int, + val x2: Int, + val y2: Int, + val x3: Int, + val y3: Int, +) { + val xMin: Int + get() = sequenceOf(x0, x1, x2, x3).minOrNull()!! + + val xMax: Int + get() = sequenceOf(x0, x1, x2, x3).maxOrNull()!! + + val yMin: Int + get() = sequenceOf(y0, y1, y2, y3).minOrNull()!! + + val yMax: Int + get() = sequenceOf(y0, y1, y2, y3).maxOrNull()!! + + val outerBox: IntRect + get() = IntRect(xMin, yMin, xMax, yMax) +} + +/** Parameter definition with a raw value reference. */ +class RawParameter(val name: String, val value: Any?) + +/** Mutable version of [InspectorNode]. */ +internal class MutableInspectorNode { + var id = UNDEFINED_ID + var key = 0 + var anchorId = 0 + val layoutNodes = mutableListOf() + val mergedSemantics = mutableListOf() + val unmergedSemantics = mutableListOf() + var name = "" + var fileName = "" + var packageHash = -1 + var lineNumber = 0 + var offset = 0 + var length = 0 + var box: IntRect = emptyBox + var bounds: QuadBounds? = null + var inlined = false + val parameters = mutableListOf() + var viewId = UNDEFINED_ID + val children = mutableListOf() + var outerBox: IntRect = outsideBox + + fun reset() { + markUnwanted() + id = UNDEFINED_ID + key = 0 + anchorId = 0 + viewId = UNDEFINED_ID + layoutNodes.clear() + mergedSemantics.clear() + unmergedSemantics.clear() + box = emptyBox + bounds = null + inlined = false + outerBox = outsideBox + children.clear() + } + + fun markUnwanted() { + name = "" + fileName = "" + packageHash = -1 + lineNumber = 0 + offset = 0 + length = 0 + parameters.clear() + } + + fun shallowCopy(node: InspectorNode): MutableInspectorNode = apply { + id = node.id + key = node.key + anchorId = node.anchorId + mergedSemantics.addAll(node.mergedSemantics) + unmergedSemantics.addAll(node.unmergedSemantics) + name = node.name + fileName = node.fileName + packageHash = node.packageHash + lineNumber = node.lineNumber + offset = node.offset + length = node.length + box = node.box + bounds = node.bounds + inlined = node.inlined + parameters.addAll(node.parameters) + viewId = node.viewId + children.addAll(node.children) + } + + fun build(withSemantics: Boolean = true): InspectorNode = + InspectorNode( + id, + key, + anchorId, + name, + fileName, + packageHash, + lineNumber, + offset, + length, + box, + bounds, + inlined, + parameters.toList(), + viewId, + if (withSemantics) mergedSemantics.toList() else emptyList(), + if (withSemantics) unmergedSemantics.toList() else emptyList(), + children.toList()) +} diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt new file mode 100644 index 000000000..56a7e8535 --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/LayoutInspectorTree.kt @@ -0,0 +1,827 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.inspector + +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.tooling.CompositionData +import androidx.compose.runtime.tooling.CompositionGroup +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.R +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.GraphicLayerInfo +import androidx.compose.ui.layout.LayoutInfo +import androidx.compose.ui.node.InteroperableComposeUiNode +import androidx.compose.ui.node.Ref +import androidx.compose.ui.node.RootForTest +import androidx.compose.ui.platform.ViewRootForInspector +import androidx.compose.ui.semantics.getAllSemanticsNodes +import androidx.compose.ui.tooling.data.ContextCache +import androidx.compose.ui.tooling.data.ParameterInformation +import androidx.compose.ui.tooling.data.SourceContext +import androidx.compose.ui.tooling.data.SourceLocation +import androidx.compose.ui.tooling.data.UiToolingDataApi +import androidx.compose.ui.tooling.data.findParameters +import androidx.compose.ui.tooling.data.mapTree +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import facebook.internal.androidx.compose.ui.inspection.util.AnchorMap +import facebook.internal.androidx.compose.ui.inspection.util.NO_ANCHOR_ID +import java.util.ArrayDeque +import java.util.Collections +import java.util.IdentityHashMap +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * The [InspectorNode.id] will be populated with: + * - the layerId from a LayoutNode if this exists + * - an id generated from an Anchor instance from the SlotTree if this exists + * - a generated id if none of the above ids are available + * + * The interval -10000..-2 is reserved for the generated ids. + */ +@VisibleForTesting const val RESERVED_FOR_GENERATED_IDS = -10000L +const val PLACEHOLDER_ID = Long.MAX_VALUE + +private val emptySize = IntSize(0, 0) + +private val unwantedCalls = + setOf( + "CompositionLocalProvider", + "Content", + "Inspectable", + "ProvideAndroidCompositionLocals", + "ProvideCommonCompositionLocals", + ) + +/** Generator of a tree for the Layout Inspector. */ +@OptIn(UiToolingDataApi::class) +class LayoutInspectorTree { + @Suppress("MemberVisibilityCanBePrivate") var hideSystemNodes = true + var includeNodesOutsizeOfWindow = true + var includeAllParameters = true + private var foundNode: InspectorNode? = null + private var windowSize = emptySize + private val inlineClassConverter = InlineClassConverter() + private val parameterFactory = ParameterFactory(inlineClassConverter) + private val cache = ArrayDeque() + private var generatedId = -1L + private val subCompositions = SubCompositionRoots() + /** Map from [LayoutInfo] to the nearest [InspectorNode] that contains it */ + private val claimedNodes = IdentityHashMap() + /** Map from parent tree to child trees that are about to be stitched together */ + private val treeMap = IdentityHashMap>() + /** Map from owner node to child trees that are about to be stitched to this owner */ + private val ownerMap = IdentityHashMap>() + /** Map from semantics id to a list of merged semantics information */ + private val semanticsMap = mutableMapOf>() + /* Map of seemantics id to a list of unmerged semantics information */ + private val unmergedSemanticsMap = mutableMapOf>() + /** Set of tree nodes that were stitched into another tree */ + private val stitched = Collections.newSetFromMap(IdentityHashMap()) + private val contextCache = ContextCache() + private val anchorMap = AnchorMap() + + /** Converts the [CompositionData] set held by [view] into a list of root nodes. */ + fun convert(view: View): List { + windowSize = IntSize(view.width, view.height) + parameterFactory.density = Density(view.context) + @Suppress("UNCHECKED_CAST") + val tables = + view.getTag(R.id.inspection_slot_table_set) as? Set ?: return emptyList() + clear() + collectSemantics(view) + val result = convert(tables, view) + clear() + return result + } + + fun findParameters(view: View, anchorId: Int): InspectorNode? { + windowSize = IntSize(view.width, view.height) + parameterFactory.density = Density(view.context) + val identity = anchorMap[anchorId] ?: return null + + @Suppress("UNCHECKED_CAST") + val tables = view.getTag(R.id.inspection_slot_table_set) as? Set ?: return null + val node = newNode().apply { this.anchorId = anchorId } + val group = tables.firstNotNullOfOrNull { it.find(identity) } ?: return null + group.findParameters(contextCache).forEach { + val castedValue = castValue(it) + node.parameters.add(RawParameter(it.name, castedValue)) + } + return buildAndRelease(node) + } + + /** + * Add the roots to sub compositions that may have been collected from a different SlotTree. + * + * See [SubCompositionRoots] for details. + */ + fun addSubCompositionRoots(view: View, nodes: List): List = + subCompositions.addRoot(view, nodes) + + /** + * Extract the merged semantics for this semantics owner such that they can be added to compose + * nodes during the conversion of Group nodes. + */ + private fun collectSemantics(view: View) { + val root = view as? RootForTest ?: return + val nodes = root.semanticsOwner.getAllSemanticsNodes(mergingEnabled = true) + val unmergedNodes = root.semanticsOwner.getAllSemanticsNodes(mergingEnabled = false) + nodes.forEach { node -> + semanticsMap[node.id] = node.config.map { RawParameter(it.key.name, it.value) } + } + unmergedNodes.forEach { node -> + unmergedSemanticsMap[node.id] = node.config.map { RawParameter(it.key.name, it.value) } + } + } + + /** Converts the [RawParameter]s of the [node] into displayable parameters. */ + fun convertParameters( + rootId: Long, + node: InspectorNode, + kind: ParameterKind, + maxRecursions: Int, + maxInitialIterableSize: Int + ): List { + val parameters = node.parametersByKind(kind) + return parameters.mapIndexed { index, parameter -> + parameterFactory.create( + rootId, + node.id, + node.anchorId, + parameter.name, + parameter.value, + kind, + index, + maxRecursions, + maxInitialIterableSize) + } + } + + /** + * Converts a part of the [RawParameter] identified by [reference] into a displayable parameter. + * If the parameter is some sort of a collection then [startIndex] and [maxElements] describes the + * scope of the data returned. + */ + fun expandParameter( + rootId: Long, + node: InspectorNode, + reference: NodeParameterReference, + startIndex: Int, + maxElements: Int, + maxRecursions: Int, + maxInitialIterableSize: Int + ): NodeParameter? { + val parameters = node.parametersByKind(reference.kind) + if (reference.parameterIndex !in parameters.indices) { + return null + } + val parameter = parameters[reference.parameterIndex] + return parameterFactory.expand( + rootId, + node.id, + node.anchorId, + parameter.name, + parameter.value, + reference, + startIndex, + maxElements, + maxRecursions, + maxInitialIterableSize) + } + + /** Reset any state accumulated between windows. */ + @Suppress("unused") + fun resetAccumulativeState() { + subCompositions.resetAccumulativeState() + parameterFactory.clearReferenceCache() + // Reset the generated id. Nodes are assigned an id if there isn't a layout node id present. + generatedId = -1L + } + + private fun clear() { + cache.clear() + inlineClassConverter.clear() + claimedNodes.clear() + treeMap.clear() + ownerMap.clear() + semanticsMap.clear() + unmergedSemanticsMap.clear() + stitched.clear() + subCompositions.clear() + foundNode = null + } + + private fun convert(tables: Set, view: View): List { + val trees = tables.mapNotNull { convert(view, it) } + return when (trees.size) { + 0 -> listOf() + 1 -> addTree(mutableListOf(), trees.single()) + else -> stitchTreesByLayoutInfo(trees) + } + } + + /** + * Stitch separate trees together using the [LayoutInfo]s found in the [CompositionData]s. + * + * Some constructs in Compose (e.g. ModalDrawer) will result is multiple [CompositionData]s. This + * code will attempt to stitch the resulting [InspectorNode] trees together by looking at the + * parent of each [LayoutInfo]. + * + * If this algorithm is successful the result of this function will be a list with a single tree. + */ + private fun stitchTreesByLayoutInfo(trees: List): List { + val layoutToTreeMap = IdentityHashMap() + trees.forEach { tree -> tree.layoutNodes.forEach { layoutToTreeMap[it] = tree } } + trees.forEach { tree -> + val layout = tree.layoutNodes.lastOrNull() + val parentLayout = + generateSequence(layout) { it.parentInfo } + .firstOrNull { + val otherTree = layoutToTreeMap[it] + otherTree != null && otherTree != tree + } + if (parentLayout != null) { + val ownerNode = claimedNodes[parentLayout] + val ownerTree = layoutToTreeMap[parentLayout] + if (ownerNode != null && ownerTree != null) { + ownerMap.getOrPut(ownerNode) { mutableListOf() }.add(tree) + treeMap.getOrPut(ownerTree) { mutableListOf() }.add(tree) + } + } + } + var parentTree = findDeepParentTree() + while (parentTree != null) { + addSubTrees(parentTree) + treeMap.remove(parentTree) + parentTree = findDeepParentTree() + } + val result = mutableListOf() + trees.asSequence().filter { !stitched.contains(it) }.forEach { addTree(result, it) } + return result + } + + /** + * Return a parent tree where the children trees (to be stitched under the parent) are not a + * parent themselves. Do this to avoid rebuilding the same tree more than once. + */ + private fun findDeepParentTree(): MutableInspectorNode? = + treeMap.entries + .asSequence() + .filter { (_, children) -> children.none { treeMap.containsKey(it) } } + .firstOrNull() + ?.key + + private fun addSubTrees(tree: MutableInspectorNode) { + for ((index, child) in tree.children.withIndex()) { + tree.children[index] = addSubTrees(child) ?: child + } + } + + /** + * Rebuild [node] with any possible sub trees added (stitched in). Return the rebuild node, or + * null if no changes were found in this node or its children. Lazily allocate the new node to + * avoid unnecessary allocations. + */ + private fun addSubTrees(node: InspectorNode): InspectorNode? { + var newNode: MutableInspectorNode? = null + for ((index, child) in node.children.withIndex()) { + val newChild = addSubTrees(child) + if (newChild != null) { + val newCopy = newNode ?: newNode(node) + newCopy.children[index] = newChild + newNode = newCopy + } + } + val trees = ownerMap[node] + if (trees == null && newNode == null) { + return null + } + val newCopy = newNode ?: newNode(node) + if (trees != null) { + trees.forEach { addTree(newCopy.children, it) } + stitched.addAll(trees) + } + return buildAndRelease(newCopy) + } + + /** + * Add [tree] to the end of the [out] list. The root nodes of [tree] may be a fake node that hold + * a list of [LayoutInfo]. + */ + private fun addTree( + out: MutableList, + tree: MutableInspectorNode + ): List { + tree.children.forEach { + if (it.name.isNotEmpty()) { + out.add(it) + } else { + out.addAll(it.children) + } + } + return out + } + + private fun convert(view: View, table: CompositionData): MutableInspectorNode? { + val fakeParent = newNode() + val group = table.mapTree(::convert, contextCache) ?: return null + addToParent(fakeParent, listOf(group), buildFakeChildNodes = true) + return if (belongsToView(fakeParent.layoutNodes, view)) fakeParent else null + } + + private fun convert( + group: CompositionGroup, + context: SourceContext, + children: List + ): MutableInspectorNode { + val parent = parse(group, context, children) + subCompositions.captureNode(parent, context) + addToParent(parent, children) + return parent + } + + /** + * Adds the nodes in [input] to the children of [parentNode]. Nodes without a reference to a + * wanted Composable are skipped unless [buildFakeChildNodes]. A single skipped render id and + * layoutNode will be added to [parentNode]. + */ + private fun addToParent( + parentNode: MutableInspectorNode, + input: List, + buildFakeChildNodes: Boolean = false + ) { + // If we're adding an unwanted node from the `input` to the parent node and it has a + // View ID, then assign it to the parent view so that we don't lose the context that we + // found a View as a descendant of the parent node. Most likely, there were one or more + // unwanted intermediate nodes between the node that actually owns the Android View + // and the desired node that the View should be associated with in the inspector. If + // there's more than one input node with a View ID, we skip this step since it's + // unclear how these views would be related. + input + .singleOrNull { it.viewId != UNDEFINED_ID } + ?.takeIf { node -> + // Take if the node has been marked as unwanted + node.id == UNDEFINED_ID + } + ?.let { nodeWithView -> parentNode.viewId = nodeWithView.viewId } + + var id: Long? = null + input.forEach { node -> + if (node.name.isEmpty() && !(buildFakeChildNodes && node.layoutNodes.isNotEmpty())) { + parentNode.children.addAll(node.children) + if (node.id > UNDEFINED_ID) { + // If multiple siblings with a render ids are dropped: + // Ignore them all. And delegate the drawing to a parent in the inspector. + id = if (id == null) node.id else UNDEFINED_ID + } + } else { + node.id = if (node.id != UNDEFINED_ID) node.id else --generatedId + val withSemantics = node.packageHash !in systemPackages + val resultNode = node.build(withSemantics) + // TODO: replace getOrPut with putIfAbsent which requires API level 24 + node.layoutNodes.forEach { claimedNodes.getOrPut(it) { resultNode } } + parentNode.children.add(resultNode) + if (withSemantics) { + node.mergedSemantics.clear() + node.unmergedSemantics.clear() + } + } + if (node.bounds != null && parentNode.box == node.box) { + parentNode.bounds = node.bounds + } + parentNode.layoutNodes.addAll(node.layoutNodes) + parentNode.mergedSemantics.addAll(node.mergedSemantics) + parentNode.unmergedSemantics.addAll(node.unmergedSemantics) + release(node) + } + val nodeId = id + parentNode.id = if (parentNode.id <= UNDEFINED_ID && nodeId != null) nodeId else parentNode.id + } + + @OptIn(InternalComposeUiApi::class) + private fun parse( + group: CompositionGroup, + context: SourceContext, + children: List + ): MutableInspectorNode { + val node = newNode() + node.name = context.name ?: "" + node.key = group.key as? Int ?: 0 + node.inlined = context.isInline + + // If this node is associated with an android View, set the node's viewId to point to + // the hosted view. We use the parent's uniqueDrawingId since the interopView returned here + // will be the view itself, but we want to use the `AndroidViewHolder` that hosts the view + // instead of the view directly. + (group.node as? InteroperableComposeUiNode?)?.getInteropView()?.let { interopView -> + (interopView.parent as? View)?.uniqueDrawingId?.let { viewId -> node.viewId = viewId } + } + + val layoutInfo = group.node as? LayoutInfo + if (layoutInfo != null) { + return parseLayoutInfo(layoutInfo, context, node) + } + if (unwantedOutsideWindow(node, children)) { + return markUnwanted(group, context, node) + } + node.box = context.bounds.emptyCheck() + if (unwantedName(node.name) || (node.box == emptyBox && !subCompositions.capturing)) { + return markUnwanted(group, context, node) + } + parseCallLocation(node, context.location) + if (isHiddenSystemNode(node)) { + return markUnwanted(group, context, node) + } + node.anchorId = anchorMap[group.identity] + node.id = syntheticId(node.anchorId) + if (includeAllParameters) { + addParameters(context, node) + } + return node + } + + private fun IntRect.emptyCheck(): IntRect = if (left >= right && top >= bottom) emptyBox else this + + private fun IntRect.inWindow(): Boolean = + !(left > windowSize.width || right < 0 || top > windowSize.height || bottom < 0) + + private fun IntRect.union(other: IntRect): IntRect { + if (this == outsideBox) return other else if (other == outsideBox) return this + + return IntRect( + left = min(left, other.left), + top = min(top, other.top), + bottom = max(bottom, other.bottom), + right = max(right, other.right)) + } + + private fun parseLayoutInfo( + layoutInfo: LayoutInfo, + context: SourceContext, + node: MutableInspectorNode + ): MutableInspectorNode { + val box = context.bounds + val size = box.size.toSize() + val coordinates = layoutInfo.coordinates + val topLeft = toIntOffset(coordinates.localToWindow(Offset.Zero)) + val topRight = toIntOffset(coordinates.localToWindow(Offset(size.width, 0f))) + val bottomRight = toIntOffset(coordinates.localToWindow(Offset(size.width, size.height))) + val bottomLeft = toIntOffset(coordinates.localToWindow(Offset(0f, size.height))) + var bounds: QuadBounds? = null + + if (topLeft.x != box.left || + topLeft.y != box.top || + topRight.x != box.right || + topRight.y != box.top || + bottomRight.x != box.right || + bottomRight.y != box.bottom || + bottomLeft.x != box.left || + bottomLeft.y != box.bottom) { + bounds = + QuadBounds( + topLeft.x, + topLeft.y, + topRight.x, + topRight.y, + bottomRight.x, + bottomRight.y, + bottomLeft.x, + bottomLeft.y, + ) + } + if (!includeNodesOutsizeOfWindow) { + // Ignore this node if the bounds are completely outside the window + node.outerBox = bounds?.outerBox ?: box + if (!node.outerBox.inWindow()) { + return node + } + } + + node.box = box.emptyCheck() + node.bounds = bounds + node.layoutNodes.add(layoutInfo) + val modifierInfo = layoutInfo.getModifierInfo() + + val unmergedSemantics = unmergedSemanticsMap[layoutInfo.semanticsId] + if (unmergedSemantics != null) { + node.unmergedSemantics.addAll(unmergedSemantics) + } + + val mergedSemantics = semanticsMap[layoutInfo.semanticsId] + if (mergedSemantics != null) { + node.mergedSemantics.addAll(mergedSemantics) + } + + node.id = + modifierInfo + .asSequence() + .map { it.extra } + .filterIsInstance() + .map { it.layerId } + .firstOrNull() + ?: UNDEFINED_ID + + return node + } + + private fun syntheticId(anchorId: Int): Long { + if (anchorId == NO_ANCHOR_ID) { + return UNDEFINED_ID + } + // The anchorId is an Int + return anchorId.toLong() - Int.MAX_VALUE.toLong() + RESERVED_FOR_GENERATED_IDS + } + + private fun belongsToView(layoutNodes: List, view: View): Boolean = + layoutNodes + .asSequence() + .flatMap { node -> + node + .getModifierInfo() + .asSequence() + .map { it.extra } + .filterIsInstance() + .map { it.ownerViewId } + } + .contains(view.uniqueDrawingId) + + private fun addParameters(context: SourceContext, node: MutableInspectorNode) { + context.parameters.forEach { + val castedValue = castValue(it) + node.parameters.add(RawParameter(it.name, castedValue)) + } + } + + private fun castValue(parameter: ParameterInformation): Any? { + val value = parameter.value ?: return null + if (parameter.inlineClass == null || !isPrimitive(value.javaClass)) return value + return inlineClassConverter.castParameterValue(parameter.inlineClass, value) + } + + private fun isPrimitive(cls: Class<*>): Boolean = cls.kotlin.javaPrimitiveType != null + + private fun toIntOffset(offset: Offset): IntOffset = + IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) + + private fun markUnwanted( + group: CompositionGroup, + context: SourceContext, + node: MutableInspectorNode + ): MutableInspectorNode = + when (node.name) { + "rememberCompositionContext" -> subCompositions.rememberCompositionContext(node, context) + "remember" -> subCompositions.remember(node, group) + else -> node.apply { markUnwanted() } + } + + private fun parseCallLocation(node: MutableInspectorNode, location: SourceLocation?) { + val fileName = location?.sourceFile ?: return + node.fileName = fileName + node.packageHash = location.packageHash + node.lineNumber = location.lineNumber + node.offset = location.offset + node.length = location.length + } + + private fun isHiddenSystemNode(node: MutableInspectorNode): Boolean = + node.packageHash in systemPackages && hideSystemNodes + + private fun unwantedName(name: String): Boolean = + name.isEmpty() || name.startsWith("remember") || name in unwantedCalls + + private fun unwantedOutsideWindow( + node: MutableInspectorNode, + children: List + ): Boolean { + if (includeNodesOutsizeOfWindow) { + return false + } + node.outerBox = + if (children.isEmpty()) outsideBox + else children.map { g -> g.outerBox }.reduce { acc, box -> box.union(acc) } + return !node.outerBox.inWindow() + } + + private fun newNode(): MutableInspectorNode = + if (cache.isNotEmpty()) cache.pop() else MutableInspectorNode() + + private fun newNode(copyFrom: InspectorNode): MutableInspectorNode = + newNode().shallowCopy(copyFrom) + + private fun release(node: MutableInspectorNode) { + node.reset() + cache.add(node) + } + + private fun buildAndRelease(node: MutableInspectorNode): InspectorNode { + val result = node.build() + release(node) + return result + } + + /** + * Keep track of sub-composition roots. + * + * Examples: + * - Popup, Dialog: When one of these is open an extra Android Window is created with its own + * AndroidComposeView. The contents of the Composable is a sub-composition that will be computed + * by calling convert. + * + * The Popup/Dialog composable itself, and a few helping composables (the root) will not be + * included in the SlotTree with the contents, instead these composables will be found in the + * SlotTree for the main app and they all have empty sizes. The aim is to collect these + * sub-composition roots such that they can be added to the [InspectorNode]s of the contents. + * - AndroidView: When this is used in a compose app we will see a similar pattern in the SlotTree + * except there isn't a sub-composition to stitch in. But we need to collect the view id + * separately from the "AndroidView" node itself. + */ + private inner class SubCompositionRoots { + /** Set to true when the nodes found should be added to a sub-composition root */ + var capturing = false + private set + + /** The `uniqueDrawingId` of the `AndroidComposeView` that owns the root being captured */ + private var ownerView = UNDEFINED_ID + + /** The node that represent the root of the sub-composition */ + private var rootNode: MutableInspectorNode? = null + + /** The depth of the parse tree the [rootNode] was found at */ + private var rootNodeDepth = 0 + + /** Last captured view that is believed to be an embbed View under an AndroidView node */ + private var androidView = UNDEFINED_ID + + /** + * The sub-composition roots found. + * + * Map from View owner to a pair of [InspectorNode] indicating the actual root, and the node + * where the content should be stitched in. + */ + private val found = mutableMapOf() + + /** Call this before converting a SlotTree for an AndroidComposeView */ + fun clear() { + capturing = false + ownerView = UNDEFINED_ID + rootNode?.markUnwanted() + rootNode?.id = UNDEFINED_ID + rootNode = null + rootNodeDepth = 0 + } + + /** Call this when starting converting a new set of windows */ + fun resetAccumulativeState() { + found.clear() + } + + /** + * When a "rememberCompositionContext" is found in the slot tree, it indicates that a + * sub-composition was started. We should capture all parent nodes with an empty size as the + * "root" of the sub-composition. + */ + fun rememberCompositionContext( + node: MutableInspectorNode, + context: SourceContext + ): MutableInspectorNode { + if (capturing) { + save() + } + capturing = true + rootNode = node + rootNodeDepth = context.depth + node.id = PLACEHOLDER_ID + return node + } + + /** + * When "remember" is found in the slot tree and we are currently capturing, the data of the + * [group] may contain the owner of the sub-composition. + */ + fun remember(node: MutableInspectorNode, group: CompositionGroup): MutableInspectorNode { + node.markUnwanted() + if (!capturing) { + return node + } + val root = findSingleRootInGroupData(group) ?: return node + + val view = root.subCompositionView + if (view != null) { + val composeOwner = if (view.childCount == 1) view.getChildAt(0) else return node + ownerView = composeOwner.uniqueDrawingId + } else { + androidView = root.viewRoot?.uniqueDrawingId ?: UNDEFINED_ID + // Store the viewRoot such that we can move the View under the compose node + // in Studio. We do not need to capture the Groups found for this case, so + // we call "reset" here to stop capturing. + clear() + } + return node + } + + private fun findSingleRootInGroupData(group: CompositionGroup): ViewRootForInspector? { + group.data.filterIsInstance().singleOrNull()?.let { + return it + } + val refs = group.data.filterIsInstance>().map { it.value } + return refs.filterIsInstance().singleOrNull() + } + + /** Capture the top node of the sub-composition root until a non empty node is found. */ + fun captureNode(node: MutableInspectorNode, context: SourceContext) { + if (!capturing) { + return + } + if (node.box != emptyBox) { + save() + return + } + val depth = context.depth + if (depth < rootNodeDepth) { + rootNode = node + rootNodeDepth = depth + } + } + + fun latestViewId(): Long { + val id = androidView + androidView = UNDEFINED_ID + return id + } + + /** If a sub-composition root has been captured, save it now. */ + private fun save() { + val node = rootNode + if (node != null && ownerView != UNDEFINED_ID) { + found[ownerView] = node.build() + } + node?.markUnwanted() + node?.id = UNDEFINED_ID + node?.children?.clear() + clear() + } + + /** + * Add the root of the sub-composition to the found tree. + * + * If a root is not found for this [owner] or if the stitching fails just return [nodes]. + */ + fun addRoot(owner: View, nodes: List): List { + val root = found[owner.uniqueDrawingId] ?: return nodes + val box = IntRect(0, 0, owner.width, owner.height) + val info = StitchInfo(nodes, box) + val result = listOf(stitch(root, info)) + return if (info.added) result else nodes + } + + private fun stitch(node: InspectorNode, info: StitchInfo): InspectorNode { + val children = node.children.map { stitch(it, info) } + val index = children.indexOfFirst { it.id == PLACEHOLDER_ID } + val newNode = newNode() + newNode.shallowCopy(node) + newNode.children.clear() + if (index < 0) { + newNode.children.addAll(children) + } else { + newNode.children.addAll(children.subList(0, index)) + newNode.children.addAll(info.nodes) + newNode.children.addAll(children.subList(index + 1, children.size)) + info.added = true + } + newNode.box = info.bounds + return buildAndRelease(newNode) + } + } + + private class StitchInfo( + /** The nodes found that should be stitched into a sub-composition root. */ + val nodes: List, + + /** The bounds of the View containing the sub-composition */ + val bounds: IntRect + ) { + /** Set this to true when the [nodes] have been added to a sub-composition root */ + var added: Boolean = false + } +} diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/NodeParameter.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/NodeParameter.kt new file mode 100644 index 000000000..b2a3ce1a0 --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/NodeParameter.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.inspector + +/** Holds data representing a Composable parameter for the Layout Inspector. */ +class NodeParameter +internal constructor( + /** The name of the parameter. */ + val name: String, + + /** The type of the parameter. */ + val type: ParameterType, + + /** The value of the parameter. */ + val value: Any? +) { + /** Sub elements of the parameter. */ + val elements = mutableListOf() + + /** Reference to value parameter. */ + var reference: NodeParameterReference? = null + + /** The index into the composite parent parameter value. */ + var index = 0 +} + +/** The type of a parameter. */ +enum class ParameterType { + String, + Boolean, + Double, + Float, + Int32, + Int64, + Color, + Resource, + DimensionDp, + DimensionSp, + DimensionEm, + Lambda, + FunctionReference, + Iterable, +} diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/NodeParameterReference.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/NodeParameterReference.kt new file mode 100644 index 000000000..1353413f0 --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/NodeParameterReference.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.inspector + +import facebook.internal.androidx.compose.ui.inspection.util.asIntArray + +/** + * A reference to a parameter to a [NodeParameter] + * + * @param nodeId is the id of the node the parameter belongs to + * @param anchorId is the anchor hash of the node the parameter belongs to + * @param kind is this a reference to a normal, merged, or unmerged semantic parameter. + * @param parameterIndex index into [InspectorNode.parameters], [InspectorNode.mergedSemantics], or + * [InspectorNode.unMergedSemantics] + * @param indices are indices into the composite parameter + */ +class NodeParameterReference( + val nodeId: Long, + val anchorId: Int, + val kind: ParameterKind, + val parameterIndex: Int, + val indices: IntArray +) { + constructor( + nodeId: Long, + anchorId: Int, + kind: ParameterKind, + parameterIndex: Int, + indices: List + ) : this(nodeId, anchorId, kind, parameterIndex, indices.asIntArray()) + + // For testing: + override fun toString(): String { + val suffix = if (indices.isNotEmpty()) ", ${indices.joinToString()}" else "" + return "[$nodeId, $anchorId, $kind, $parameterIndex$suffix]" + } +} + +/** Identifies which kind of parameter the [NodeParameterReference] is a reference to. */ +enum class ParameterKind { + Normal, + MergedSemantics, + UnmergedSemantics +} diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/PackageHashes.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/PackageHashes.kt new file mode 100644 index 000000000..642e9937f --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/PackageHashes.kt @@ -0,0 +1,60 @@ +// WARNING: DO NOT EDIT THIS FILE MANUALLY. It's automatically generated by running: +// frameworks/support/compose/ui/ui-inspection/generate-packages/generate_compose_packages.py -r +package facebook.internal.androidx.compose.ui.inspection.inspector + +import androidx.annotation.VisibleForTesting +import kotlin.math.absoluteValue + +@VisibleForTesting +fun packageNameHash(packageName: String) = + packageName.fold(0) { hash, char -> hash * 31 + char.code }.absoluteValue + +val systemPackages = + setOf( + -1, + packageNameHash("androidx.compose.animation"), + packageNameHash("androidx.compose.animation.core"), + packageNameHash("androidx.compose.animation.graphics.vector"), + packageNameHash("androidx.compose.desktop"), + packageNameHash("androidx.compose.foundation"), + packageNameHash("androidx.compose.foundation.gestures"), + packageNameHash("androidx.compose.foundation.gestures.snapping"), + packageNameHash("androidx.compose.foundation.interaction"), + packageNameHash("androidx.compose.foundation.layout"), + packageNameHash("androidx.compose.foundation.lazy"), + packageNameHash("androidx.compose.foundation.lazy.grid"), + packageNameHash("androidx.compose.foundation.lazy.layout"), + packageNameHash("androidx.compose.foundation.lazy.staggeredgrid"), + packageNameHash("androidx.compose.foundation.newtext.text"), + packageNameHash("androidx.compose.foundation.newtext.text.copypasta"), + packageNameHash("androidx.compose.foundation.newtext.text.copypasta.selection"), + packageNameHash("androidx.compose.foundation.pager"), + packageNameHash("androidx.compose.foundation.relocation"), + packageNameHash("androidx.compose.foundation.text"), + packageNameHash("androidx.compose.foundation.text.selection"), + packageNameHash("androidx.compose.foundation.window"), + packageNameHash("androidx.compose.material"), + packageNameHash("androidx.compose.material.internal"), + packageNameHash("androidx.compose.material.pullrefresh"), + packageNameHash("androidx.compose.material.ripple"), + packageNameHash("androidx.compose.material3"), + packageNameHash("androidx.compose.material3.internal"), + packageNameHash("androidx.compose.material3.windowsizeclass"), + packageNameHash("androidx.compose.runtime"), + packageNameHash("androidx.compose.runtime.livedata"), + packageNameHash("androidx.compose.runtime.mock"), + packageNameHash("androidx.compose.runtime.reflect"), + packageNameHash("androidx.compose.runtime.rxjava2"), + packageNameHash("androidx.compose.runtime.rxjava3"), + packageNameHash("androidx.compose.runtime.saveable"), + packageNameHash("androidx.compose.ui"), + packageNameHash("androidx.compose.ui.awt"), + packageNameHash("androidx.compose.ui.graphics.benchmark"), + packageNameHash("androidx.compose.ui.graphics.vector"), + packageNameHash("androidx.compose.ui.layout"), + packageNameHash("androidx.compose.ui.platform"), + packageNameHash("androidx.compose.ui.text"), + packageNameHash("androidx.compose.ui.util"), + packageNameHash("androidx.compose.ui.viewinterop"), + packageNameHash("androidx.compose.ui.window"), + ) diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/ParameterFactory.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/ParameterFactory.kt new file mode 100644 index 000000000..e74e49ed4 --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/ParameterFactory.kt @@ -0,0 +1,1001 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.inspector + +import android.util.Log +import android.view.View +import androidx.compose.runtime.internal.ComposableLambda +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.InspectableModifier +import androidx.compose.ui.platform.InspectableValue +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontListFontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.ResourceFont +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import facebook.internal.androidx.compose.ui.inspection.inspector.ParameterType.DimensionDp +import java.lang.reflect.Field +import java.lang.reflect.Modifier as JavaModifier +import java.util.IdentityHashMap +import kotlin.jvm.internal.FunctionReference +import kotlin.jvm.internal.Lambda +import kotlin.math.abs +import kotlin.reflect.KClass +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.allSuperclasses +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.jvm.isAccessible +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaGetter + +private val reflectionScope: ReflectionScope = ReflectionScope() + +/** + * Factory of [NodeParameter]s. + * + * Each parameter value is converted to a user readable value. + */ +internal class ParameterFactory(private val inlineClassConverter: InlineClassConverter) { + /** A map from known values to a user readable string representation. */ + private val valueLookup = mutableMapOf() + + /** The classes we have loaded constants from. */ + private val valuesLoaded = mutableSetOf>() + + /** + * Do not load constant names from instances of these classes. We prefer showing the raw values of + * Color and Dimensions. + */ + private val ignoredClasses = listOf(Color::class.java, Dp::class.java) + private var creatorCache: ParameterCreator? = null + + /** + * Do not decompose instances or lookup constants from these package prefixes + * + * The following instances are known to contain self recursion: + * - kotlinx.coroutines.flow.StateFlowImpl + * - androidx.compose.ui.node.LayoutNode + */ + private val ignoredPackagePrefixes = + listOf("android.", "java.", "javax.", "kotlinx.", "androidx.compose.ui.node.") + + var density = Density(1.0f) + + init { + val textDecorationCombination = + TextDecoration.combine(listOf(TextDecoration.LineThrough, TextDecoration.Underline)) + valueLookup[textDecorationCombination] = "LineThrough+Underline" + valueLookup[Color.Unspecified] = "Unspecified" + valueLookup[RectangleShape] = "RectangleShape" + valuesLoaded.add(Enum::class.java) + valuesLoaded.add(Any::class.java) + + // AbsoluteAlignment is not found from an instance of BiasAbsoluteAlignment, + // because Alignment has no file level class. + reflectionScope.withReflectiveAccess { + loadConstantsFromEnclosedClasses(AbsoluteAlignment::class.java) + } + } + + /** + * Create a [NodeParameter] from the specified parameter [name] and [value]. + * + * Attempt to convert the value to a user readable value. For now: return null when a conversion + * is not possible/found. + */ + fun create( + rootId: Long, + nodeId: Long, + anchorId: Int, + name: String, + value: Any?, + kind: ParameterKind, + parameterIndex: Int, + maxRecursions: Int, + maxInitialIterableSize: Int + ): NodeParameter { + val creator = creatorCache ?: ParameterCreator() + try { + return reflectionScope.withReflectiveAccess { + creator.create( + rootId, + nodeId, + anchorId, + name, + value, + kind, + parameterIndex, + maxRecursions, + maxInitialIterableSize) + } + } finally { + creatorCache = creator + } + } + + /** + * Create/expand the [NodeParameter] specified by [reference]. + * + * @param rootId is the root id of the specified [nodeId]. + * @param nodeId is the [InspectorNode.id] of the node the parameter belongs to. + * @param anchorId is the [InspectorNode.anchorId] of the node the parameter belongs to. + * @param name is the name of the [reference].parameterIndex'th parameter of the node. + * @param value is the value of the [reference].parameterIndex'th parameter of the node. + * @param startIndex is the index of the 1st wanted element of a List/Array. + * @param maxElements is the max number of elements wanted from a List/Array. + * @param maxRecursions is the max recursion into composite types starting from reference. + * @param maxInitialIterableSize is the max number of elements wanted in new List/Array values. + */ + fun expand( + rootId: Long, + nodeId: Long, + anchorId: Int, + name: String, + value: Any?, + reference: NodeParameterReference, + startIndex: Int, + maxElements: Int, + maxRecursions: Int, + maxInitialIterableSize: Int + ): NodeParameter? { + val creator = creatorCache ?: ParameterCreator() + try { + return reflectionScope.withReflectiveAccess { + creator.expand( + rootId, + nodeId, + anchorId, + name, + value, + reference, + startIndex, + maxElements, + maxRecursions, + maxInitialIterableSize) + } + } finally { + creatorCache = creator + } + } + + fun clearReferenceCache() { + val creator = creatorCache ?: return + creator.clearReferenceCache() + } + + private fun loadConstantsFrom(javaClass: Class<*>) { + if (valuesLoaded.contains(javaClass) || + ignoredPackagePrefixes.any { javaClass.name.startsWith(it) }) { + return + } + val related = generateSequence(javaClass) { it.superclass }.plus(javaClass.interfaces) + related.forEach { aClass -> + val topClass = generateSequence(aClass) { safeEnclosingClass(it) }.last() + loadConstantsFromEnclosedClasses(topClass) + findPackageLevelClass(topClass)?.let { loadConstantsFromStaticFinal(it) } + } + } + + private fun safeEnclosingClass(klass: Class<*>): Class<*>? = + try { + klass.enclosingClass + } catch (_: Error) { + // Exceptions seen on API 23... + null + } + + private fun findPackageLevelClass(javaClass: Class<*>): Class<*>? = + try { + // Note: This doesn't work when @file.JvmName is specified + Class.forName("${javaClass.name}Kt") + } catch (ex: Throwable) { + null + } + + private fun loadConstantsFromEnclosedClasses(javaClass: Class<*>) { + if (valuesLoaded.contains(javaClass)) { + return + } + loadConstantsFromObjectInstance(javaClass.kotlin) + loadConstantsFromStaticFinal(javaClass) + valuesLoaded.add(javaClass) + javaClass.declaredClasses.forEach { loadConstantsFromEnclosedClasses(it) } + } + + /** + * Load all constants from companion objects and singletons + * + * Exclude: primary types and types of ignoredClasses, open and lateinit vals. + */ + private fun loadConstantsFromObjectInstance(kClass: KClass<*>) { + try { + val instance = kClass.objectInstance ?: return + kClass.declaredMemberProperties + .asSequence() + .filter { it.isFinal && !it.isLateinit } + .mapNotNull { constantValueOf(it, instance)?.let { key -> Pair(key, it.name) } } + .filter { !ignoredValue(it.first) } + .toMap(valueLookup) + } catch (_: Throwable) { + // KT-16479 : kotlin reflection does currently not support packages and files. + // We load top level values using Java reflection instead. + // Ignore other reflection errors as well + } + } + + /** + * Load all constants from top level values from Java. + * + * Exclude: primary types and types of ignoredClasses. Since this is Java, inline types will also + * (unfortunately) be excluded. + */ + private fun loadConstantsFromStaticFinal(javaClass: Class<*>) { + try { + javaClass.declaredMethods + .asSequence() + .filter { + it.returnType != Void.TYPE && + JavaModifier.isStatic(it.modifiers) && + JavaModifier.isFinal(it.modifiers) && + !it.returnType.isPrimitive && + it.parameterTypes.isEmpty() && + it.name.startsWith("get") + } + .mapNotNull { javaClass.getDeclaredField(it.name.substring(3)) } + .mapNotNull { constantValueOf(it)?.let { key -> Pair(key, it.name) } } + .filter { !ignoredValue(it.first) } + .toMap(valueLookup) + } catch (_: ReflectiveOperationException) { + // ignore reflection errors + } catch (_: NoClassDefFoundError) { + // ignore missing classes on lower level SDKs + } + } + + private fun constantValueOf(field: Field?): Any? = + try { + field?.isAccessible = true + field?.get(null) + } catch (_: ReflectiveOperationException) { + // ignore reflection errors + null + } + + private fun constantValueOf(property: KProperty1, instance: Any): Any? = + try { + val field = property.javaField + field?.isAccessible = true + inlineClassConverter.castParameterValue(inlineResultClass(property), field?.get(instance)) + } catch (_: ReflectiveOperationException) { + // ignore reflection errors + null + } + + private fun inlineResultClass(property: KProperty1): String? { + // The Java getter name will be mangled if it contains parameters of an inline class. + // The mangled part starts with a '-'. + if (property.javaGetter?.name?.contains('-') == true) { + return property.returnType.toString() + } + return null + } + + private fun ignoredValue(value: Any?): Boolean = + value == null || + ignoredClasses.any { ignored -> ignored.isInstance(value) } || + value::class.java.isPrimitive + + /** Convenience class for building [NodeParameter]s. */ + private inner class ParameterCreator { + private var rootId = 0L + private var nodeId = 0L + private var anchorId = 0 + private var kind: ParameterKind = ParameterKind.Normal + private var parameterIndex = 0 + private var maxRecursions = 0 + private var maxInitialIterableSize = 0 + private var recursions = 0 + private val valueIndex = mutableListOf() + private val valueLazyReferenceMap = IdentityHashMap>() + private val rootValueIndexCache = + mutableMapOf>() + private var valueIndexMap = IdentityHashMap() + + fun create( + rootId: Long, + nodeId: Long, + anchorId: Int, + name: String, + value: Any?, + kind: ParameterKind, + parameterIndex: Int, + maxRecursions: Int, + maxInitialIterableSize: Int + ): NodeParameter = + try { + setup( + rootId, nodeId, anchorId, kind, parameterIndex, maxRecursions, maxInitialIterableSize) + create(name, value, null) ?: createEmptyParameter(name) + } finally { + setup() + } + + fun expand( + rootId: Long, + nodeId: Long, + anchorId: Int, + name: String, + value: Any?, + reference: NodeParameterReference, + startIndex: Int, + maxElements: Int, + maxRecursions: Int, + maxInitialIterableSize: Int + ): NodeParameter? { + setup( + rootId, + nodeId, + anchorId, + reference.kind, + reference.parameterIndex, + maxRecursions, + maxInitialIterableSize) + var parent: Pair? = null + var new = Pair(name, value) + for (i in reference.indices) { + parent = new + new = find(new.first, new.second, i) ?: return null + } + recursions = 0 + valueIndex.addAll(reference.indices.asSequence()) + val parameter = + if (startIndex == 0) { + create(new.first, new.second, parent?.second) + } else { + createFromCompositeValue(new.first, new.second, parent?.second, startIndex, maxElements) + } + if (parameter == null && reference.indices.isEmpty()) { + return createEmptyParameter(name) + } + return parameter + } + + fun clearReferenceCache() { + rootValueIndexCache.clear() + } + + private fun setup( + newRootId: Long = 0, + newNodeId: Long = 0, + newAnchorId: Int = 0, + newKind: ParameterKind = ParameterKind.Normal, + newParameterIndex: Int = 0, + maxRecursions: Int = 0, + maxInitialIterableSize: Int = 0 + ) { + rootId = newRootId + nodeId = newNodeId + anchorId = newAnchorId + kind = newKind + parameterIndex = newParameterIndex + this.maxRecursions = maxRecursions + this.maxInitialIterableSize = maxInitialIterableSize + recursions = 0 + valueIndex.clear() + valueLazyReferenceMap.clear() + valueIndexMap = rootValueIndexCache.getOrPut(newRootId) { IdentityHashMap() } + } + + private fun create(name: String, value: Any?, parentValue: Any?): NodeParameter? { + if (value == null) { + return null + } + createFromSimpleValue(name, value)?.let { + return it + } + + val existing = + valueIndexMap[value] ?: return createFromCompositeValue(name, value, parentValue) + + // Do not decompose an instance we already decomposed. + // Instead reference the data that was already decomposed. + return createReferenceToExistingValue(name, value, parentValue, existing) + } + + private fun create( + name: String, + value: Any?, + parentValue: Any?, + specifiedIndex: Int = 0 + ): NodeParameter? = create(name, value, parentValue)?.apply { index = specifiedIndex } + + private fun createFromSimpleValue(name: String, value: Any?): NodeParameter? { + if (value == null) { + return null + } + createFromConstant(name, value)?.let { + return it + } + return when (value) { + is AnnotatedString -> NodeParameter(name, ParameterType.String, value.text) + is BaselineShift -> createFromBaselineShift(name, value) + is Boolean -> NodeParameter(name, ParameterType.Boolean, value) + is ComposableLambda -> createFromCLambda(name, value) + is Color -> NodeParameter(name, ParameterType.Color, value.toArgb()) + is Double -> NodeParameter(name, ParameterType.Double, value) + is Dp -> NodeParameter(name, DimensionDp, value.value) + is Enum<*> -> NodeParameter(name, ParameterType.String, value.toString()) + is Float -> NodeParameter(name, ParameterType.Float, value) + is FunctionReference -> createFromFunctionReference(name, value) + is FontListFontFamily -> createFromFontListFamily(name, value) + is FontWeight -> NodeParameter(name, ParameterType.Int32, value.weight) + is Int -> NodeParameter(name, ParameterType.Int32, value) + is Lambda<*> -> createFromLambda(name, value) + is Locale -> NodeParameter(name, ParameterType.String, value.toString()) + is Long -> NodeParameter(name, ParameterType.Int64, value) + is SolidColor -> NodeParameter(name, ParameterType.Color, value.value.toArgb()) + is String -> NodeParameter(name, ParameterType.String, value) + is TextUnit -> createFromTextUnit(name, value) + is ImageVector -> createFromImageVector(name, value) + is View -> NodeParameter(name, ParameterType.String, value.javaClass.simpleName) + else -> null + } + } + + private fun createFromCompositeValue( + name: String, + value: Any?, + parentValue: Any?, + startIndex: Int = 0, + maxElements: Int = maxInitialIterableSize + ): NodeParameter? = + when { + value == null -> null + value is Modifier -> createFromModifier(name, value) + value is InspectableValue -> createFromInspectableValue(name, value) + value is Sequence<*> -> createFromSequence(name, value, value, startIndex, maxElements) + value is Map<*, *> -> + createFromSequence(name, value, value.asSequence(), startIndex, maxElements) + value is Map.Entry<*, *> -> createFromMapEntry(name, value, parentValue) + value is Iterable<*> -> + createFromSequence(name, value, value.asSequence(), startIndex, maxElements) + value.javaClass.isArray -> createFromArray(name, value, startIndex, maxElements) + value is Offset -> createFromOffset(name, value) + value is Shadow -> createFromShadow(name, value) + value is TextStyle -> createFromTextStyle(name, value) + else -> createFromKotlinReflection(name, value) + } + + private fun find(name: String, value: Any?, index: Int): Pair? = + when { + value == null -> null + value is Modifier -> findFromModifier(name, value, index) + value is InspectableValue -> findFromInspectableValue(value, index) + value is Sequence<*> -> findFromSequence(value, index) + value is Map<*, *> -> findFromSequence(value.asSequence(), index) + value is Map.Entry<*, *> -> findFromMapEntry(value, index) + value is Iterable<*> -> findFromSequence(value.asSequence(), index) + value.javaClass.isArray -> findFromArray(value, index) + value is Offset -> findFromOffset(value, index) + value is Shadow -> findFromShadow(value, index) + value is TextStyle -> findFromTextStyle(value, index) + else -> findFromKotlinReflection(value, index) + } + + private fun createRecursively( + name: String, + value: Any?, + parentValue: Any?, + index: Int + ): NodeParameter? { + valueIndex.add(index) + recursions++ + val parameter = create(name, value, parentValue)?.apply { this.index = index } + recursions-- + valueIndex.removeLast() + return parameter + } + + private fun shouldRecurseDeeper(): Boolean = recursions < maxRecursions + + /** + * Create a [NodeParameter] as a reference to a previously created parameter. + * + * Use [createFromCompositeValue] to compute the data type and top value, however no children + * will be created. Instead a reference to the previously created parameter is specified. + */ + private fun createReferenceToExistingValue( + name: String, + value: Any?, + parentValue: Any?, + ref: NodeParameterReference + ): NodeParameter? { + val remember = recursions + recursions = maxRecursions + val parameter = createFromCompositeValue(name, value, parentValue)?.apply { reference = ref } + recursions = remember + return parameter + } + + /** + * Returns `true` if the value can be mapped to a [NodeParameter]. + * + * Composite values should NOT be added to the [valueIndexMap] since we do not intend to include + * this parameter in the response. + */ + private fun hasMappableValue(value: Any?): Boolean { + if (value == null) { + return false + } + if (valueIndexMap.containsKey(value)) { + return true + } + val remember = recursions + recursions = maxRecursions + val parameter = create("p", value, null) + recursions = remember + valueIndexMap.remove(value) + return parameter != null + } + + /** + * Store the reference of this [NodeParameter] by its [value] + * + * If the value is seen in other parameter values again, there is no need to create child + * parameters a second time. + */ + private fun NodeParameter.store(value: Any?): NodeParameter { + if (value != null) { + val index = valueIndexToReference() + valueIndexMap[value] = index + } + return this + } + + /** Remove the [value] of this [NodeParameter] if there are no child elements. */ + private fun NodeParameter.removeIfEmpty(value: Any?): NodeParameter { + if (value != null) { + if (elements.isEmpty()) { + valueIndexMap.remove(value) + } + val reference = valueIndexMap[value] + valueLazyReferenceMap.remove(value)?.forEach { it.reference = reference } + } + return this + } + + /** + * Delay the creation of all child parameters of this composite parameter. + * + * If the child parameters are omitted because of [maxRecursions], store the parameter itself + * such that its reference can be updated if it turns out that child [NodeParameter]s need to be + * generated later. + */ + private fun NodeParameter.withChildReference(value: Any): NodeParameter { + valueLazyReferenceMap.getOrPut(value, { mutableListOf() }).add(this) + reference = valueIndexToReference() + return this + } + + private fun valueIndexToReference(): NodeParameterReference = + NodeParameterReference(nodeId, anchorId, kind, parameterIndex, valueIndex) + + private fun createEmptyParameter(name: String): NodeParameter = + NodeParameter(name, ParameterType.String, "") + + private fun createFromArray( + name: String, + value: Any, + startIndex: Int, + maxElements: Int + ): NodeParameter? { + val sequence = arrayToSequence(value) ?: return null + return createFromSequence(name, value, sequence, startIndex, maxElements) + } + + private fun findFromArray(value: Any, index: Int): Pair? { + val sequence = arrayToSequence(value) ?: return null + return findFromSequence(sequence, index) + } + + private fun arrayToSequence(value: Any): Sequence<*>? = + when (value) { + is Array<*> -> value.asSequence() + is ByteArray -> value.asSequence() + is IntArray -> value.asSequence() + is LongArray -> value.asSequence() + is FloatArray -> value.asSequence() + is DoubleArray -> value.asSequence() + is BooleanArray -> value.asSequence() + is CharArray -> value.asSequence() + else -> null + } + + private fun createFromBaselineShift(name: String, value: BaselineShift): NodeParameter { + val converted = + when (value.multiplier) { + BaselineShift.None.multiplier -> "None" + BaselineShift.Subscript.multiplier -> "Subscript" + BaselineShift.Superscript.multiplier -> "Superscript" + else -> return NodeParameter(name, ParameterType.Float, value.multiplier) + } + return NodeParameter(name, ParameterType.String, converted) + } + + private fun createFromCLambda(name: String, value: ComposableLambda): NodeParameter? = + try { + val lambda = + value.javaClass.getDeclaredField("_block").apply { isAccessible = true }.get(value) + NodeParameter(name, ParameterType.Lambda, arrayOf(lambda)) + } catch (_: Throwable) { + null + } + + private fun createFromConstant(name: String, value: Any): NodeParameter? { + loadConstantsFrom(value.javaClass) + return valueLookup[value]?.let { NodeParameter(name, ParameterType.String, it) } + } + + // For now: select ResourceFontFont closest to W400 and Normal, and return the resId + private fun createFromFontListFamily(name: String, value: FontListFontFamily): NodeParameter? = + findBestResourceFont(value)?.let { NodeParameter(name, ParameterType.Resource, it.resId) } + + private fun createFromFunctionReference(name: String, value: FunctionReference): NodeParameter = + NodeParameter(name, ParameterType.FunctionReference, arrayOf(value, value.name)) + + private fun createFromKotlinReflection(name: String, value: Any): NodeParameter? { + val simpleName = value::class.simpleName + val properties = lookup(value) ?: return null + val parameter = NodeParameter(name, ParameterType.String, simpleName) + return when { + properties.isEmpty() -> parameter + !shouldRecurseDeeper() -> parameter.withChildReference(value) + else -> { + val elements = parameter.store(value).elements + properties.values.mapIndexedNotNullTo(elements) { index, part -> + createRecursively(part.name, valueOf(part, value), value, index) + } + parameter.removeIfEmpty(value) + } + } + } + + private fun findFromKotlinReflection(value: Any, index: Int): Pair? { + val properties = lookup(value)?.entries?.iterator()?.asSequence() ?: return null + val element = properties.elementAtOrNull(index)?.value ?: return null + return Pair(element.name, valueOf(element, value)) + } + + private fun lookup(value: Any): Map>? { + val kClass = value::class + val simpleName = kClass.simpleName + val qualifiedName = kClass.qualifiedName + if (simpleName == null || + qualifiedName == null || + ignoredPackagePrefixes.any { qualifiedName.startsWith(it) }) { + // Exit without creating a parameter for: + // - internal synthetic classes + // - certain android packages + return null + } + return try { + sequenceOf(kClass) + .plus(kClass.allSuperclasses.asSequence()) + .flatMap { it.declaredMemberProperties.asSequence() } + .associateBy { it.name } + } catch (ex: Throwable) { + Log.w("Compose", "Could not decompose ${kClass.simpleName}", ex) + null + } + } + + private fun valueOf(property: KProperty<*>, instance: Any): Any? = + try { + property.isAccessible = true + // Bug in kotlin reflection API: if the type is a nullable inline type with a null + // value, we get an IllegalArgumentException in this line: + property.getter.call(instance) + } catch (ex: Throwable) { + // TODO: Remove this warning since this is expected with nullable inline types + Log.w("Compose", "Could not get value of ${property.name}") + null + } + + private fun createFromInspectableValue(name: String, value: InspectableValue): NodeParameter { + val tempValue = value.valueOverride ?: "" + val parameterName = name.ifEmpty { value.nameFallback } ?: "element" + val parameterValue = if (tempValue is InspectableValue) "" else tempValue + val parameter = + createFromSimpleValue(parameterName, parameterValue) + ?: NodeParameter(parameterName, ParameterType.String, "") + if (!shouldRecurseDeeper()) { + return parameter.withChildReference(value) + } + val elements = parameter.store(value).elements + value.inspectableElements.mapIndexedNotNullTo(elements) { index, element -> + createRecursively(element.name, element.value, value, index) + } + return parameter.removeIfEmpty(value) + } + + private fun findFromInspectableValue(value: InspectableValue, index: Int): Pair? { + val elements = value.inspectableElements.toList() + if (index !in elements.indices) { + return null + } + val element = elements[index] + return Pair(element.name, element.value) + } + + private fun createFromMapEntry( + name: String, + entry: Map.Entry<*, *>, + parentValue: Any? + ): NodeParameter? { + val key = createRecursively("key", entry.key, entry, 0) ?: return null + val value = createRecursively("value", entry.value, entry, 1) ?: return null + val keyName = (key.value?.toString() ?: "").ifEmpty { "entry" } + val valueName = value.value?.toString()?.ifEmpty { null } + val nodeName = if (parentValue is Map<*, *>) "[$keyName]" else name + return NodeParameter(nodeName, ParameterType.String, valueName).apply { + elements.add(key) + elements.add(value) + } + } + + private fun findFromMapEntry(entry: Map.Entry<*, *>, index: Int): Pair? = + when (index) { + 0 -> Pair("key", entry.key) + 1 -> Pair("value", entry.value) + else -> null + } + + private fun createFromSequence( + name: String, + value: Any, + sequence: Sequence<*>, + startIndex: Int, + maxElements: Int + ): NodeParameter { + val parameter = NodeParameter(name, ParameterType.Iterable, sequenceName(value)) + return when { + !sequence.any() -> parameter + !shouldRecurseDeeper() -> parameter.withChildReference(value) + else -> { + val elements = parameter.store(value).elements + val rest = sequence.drop(startIndex).iterator() + var index = startIndex + while (rest.hasNext() && elements.size < maxElements) { + createRecursively("[$index]", rest.next(), value, index)?.let { elements.add(it) } + index++ + } + while (rest.hasNext()) { + if (hasMappableValue(rest.next())) { + parameter.withChildReference(value) + break + } + } + parameter.removeIfEmpty(value) + } + } + } + + private fun findFromSequence(value: Sequence<*>, index: Int): Pair? { + val element = value.elementAtOrNull(index) ?: return null + return Pair("[$index]", element) + } + + private fun sequenceName(value: Any): String = + when (value) { + is Array<*> -> "Array[${value.size}]" + is ByteArray -> "ByteArray[${value.size}]" + is IntArray -> "IntArray[${value.size}]" + is LongArray -> "LongArray[${value.size}]" + is FloatArray -> "FloatArray[${value.size}]" + is DoubleArray -> "DoubleArray[${value.size}]" + is BooleanArray -> "BooleanArray[${value.size}]" + is CharArray -> "CharArray[${value.size}]" + is List<*> -> "List[${value.size}]" + is Set<*> -> "Set[${value.size}]" + is Map<*, *> -> "Map[${value.size}]" + is Collection<*> -> "Collection[${value.size}]" + is Iterable<*> -> "Iterable" + else -> "Sequence" + } + + private fun createFromLambda(name: String, value: Lambda<*>): NodeParameter = + NodeParameter(name, ParameterType.Lambda, arrayOf(value)) + + private fun createFromModifier(name: String, value: Modifier): NodeParameter? = + when { + name.isNotEmpty() -> { + val parameter = NodeParameter(name, ParameterType.String, "") + val modifiers = unwrap(value) + when { + modifiers.isEmpty() -> parameter + !shouldRecurseDeeper() -> parameter.withChildReference(value) + else -> { + val elements = parameter.elements + modifiers.mapIndexedNotNullTo(elements) { index, element -> + createRecursively("", element, value, index) + } + parameter.store(value).removeIfEmpty(value) + } + } + } + value is InspectableValue -> createFromInspectableValue(name, value) + else -> null + } + + private fun unwrap(value: Modifier): List { + val collector = ModifierCollector() + value.foldIn(collector) { acc, m -> acc.apply { add(m) } } + return collector.modifiers + } + + private fun findFromModifier(name: String, value: Modifier, index: Int): Pair? = + when { + name.isNotEmpty() -> { + val modifiers = unwrap(value) + if (index in modifiers.indices) Pair("", modifiers[index]) else null + } + value is InspectableValue -> findFromInspectableValue(value, index) + else -> null + } + + private fun createFromOffset(name: String, value: Offset): NodeParameter { + val parameter = NodeParameter(name, ParameterType.String, Offset::class.java.simpleName) + val elements = parameter.elements + val x = with(density) { value.x.toDp().value } + val y = with(density) { value.y.toDp().value } + elements.add(NodeParameter("x", DimensionDp, x)) + elements.add(NodeParameter("y", DimensionDp, y).apply { index = 1 }) + return parameter + } + + private fun findFromOffset(value: Offset, index: Int): Pair? = + when (index) { + 0 -> Pair("x", with(density) { value.x.toDp() }) + 1 -> Pair("y", with(density) { value.y.toDp() }) + else -> null + } + + // Special handling of blurRadius: convert to dp: + private fun createFromShadow(name: String, value: Shadow): NodeParameter? { + val parameter = createFromKotlinReflection(name, value) ?: return null + val elements = parameter.elements + val index = elements.indexOfFirst { it.name == "blurRadius" } + if (index >= 0) { + val existing = elements[index] + val blurRadius = with(density) { value.blurRadius.toDp().value } + elements[index] = NodeParameter("blurRadius", DimensionDp, blurRadius) + elements[index].index = existing.index + } + return parameter + } + + private fun findFromShadow(value: Shadow, index: Int): Pair? { + val result = findFromKotlinReflection(value, index) + if (result == null || result.first != "blurRadius") { + return result + } + return Pair("blurRadius", with(density) { value.blurRadius.toDp() }) + } + + // Temporary handling of TextStyle: remove when TextStyle implements InspectableValue + // Hide: paragraphStyle, spanStyle, platformStyle, lineHeightStyle + private fun createFromTextStyle(name: String, value: TextStyle): NodeParameter? { + val parameter = NodeParameter(name, ParameterType.String, TextStyle::class.java.simpleName) + val elements = parameter.elements + create("color", value.color, value)?.let { elements.add(it) } + create("fontSize", value.fontSize, value, 1)?.let { elements.add(it) } + create("fontWeight", value.fontWeight, value, 2)?.let { elements.add(it) } + create("fontStyle", value.fontStyle, value, 3)?.let { elements.add(it) } + create("fontSynthesis", value.fontSynthesis, value, 4)?.let { elements.add(it) } + create("fontFamily", value.fontFamily, value, 5)?.let { elements.add(it) } + create("fontFeatureSettings", value.fontFeatureSettings, value, 6)?.let { elements.add(it) } + create("letterSpacing", value.letterSpacing, value, 7)?.let { elements.add(it) } + create("baselineShift", value.baselineShift, value, 8)?.let { elements.add(it) } + create("textGeometricTransform", value.textGeometricTransform, value, 9)?.let { + elements.add(it) + } + create("localeList", value.localeList, value, 10)?.let { elements.add(it) } + create("background", value.background, value, 11)?.let { elements.add(it) } + create("textDecoration", value.textDecoration, value, 12)?.let { elements.add(it) } + create("shadow", value.shadow, value, 13)?.let { elements.add(it) } + create("textDirection", value.textDirection, value, 14)?.let { elements.add(it) } + create("lineHeight", value.lineHeight, value, 15)?.let { elements.add(it) } + create("textIndent", value.textIndent, value, 16)?.let { elements.add(it) } + return parameter + } + + private fun findFromTextStyle(value: TextStyle, index: Int): Pair? = + when (index) { + 0 -> Pair("color", value.color) + 1 -> Pair("fontSize", value.fontSize) + 2 -> Pair("fontWeight", value.fontWeight) + 3 -> Pair("fontStyle", value.fontStyle) + 4 -> Pair("fontSynthesis", value.fontSynthesis) + 5 -> Pair("fontFamily", value.fontFamily) + 6 -> Pair("fontFeatureSettings", value.fontFeatureSettings) + 7 -> Pair("letterSpacing", value.letterSpacing) + 8 -> Pair("baselineShift", value.baselineShift) + 9 -> Pair("textGeometricTransform", value.textGeometricTransform) + 10 -> Pair("localeList", value.localeList) + 11 -> Pair("background", value.background) + 12 -> Pair("textDecoration", value.textDecoration) + 13 -> Pair("shadow", value.shadow) + 14 -> Pair("textDirection", value.textDirection) + 15 -> Pair("lineHeight", value.lineHeight) + 16 -> Pair("textIndent", value.textIndent) + else -> null + } + + @Suppress("DEPRECATION") + private fun createFromTextUnit(name: String, value: TextUnit): NodeParameter = + when (value.type) { + TextUnitType.Sp -> NodeParameter(name, ParameterType.DimensionSp, value.value) + TextUnitType.Em -> NodeParameter(name, ParameterType.DimensionEm, value.value) + else -> NodeParameter(name, ParameterType.String, "Unspecified") + } + + private fun createFromImageVector(name: String, value: ImageVector): NodeParameter = + NodeParameter(name, ParameterType.String, value.name) + + /** + * Select a resource font among the font in the family to represent the font + * + * Prefer the font closest to [FontWeight.Normal] and [FontStyle.Normal] + */ + private fun findBestResourceFont(value: FontListFontFamily): ResourceFont? = + value.fonts.asSequence().filterIsInstance().minByOrNull { + abs(it.weight.weight - FontWeight.Normal.weight) + it.style.value + } + } + + private class ModifierCollector { + val modifiers = mutableListOf() + var start: InspectableModifier? = null + + fun add(element: Modifier.Element) = + when { + element == start?.end -> start = null + start != null -> {} + else -> { + modifiers.add(element) + start = element as? InspectableModifier + } + } + } +} diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/ReflectionScope.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/ReflectionScope.kt new file mode 100644 index 000000000..8deae6a1b --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/inspector/ReflectionScope.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.inspector + +import android.annotation.SuppressLint +import java.lang.reflect.Field +import java.lang.reflect.Modifier +import kotlin.jvm.internal.FunctionBase +import kotlin.jvm.internal.FunctionReference +import kotlin.jvm.internal.Lambda +import kotlin.jvm.internal.MutablePropertyReference0 +import kotlin.jvm.internal.MutablePropertyReference1 +import kotlin.jvm.internal.MutablePropertyReference2 +import kotlin.jvm.internal.PropertyReference0 +import kotlin.jvm.internal.PropertyReference1 +import kotlin.jvm.internal.PropertyReference2 +import kotlin.jvm.internal.Reflection +import kotlin.jvm.internal.ReflectionFactory +import kotlin.reflect.KClass +import kotlin.reflect.KClassifier +import kotlin.reflect.KDeclarationContainer +import kotlin.reflect.KFunction +import kotlin.reflect.KMutableProperty0 +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.KMutableProperty2 +import kotlin.reflect.KProperty0 +import kotlin.reflect.KProperty1 +import kotlin.reflect.KProperty2 +import kotlin.reflect.KType +import kotlin.reflect.KTypeParameter +import kotlin.reflect.KTypeProjection +import kotlin.reflect.KVariance +import kotlin.reflect.jvm.internal.ReflectionFactoryImpl + +/** + * Scope that allows to use jarjar-ed kotlin-reflect artifact that is shipped with inspector itself. + * + * Issue with kotlin-reflect. Many of reflective calls such as "foo::class" rely on static functions + * defined in kotlin-stdlib's Reflection.java that delegate to ReflectionFactory. In order to + * initialize that factory kotlin-stdlib statically detects presence or absence of kotlin-reflect in + * classloader and chooses a factory accordingly. If there is no kotlin-reflect, very limited + * version of ReflectionFactory is used. + * + * It is an issue for inspectors because they could be loaded after that factory is initialised, and + * even if they are loaded before, they live in a separate child classloader, thus kotlin-reflect in + * inspector wouldn't exist for kotlin-stdlib in app. + * + * First step to avoid the issue is using ReflectionFactoryImpl that is bundled with inspector. Code + * for that would be fairly simple, for example instead of directly calling + * `kClass.declaredMemberProperties`, correct instance of kClass should be obtained from factory: + * `factory.getOrCreateKotlinClass(kClass.java).declaredMemberProperties`. + * + * That would work if code that works with correct KClass full implementation would never try to + * access a default factory installed in Reflection.java. Unfortunately it is not true, it + * eventually calls `CallableReference.getOwner()` in stdlib that uses default factory. + * + * As a result we have to replace the factory in Reflection.java. To avoid issues with user's code + * factory that we setup is smart, by default it simply delegates to a factory that was previously + * installed. Only within `reflectionScope.withReflectiveAccess{ }` factory from kotlin-reflect is + * used. + */ +@SuppressLint("BanUncheckedReflection") +class ReflectionScope { + + companion object { + init { + allowHiddenApi() + } + } + + private val scopedReflectionFactory = installScopedReflectionFactory() + + /** Runs `block` with access to kotlin-reflect. */ + fun withReflectiveAccess(block: () -> T): T { + return scopedReflectionFactory.withMainFactory(block) + } + + private fun installScopedReflectionFactory(): ScopedReflectionFactory { + val factoryField = Reflection::class.java.getDeclaredField("factory") + factoryField.isAccessible = true + val original: ReflectionFactory = factoryField.get(null) as ReflectionFactory + val modifiersField: Field = Field::class.java.getDeclaredField("accessFlags") + modifiersField.isAccessible = true + // make field non-final 😅 b/179685774 https://youtrack.jetbrains.com/issue/KT-44795 + modifiersField.setInt(factoryField, factoryField.modifiers and Modifier.FINAL.inv()) + val scopedReflectionFactory = ScopedReflectionFactory(original) + factoryField.set(null, scopedReflectionFactory) + return scopedReflectionFactory + } +} + +@SuppressLint("BanUncheckedReflection") +private fun allowHiddenApi() { + try { + val vmDebug = Class.forName("dalvik.system.VMDebug") + val allowHiddenApiReflectionFrom = + vmDebug.getDeclaredMethod("allowHiddenApiReflectionFrom", Class::class.java) + allowHiddenApiReflectionFrom.invoke(null, ReflectionScope::class.java) + } catch (e: Throwable) { + // ignore failure, let's try to proceed without it + } +} + +private class ScopedReflectionFactory( + private val original: ReflectionFactory, +) : ReflectionFactory() { + private val mainFactory = ReflectionFactoryImpl() + private val threadLocalFactory = ThreadLocal() + + fun withMainFactory(block: () -> T): T { + threadLocalFactory.set(mainFactory) + try { + return block() + } finally { + threadLocalFactory.set(null) + } + } + + val factory: ReflectionFactory + get() = threadLocalFactory.get() ?: original + + override fun createKotlinClass(javaClass: Class<*>?): KClass<*> { + return factory.createKotlinClass(javaClass) + } + + override fun createKotlinClass(javaClass: Class<*>?, internalName: String?): KClass<*> { + return factory.createKotlinClass(javaClass, internalName) + } + + override fun getOrCreateKotlinPackage( + javaClass: Class<*>?, + moduleName: String? + ): KDeclarationContainer { + return factory.getOrCreateKotlinPackage(javaClass, moduleName) + } + + override fun getOrCreateKotlinClass(javaClass: Class<*>?): KClass<*> { + return factory.getOrCreateKotlinClass(javaClass) + } + + override fun getOrCreateKotlinClass(javaClass: Class<*>?, internalName: String?): KClass<*> { + return factory.getOrCreateKotlinClass(javaClass, internalName) + } + + override fun renderLambdaToString(lambda: Lambda<*>?): String { + return factory.renderLambdaToString(lambda) + } + + override fun renderLambdaToString(lambda: FunctionBase<*>?): String { + return factory.renderLambdaToString(lambda) + } + + override fun function(f: FunctionReference?): KFunction<*> { + return factory.function(f) + } + + override fun property0(p: PropertyReference0?): KProperty0<*> { + return factory.property0(p) + } + + override fun mutableProperty0(p: MutablePropertyReference0?): KMutableProperty0<*> { + return factory.mutableProperty0(p) + } + + override fun property1(p: PropertyReference1?): KProperty1<*, *> { + return factory.property1(p) + } + + override fun mutableProperty1(p: MutablePropertyReference1?): KMutableProperty1<*, *> { + return factory.mutableProperty1(p) + } + + override fun property2(p: PropertyReference2?): KProperty2<*, *, *> { + return factory.property2(p) + } + + override fun mutableProperty2(p: MutablePropertyReference2?): KMutableProperty2<*, *, *> { + return factory.mutableProperty2(p) + } + + override fun typeOf( + klass: KClassifier?, + arguments: MutableList?, + isMarkedNullable: Boolean + ): KType { + return factory.typeOf(klass, arguments, isMarkedNullable) + } + + override fun typeParameter( + container: Any?, + name: String?, + variance: KVariance?, + isReified: Boolean + ): KTypeParameter { + return factory.typeParameter(container, name, variance, isReified) + } + + override fun setUpperBounds(typeParameter: KTypeParameter?, bounds: MutableList?) { + factory.setUpperBounds(typeParameter, bounds) + } +} diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/util/AnchorMap.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/util/AnchorMap.kt new file mode 100644 index 000000000..b7af6df91 --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/util/AnchorMap.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.util + +import java.util.IdentityHashMap + +const val NO_ANCHOR_ID = 0 + +/** A map of anchors with a unique id generator. */ +class AnchorMap { + private val anchorLookup = mutableMapOf() + private val idLookup = IdentityHashMap() + + /** Return a unique id for the specified [anchor] instance. */ + operator fun get(anchor: Any?): Int = + anchor?.let { idLookup.getOrPut(it) { generateUniqueId(it) } } ?: NO_ANCHOR_ID + + /** Return the anchor associated with a given unique anchor [id]. */ + operator fun get(id: Int): Any? = anchorLookup[id] + + private fun generateUniqueId(anchor: Any): Int { + var id = anchor.hashCode() + while (id == NO_ANCHOR_ID || anchorLookup.containsKey(id)) { + id++ + } + anchorLookup[id] = anchor + return id + } +} diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/util/IntArray.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/util/IntArray.kt new file mode 100644 index 000000000..8287eb7a3 --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/util/IntArray.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.util + +private val EMPTY_INT_ARRAY = intArrayOf() + +fun List.asIntArray() = if (isNotEmpty()) toIntArray() else EMPTY_INT_ARRAY diff --git a/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/util/ThreadUtils.kt b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/util/ThreadUtils.kt new file mode 100644 index 000000000..80320d9f7 --- /dev/null +++ b/android/plugins/jetpack-compose/src/main/java/facebook/internal/androidx/compose/ui/inspection/util/ThreadUtils.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * 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 facebook.internal.androidx.compose.ui.inspection.util + +import android.os.Handler +import android.os.Looper +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Future + +object ThreadUtils { + fun assertOnMainThread() { + if (!Looper.getMainLooper().isCurrentThread) { + error("This work is required on the main thread") + } + } + + fun assertOffMainThread() { + if (Looper.getMainLooper().isCurrentThread) { + error("This work is required off the main thread") + } + } + + /** + * Run some logic on the main thread, returning a future that will contain any data computed by + * and returned from the block. + * + * If this method is called from the main thread, it will run immediately. + */ + fun runOnMainThread(block: () -> T): Future { + return if (!Looper.getMainLooper().isCurrentThread) { + val future = CompletableFuture() + Handler.createAsync(Looper.getMainLooper()).post { future.complete(block()) } + future + } else { + CompletableFuture.completedFuture(block()) + } + } +}