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