From 2836309851007c8d9c181e718f0afa573a0546f8 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 28 Oct 2022 17:11:04 -0700 Subject: [PATCH] Content provider for quick affordances. This is the content provider that wallpaper picker will be using to query for slots, affordances, and selections, and for setting or unsetting selections. The CL also includes a contract definition and "client" utility functions that any customer can use to query and update the content provider. Fix: 254857637 Test: end-to-end unit tests included. Manually verified the API by issuing adb shell content commands and seeing affordances appear and disappear on the actual lock screen. Change-Id: I08eefc931dcc7b5133c412f7b97715fedfe84f30 --- packages/SystemUI/AndroidManifest.xml | 10 + .../KeyguardQuickAffordanceProviderClient.kt | 326 ++++++++++++++++++ ...KeyguardQuickAffordanceProviderContract.kt | 111 ++++++ .../dagger/ReferenceSysUIComponent.java | 6 + .../KeyguardQuickAffordanceProvider.kt | 297 ++++++++++++++++ .../KeyguardQuickAffordanceProviderTest.kt | 302 ++++++++++++++++ 6 files changed, 1052 insertions(+) create mode 100644 packages/SystemUI/shared/src/com/android/systemui/shared/keyguard/data/content/KeyguardQuickAffordanceProviderClient.kt create mode 100644 packages/SystemUI/shared/src/com/android/systemui/shared/keyguard/data/content/KeyguardQuickAffordanceProviderContract.kt create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/KeyguardQuickAffordanceProvider.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/keyguard/KeyguardQuickAffordanceProviderTest.kt 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/shared/src/com/android/systemui/shared/keyguard/data/content/KeyguardQuickAffordanceProviderClient.kt b/packages/SystemUI/shared/src/com/android/systemui/shared/keyguard/data/content/KeyguardQuickAffordanceProviderClient.kt new file mode 100644 index 0000000000000..8612b3a2c587c --- /dev/null +++ b/packages/SystemUI/shared/src/com/android/systemui/shared/keyguard/data/content/KeyguardQuickAffordanceProviderClient.kt @@ -0,0 +1,326 @@ +/* + * 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.annotation.SuppressLint +import android.content.ContentValues +import android.content.Context +import android.database.ContentObserver +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.UserHandle +import androidx.annotation.DrawableRes +import com.android.systemui.shared.keyguard.data.content.KeyguardQuickAffordanceProviderContract as Contract +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext + +/** Collection of utility functions for using a content provider implementing the [Contract]. */ +object KeyguardQuickAffordanceProviderClient { + + /** + * Selects an affordance with the given ID for a slot on the lock screen with the given ID. + * + * Note that the maximum number of selected affordances on this slot is automatically enforced. + * Selecting a slot that is already full (e.g. already has a number of selected affordances at + * its maximum capacity) will automatically remove the oldest selected affordance before adding + * the one passed in this call. Additionally, selecting an affordance that's already one of the + * selected affordances on the slot will move the selected affordance to the newest location in + * the slot. + */ + suspend fun insertSelection( + context: Context, + slotId: String, + affordanceId: String, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ) { + withContext(dispatcher) { + context.contentResolver.insert( + Contract.SelectionTable.URI, + ContentValues().apply { + put(Contract.SelectionTable.Columns.SLOT_ID, slotId) + put(Contract.SelectionTable.Columns.AFFORDANCE_ID, affordanceId) + } + ) + } + } + + /** Returns all available slots supported by the device. */ + suspend fun querySlots( + context: Context, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + ): List { + 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/src/com/android/systemui/keyguard/KeyguardQuickAffordanceProviderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/keyguard/KeyguardQuickAffordanceProviderTest.kt new file mode 100644 index 0000000000000..4d66a168303c1 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/keyguard/KeyguardQuickAffordanceProviderTest.kt @@ -0,0 +1,302 @@ +/* + * 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.pm.PackageManager +import android.content.pm.ProviderInfo +import androidx.test.filters.SmallTest +import com.android.internal.widget.LockPatternUtils +import com.android.systemui.SystemUIAppComponentFactoryBase +import com.android.systemui.SysuiTestCase +import com.android.systemui.flags.FakeFeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.keyguard.data.quickaffordance.FakeKeyguardQuickAffordanceConfig +import com.android.systemui.keyguard.data.quickaffordance.KeyguardQuickAffordanceSelectionManager +import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository +import com.android.systemui.keyguard.data.repository.KeyguardQuickAffordanceRepository +import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor +import com.android.systemui.keyguard.domain.interactor.KeyguardQuickAffordanceInteractor +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.settings.UserTracker +import com.android.systemui.shared.keyguard.data.content.KeyguardQuickAffordanceProviderClient as Client +import com.android.systemui.shared.keyguard.data.content.KeyguardQuickAffordanceProviderContract as Contract +import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots +import com.android.systemui.statusbar.policy.KeyguardStateController +import com.android.systemui.util.mockito.mock +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(JUnit4::class) +class KeyguardQuickAffordanceProviderTest : SysuiTestCase() { + + @Mock private lateinit var lockPatternUtils: LockPatternUtils + @Mock private lateinit var keyguardStateController: KeyguardStateController + @Mock private lateinit var userTracker: UserTracker + @Mock private lateinit var activityStarter: ActivityStarter + + private lateinit var underTest: KeyguardQuickAffordanceProvider + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + underTest = KeyguardQuickAffordanceProvider() + val quickAffordanceRepository = + KeyguardQuickAffordanceRepository( + scope = CoroutineScope(IMMEDIATE), + backgroundDispatcher = IMMEDIATE, + selectionManager = KeyguardQuickAffordanceSelectionManager(), + configs = + setOf( + FakeKeyguardQuickAffordanceConfig( + key = AFFORDANCE_1, + pickerIconResourceId = 1, + ), + FakeKeyguardQuickAffordanceConfig( + key = AFFORDANCE_2, + pickerIconResourceId = 2, + ), + ), + ) + underTest.interactor = + KeyguardQuickAffordanceInteractor( + keyguardInteractor = + KeyguardInteractor( + repository = FakeKeyguardRepository(), + ), + registry = mock(), + lockPatternUtils = lockPatternUtils, + keyguardStateController = keyguardStateController, + userTracker = userTracker, + activityStarter = activityStarter, + featureFlags = + FakeFeatureFlags().apply { + set(Flags.CUSTOMIZABLE_LOCK_SCREEN_QUICK_AFFORDANCES, true) + }, + repository = { quickAffordanceRepository }, + ) + + underTest.attachInfoForTesting( + context, + ProviderInfo().apply { authority = Contract.AUTHORITY }, + ) + context.contentResolver.addProvider(Contract.AUTHORITY, underTest) + context.testablePermissions.setPermission( + Contract.PERMISSION, + PackageManager.PERMISSION_GRANTED, + ) + } + + @Test + fun `onAttachInfo - reportsContext`() { + val callback: SystemUIAppComponentFactoryBase.ContextAvailableCallback = mock() + underTest.setContextAvailableCallback(callback) + + underTest.attachInfo(context, null) + + verify(callback).onContextAvailable(context) + } + + @Test + fun getType() { + assertThat(underTest.getType(Contract.AffordanceTable.URI)) + .isEqualTo( + "vnd.android.cursor.dir/vnd." + + "${Contract.AUTHORITY}.${Contract.AffordanceTable.TABLE_NAME}" + ) + assertThat(underTest.getType(Contract.SlotTable.URI)) + .isEqualTo( + "vnd.android.cursor.dir/vnd.${Contract.AUTHORITY}.${Contract.SlotTable.TABLE_NAME}" + ) + assertThat(underTest.getType(Contract.SelectionTable.URI)) + .isEqualTo( + "vnd.android.cursor.dir/vnd." + + "${Contract.AUTHORITY}.${Contract.SelectionTable.TABLE_NAME}" + ) + } + + @Test + fun `insert and query selection`() = + runBlocking(IMMEDIATE) { + val slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START + val affordanceId = AFFORDANCE_2 + + Client.insertSelection( + context = context, + slotId = slotId, + affordanceId = affordanceId, + dispatcher = IMMEDIATE, + ) + + assertThat( + Client.querySelections( + context = context, + dispatcher = IMMEDIATE, + ) + ) + .isEqualTo( + listOf( + Client.Selection( + slotId = slotId, + affordanceId = affordanceId, + ) + ) + ) + } + + @Test + fun `query slots`() = + runBlocking(IMMEDIATE) { + assertThat( + Client.querySlots( + context = context, + dispatcher = IMMEDIATE, + ) + ) + .isEqualTo( + listOf( + Client.Slot( + id = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START, + capacity = 1, + ), + Client.Slot( + id = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END, + capacity = 1, + ), + ) + ) + } + + @Test + fun `query affordances`() = + runBlocking(IMMEDIATE) { + assertThat( + Client.queryAffordances( + context = context, + dispatcher = IMMEDIATE, + ) + ) + .isEqualTo( + listOf( + Client.Affordance( + id = AFFORDANCE_1, + name = AFFORDANCE_1, + iconResourceId = 1, + ), + Client.Affordance( + id = AFFORDANCE_2, + name = AFFORDANCE_2, + iconResourceId = 2, + ), + ) + ) + } + + @Test + fun `delete and query selection`() = + runBlocking(IMMEDIATE) { + Client.insertSelection( + context = context, + slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START, + affordanceId = AFFORDANCE_1, + dispatcher = IMMEDIATE, + ) + Client.insertSelection( + context = context, + slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END, + affordanceId = AFFORDANCE_2, + dispatcher = IMMEDIATE, + ) + + Client.deleteSelection( + context = context, + slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END, + affordanceId = AFFORDANCE_2, + dispatcher = IMMEDIATE, + ) + + assertThat( + Client.querySelections( + context = context, + dispatcher = IMMEDIATE, + ) + ) + .isEqualTo( + listOf( + Client.Selection( + slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START, + affordanceId = AFFORDANCE_1, + ) + ) + ) + } + + @Test + fun `delete all selections in a slot`() = + runBlocking(IMMEDIATE) { + Client.insertSelection( + context = context, + slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START, + affordanceId = AFFORDANCE_1, + dispatcher = IMMEDIATE, + ) + Client.insertSelection( + context = context, + slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END, + affordanceId = AFFORDANCE_2, + dispatcher = IMMEDIATE, + ) + + Client.deleteAllSelections( + context = context, + slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END, + dispatcher = IMMEDIATE, + ) + + assertThat( + Client.querySelections( + context = context, + dispatcher = IMMEDIATE, + ) + ) + .isEqualTo( + listOf( + Client.Selection( + slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START, + affordanceId = AFFORDANCE_1, + ) + ) + ) + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + private const val AFFORDANCE_1 = "affordance_1" + private const val AFFORDANCE_2 = "affordance_2" + } +}