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