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