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.glance.appwidget
18 
19 import android.content.Context
20 import android.os.Build
21 import android.util.Log
22 import androidx.annotation.RequiresApi
23 import androidx.annotation.VisibleForTesting
24 import androidx.datastore.core.CorruptionException
25 import androidx.datastore.core.DataStore
26 import androidx.datastore.core.DataStoreFactory
27 import androidx.datastore.dataStoreFile
28 import androidx.glance.Emittable
29 import androidx.glance.EmittableButton
30 import androidx.glance.EmittableImage
31 import androidx.glance.EmittableWithChildren
32 import androidx.glance.GlanceId
33 import androidx.glance.GlanceModifier
34 import androidx.glance.action.ActionModifier
35 import androidx.glance.appwidget.lazy.EmittableLazyColumn
36 import androidx.glance.appwidget.lazy.EmittableLazyList
37 import androidx.glance.appwidget.lazy.EmittableLazyListItem
38 import androidx.glance.appwidget.lazy.EmittableLazyVerticalGrid
39 import androidx.glance.appwidget.lazy.EmittableLazyVerticalGridListItem
40 import androidx.glance.appwidget.proto.LayoutProto
41 import androidx.glance.appwidget.proto.LayoutProto.LayoutDefinition
42 import androidx.glance.appwidget.proto.LayoutProto.LayoutNode
43 import androidx.glance.appwidget.proto.LayoutProto.LayoutType
44 import androidx.glance.appwidget.proto.LayoutProto.NodeIdentity
45 import androidx.glance.appwidget.proto.LayoutProtoSerializer
46 import androidx.glance.findModifier
47 import androidx.glance.isDecorative
48 import androidx.glance.layout.Alignment
49 import androidx.glance.layout.EmittableBox
50 import androidx.glance.layout.EmittableColumn
51 import androidx.glance.layout.EmittableRow
52 import androidx.glance.layout.EmittableSpacer
53 import androidx.glance.layout.HeightModifier
54 import androidx.glance.layout.WidthModifier
55 import androidx.glance.state.GlanceState
56 import androidx.glance.state.GlanceStateDefinition
57 import androidx.glance.text.EmittableText
58 import androidx.glance.unit.Dimension
59 import java.io.File
60 import java.io.IOException
61 
62 /**
63  * Manager for layout configurations and their associated layout indexes.
64  *
65  * An instance of this object should be created for each update of an App Widget ID. The same
66  * instance must be used for all the variants of the layout. It will detect layout changes and
67  * ensure the same layout IDs are not re-used.
68  *
69  * Layout indexes are numbers between 0 and [TopLevelLayoutsCount]-1, which need to be passed to
70  * [translateComposition] to generate the [android.widget.RemoteViews] corresponding to a given
71  * layout.
72  */
73 internal class LayoutConfiguration
74 private constructor(
75     private val context: Context,
76     /**
77      * Map the known layout configs to a unique layout index. It will contain all the layouts stored
78      * in files, and the layouts currently in use.
79      */
80     private val layoutConfig: MutableMap<LayoutNode, Int>,
81     private var nextIndex: Int,
82     private val appWidgetId: Int,
83     /** Set of layout indexes that have been assigned since the creation of this object. */
84     private val usedLayoutIds: MutableSet<Int> = mutableSetOf(),
85     /** Set of all layout ids in [layoutConfig]. None of them can be re-used. */
86     private val existingLayoutIds: MutableSet<Int> = mutableSetOf(),
87 ) {
88 
89     internal companion object {
90 
91         /** Creates a [LayoutConfiguration] retrieving known layouts from file, if they exist. */
92         internal suspend fun load(context: Context, appWidgetId: Int): LayoutConfiguration {
93             val config =
94                 try {
95                     GlanceState.getValue(
96                         context,
97                         LayoutStateDefinition,
98                         layoutDatastoreKey(appWidgetId)
99                     )
100                 } catch (ex: CorruptionException) {
101                     Log.e(
102                         GlanceAppWidgetTag,
103                         "Set of layout structures for App Widget id $appWidgetId is corrupted",
104                         ex
105                     )
106                     LayoutProto.LayoutConfig.getDefaultInstance()
107                 } catch (ex: IOException) {
108                     Log.e(
109                         GlanceAppWidgetTag,
110                         "I/O error reading set of layout structures for App Widget id $appWidgetId",
111                         ex
112                     )
113                     LayoutProto.LayoutConfig.getDefaultInstance()
114                 }
115             val layouts = config.layoutList.associate { it.layout to it.layoutIndex }.toMutableMap()
116             return LayoutConfiguration(
117                 context,
118                 layouts,
119                 nextIndex = config.nextIndex,
120                 appWidgetId = appWidgetId,
121                 existingLayoutIds = layouts.values.toMutableSet()
122             )
123         }
124 
125         /** Create a new, empty, [LayoutConfiguration]. */
126         internal fun create(context: Context, appWidgetId: Int): LayoutConfiguration =
127             LayoutConfiguration(
128                 context,
129                 layoutConfig = mutableMapOf(),
130                 nextIndex = 0,
131                 appWidgetId = appWidgetId,
132             )
133 
134         /** Create a new, pre-defined [LayoutConfiguration]. */
135         @VisibleForTesting
136         internal fun create(
137             context: Context,
138             appWidgetId: Int,
139             nextIndex: Int,
140             existingLayoutIds: Collection<Int> = emptyList()
141         ): LayoutConfiguration =
142             LayoutConfiguration(
143                 context,
144                 appWidgetId = appWidgetId,
145                 layoutConfig = mutableMapOf(),
146                 nextIndex = nextIndex,
147                 existingLayoutIds = existingLayoutIds.toMutableSet(),
148             )
149 
150         /** @return the file after delete() has been called on it. This is for testing. */
151         fun delete(context: Context, id: GlanceId): Boolean {
152 
153             if (id is AppWidgetId && id.isRealId) {
154                 val key = layoutDatastoreKey(id.appWidgetId)
155                 val file = context.dataStoreFile(key)
156                 try {
157                     return file.delete()
158                 } catch (e: Exception) {
159                     // This is a minor error, File.delete() shouldn't throw an exception and these
160                     // files
161                     // are <1kb.
162                     Log.d(
163                         GlanceAppWidgetTag,
164                         "Could not delete LayoutConfiguration dataStoreFile when cleaning up" +
165                             "old appwidget id $id",
166                         e
167                     )
168                 }
169             }
170             return false
171         }
172     }
173 
174     /**
175      * Add a layout to the set of known layouts.
176      *
177      * The layout index is retricted to the range 0 - [TopLevelLayoutsCount]-1. Once the layout
178      * index reaches [TopLevelLayoutsCount], it cycles back to 0, making sure we are not re-using
179      * any layout index used either for the current or previous set of layouts. The number of layout
180      * indexes we have should be sufficient to mostly avoid collisions, but there is still a risk if
181      * many updates are not rendered, or if all the indexes are used for lazy list items.
182      *
183      * @return the layout index that should be used to generate it
184      */
185     fun addLayout(layoutRoot: Emittable): Int {
186         val root = createNode(context, layoutRoot)
187         synchronized(this) {
188             layoutConfig[root]?.let { index ->
189                 usedLayoutIds += index
190                 return index
191             }
192             var index = nextIndex
193             while (index in existingLayoutIds) {
194                 index = (index + 1) % TopLevelLayoutsCount
195                 require(index != nextIndex) {
196                     "Cannot assign a valid layout index to the new layout: no free index left."
197                 }
198             }
199             nextIndex = (index + 1) % TopLevelLayoutsCount
200             usedLayoutIds += index
201             existingLayoutIds += index
202             layoutConfig[root] = index
203             return index
204         }
205     }
206 
207     /** Save the known layouts to file at the end of the layout generation. */
208     suspend fun save() {
209         GlanceState.updateValue(context, LayoutStateDefinition, layoutDatastoreKey(appWidgetId)) {
210             config ->
211             config
212                 .toBuilder()
213                 .apply {
214                     nextIndex = nextIndex
215                     clearLayout()
216                     layoutConfig.entries.forEach { (node, index) ->
217                         if (index in usedLayoutIds) {
218                             addLayout(
219                                 LayoutDefinition.newBuilder().apply {
220                                     layout = node
221                                     layoutIndex = index
222                                 }
223                             )
224                         }
225                     }
226                 }
227                 .build()
228         }
229     }
230 }
231 
232 /**
233  * Returns the proto layout tree corresponding to the provided root node.
234  *
235  * A node should change if either the [LayoutType] selected by the translation of that node changes,
236  * if the [SizeSelector] used to find the stub to be replaced changes or if the [ContainerSelector]
237  * used to find the container's layout changes.
238  *
239  * Note: The number of children, although an element in [ContainerSelector] is not used, as this
240  * will anyway invalidate the structure.
241  */
createNodenull242 internal fun createNode(context: Context, element: Emittable): LayoutNode =
243     LayoutNode.newBuilder()
244         .apply {
245             type = element.getLayoutType()
246             width = element.modifier.widthModifier.toProto(context)
247             height = element.modifier.heightModifier.toProto(context)
248             hasAction = element.modifier.findModifier<ActionModifier>() != null
249             if (element.modifier.findModifier<AppWidgetBackgroundModifier>() != null) {
250                 identity = NodeIdentity.BACKGROUND_NODE
251             }
252             when (element) {
253                 is EmittableImage -> setImageNode(element)
254                 is EmittableColumn -> setColumnNode(element)
255                 is EmittableRow -> setRowNode(element)
256                 is EmittableBox -> setBoxNode(element)
257                 is EmittableLazyColumn -> setLazyListColumn(element)
258             }
259             if (element is EmittableWithChildren && element !is EmittableLazyList) {
260                 addAllChildren(element.children.map { createNode(context, it) })
261             }
262         }
263         .build()
264 
LayoutNodenull265 private fun LayoutNode.Builder.setImageNode(element: EmittableImage) {
266     imageScale =
267         when (element.contentScale) {
268             androidx.glance.layout.ContentScale.Fit -> LayoutProto.ContentScale.FIT
269             androidx.glance.layout.ContentScale.Crop -> LayoutProto.ContentScale.CROP
270             androidx.glance.layout.ContentScale.FillBounds -> LayoutProto.ContentScale.FILL_BOUNDS
271             else -> error("Unknown content scale ${element.contentScale}")
272         }
273     hasImageDescription = !element.isDecorative()
274     hasImageColorFilter = element.colorFilterParams != null
275     hasImageAlpha = element.alpha != null
276 }
277 
LayoutNodenull278 private fun LayoutNode.Builder.setColumnNode(element: EmittableColumn) {
279     horizontalAlignment = element.horizontalAlignment.toProto()
280 }
281 
LayoutNodenull282 private fun LayoutNode.Builder.setLazyListColumn(element: EmittableLazyColumn) {
283     horizontalAlignment = element.horizontalAlignment.toProto()
284 }
285 
LayoutNodenull286 private fun LayoutNode.Builder.setRowNode(element: EmittableRow) {
287     verticalAlignment = element.verticalAlignment.toProto()
288 }
289 
LayoutNodenull290 private fun LayoutNode.Builder.setBoxNode(element: EmittableBox) {
291     horizontalAlignment = element.contentAlignment.horizontal.toProto()
292     verticalAlignment = element.contentAlignment.vertical.toProto()
293 }
294 
295 private val GlanceModifier.widthModifier: Dimension
296     get() = findModifier<WidthModifier>()?.width ?: Dimension.Wrap
297 
298 private val GlanceModifier.heightModifier: Dimension
299     get() = findModifier<HeightModifier>()?.height ?: Dimension.Wrap
300 
301 @VisibleForTesting
layoutDatastoreKeynull302 internal fun layoutDatastoreKey(appWidgetId: Int): String = "appWidgetLayout-$appWidgetId"
303 
304 private object LayoutStateDefinition : GlanceStateDefinition<LayoutProto.LayoutConfig> {
305     override fun getLocation(context: Context, fileKey: String): File =
306         context.dataStoreFile(fileKey)
307 
308     override suspend fun getDataStore(
309         context: Context,
310         fileKey: String,
311     ): DataStore<LayoutProto.LayoutConfig> =
312         DataStoreFactory.create(serializer = LayoutProtoSerializer) {
313             context.dataStoreFile(fileKey)
314         }
315 }
316 
Alignmentnull317 private fun Alignment.Vertical.toProto() =
318     when (this) {
319         Alignment.Vertical.Top -> LayoutProto.VerticalAlignment.TOP
320         Alignment.Vertical.CenterVertically -> LayoutProto.VerticalAlignment.CENTER_VERTICALLY
321         Alignment.Vertical.Bottom -> LayoutProto.VerticalAlignment.BOTTOM
322         else -> error("unknown vertical alignment $this")
323     }
324 
Alignmentnull325 private fun Alignment.Horizontal.toProto() =
326     when (this) {
327         Alignment.Horizontal.Start -> LayoutProto.HorizontalAlignment.START
328         Alignment.Horizontal.CenterHorizontally ->
329             LayoutProto.HorizontalAlignment.CENTER_HORIZONTALLY
330         Alignment.Horizontal.End -> LayoutProto.HorizontalAlignment.END
331         else -> error("unknown horizontal alignment $this")
332     }
333 
getLayoutTypenull334 private fun Emittable.getLayoutType(): LayoutProto.LayoutType =
335     when (this) {
336         is EmittableBox -> LayoutProto.LayoutType.BOX
337         is EmittableButton -> LayoutProto.LayoutType.BUTTON
338         is EmittableRow -> {
339             if (modifier.isSelectableGroup) {
340                 LayoutProto.LayoutType.RADIO_ROW
341             } else {
342                 LayoutProto.LayoutType.ROW
343             }
344         }
345         is EmittableColumn -> {
346             if (modifier.isSelectableGroup) {
347                 LayoutProto.LayoutType.RADIO_COLUMN
348             } else {
349                 LayoutProto.LayoutType.COLUMN
350             }
351         }
352         is EmittableText -> LayoutProto.LayoutType.TEXT
353         is EmittableLazyListItem -> LayoutProto.LayoutType.LIST_ITEM
354         is EmittableLazyColumn -> LayoutProto.LayoutType.LAZY_COLUMN
355         is EmittableAndroidRemoteViews -> LayoutProto.LayoutType.ANDROID_REMOTE_VIEWS
356         is EmittableCheckBox -> LayoutProto.LayoutType.CHECK_BOX
357         is EmittableSpacer -> LayoutProto.LayoutType.SPACER
358         is EmittableSwitch -> LayoutProto.LayoutType.SWITCH
359         is EmittableImage -> LayoutProto.LayoutType.IMAGE
360         is EmittableLinearProgressIndicator -> LayoutProto.LayoutType.LINEAR_PROGRESS_INDICATOR
361         is EmittableCircularProgressIndicator -> LayoutProto.LayoutType.CIRCULAR_PROGRESS_INDICATOR
362         is EmittableLazyVerticalGrid -> LayoutProto.LayoutType.LAZY_VERTICAL_GRID
363         is EmittableLazyVerticalGridListItem -> LayoutProto.LayoutType.LIST_ITEM
364         is RemoteViewsRoot -> LayoutProto.LayoutType.REMOTE_VIEWS_ROOT
365         is EmittableRadioButton -> LayoutProto.LayoutType.RADIO_BUTTON
366         is EmittableSizeBox -> LayoutProto.LayoutType.SIZE_BOX
367         else ->
368             throw IllegalArgumentException("Unknown element type ${this.javaClass.canonicalName}")
369     }
370 
toProtonull371 private fun Dimension.toProto(context: Context): LayoutProto.DimensionType {
372     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
373         return WidgetLayoutImpl31.toProto(this)
374     }
375     return when (resolveDimension(context)) {
376         is Dimension.Dp -> LayoutProto.DimensionType.EXACT
377         is Dimension.Wrap -> LayoutProto.DimensionType.WRAP
378         is Dimension.Fill -> LayoutProto.DimensionType.FILL
379         is Dimension.Expand -> LayoutProto.DimensionType.EXPAND
380         else -> error("After resolution, no other type should be present")
381     }
382 }
383 
384 @RequiresApi(Build.VERSION_CODES.S)
385 private object WidgetLayoutImpl31 {
toProtonull386     fun toProto(dimension: Dimension) =
387         if (dimension is Dimension.Expand) {
388             LayoutProto.DimensionType.EXPAND
389         } else {
390             LayoutProto.DimensionType.WRAP
391         }
392 }
393