1 /*
<lambda>null2  * Copyright (C) 2022 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 
18 package com.android.systemui.shared.customization.data.content
19 
20 import android.annotation.SuppressLint
21 import android.content.ContentValues
22 import android.content.Context
23 import android.content.Intent
24 import android.database.ContentObserver
25 import android.graphics.Color
26 import android.graphics.drawable.Drawable
27 import android.net.Uri
28 import android.util.Log
29 import androidx.annotation.DrawableRes
30 import com.android.systemui.shared.customization.data.content.CustomizationProviderContract as Contract
31 import java.net.URISyntaxException
32 import kotlinx.coroutines.CoroutineDispatcher
33 import kotlinx.coroutines.channels.awaitClose
34 import kotlinx.coroutines.flow.Flow
35 import kotlinx.coroutines.flow.callbackFlow
36 import kotlinx.coroutines.flow.flowOn
37 import kotlinx.coroutines.flow.map
38 import kotlinx.coroutines.flow.onStart
39 import kotlinx.coroutines.withContext
40 
41 /** Client for using a content provider implementing the [Contract]. */
42 interface CustomizationProviderClient {
43 
44     /**
45      * Selects an affordance with the given ID for a slot on the lock screen with the given ID.
46      *
47      * Note that the maximum number of selected affordances on this slot is automatically enforced.
48      * Selecting a slot that is already full (e.g. already has a number of selected affordances at
49      * its maximum capacity) will automatically remove the oldest selected affordance before adding
50      * the one passed in this call. Additionally, selecting an affordance that's already one of the
51      * selected affordances on the slot will move the selected affordance to the newest location in
52      * the slot.
53      */
54     suspend fun insertSelection(
55         slotId: String,
56         affordanceId: String,
57     )
58 
59     /** Returns all available slots supported by the device. */
60     suspend fun querySlots(): List<Slot>
61 
62     /** Returns the list of flags. */
63     suspend fun queryFlags(): List<Flag>
64 
65     /**
66      * Returns [Flow] for observing the collection of slots.
67      *
68      * @see [querySlots]
69      */
70     fun observeSlots(): Flow<List<Slot>>
71 
72     /**
73      * Returns [Flow] for observing the collection of flags.
74      *
75      * @see [queryFlags]
76      */
77     fun observeFlags(): Flow<List<Flag>>
78 
79     /**
80      * Returns all available affordances supported by the device, regardless of current slot
81      * placement.
82      */
83     suspend fun queryAffordances(): List<Affordance>
84 
85     /**
86      * Returns [Flow] for observing the collection of affordances.
87      *
88      * @see [queryAffordances]
89      */
90     fun observeAffordances(): Flow<List<Affordance>>
91 
92     /** Returns the current slot-affordance selections. */
93     suspend fun querySelections(): List<Selection>
94 
95     /**
96      * Returns [Flow] for observing the collection of selections.
97      *
98      * @see [querySelections]
99      */
100     fun observeSelections(): Flow<List<Selection>>
101 
102     /** Unselects an affordance with the given ID from the slot with the given ID. */
103     suspend fun deleteSelection(
104         slotId: String,
105         affordanceId: String,
106     )
107 
108     /** Unselects all affordances from the slot with the given ID. */
109     suspend fun deleteAllSelections(
110         slotId: String,
111     )
112 
113     /** Returns a [Drawable] with the given ID, loaded from the system UI package. */
114     suspend fun getAffordanceIcon(
115         @DrawableRes iconResourceId: Int,
116         tintColor: Int = Color.WHITE,
117     ): Drawable
118 
119     /** Models a slot. A position that quick affordances can be positioned in. */
120     data class Slot(
121         /** Unique ID of the slot. */
122         val id: String,
123         /**
124          * The maximum number of quick affordances that are allowed to be positioned in this slot.
125          */
126         val capacity: Int,
127     )
128 
129     /**
130      * Models a quick affordance. An action that can be selected by the user to appear in one or
131      * more slots on the lock screen.
132      */
133     data class Affordance(
134         /** Unique ID of the quick affordance. */
135         val id: String,
136         /** User-facing label for this affordance. */
137         val name: String,
138         /**
139          * Resource ID for the user-facing icon for this affordance. This resource is hosted by the
140          * System UI process so it must be used with
141          * `PackageManager.getResourcesForApplication(String)`.
142          */
143         val iconResourceId: Int,
144         /**
145          * Whether the affordance is enabled. Disabled affordances should be shown on the picker but
146          * should be rendered as "disabled". When tapped, the enablement properties should be used
147          * to populate UI that would explain to the user what to do in order to re-enable this
148          * affordance.
149          */
150         val isEnabled: Boolean = true,
151         /**
152          * If the affordance is disabled, this is the explanation to be shown to the user when the
153          * disabled affordance is selected. The instructions should help the user figure out what to
154          * do in order to re-neable this affordance.
155          */
156         val enablementExplanation: String? = null,
157         /**
158          * If the affordance is disabled, this is a label for a button shown together with the set
159          * of instruction messages when the disabled affordance is selected. The button should help
160          * send the user to a flow that would help them achieve the instructions and re-enable this
161          * affordance.
162          *
163          * If `null`, the button should not be shown.
164          */
165         val enablementActionText: String? = null,
166         /**
167          * If the affordance is disabled, this is an [Intent] to be used with `startActivity` when
168          * the action button (shown together with the set of instruction messages when the disabled
169          * affordance is selected) is clicked by the user. The button should help send the user to a
170          * flow that would help them achieve the instructions and re-enable this affordance.
171          *
172          * If `null`, the button should not be shown.
173          */
174         val enablementActionIntent: Intent? = null,
175         /** Optional [Intent] to use to start an activity to configure this affordance. */
176         val configureIntent: Intent? = null,
177     )
178 
179     /** Models a selection of a quick affordance on a slot. */
180     data class Selection(
181         /** The unique ID of the slot. */
182         val slotId: String,
183         /** The unique ID of the quick affordance. */
184         val affordanceId: String,
185         /** The user-visible label for the quick affordance. */
186         val affordanceName: String,
187     )
188 
189     /** Models a System UI flag. */
190     data class Flag(
191         /** The name of the flag. */
192         val name: String,
193         /** The value of the flag. */
194         val value: Boolean,
195     )
196 }
197 
198 class CustomizationProviderClientImpl(
199     private val context: Context,
200     private val backgroundDispatcher: CoroutineDispatcher,
201 ) : CustomizationProviderClient {
202 
insertSelectionnull203     override suspend fun insertSelection(
204         slotId: String,
205         affordanceId: String,
206     ) {
207         withContext(backgroundDispatcher) {
208             context.contentResolver.insert(
209                 Contract.LockScreenQuickAffordances.SelectionTable.URI,
210                 ContentValues().apply {
211                     put(Contract.LockScreenQuickAffordances.SelectionTable.Columns.SLOT_ID, slotId)
212                     put(
213                         Contract.LockScreenQuickAffordances.SelectionTable.Columns.AFFORDANCE_ID,
214                         affordanceId
215                     )
216                 }
217             )
218         }
219     }
220 
querySlotsnull221     override suspend fun querySlots(): List<CustomizationProviderClient.Slot> {
222         return withContext(backgroundDispatcher) {
223             context.contentResolver
224                 .query(
225                     Contract.LockScreenQuickAffordances.SlotTable.URI,
226                     null,
227                     null,
228                     null,
229                     null,
230                 )
231                 ?.use { cursor ->
232                     buildList {
233                         val idColumnIndex =
234                             cursor.getColumnIndex(
235                                 Contract.LockScreenQuickAffordances.SlotTable.Columns.ID
236                             )
237                         val capacityColumnIndex =
238                             cursor.getColumnIndex(
239                                 Contract.LockScreenQuickAffordances.SlotTable.Columns.CAPACITY
240                             )
241                         if (idColumnIndex == -1 || capacityColumnIndex == -1) {
242                             return@buildList
243                         }
244 
245                         while (cursor.moveToNext()) {
246                             add(
247                                 CustomizationProviderClient.Slot(
248                                     id = cursor.getString(idColumnIndex),
249                                     capacity = cursor.getInt(capacityColumnIndex),
250                                 )
251                             )
252                         }
253                     }
254                 }
255         }
256             ?: emptyList()
257     }
258 
queryFlagsnull259     override suspend fun queryFlags(): List<CustomizationProviderClient.Flag> {
260         return withContext(backgroundDispatcher) {
261             context.contentResolver
262                 .query(
263                     Contract.FlagsTable.URI,
264                     null,
265                     null,
266                     null,
267                     null,
268                 )
269                 ?.use { cursor ->
270                     buildList {
271                         val nameColumnIndex =
272                             cursor.getColumnIndex(Contract.FlagsTable.Columns.NAME)
273                         val valueColumnIndex =
274                             cursor.getColumnIndex(Contract.FlagsTable.Columns.VALUE)
275                         if (nameColumnIndex == -1 || valueColumnIndex == -1) {
276                             return@buildList
277                         }
278 
279                         while (cursor.moveToNext()) {
280                             add(
281                                 CustomizationProviderClient.Flag(
282                                     name = cursor.getString(nameColumnIndex),
283                                     value = cursor.getInt(valueColumnIndex) == 1,
284                                 )
285                             )
286                         }
287                     }
288                 }
289         }
290             ?: emptyList()
291     }
292 
observeSlotsnull293     override fun observeSlots(): Flow<List<CustomizationProviderClient.Slot>> {
294         return observeUri(Contract.LockScreenQuickAffordances.SlotTable.URI).map { querySlots() }
295     }
296 
observeFlagsnull297     override fun observeFlags(): Flow<List<CustomizationProviderClient.Flag>> {
298         return observeUri(Contract.FlagsTable.URI).map { queryFlags() }
299     }
300 
queryAffordancesnull301     override suspend fun queryAffordances(): List<CustomizationProviderClient.Affordance> {
302         return withContext(backgroundDispatcher) {
303             context.contentResolver
304                 .query(
305                     Contract.LockScreenQuickAffordances.AffordanceTable.URI,
306                     null,
307                     null,
308                     null,
309                     null,
310                 )
311                 ?.use { cursor ->
312                     buildList {
313                         val idColumnIndex =
314                             cursor.getColumnIndex(
315                                 Contract.LockScreenQuickAffordances.AffordanceTable.Columns.ID
316                             )
317                         val nameColumnIndex =
318                             cursor.getColumnIndex(
319                                 Contract.LockScreenQuickAffordances.AffordanceTable.Columns.NAME
320                             )
321                         val iconColumnIndex =
322                             cursor.getColumnIndex(
323                                 Contract.LockScreenQuickAffordances.AffordanceTable.Columns.ICON
324                             )
325                         val isEnabledColumnIndex =
326                             cursor.getColumnIndex(
327                                 Contract.LockScreenQuickAffordances.AffordanceTable.Columns
328                                     .IS_ENABLED
329                             )
330                         val enablementExplanationColumnIndex =
331                             cursor.getColumnIndex(
332                                 Contract.LockScreenQuickAffordances.AffordanceTable.Columns
333                                     .ENABLEMENT_EXPLANATION
334                             )
335                         val enablementActionTextColumnIndex =
336                             cursor.getColumnIndex(
337                                 Contract.LockScreenQuickAffordances.AffordanceTable.Columns
338                                     .ENABLEMENT_ACTION_TEXT
339                             )
340                         val enablementActionIntentColumnIndex =
341                             cursor.getColumnIndex(
342                                 Contract.LockScreenQuickAffordances.AffordanceTable.Columns
343                                     .ENABLEMENT_ACTION_INTENT
344                             )
345                         val configureIntentColumnIndex =
346                             cursor.getColumnIndex(
347                                 Contract.LockScreenQuickAffordances.AffordanceTable.Columns
348                                     .CONFIGURE_INTENT
349                             )
350                         if (
351                             idColumnIndex == -1 ||
352                                 nameColumnIndex == -1 ||
353                                 iconColumnIndex == -1 ||
354                                 isEnabledColumnIndex == -1 ||
355                                 enablementExplanationColumnIndex == -1 ||
356                                 enablementActionTextColumnIndex == -1 ||
357                                 enablementActionIntentColumnIndex == -1 ||
358                                 configureIntentColumnIndex == -1
359                         ) {
360                             return@buildList
361                         }
362 
363                         while (cursor.moveToNext()) {
364                             val affordanceId = cursor.getString(idColumnIndex)
365                             add(
366                                 CustomizationProviderClient.Affordance(
367                                     id = affordanceId,
368                                     name = cursor.getString(nameColumnIndex),
369                                     iconResourceId = cursor.getInt(iconColumnIndex),
370                                     isEnabled = cursor.getInt(isEnabledColumnIndex) == 1,
371                                     enablementExplanation =
372                                         cursor.getString(enablementExplanationColumnIndex),
373                                     enablementActionText =
374                                         cursor.getString(enablementActionTextColumnIndex),
375                                     enablementActionIntent =
376                                         cursor
377                                             .getString(enablementActionIntentColumnIndex)
378                                             ?.toIntent(
379                                                 affordanceId = affordanceId,
380                                             ),
381                                     configureIntent =
382                                         cursor
383                                             .getString(configureIntentColumnIndex)
384                                             ?.toIntent(
385                                                 affordanceId = affordanceId,
386                                             ),
387                                 )
388                             )
389                         }
390                     }
391                 }
392         }
393             ?: emptyList()
394     }
395 
observeAffordancesnull396     override fun observeAffordances(): Flow<List<CustomizationProviderClient.Affordance>> {
397         return observeUri(Contract.LockScreenQuickAffordances.AffordanceTable.URI).map {
398             queryAffordances()
399         }
400     }
401 
querySelectionsnull402     override suspend fun querySelections(): List<CustomizationProviderClient.Selection> {
403         return withContext(backgroundDispatcher) {
404             context.contentResolver
405                 .query(
406                     Contract.LockScreenQuickAffordances.SelectionTable.URI,
407                     null,
408                     null,
409                     null,
410                     null,
411                 )
412                 ?.use { cursor ->
413                     buildList {
414                         val slotIdColumnIndex =
415                             cursor.getColumnIndex(
416                                 Contract.LockScreenQuickAffordances.SelectionTable.Columns.SLOT_ID
417                             )
418                         val affordanceIdColumnIndex =
419                             cursor.getColumnIndex(
420                                 Contract.LockScreenQuickAffordances.SelectionTable.Columns
421                                     .AFFORDANCE_ID
422                             )
423                         val affordanceNameColumnIndex =
424                             cursor.getColumnIndex(
425                                 Contract.LockScreenQuickAffordances.SelectionTable.Columns
426                                     .AFFORDANCE_NAME
427                             )
428                         if (
429                             slotIdColumnIndex == -1 ||
430                                 affordanceIdColumnIndex == -1 ||
431                                 affordanceNameColumnIndex == -1
432                         ) {
433                             return@buildList
434                         }
435 
436                         while (cursor.moveToNext()) {
437                             add(
438                                 CustomizationProviderClient.Selection(
439                                     slotId = cursor.getString(slotIdColumnIndex),
440                                     affordanceId = cursor.getString(affordanceIdColumnIndex),
441                                     affordanceName = cursor.getString(affordanceNameColumnIndex),
442                                 )
443                             )
444                         }
445                     }
446                 }
447         }
448             ?: emptyList()
449     }
450 
observeSelectionsnull451     override fun observeSelections(): Flow<List<CustomizationProviderClient.Selection>> {
452         return observeUri(Contract.LockScreenQuickAffordances.SelectionTable.URI).map {
453             querySelections()
454         }
455     }
456 
deleteSelectionnull457     override suspend fun deleteSelection(
458         slotId: String,
459         affordanceId: String,
460     ) {
461         withContext(backgroundDispatcher) {
462             context.contentResolver.delete(
463                 Contract.LockScreenQuickAffordances.SelectionTable.URI,
464                 "${Contract.LockScreenQuickAffordances.SelectionTable.Columns.SLOT_ID} = ? AND" +
465                     " ${Contract.LockScreenQuickAffordances.SelectionTable.Columns.AFFORDANCE_ID}" +
466                     " = ?",
467                 arrayOf(
468                     slotId,
469                     affordanceId,
470                 ),
471             )
472         }
473     }
474 
deleteAllSelectionsnull475     override suspend fun deleteAllSelections(
476         slotId: String,
477     ) {
478         withContext(backgroundDispatcher) {
479             context.contentResolver.delete(
480                 Contract.LockScreenQuickAffordances.SelectionTable.URI,
481                 Contract.LockScreenQuickAffordances.SelectionTable.Columns.SLOT_ID,
482                 arrayOf(
483                     slotId,
484                 ),
485             )
486         }
487     }
488 
489     @SuppressLint("UseCompatLoadingForDrawables")
getAffordanceIconnull490     override suspend fun getAffordanceIcon(
491         @DrawableRes iconResourceId: Int,
492         tintColor: Int,
493     ): Drawable {
494         return withContext(backgroundDispatcher) {
495             context.packageManager
496                 .getResourcesForApplication(SYSTEM_UI_PACKAGE_NAME)
497                 .getDrawable(iconResourceId, context.theme)
498                 .apply { setTint(tintColor) }
499         }
500     }
501 
observeUrinull502     private fun observeUri(
503         uri: Uri,
504     ): Flow<Unit> {
505         return callbackFlow {
506                 val observer =
507                     object : ContentObserver(null) {
508                         override fun onChange(selfChange: Boolean) {
509                             trySend(Unit)
510                         }
511                     }
512 
513                 context.contentResolver.registerContentObserver(
514                     uri,
515                     /* notifyForDescendants= */ true,
516                     observer,
517                 )
518 
519                 awaitClose { context.contentResolver.unregisterContentObserver(observer) }
520             }
521             .onStart { emit(Unit) }
522             .flowOn(backgroundDispatcher)
523     }
524 
toIntentnull525     private fun String.toIntent(
526         affordanceId: String,
527     ): Intent? {
528         return try {
529             Intent.parseUri(this, Intent.URI_INTENT_SCHEME)
530         } catch (e: URISyntaxException) {
531             Log.w(TAG, "Cannot parse Uri into Intent for affordance with ID \"$affordanceId\"!")
532             null
533         }
534     }
535 
536     companion object {
537         private const val TAG = "CustomizationProviderClient"
538         private const val SYSTEM_UI_PACKAGE_NAME = "com.android.systemui"
539     }
540 }
541