1 /*
<lambda>null2  * Copyright 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.ui.tooling
18 
19 import android.annotation.SuppressLint
20 import android.content.Context
21 import android.graphics.Canvas
22 import android.graphics.DashPathEffect
23 import android.graphics.Paint
24 import android.os.Bundle
25 import android.util.AttributeSet
26 import android.util.Log
27 import android.widget.FrameLayout
28 import androidx.activity.OnBackPressedDispatcher
29 import androidx.activity.OnBackPressedDispatcherOwner
30 import androidx.activity.compose.LocalActivityResultRegistryOwner
31 import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
32 import androidx.activity.result.ActivityResultRegistry
33 import androidx.activity.result.ActivityResultRegistryOwner
34 import androidx.activity.result.contract.ActivityResultContract
35 import androidx.annotation.VisibleForTesting
36 import androidx.compose.runtime.Composable
37 import androidx.compose.runtime.Composition
38 import androidx.compose.runtime.CompositionLocalProvider
39 import androidx.compose.runtime.SideEffect
40 import androidx.compose.runtime.currentComposer
41 import androidx.compose.runtime.snapshots.Snapshot
42 import androidx.compose.ui.ExperimentalComposeUiApi
43 import androidx.compose.ui.graphics.Color
44 import androidx.compose.ui.graphics.toArgb
45 import androidx.compose.ui.layout.LayoutInfo
46 import androidx.compose.ui.platform.ComposeView
47 import androidx.compose.ui.platform.LocalFontFamilyResolver
48 import androidx.compose.ui.platform.LocalFontLoader
49 import androidx.compose.ui.platform.ViewRootForTest
50 import androidx.compose.ui.text.font.createFontFamilyResolver
51 import androidx.compose.ui.tooling.animation.AnimationSearch
52 import androidx.compose.ui.tooling.animation.PreviewAnimationClock
53 import androidx.compose.ui.tooling.data.Group
54 import androidx.compose.ui.tooling.data.NodeGroup
55 import androidx.compose.ui.tooling.data.SourceLocation
56 import androidx.compose.ui.tooling.data.UiToolingDataApi
57 import androidx.compose.ui.tooling.data.asTree
58 import androidx.compose.ui.tooling.preview.Preview
59 import androidx.compose.ui.tooling.preview.PreviewParameterProvider
60 import androidx.compose.ui.unit.IntRect
61 import androidx.core.app.ActivityOptionsCompat
62 import androidx.lifecycle.Lifecycle
63 import androidx.lifecycle.LifecycleRegistry
64 import androidx.lifecycle.ViewModelStore
65 import androidx.lifecycle.ViewModelStoreOwner
66 import androidx.lifecycle.setViewTreeLifecycleOwner
67 import androidx.lifecycle.setViewTreeViewModelStoreOwner
68 import androidx.savedstate.SavedStateRegistry
69 import androidx.savedstate.SavedStateRegistryController
70 import androidx.savedstate.SavedStateRegistryOwner
71 import androidx.savedstate.setViewTreeSavedStateRegistryOwner
72 import java.lang.reflect.Method
73 
74 private const val TOOLS_NS_URI = "http://schemas.android.com/tools"
75 private const val DESIGN_INFO_METHOD = "getDesignInfo"
76 
77 private const val REMEMBER = "remember"
78 
79 private val emptyContent: @Composable () -> Unit = @Composable {}
80 
81 /**
82  * Class containing the minimum information needed by the Preview to map components to the source
83  * code and render boundaries.
84  */
85 @OptIn(UiToolingDataApi::class)
86 internal data class ViewInfo(
87     val fileName: String,
88     val lineNumber: Int,
89     val bounds: IntRect,
90     val location: SourceLocation?,
91     val children: List<ViewInfo>,
92     val layoutInfo: Any?,
93     val name: String?
94 ) {
hasBoundsnull95     fun hasBounds(): Boolean = bounds.bottom != 0 && bounds.right != 0
96 
97     fun allChildren(): List<ViewInfo> = children + children.flatMap { it.allChildren() }
98 
toStringnull99     override fun toString(): String =
100         """($fileName:$lineNumber,
101             |bounds=(top=${bounds.top}, left=${bounds.left},
102             |location=${location?.let { "(${it.offset}L${it.length}" } ?: "<none>"}
103             |bottom=${bounds.bottom}, right=${bounds.right}),
104             |childrenCount=${children.size})"""
105             .trimMargin()
106 }
107 
108 /**
109  * View adapter that renders a `@Composable`. The `@Composable` is found by reading the
110  * `tools:composableName` attribute that contains the FQN. Additional attributes can be used to
111  * customize the behaviour of this view:
112  * - `tools:parameterProviderClass`: FQN of the [PreviewParameterProvider] to be instantiated by the
113  *   [ComposeViewAdapter] that will be used as source for the `@Composable` parameters.
114  * - `tools:parameterProviderIndex`: The index within the [PreviewParameterProvider] of the value to
115  *   be used in this particular instance.
116  * - `tools:paintBounds`: If true, the component boundaries will be painted. This is only meant for
117  *   debugging purposes.
118  * - `tools:printViewInfos`: If true, the [ComposeViewAdapter] will log the tree of [ViewInfo] to
119  *   logcat for debugging.
120  * - `tools:animationClockStartTime`: When set, a [PreviewAnimationClock] will control the
121  *   animations in the [ComposeViewAdapter] context.
122  */
123 @Suppress("unused")
124 @OptIn(UiToolingDataApi::class)
125 internal class ComposeViewAdapter : FrameLayout {
126     private val TAG = "ComposeViewAdapter"
127 
128     /** [ComposeView] that will contain the [Composable] to preview. */
129     private val composeView = ComposeView(context)
130 
131     /**
132      * When enabled, generate and cache [ViewInfo] tree that can be inspected by the Preview to map
133      * components to source code.
134      */
135     private var debugViewInfos = false
136 
137     /** When enabled, paint the boundaries generated by layout nodes. */
138     private var debugPaintBounds = false
139     internal var viewInfos: List<ViewInfo> = emptyList()
140     internal var designInfoList: List<String> = emptyList()
141     private val slotTableRecord = CompositionDataRecord.create()
142 
143     /** Simple function name of the Composable being previewed. */
144     private var composableName = ""
145 
146     /** Whether the current Composable has animations. */
147     private var hasAnimations = false
148 
149     /**
150      * Saved exception from the last composition. Since we can not handle the exception during the
151      * composition, we save it and throw it during onLayout, this allows Studio to catch it and
152      * display it to the user.
153      */
154     private val delayedException = ThreadSafeException()
155 
156     /**
157      * The [Composable] to be rendered in the preview. It is initialized when this adapter is
158      * initialized.
159      */
160     private var previewComposition: @Composable () -> Unit = {}
161 
162     /**
163      * When true, the adapter will try to look objects that support the call [DESIGN_INFO_METHOD]
164      * within the slot table and populate [designInfoList]. Used to support rendering in Studio.
165      */
166     private var lookForDesignInfoProviders = false
167 
168     /**
169      * An additional [String] argument that will be passed to objects that support the
170      * [DESIGN_INFO_METHOD] call. Meant to be used by studio to as a way to request additional
171      * information from the Preview.
172      */
173     private var designInfoProvidersArgument: String = ""
174 
175     /** Callback invoked when onDraw has been called. */
176     private var onDraw = {}
177 
178     internal var stitchTrees = true
179 
180     private val debugBoundsPaint =
181         Paint().apply {
182             pathEffect = DashPathEffect(floatArrayOf(5f, 10f, 15f, 20f), 0f)
183             style = Paint.Style.STROKE
184             color = Color.Red.toArgb()
185         }
186 
187     private var composition: Composition? = null
188 
189     constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
190         init(attrs)
191     }
192 
193     constructor(
194         context: Context,
195         attrs: AttributeSet,
196         defStyleAttr: Int
197     ) : super(context, attrs, defStyleAttr) {
198         init(attrs)
199     }
200 
201     private val Group.fileName: String
202         get() = location?.sourceFile ?: ""
203 
204     private val Group.lineNumber: Int
205         get() = location?.lineNumber ?: -1
206 
207     /** Returns true if this [Group] has no source position information */
208     private fun Group.hasNullSourcePosition(): Boolean = fileName.isEmpty() && lineNumber == -1
209 
210     /** Returns true if this [Group] has no source position information and no children */
211     private fun Group.isNullGroup(): Boolean =
212         hasNullSourcePosition() &&
213             children.isEmpty() &&
214             ((this as? NodeGroup)?.node as? LayoutInfo) == null
215 
216     private fun Group.toViewInfo(): ViewInfo {
217         val layoutInfo = ((this as? NodeGroup)?.node as? LayoutInfo)
218 
219         if (children.size == 1 && hasNullSourcePosition() && layoutInfo == null) {
220             // There is no useful information in this intermediate node, remove.
221             return children.single().toViewInfo()
222         }
223 
224         val childrenViewInfo = children.filter { !it.isNullGroup() }.map { it.toViewInfo() }
225 
226         // TODO: Use group names instead of indexing once it's supported
227         return ViewInfo(
228             location?.sourceFile ?: "",
229             location?.lineNumber ?: -1,
230             box,
231             location,
232             childrenViewInfo,
233             layoutInfo,
234             name
235         )
236     }
237 
238     /** Processes the recorded slot table and re-generates the [viewInfos] attribute. */
239     private fun processViewInfos() {
240         val newViewInfos = slotTableRecord.store.map { it.asTree().toViewInfo() }.toList()
241 
242         viewInfos = if (stitchTrees) stitchTrees(newViewInfos) else newViewInfos
243 
244         if (debugViewInfos) {
245             val debugString = viewInfos.toDebugString()
246             Log.d(TAG, debugString)
247         }
248     }
249 
250     override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
251         super.onLayout(changed, left, top, right, bottom)
252 
253         // If there was a pending exception then throw it here since Studio will catch it and show
254         // it to the user.
255         delayedException.throwIfPresent()
256 
257         processViewInfos()
258         if (composableName.isNotEmpty()) {
259             findAndTrackAnimations()
260             if (lookForDesignInfoProviders) {
261                 findDesignInfoProviders()
262             }
263         }
264     }
265 
266     override fun onAttachedToWindow() {
267         composeView.rootView.setViewTreeLifecycleOwner(FakeSavedStateRegistryOwner)
268         super.onAttachedToWindow()
269     }
270 
271     /**
272      * Finds all animations defined in the Compose tree where the root is the `@Composable` being
273      * previewed.
274      */
275     private fun findAndTrackAnimations() {
276         val slotTrees = slotTableRecord.store.map { it.asTree() }
277         val isAnimationPreview = ::clock.isInitialized
278         AnimationSearch(::clock, ::requestLayout).let {
279             hasAnimations = it.searchAny(slotTrees)
280             if (isAnimationPreview && hasAnimations) {
281                 it.attachAllAnimations(slotTrees)
282             }
283         }
284     }
285 
286     /**
287      * Find all data objects within the slotTree that can invoke '[DESIGN_INFO_METHOD]', and store
288      * their result in [designInfoList].
289      */
290     private fun findDesignInfoProviders() {
291         val slotTrees = slotTableRecord.store.map { it.asTree() }
292 
293         designInfoList =
294             slotTrees.flatMap { rootGroup ->
295                 rootGroup
296                     .findAll { group ->
297                         (group.name != REMEMBER && group.hasDesignInfo()) ||
298                             group.children.any { child ->
299                                 child.name == REMEMBER && child.hasDesignInfo()
300                             }
301                     }
302                     .mapNotNull { group ->
303                         // Get the DesignInfoProviders from the group or one of its children
304                         group.getDesignInfoOrNull(group.box)
305                             ?: group.children.firstNotNullOfOrNull {
306                                 it.getDesignInfoOrNull(group.box)
307                             }
308                     }
309             }
310     }
311 
312     private fun Group.hasDesignInfo(): Boolean =
313         data.any { it?.getDesignInfoMethodOrNull() != null }
314 
315     private fun Group.getDesignInfoOrNull(box: IntRect): String? =
316         data.firstNotNullOfOrNull { it?.invokeGetDesignInfo(box.left, box.right) }
317 
318     /**
319      * Check if the object supports the method call for [DESIGN_INFO_METHOD], which is expected to
320      * take two Integer arguments for coordinates and a String for additional encoded arguments that
321      * may be provided from Studio.
322      */
323     private fun Any.getDesignInfoMethodOrNull(): Method? {
324         return try {
325             javaClass.getDeclaredMethod(
326                 DESIGN_INFO_METHOD,
327                 Integer.TYPE,
328                 Integer.TYPE,
329                 String::class.java
330             )
331         } catch (e: NoSuchMethodException) {
332             null
333         }
334     }
335 
336     @Suppress("BanUncheckedReflection")
337     private fun Any.invokeGetDesignInfo(x: Int, y: Int): String? {
338         return this.getDesignInfoMethodOrNull()?.let { designInfoMethod ->
339             try {
340                 // Workaround for unchecked Method.invoke
341                 val result = designInfoMethod.invoke(this, x, y, designInfoProvidersArgument)
342                 (result as String).ifEmpty { null }
343             } catch (e: Exception) {
344                 null
345             }
346         }
347     }
348 
349     override fun dispatchDraw(canvas: Canvas) {
350         super.dispatchDraw(canvas)
351 
352         onDraw()
353         if (!debugPaintBounds) {
354             return
355         }
356 
357         viewInfos
358             .flatMap { listOf(it) + it.allChildren() }
359             .forEach {
360                 if (it.hasBounds()) {
361                     canvas.apply {
362                         val pxBounds =
363                             android.graphics.Rect(
364                                 it.bounds.left,
365                                 it.bounds.top,
366                                 it.bounds.right,
367                                 it.bounds.bottom
368                             )
369                         drawRect(pxBounds, debugBoundsPaint)
370                     }
371                 }
372             }
373     }
374 
375     /** Clock that controls the animations defined in the context of this [ComposeViewAdapter]. */
376     @VisibleForTesting internal lateinit var clock: PreviewAnimationClock
377 
378     /** Wraps a given [Preview] method an does any necessary setup. */
379     @Composable
380     private fun WrapPreview(content: @Composable () -> Unit) {
381         // We need to replace the FontResourceLoader to avoid using ResourcesCompat.
382         // ResourcesCompat can not load fonts within Layoutlib and, since Layoutlib always runs
383         // the latest version, we do not need it.
384         @Suppress("DEPRECATION")
385         CompositionLocalProvider(
386             LocalFontLoader provides LayoutlibFontResourceLoader(context),
387             LocalFontFamilyResolver provides createFontFamilyResolver(context),
388             LocalOnBackPressedDispatcherOwner provides FakeOnBackPressedDispatcherOwner,
389             LocalActivityResultRegistryOwner provides FakeActivityResultRegistryOwner,
390         ) {
391             Inspectable(slotTableRecord, content)
392         }
393     }
394 
395     /**
396      * Initializes the adapter and populates it with the given [Preview] composable.
397      *
398      * @param className name of the class containing the preview function
399      * @param methodName `@Preview` method name
400      * @param parameterProvider [Class] for the [PreviewParameterProvider] to be used as parameter
401      *   input for this call. If null, no parameters will be passed to the composable.
402      * @param parameterProviderIndex when [parameterProvider] is not null, this index will reference
403      *   the element in the [Sequence] to be used as parameter.
404      * @param debugPaintBounds if true, the view will paint the boundaries around the layout
405      *   elements.
406      * @param debugViewInfos if true, it will generate the [ViewInfo] structures and will log it.
407      * @param animationClockStartTime if positive, [clock] will be defined and will control the
408      *   animations defined in the context of the `@Composable` being previewed.
409      * @param lookForDesignInfoProviders if true, it will try to populate [designInfoList].
410      * @param designInfoProvidersArgument String to use as an argument when populating
411      *   [designInfoList].
412      * @param onCommit callback invoked after every commit of the preview composable.
413      * @param onDraw callback invoked after every draw of the adapter. Only for test use.
414      */
415     @Suppress("DEPRECATION")
416     @OptIn(ExperimentalComposeUiApi::class)
417     @VisibleForTesting
418     internal fun init(
419         className: String,
420         methodName: String,
421         parameterProvider: Class<out PreviewParameterProvider<*>>? = null,
422         parameterProviderIndex: Int = 0,
423         debugPaintBounds: Boolean = false,
424         debugViewInfos: Boolean = false,
425         animationClockStartTime: Long = -1,
426         lookForDesignInfoProviders: Boolean = false,
427         designInfoProvidersArgument: String? = null,
428         onCommit: () -> Unit = {},
429         onDraw: () -> Unit = {}
430     ) {
431         this.debugPaintBounds = debugPaintBounds
432         this.debugViewInfos = debugViewInfos
433         this.composableName = methodName
434         this.lookForDesignInfoProviders = lookForDesignInfoProviders
435         this.designInfoProvidersArgument = designInfoProvidersArgument ?: ""
436         this.onDraw = onDraw
437 
438         previewComposition =
439             @Composable {
440                 SideEffect(onCommit)
441 
442                 WrapPreview {
443                     val composer = currentComposer
444                     // We need to delay the reflection instantiation of the class until we are in
445                     // the
446                     // composable to ensure all the right initialization has happened and the
447                     // Composable
448                     // class loads correctly.
449                     val composable = {
450                         try {
451                             ComposableInvoker.invokeComposable(
452                                 className,
453                                 methodName,
454                                 composer,
455                                 *getPreviewProviderParameters(
456                                     parameterProvider,
457                                     parameterProviderIndex
458                                 )
459                             )
460                         } catch (t: Throwable) {
461                             // If there is an exception, store it for later but do not catch it so
462                             // compose can handle it and dispose correctly.
463                             var exception: Throwable = t
464                             // Find the root cause and use that for the delayedException.
465                             while (exception is ReflectiveOperationException) {
466                                 exception = exception.cause ?: break
467                             }
468                             delayedException.set(exception)
469                             throw t
470                         }
471                     }
472                     if (animationClockStartTime >= 0) {
473                         // When animation inspection is enabled, i.e. when a valid (non-negative)
474                         // `animationClockStartTime` is passed, set the Preview Animation Clock.
475                         // This
476                         // clock will control the animations defined in this `ComposeViewAdapter`
477                         // from Android Studio.
478                         clock = PreviewAnimationClock {
479                             // Invalidate the descendants of this ComposeViewAdapter's only
480                             // grandchild
481                             // (an AndroidOwner) when setting the clock time to make sure the
482                             // Compose
483                             // Preview will animate when the states are read inside the draw scope.
484                             val composeView = getChildAt(0) as ComposeView
485                             (composeView.getChildAt(0) as? ViewRootForTest)?.invalidateDescendants()
486                             // Send pending apply notifications to ensure the animation duration
487                             // will
488                             // be read in the correct frame.
489                             Snapshot.sendApplyNotifications()
490                         }
491                     }
492                     composable()
493                 }
494             }
495         composeView.setContent(previewComposition)
496         invalidate()
497     }
498 
499     /** Disposes the Compose elements allocated during [init] */
500     internal fun dispose() {
501         composeView.disposeComposition()
502         if (::clock.isInitialized) {
503             clock.dispose()
504         }
505         FakeSavedStateRegistryOwner.lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
506         FakeViewModelStoreOwner.viewModelStore.clear()
507     }
508 
509     /**
510      * Returns whether this `@Composable` has animations. This allows Android Studio to decide if
511      * the Animation Inspector icon should be displayed for this preview. The reason for using a
512      * method instead of the property directly is we use Java reflection to call it from Android
513      * Studio, and to find the property we'd need to filter the method names using `contains`
514      * instead of `equals`.
515      */
516     fun hasAnimations() = hasAnimations
517 
518     private fun init(attrs: AttributeSet) {
519         // ComposeView and lifecycle initialization
520         setViewTreeLifecycleOwner(FakeSavedStateRegistryOwner)
521         setViewTreeSavedStateRegistryOwner(FakeSavedStateRegistryOwner)
522         setViewTreeViewModelStoreOwner(FakeViewModelStoreOwner)
523         addView(composeView)
524 
525         val composableName = attrs.getAttributeValue(TOOLS_NS_URI, "composableName") ?: return
526         val className = composableName.substringBeforeLast('.')
527         val methodName = composableName.substringAfterLast('.')
528         val parameterProviderIndex =
529             attrs.getAttributeIntValue(TOOLS_NS_URI, "parameterProviderIndex", 0)
530         val parameterProviderClass =
531             attrs
532                 .getAttributeValue(TOOLS_NS_URI, "parameterProviderClass")
533                 ?.asPreviewProviderClass()
534 
535         val animationClockStartTime =
536             try {
537                 attrs.getAttributeValue(TOOLS_NS_URI, "animationClockStartTime").toLong()
538             } catch (e: Exception) {
539                 -1L
540             }
541 
542         init(
543             className = className,
544             methodName = methodName,
545             parameterProvider = parameterProviderClass,
546             parameterProviderIndex = parameterProviderIndex,
547             debugPaintBounds =
548                 attrs.getAttributeBooleanValue(TOOLS_NS_URI, "paintBounds", debugPaintBounds),
549             debugViewInfos =
550                 attrs.getAttributeBooleanValue(TOOLS_NS_URI, "printViewInfos", debugViewInfos),
551             animationClockStartTime = animationClockStartTime,
552             lookForDesignInfoProviders =
553                 attrs.getAttributeBooleanValue(
554                     TOOLS_NS_URI,
555                     "findDesignInfoProviders",
556                     lookForDesignInfoProviders
557                 ),
558             designInfoProvidersArgument =
559                 attrs.getAttributeValue(TOOLS_NS_URI, "designInfoProvidersArgument")
560         )
561     }
562 
563     @SuppressLint("VisibleForTests")
564     private val FakeSavedStateRegistryOwner =
565         object : SavedStateRegistryOwner {
566             val lifecycleRegistry = LifecycleRegistry.createUnsafe(this)
567             private val controller =
568                 SavedStateRegistryController.create(this).apply { performRestore(Bundle()) }
569 
570             init {
571                 lifecycleRegistry.currentState = Lifecycle.State.RESUMED
572             }
573 
574             override val savedStateRegistry: SavedStateRegistry
575                 get() = controller.savedStateRegistry
576 
577             override val lifecycle: LifecycleRegistry
578                 get() = lifecycleRegistry
579         }
580 
581     private val FakeViewModelStoreOwner =
582         object : ViewModelStoreOwner {
583             private val vmStore = ViewModelStore()
584 
585             override val viewModelStore = vmStore
586         }
587 
588     private val FakeOnBackPressedDispatcherOwner =
589         object : OnBackPressedDispatcherOwner {
590             override val onBackPressedDispatcher = OnBackPressedDispatcher()
591 
592             override val lifecycle: LifecycleRegistry
593                 get() = FakeSavedStateRegistryOwner.lifecycleRegistry
594         }
595 
596     private val FakeActivityResultRegistryOwner =
597         object : ActivityResultRegistryOwner {
598             override val activityResultRegistry =
599                 object : ActivityResultRegistry() {
600                     override fun <I : Any?, O : Any?> onLaunch(
601                         requestCode: Int,
602                         contract: ActivityResultContract<I, O>,
603                         input: I,
604                         options: ActivityOptionsCompat?
605                     ) {
606                         throw IllegalStateException("Calling launch() is not supported in Preview")
607                     }
608                 }
609         }
610 }
611