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 package androidx.glance.appwidget
17 
18 import android.os.Build
19 import android.util.Log
20 import androidx.compose.ui.unit.dp
21 import androidx.glance.BackgroundModifier
22 import androidx.glance.Emittable
23 import androidx.glance.EmittableButton
24 import androidx.glance.EmittableImage
25 import androidx.glance.EmittableLazyItemWithChildren
26 import androidx.glance.EmittableWithChildren
27 import androidx.glance.GlanceModifier
28 import androidx.glance.ImageProvider
29 import androidx.glance.action.ActionModifier
30 import androidx.glance.action.LambdaAction
31 import androidx.glance.action.NoRippleOverride
32 import androidx.glance.addChild
33 import androidx.glance.addChildIfNotNull
34 import androidx.glance.appwidget.action.CompoundButtonAction
35 import androidx.glance.extractModifier
36 import androidx.glance.findModifier
37 import androidx.glance.layout.Alignment
38 import androidx.glance.layout.ContentScale
39 import androidx.glance.layout.EmittableBox
40 import androidx.glance.layout.HeightModifier
41 import androidx.glance.layout.PaddingModifier
42 import androidx.glance.layout.WidthModifier
43 import androidx.glance.layout.fillMaxHeight
44 import androidx.glance.layout.fillMaxSize
45 import androidx.glance.layout.fillMaxWidth
46 import androidx.glance.layout.padding
47 import androidx.glance.removeModifiersOfType
48 import androidx.glance.text.EmittableText
49 import androidx.glance.toEmittableText
50 import androidx.glance.unit.Dimension
51 
52 internal fun normalizeCompositionTree(
53     root: RemoteViewsRoot,
54     isPreviewComposition: Boolean = false
55 ) {
56     coerceToOneChild(root)
57     root.normalizeSizes()
58     root.transformTree { view ->
59         if (isPreviewComposition) {
60             view.removeActionModifiers()
61         }
62         if (view is EmittableLazyItemWithChildren) normalizeLazyListItem(view)
63         view.transformBackgroundImageAndActionRipple()
64     }
65 }
66 
67 /** Remove any action modifiers within the tree. */
Emittablenull68 private fun Emittable.removeActionModifiers() {
69     if (this !is EmittableSizeBox && this.modifier.any { it is ActionModifier }) {
70         this.modifier = this.modifier.removeModifiersOfType<ActionModifier>()
71     }
72 }
73 
74 /**
75  * Ensure that [container] has only one direct child.
76  *
77  * If [container] has multiple children, wrap them in an [EmittableBox] and make that the only child
78  * of container. If [container] contains only children of type [EmittableSizeBox], then we will make
79  * sure each of the [EmittableSizeBox]es has one child by wrapping their children in an
80  * [EmittableBox].
81  */
coerceToOneChildnull82 private fun coerceToOneChild(container: EmittableWithChildren) {
83     if (container.children.isNotEmpty() && container.children.all { it is EmittableSizeBox }) {
84         for (item in container.children) {
85             item as EmittableSizeBox
86             if (item.children.size == 1) continue
87             val box = EmittableBox()
88             box.children += item.children
89             item.children.clear()
90             item.children += box
91         }
92         return
93     } else if (container.children.size == 1) {
94         return
95     }
96     val box = EmittableBox()
97     box.children += container.children
98     container.children.clear()
99     container.children += box
100 }
101 
102 /**
103  * Resolve mixing wrapToContent and fillMaxSize on containers.
104  *
105  * Make sure that if a node with wrapToContent has a child with fillMaxSize, then it becomes
106  * fillMaxSize. Otherwise, the behavior depends on the version of Android.
107  */
EmittableWithChildrennull108 private fun EmittableWithChildren.normalizeSizes() {
109     children.forEach { child ->
110         if (child is EmittableWithChildren) {
111             child.normalizeSizes()
112         }
113     }
114     if (
115         (modifier.findModifier<HeightModifier>()?.height ?: Dimension.Wrap) is Dimension.Wrap &&
116             children.any { child ->
117                 child.modifier.findModifier<HeightModifier>()?.height is Dimension.Fill
118             }
119     ) {
120         modifier = modifier.fillMaxHeight()
121     }
122     if (
123         (modifier.findModifier<WidthModifier>()?.width ?: Dimension.Wrap) is Dimension.Wrap &&
124             children.any { child ->
125                 child.modifier.findModifier<WidthModifier>()?.width is Dimension.Fill
126             }
127     ) {
128         modifier = modifier.fillMaxWidth()
129     }
130 }
131 
132 /** Transform each node in the tree. */
transformTreenull133 private fun EmittableWithChildren.transformTree(block: (Emittable) -> Emittable) {
134     children.forEachIndexed { index, child ->
135         val newChild = block(child)
136         children[index] = newChild
137         if (newChild is EmittableWithChildren) newChild.transformTree(block)
138     }
139 }
140 
141 /**
142  * Walks through the Emittable tree and updates the key for all LambdaActions.
143  *
144  * This function updates the key such that the final key is equal to the original key plus a string
145  * indicating its index among its siblings. This is because sibling Composables will often have the
146  * same key due to how [androidx.compose.runtime.currentCompositeKeyHash] works. Adding the index
147  * makes sure that all of these keys are unique.
148  *
149  * Note that, because we run the same composition multiple times for different sizes in certain
150  * modes (see [ForEachSize]), action keys in one SizeBox should mirror the action keys in other
151  * SizeBoxes, so that if an action is triggered on the widget being displayed in one size, the state
152  * will be updated for the composition in all sizes. This is why there can be multiple LambdaActions
153  * for each key, even after de-duping.
154  */
updateLambdaActionKeysnull155 internal fun EmittableWithChildren.updateLambdaActionKeys(): Map<String, List<LambdaAction>> =
156     children.foldIndexed(mutableMapOf<String, MutableList<LambdaAction>>()) { index, actions, child
157         ->
158         val (action: LambdaAction?, modifiers: GlanceModifier) =
159             child.modifier.extractLambdaAction()
160         if (
161             action != null && child !is EmittableSizeBox && child !is EmittableLazyItemWithChildren
162         ) {
163             val newKey = action.key + "+$index"
164             val newAction = LambdaAction(newKey, action.block)
165             actions.getOrPut(newKey) { mutableListOf() }.add(newAction)
166             child.modifier = modifiers.then(ActionModifier(newAction))
167         }
168         if (child is EmittableWithChildren) {
169             child.updateLambdaActionKeys().forEach { (key, childActions) ->
170                 actions.getOrPut(key) { mutableListOf() }.addAll(childActions)
171             }
172         }
173         actions
174     }
175 
GlanceModifiernull176 private fun GlanceModifier.extractLambdaAction(): Pair<LambdaAction?, GlanceModifier> =
177     extractModifier<ActionModifier>().let { (actionModifier, modifiers) ->
178         val action = actionModifier?.action
179         when {
180             action is LambdaAction -> action to modifiers
181             action is CompoundButtonAction && action.innerAction is LambdaAction ->
182                 action.innerAction to modifiers
183             else -> null to modifiers
184         }
185     }
186 
normalizeLazyListItemnull187 private fun normalizeLazyListItem(view: EmittableLazyItemWithChildren) {
188     val box = EmittableBox()
189     box.children += view.children
190     box.contentAlignment = view.alignment
191     box.modifier = view.modifier
192     view.children.clear()
193     view.children += box
194     view.alignment = Alignment.CenterStart
195 }
196 
197 /**
198  * If this [Emittable] has a background image or a ripple, transform the emittable so that it is
199  * wrapped in an [EmittableBox], with the background and ripple added as [ImageView]s in the
200  * background and foreground.
201  *
202  * If this is an [EmittableButton], we additonally set a clip outline on the wrapper box, and
203  * convert the target emittable to an [EmittableText]
204  */
Emittablenull205 private fun Emittable.transformBackgroundImageAndActionRipple(): Emittable {
206     // EmittableLazyItemWithChildren and EmittableSizeBox are wrappers for their immediate
207     // only child, and do not get translated to their own element. We will transform their child
208     // instead.
209     if (this is EmittableLazyItemWithChildren || this is EmittableSizeBox) return this
210 
211     var target = this
212     val isButton = target is EmittableButton
213 
214     // Button ignores background modifiers.
215     if (isButton && Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
216         // Buttons cannot have a background image modifier. Remove BackgroundModifier.Image from
217         // the button if it exists.
218         val (maybeBgImageModifier, modifiersMinusBgImage) =
219             target.modifier.extractModifier<BackgroundModifier.Image>()
220         if (maybeBgImageModifier != null) {
221             Log.w(
222                 GlanceAppWidgetTag,
223                 "Glance Buttons should not have a background image modifier. " +
224                     "Consider an image with a clickable modifier."
225             )
226             target.modifier = modifiersMinusBgImage
227         }
228 
229         // Buttons ignore background color modifier. Remove it.
230         val (maybeBgColorModifier, modifiersMinusBgColor) =
231             target.modifier.extractModifier<BackgroundModifier.Image>()
232         if (maybeBgColorModifier != null) {
233             Log.w(
234                 GlanceAppWidgetTag,
235                 "Glance Buttons should not have a background color modifier. " +
236                     "Consider a tinted image with a clickable modifier"
237             )
238             target.modifier = modifiersMinusBgColor
239         }
240     }
241 
242     val shouldWrapTargetInABox =
243         target.modifier.any {
244             // Background images (i.e. BitMap or drawable resources) are emulated by placing the
245             // image
246             // before the target in the wrapper box. This allows us to support content scale as well
247             // as
248             // can help support additional processing on background images. Note: Button's don't
249             // support
250             // bg image modifier.
251             (it is BackgroundModifier.Image) ||
252                 // R- buttons are implemented using box, images and text.
253                 (isButton && Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) ||
254                 // Ripples are implemented by placing a drawable after the target in the wrapper
255                 // box.
256                 (it is ActionModifier && !hasBuiltinRipple())
257         }
258     if (!shouldWrapTargetInABox) return target
259 
260     // Hoisted modifiers are subtracted from the target one by one and added to the box and the
261     // remaining modifiers are applied to the target.
262     val boxModifiers = mutableListOf<GlanceModifier?>()
263     val targetModifiers = mutableListOf<GlanceModifier?>()
264     var backgroundImage: EmittableImage? = null
265     var rippleImage: EmittableImage? = null
266 
267     val (bgModifier, targetModifiersMinusBg) = target.modifier.extractModifier<BackgroundModifier>()
268 
269     if (bgModifier != null) {
270         if (isButton) {
271             // Emulate rounded corners (fixed radius) using a drawable and apply background colors
272             // to it. Note: Currently, button doesn't support bg image modifier, but only button
273             // colors.
274             backgroundImage =
275                 EmittableImage().apply {
276                     modifier = GlanceModifier.fillMaxSize()
277                     provider = ImageProvider(R.drawable.glance_button_outline)
278                     // Without setting alpha, if this drawable's base was transparent, solid color
279                     // won't
280                     // be applied as the default blending mode uses alpha from base. And if this
281                     // drawable's base was white/none, applying transparent tint will lead to black
282                     // color. This shouldn't be issue for icon type drawables, but in this case we
283                     // are
284                     // emulating colored outline. So, we apply tint as well as alpha.
285                     (bgModifier as? BackgroundModifier.Color)?.colorProvider?.let {
286                         colorFilterParams = TintAndAlphaColorFilterParams(it)
287                     }
288                     contentScale = ContentScale.FillBounds
289                 }
290         } else {
291             // bgModifier.imageProvider is converted to an actual image but bgModifier.colorProvider
292             // is applied back to the target. Note: We could have hoisted the bg color to box
293             // instead of adding it back to the target, but for buttons, we also add an outline
294             // background to the box.
295             when (bgModifier) {
296                 is BackgroundModifier.Image -> {
297                     backgroundImage =
298                         EmittableImage().apply {
299                             modifier = GlanceModifier.fillMaxSize()
300                             provider = bgModifier.imageProvider
301                             contentScale = bgModifier.contentScale
302                             colorFilterParams = bgModifier.colorFilter?.colorFilterParams
303                             alpha = bgModifier.alpha
304                         }
305                 }
306                 is BackgroundModifier.Color -> {
307                     targetModifiers += bgModifier
308                 }
309             }
310         }
311     }
312 
313     // Action modifiers are hoisted on the wrapping box and a ripple image is added to the
314     // foreground if the target doesn't have it built-in.
315     targetModifiersMinusBg.warnIfMultipleClickableActions()
316     val (actionModifier, targetModifiersMinusAction) =
317         targetModifiersMinusBg.extractModifier<ActionModifier>()
318     boxModifiers += actionModifier
319     if (actionModifier != null && !hasBuiltinRipple()) {
320         val maybeRippleOverride = actionModifier.rippleOverride
321         val rippleImageProvider =
322             if (maybeRippleOverride != NoRippleOverride) {
323                 ImageProvider(maybeRippleOverride)
324             } else if (isButton) {
325                 ImageProvider(R.drawable.glance_button_ripple)
326             } else {
327                 ImageProvider(R.drawable.glance_ripple)
328             }
329         rippleImage =
330             EmittableImage().apply {
331                 modifier = GlanceModifier.fillMaxSize()
332                 provider = rippleImageProvider
333             }
334     }
335 
336     // Hoist the size and corner radius modifiers to the wrapping Box, then set the target element
337     // to fill the given space.
338     val (sizeAndCornerModifiers, targetModifiersMinusSizeAndCornerRadius) =
339         targetModifiersMinusAction.extractSizeAndCornerRadiusModifiers()
340     boxModifiers += sizeAndCornerModifiers
341     targetModifiers += targetModifiersMinusSizeAndCornerRadius.fillMaxSize()
342 
343     if (target is EmittableButton) {
344         boxModifiers += GlanceModifier.enabled(target.enabled)
345         target = target.toEmittableText()
346         if (target.modifier.findModifier<PaddingModifier>() == null) {
347             targetModifiers += GlanceModifier.padding(horizontal = 16.dp, vertical = 8.dp)
348         }
349     }
350 
351     return EmittableBox().apply {
352         modifier = boxModifiers.collect()
353         target.modifier = targetModifiers.collect()
354 
355         if (isButton) contentAlignment = Alignment.Center
356 
357         addChildIfNotNull(backgroundImage)
358         addChild(target)
359         addChildIfNotNull(rippleImage)
360     }
361 }
362 
hasBuiltinRipplenull363 private fun Emittable.hasBuiltinRipple() =
364     this is EmittableSwitch ||
365         this is EmittableRadioButton ||
366         this is EmittableCheckBox ||
367         // S+ versions use a native button with fixed rounded corners and matching ripple set in
368         // layout xml. In R- versions, buttons are implemented using a background drawable with
369         // rounded corners and an EmittableText in R- versions.
370         (this is EmittableButton && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
371 
372 private data class ExtractedSizeAndCornerModifiers(
373     val sizeAndCornerModifiers: GlanceModifier = GlanceModifier,
374     val nonSizeOrCornerModifiers: GlanceModifier = GlanceModifier,
375 )
376 
377 /**
378  * Split the [GlanceModifier] into one that contains the [WidthModifier]s, [HeightModifier]s,
379  * [CornerRadiusModifier]s, [AppWidgetBackgroundModifier] and one that contains the rest.
380  *
381  * The [AppWidgetBackgroundModifier] is relevant to corner radius.
382  */
383 private fun GlanceModifier.extractSizeAndCornerRadiusModifiers() =
384     if (
385         any {
386             it is WidthModifier ||
387                 it is HeightModifier ||
388                 it is CornerRadiusModifier ||
389                 it is AppWidgetBackgroundModifier
390         }
391     ) {
accnull392         foldIn(ExtractedSizeAndCornerModifiers()) { acc, modifier ->
393             if (
394                 modifier is WidthModifier ||
395                     modifier is HeightModifier ||
396                     modifier is CornerRadiusModifier ||
397                     modifier is AppWidgetBackgroundModifier
398             ) {
399                 acc.copy(sizeAndCornerModifiers = acc.sizeAndCornerModifiers.then(modifier))
400             } else {
401                 acc.copy(nonSizeOrCornerModifiers = acc.nonSizeOrCornerModifiers.then(modifier))
402             }
403         }
404     } else {
405         ExtractedSizeAndCornerModifiers(nonSizeOrCornerModifiers = this)
406     }
407 
GlanceModifiernull408 private fun GlanceModifier.warnIfMultipleClickableActions() {
409     val actionCount =
410         foldIn(0) { count, modifier -> if (modifier is ActionModifier) count + 1 else count }
411     if (actionCount > 1) {
412         Log.w(
413             GlanceAppWidgetTag,
414             "More than one clickable defined on the same GlanceModifier, " +
415                 "only the last one will be used."
416         )
417     }
418 }
419 
MutableListnull420 private fun MutableList<GlanceModifier?>.collect(): GlanceModifier =
421     fold(GlanceModifier) { acc: GlanceModifier, mod: GlanceModifier? ->
422         mod?.let { acc.then(mod) } ?: acc
423     }
424