diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml index 47771aa3c774b..11237dca78047 100644 --- a/packages/SystemUI/AndroidManifest.xml +++ b/packages/SystemUI/AndroidManifest.xml @@ -195,6 +195,9 @@ + + @@ -993,5 +996,12 @@ android:excludeFromRecents="true" android:exported="false"> + + diff --git a/packages/SystemUI/compose/features/AndroidManifest.xml b/packages/SystemUI/compose/features/AndroidManifest.xml index eada40e6a40dc..278a89f7dba33 100644 --- a/packages/SystemUI/compose/features/AndroidManifest.xml +++ b/packages/SystemUI/compose/features/AndroidManifest.xml @@ -34,6 +34,11 @@ android:enabled="false" tools:replace="android:authorities" tools:node="remove" /> + { + return withContext(dispatcher) { + context.contentResolver + .query( + Contract.SlotTable.URI, + null, + null, + null, + null, + ) + ?.use { cursor -> + buildList { + val idColumnIndex = cursor.getColumnIndex(Contract.SlotTable.Columns.ID) + val capacityColumnIndex = + cursor.getColumnIndex(Contract.SlotTable.Columns.CAPACITY) + if (idColumnIndex == -1 || capacityColumnIndex == -1) { + return@buildList + } + + while (cursor.moveToNext()) { + add( + Slot( + id = cursor.getString(idColumnIndex), + capacity = cursor.getInt(capacityColumnIndex), + ) + ) + } + } + } + } + ?: emptyList() + } + + /** + * Returns [Flow] for observing the collection of slots. + * + * @see [querySlots] + */ + fun observeSlots( + context: Context, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): Flow> { + return observeUri( + context, + Contract.SlotTable.URI, + ) + .map { querySlots(context, dispatcher) } + } + + /** + * Returns all available affordances supported by the device, regardless of current slot + * placement. + */ + suspend fun queryAffordances( + context: Context, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): List { + return withContext(dispatcher) { + context.contentResolver + .query( + Contract.AffordanceTable.URI, + null, + null, + null, + null, + ) + ?.use { cursor -> + buildList { + val idColumnIndex = + cursor.getColumnIndex(Contract.AffordanceTable.Columns.ID) + val nameColumnIndex = + cursor.getColumnIndex(Contract.AffordanceTable.Columns.NAME) + val iconColumnIndex = + cursor.getColumnIndex(Contract.AffordanceTable.Columns.ICON) + if (idColumnIndex == -1 || nameColumnIndex == -1 || iconColumnIndex == -1) { + return@buildList + } + + while (cursor.moveToNext()) { + add( + Affordance( + id = cursor.getString(idColumnIndex), + name = cursor.getString(nameColumnIndex), + iconResourceId = cursor.getInt(iconColumnIndex), + ) + ) + } + } + } + } + ?: emptyList() + } + + /** + * Returns [Flow] for observing the collection of affordances. + * + * @see [queryAffordances] + */ + fun observeAffordances( + context: Context, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): Flow> { + return observeUri( + context, + Contract.AffordanceTable.URI, + ) + .map { queryAffordances(context, dispatcher) } + } + + /** Returns the current slot-affordance selections. */ + suspend fun querySelections( + context: Context, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): List { + return withContext(dispatcher) { + context.contentResolver + .query( + Contract.SelectionTable.URI, + null, + null, + null, + null, + ) + ?.use { cursor -> + buildList { + val slotIdColumnIndex = + cursor.getColumnIndex(Contract.SelectionTable.Columns.SLOT_ID) + val affordanceIdColumnIndex = + cursor.getColumnIndex(Contract.SelectionTable.Columns.AFFORDANCE_ID) + if (slotIdColumnIndex == -1 || affordanceIdColumnIndex == -1) { + return@buildList + } + + while (cursor.moveToNext()) { + add( + Selection( + slotId = cursor.getString(slotIdColumnIndex), + affordanceId = cursor.getString(affordanceIdColumnIndex), + ) + ) + } + } + } + } + ?: emptyList() + } + + /** + * Returns [Flow] for observing the collection of selections. + * + * @see [querySelections] + */ + fun observeSelections( + context: Context, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): Flow> { + return observeUri( + context, + Contract.SelectionTable.URI, + ) + .map { querySelections(context, dispatcher) } + } + + /** Unselects an affordance with the given ID from the slot with the given ID. */ + suspend fun deleteSelection( + context: Context, + slotId: String, + affordanceId: String, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ) { + withContext(dispatcher) { + context.contentResolver.delete( + Contract.SelectionTable.URI, + "${Contract.SelectionTable.Columns.SLOT_ID} = ? AND" + + " ${Contract.SelectionTable.Columns.AFFORDANCE_ID} = ?", + arrayOf( + slotId, + affordanceId, + ), + ) + } + } + + /** Unselects all affordances from the slot with the given ID. */ + suspend fun deleteAllSelections( + context: Context, + slotId: String, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ) { + withContext(dispatcher) { + context.contentResolver.delete( + Contract.SelectionTable.URI, + "${Contract.SelectionTable.Columns.SLOT_ID}", + arrayOf( + slotId, + ), + ) + } + } + + private fun observeUri( + context: Context, + uri: Uri, + ): Flow { + return callbackFlow { + val observer = + object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + + context.contentResolver.registerContentObserver( + uri, + /* notifyForDescendants= */ true, + observer, + UserHandle.USER_CURRENT, + ) + + awaitClose { context.contentResolver.unregisterContentObserver(observer) } + } + .onStart { emit(Unit) } + } + + @SuppressLint("UseCompatLoadingForDrawables") + suspend fun getAffordanceIcon( + context: Context, + @DrawableRes iconResourceId: Int, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): Drawable { + return withContext(dispatcher) { + context.packageManager + .getResourcesForApplication(SYSTEM_UI_PACKAGE_NAME) + .getDrawable(iconResourceId) + } + } + + data class Slot( + val id: String, + val capacity: Int, + ) + + data class Affordance( + val id: String, + val name: String, + val iconResourceId: Int, + ) + + data class Selection( + val slotId: String, + val affordanceId: String, + ) + + private const val SYSTEM_UI_PACKAGE_NAME = "com.android.systemui" +} diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/keyguard/data/content/KeyguardQuickAffordanceProviderContract.kt b/packages/SystemUI/shared/src/com/android/systemui/shared/keyguard/data/content/KeyguardQuickAffordanceProviderContract.kt new file mode 100644 index 0000000000000..c2658a9e61b12 --- /dev/null +++ b/packages/SystemUI/shared/src/com/android/systemui/shared/keyguard/data/content/KeyguardQuickAffordanceProviderContract.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.shared.keyguard.data.content + +import android.content.ContentResolver +import android.net.Uri + +/** Contract definitions for querying content about keyguard quick affordances. */ +object KeyguardQuickAffordanceProviderContract { + + const val AUTHORITY = "com.android.systemui.keyguard.quickaffordance" + const val PERMISSION = "android.permission.ACCESS_KEYGUARD_QUICK_AFFORDANCES" + + private val BASE_URI: Uri = + Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT).authority(AUTHORITY).build() + + /** + * Table for slots. + * + * Slots are positions where affordances can be placed on the lock screen. Affordances that are + * placed on slots are said to be "selected". The system supports the idea of multiple + * affordances per slot, though the implementation may limit the number of affordances on each + * slot. + * + * Supported operations: + * - Query - to know which slots are available, query the [SlotTable.URI] [Uri]. The result set + * will contain rows with the [SlotTable.Columns] columns. + */ + object SlotTable { + const val TABLE_NAME = "slots" + val URI: Uri = BASE_URI.buildUpon().path(TABLE_NAME).build() + + object Columns { + /** String. Unique ID for this slot. */ + const val ID = "id" + /** Integer. The maximum number of affordances that can be placed in the slot. */ + const val CAPACITY = "capacity" + } + } + + /** + * Table for affordances. + * + * Affordances are actions/buttons that the user can execute. They are placed on slots on the + * lock screen. + * + * Supported operations: + * - Query - to know about all the affordances that are available on the device, regardless of + * which ones are currently selected, query the [AffordanceTable.URI] [Uri]. The result set will + * contain rows, each with the columns specified in [AffordanceTable.Columns]. + */ + object AffordanceTable { + const val TABLE_NAME = "affordances" + val URI: Uri = BASE_URI.buildUpon().path(TABLE_NAME).build() + + object Columns { + /** String. Unique ID for this affordance. */ + const val ID = "id" + /** String. User-visible name for this affordance. */ + const val NAME = "name" + /** + * Integer. Resource ID for the drawable to load for this affordance. This is a resource + * ID from the system UI package. + */ + const val ICON = "icon" + } + } + + /** + * Table for selections. + * + * Selections are pairs of slot and affordance IDs. + * + * Supported operations: + * - Insert - to insert an affordance and place it in a slot, insert values for the columns into + * the [SelectionTable.URI] [Uri]. The maximum capacity rule is enforced by the system. + * Selecting a new affordance for a slot that is already full will automatically remove the + * oldest affordance from the slot. + * - Query - to know which affordances are set on which slots, query the [SelectionTable.URI] + * [Uri]. The result set will contain rows, each of which with the columns from + * [SelectionTable.Columns]. + * - Delete - to unselect an affordance, removing it from a slot, delete from the + * [SelectionTable.URI] [Uri], passing in values for each column. + */ + object SelectionTable { + const val TABLE_NAME = "selections" + val URI: Uri = BASE_URI.buildUpon().path(TABLE_NAME).build() + + object Columns { + /** String. Unique ID for the slot. */ + const val SLOT_ID = "slot_id" + /** String. Unique ID for the selected affordance. */ + const val AFFORDANCE_ID = "affordance_id" + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSysUIComponent.java b/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSysUIComponent.java index 7ab36e84178e3..d3555eec02435 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSysUIComponent.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSysUIComponent.java @@ -16,6 +16,7 @@ package com.android.systemui.dagger; +import com.android.systemui.keyguard.KeyguardQuickAffordanceProvider; import com.android.systemui.statusbar.QsFrameTranslateModule; import dagger.Subcomponent; @@ -42,4 +43,9 @@ public interface ReferenceSysUIComponent extends SysUIComponent { interface Builder extends SysUIComponent.Builder { ReferenceSysUIComponent build(); } + + /** + * Member injection into the supplied argument. + */ + void inject(KeyguardQuickAffordanceProvider keyguardQuickAffordanceProvider); } diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardQuickAffordanceProvider.kt b/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardQuickAffordanceProvider.kt new file mode 100644 index 0000000000000..0f4581ce3e616 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardQuickAffordanceProvider.kt @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.systemui.keyguard + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.UriMatcher +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.util.Log +import com.android.systemui.SystemUIAppComponentFactoryBase +import com.android.systemui.SystemUIAppComponentFactoryBase.ContextAvailableCallback +import com.android.systemui.keyguard.domain.interactor.KeyguardQuickAffordanceInteractor +import com.android.systemui.shared.keyguard.data.content.KeyguardQuickAffordanceProviderContract as Contract +import javax.inject.Inject +import kotlinx.coroutines.runBlocking + +class KeyguardQuickAffordanceProvider : + ContentProvider(), SystemUIAppComponentFactoryBase.ContextInitializer { + + @Inject lateinit var interactor: KeyguardQuickAffordanceInteractor + + private lateinit var contextAvailableCallback: ContextAvailableCallback + + private val uriMatcher = + UriMatcher(UriMatcher.NO_MATCH).apply { + addURI( + Contract.AUTHORITY, + Contract.SlotTable.TABLE_NAME, + MATCH_CODE_ALL_SLOTS, + ) + addURI( + Contract.AUTHORITY, + Contract.AffordanceTable.TABLE_NAME, + MATCH_CODE_ALL_AFFORDANCES, + ) + addURI( + Contract.AUTHORITY, + Contract.SelectionTable.TABLE_NAME, + MATCH_CODE_ALL_SELECTIONS, + ) + } + + override fun onCreate(): Boolean { + return true + } + + override fun attachInfo(context: Context?, info: ProviderInfo?) { + contextAvailableCallback.onContextAvailable(checkNotNull(context)) + super.attachInfo(context, info) + } + + override fun setContextAvailableCallback(callback: ContextAvailableCallback) { + contextAvailableCallback = callback + } + + override fun getType(uri: Uri): String? { + val prefix = + when (uriMatcher.match(uri)) { + MATCH_CODE_ALL_SLOTS, + MATCH_CODE_ALL_AFFORDANCES, + MATCH_CODE_ALL_SELECTIONS -> "vnd.android.cursor.dir/vnd." + else -> null + } + + val tableName = + when (uriMatcher.match(uri)) { + MATCH_CODE_ALL_SLOTS -> Contract.SlotTable.TABLE_NAME + MATCH_CODE_ALL_AFFORDANCES -> Contract.AffordanceTable.TABLE_NAME + MATCH_CODE_ALL_SELECTIONS -> Contract.SelectionTable.TABLE_NAME + else -> null + } + + if (prefix == null || tableName == null) { + return null + } + + return "$prefix${Contract.AUTHORITY}.$tableName" + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + if (uriMatcher.match(uri) != MATCH_CODE_ALL_SELECTIONS) { + throw UnsupportedOperationException() + } + + return insertSelection(values) + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? { + return when (uriMatcher.match(uri)) { + MATCH_CODE_ALL_AFFORDANCES -> queryAffordances() + MATCH_CODE_ALL_SLOTS -> querySlots() + MATCH_CODE_ALL_SELECTIONS -> querySelections() + else -> null + } + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int { + Log.e(TAG, "Update is not supported!") + return 0 + } + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int { + if (uriMatcher.match(uri) != MATCH_CODE_ALL_SELECTIONS) { + throw UnsupportedOperationException() + } + + return deleteSelection(uri, selectionArgs) + } + + private fun insertSelection(values: ContentValues?): Uri? { + if (values == null) { + throw IllegalArgumentException("Cannot insert selection, no values passed in!") + } + + if (!values.containsKey(Contract.SelectionTable.Columns.SLOT_ID)) { + throw IllegalArgumentException( + "Cannot insert selection, " + + "\"${Contract.SelectionTable.Columns.SLOT_ID}\" not specified!" + ) + } + + if (!values.containsKey(Contract.SelectionTable.Columns.AFFORDANCE_ID)) { + throw IllegalArgumentException( + "Cannot insert selection, " + + "\"${Contract.SelectionTable.Columns.AFFORDANCE_ID}\" not specified!" + ) + } + + val slotId = values.getAsString(Contract.SelectionTable.Columns.SLOT_ID) + val affordanceId = values.getAsString(Contract.SelectionTable.Columns.AFFORDANCE_ID) + + if (slotId.isNullOrEmpty()) { + throw IllegalArgumentException("Cannot insert selection, slot ID was empty!") + } + + if (affordanceId.isNullOrEmpty()) { + throw IllegalArgumentException("Cannot insert selection, affordance ID was empty!") + } + + val success = runBlocking { + interactor.select( + slotId = slotId, + affordanceId = affordanceId, + ) + } + + return if (success) { + Log.d(TAG, "Successfully selected $affordanceId for slot $slotId") + context?.contentResolver?.notifyChange(Contract.SelectionTable.URI, null) + Contract.SelectionTable.URI + } else { + Log.d(TAG, "Failed to select $affordanceId for slot $slotId") + null + } + } + + private fun querySelections(): Cursor { + return MatrixCursor( + arrayOf( + Contract.SelectionTable.Columns.SLOT_ID, + Contract.SelectionTable.Columns.AFFORDANCE_ID, + ) + ) + .apply { + val affordanceIdsBySlotId = runBlocking { interactor.getSelections() } + affordanceIdsBySlotId.entries.forEach { (slotId, affordanceIds) -> + affordanceIds.forEach { affordanceId -> + addRow( + arrayOf( + slotId, + affordanceId, + ) + ) + } + } + } + } + + private fun queryAffordances(): Cursor { + return MatrixCursor( + arrayOf( + Contract.AffordanceTable.Columns.ID, + Contract.AffordanceTable.Columns.NAME, + Contract.AffordanceTable.Columns.ICON, + ) + ) + .apply { + interactor.getAffordancePickerRepresentations().forEach { representation -> + addRow( + arrayOf( + representation.id, + representation.name, + representation.iconResourceId, + ) + ) + } + } + } + + private fun querySlots(): Cursor { + return MatrixCursor( + arrayOf( + Contract.SlotTable.Columns.ID, + Contract.SlotTable.Columns.CAPACITY, + ) + ) + .apply { + interactor.getSlotPickerRepresentations().forEach { representation -> + addRow( + arrayOf( + representation.id, + representation.maxSelectedAffordances, + ) + ) + } + } + } + + private fun deleteSelection( + uri: Uri, + selectionArgs: Array?, + ): Int { + if (selectionArgs == null) { + throw IllegalArgumentException( + "Cannot delete selection, selection arguments not included!" + ) + } + + val (slotId, affordanceId) = + when (selectionArgs.size) { + 1 -> Pair(selectionArgs[0], null) + 2 -> Pair(selectionArgs[0], selectionArgs[1]) + else -> + throw IllegalArgumentException( + "Cannot delete selection, selection arguments has wrong size, expected to" + + " have 1 or 2 arguments, had ${selectionArgs.size} instead!" + ) + } + + val deleted = runBlocking { + interactor.unselect( + slotId = slotId, + affordanceId = affordanceId, + ) + } + + return if (deleted) { + Log.d(TAG, "Successfully unselected $affordanceId for slot $slotId") + context?.contentResolver?.notifyChange(uri, null) + 1 + } else { + Log.d(TAG, "Failed to unselect $affordanceId for slot $slotId") + 0 + } + } + + companion object { + private const val TAG = "KeyguardQuickAffordanceProvider" + private const val MATCH_CODE_ALL_SLOTS = 1 + private const val MATCH_CODE_ALL_AFFORDANCES = 2 + private const val MATCH_CODE_ALL_SELECTIONS = 3 + } +} diff --git a/packages/SystemUI/tests/AndroidManifest.xml b/packages/SystemUI/tests/AndroidManifest.xml index f92a1baec6276..48913390c23d0 100644 --- a/packages/SystemUI/tests/AndroidManifest.xml +++ b/packages/SystemUI/tests/AndroidManifest.xml @@ -140,6 +140,12 @@ tools:replace="android:authorities" tools:node="remove" /> + +