diff --git a/packages/SystemUI/res/drawable/privacy_dialog_bg.xml b/packages/SystemUI/res/drawable/privacy_dialog_bg.xml new file mode 100644 index 0000000000000..96136c4afab8b --- /dev/null +++ b/packages/SystemUI/res/drawable/privacy_dialog_bg.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/drawable/privacy_item_circle_camera.xml b/packages/SystemUI/res/drawable/privacy_item_circle_camera.xml new file mode 100644 index 0000000000000..cf6413642c632 --- /dev/null +++ b/packages/SystemUI/res/drawable/privacy_item_circle_camera.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/drawable/privacy_item_circle_location.xml b/packages/SystemUI/res/drawable/privacy_item_circle_location.xml new file mode 100644 index 0000000000000..0a6a4a31d0a54 --- /dev/null +++ b/packages/SystemUI/res/drawable/privacy_item_circle_location.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/drawable/privacy_item_circle_microphone.xml b/packages/SystemUI/res/drawable/privacy_item_circle_microphone.xml new file mode 100644 index 0000000000000..0a6a4a31d0a54 --- /dev/null +++ b/packages/SystemUI/res/drawable/privacy_item_circle_microphone.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/layout/privacy_dialog.xml b/packages/SystemUI/res/layout/privacy_dialog.xml new file mode 100644 index 0000000000000..5db247e25d503 --- /dev/null +++ b/packages/SystemUI/res/layout/privacy_dialog.xml @@ -0,0 +1,28 @@ + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/layout/privacy_dialog_item.xml b/packages/SystemUI/res/layout/privacy_dialog_item.xml new file mode 100644 index 0000000000000..882e9680407ee --- /dev/null +++ b/packages/SystemUI/res/layout/privacy_dialog_item.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/values-night/colors.xml b/packages/SystemUI/res/values-night/colors.xml index c4cf440d4961a..3153d0d0123d4 100644 --- a/packages/SystemUI/res/values-night/colors.xml +++ b/packages/SystemUI/res/values-night/colors.xml @@ -96,4 +96,8 @@ #3C4043 + + #81C995 + #FCAD70 + diff --git a/packages/SystemUI/res/values/colors.xml b/packages/SystemUI/res/values/colors.xml index 3f6b8ef44589a..a7cf3e91dbbae 100644 --- a/packages/SystemUI/res/values/colors.xml +++ b/packages/SystemUI/res/values/colors.xml @@ -266,4 +266,8 @@ #F28B82 #E94235 + + + #1E8E3E + #E8710A diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index d104d17f37b7b..79cb236ff34a8 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -1174,6 +1174,10 @@ 16dp + @dimen/notification_corner_radius + + @dimen/notification_shade_content_margin_horizontal + 10sp diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index 6e9c5dcde95be..5161dec1f2696 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -2547,6 +2547,21 @@ \u0020and\u0020 + + %1$s is using the %2$s + + + %1$s used the %2$s recently + + + (enterprise) + + + Phonecall + + + (through %s) + camera diff --git a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java index 2c06c7b968319..04c4e977b2cff 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java @@ -54,6 +54,7 @@ import android.os.PowerManager; import android.os.ServiceManager; import android.os.UserManager; import android.os.Vibrator; +import android.permission.PermissionManager; import android.service.dreams.DreamService; import android.service.dreams.IDreamManager; import android.telecom.TelecomManager; @@ -379,4 +380,10 @@ public class FrameworkServicesModule { static WindowManager provideWindowManager(Context context) { return context.getSystemService(WindowManager.class); } + + @Provides + @Singleton + static PermissionManager providePermissionManager(Context context) { + return context.getSystemService(PermissionManager.class); + } } diff --git a/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialog.kt b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialog.kt new file mode 100644 index 0000000000000..66c535f869f03 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialog.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2021 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.privacy + +import android.content.Context +import android.graphics.drawable.LayerDrawable +import android.os.Bundle +import android.text.TextUtils +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.view.WindowInsets +import android.widget.ImageView +import android.widget.TextView +import com.android.settingslib.Utils +import com.android.systemui.R +import com.android.systemui.statusbar.phone.SystemUIDialog +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Dialog to show ongoing and recent app ops usage. + * + * @see PrivacyDialogController + * @param context A context to create the dialog + * @param list list of elements to show in the dialog. The elements will show in the same order they + * appear in the list + * @param activityStarter a callback to start an activity for a given permission group name (as + * given by [PrivacyType.permGroupName]) + */ +class PrivacyDialog( + context: Context, + private val list: List, + activityStarter: (String) -> Unit +) : SystemUIDialog(context, R.style.ScreenRecord) { + + private val dismissListeners = mutableListOf>() + private val dismissed = AtomicBoolean(false) + + private val iconColorSolid = Utils.getColorAttrDefaultColor( + this.context, com.android.internal.R.attr.colorPrimary + ) + private val enterpriseText = " ${context.getString(R.string.ongoing_privacy_dialog_enterprise)}" + private val phonecall = context.getString(R.string.ongoing_privacy_dialog_phonecall) + + private lateinit var rootView: ViewGroup + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window?.apply { + attributes.fitInsetsTypes = attributes.fitInsetsTypes or WindowInsets.Type.statusBars() + setLayout(MATCH_PARENT, WRAP_CONTENT) + setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL) + setBackgroundDrawable(null) + } + + setContentView(R.layout.privacy_dialog) + rootView = requireViewById(R.id.root) + + list.forEach { + rootView.addView(createView(it)) + } + } + + /** + * Add a listener that will be called when the dialog is dismissed. + * + * If the dialog has already been dismissed, the listener will be called immediately, in the + * same thread. + */ + fun addOnDismissListener(listener: OnDialogDismissed) { + if (dismissed.get()) { + listener.onDialogDismissed() + } else { + dismissListeners.add(WeakReference(listener)) + } + } + + override fun onStop() { + super.onStop() + dismissed.set(true) + val iterator = dismissListeners.iterator() + while (iterator.hasNext()) { + val el = iterator.next() + iterator.remove() + el.get()?.onDialogDismissed() + } + } + + private fun createView(element: PrivacyElement): View { + val newView = LayoutInflater.from(context).inflate( + R.layout.privacy_dialog_item, rootView, false + ) as ViewGroup + val d = getDrawableForType(element.type) + d.findDrawableByLayerId(R.id.icon).setTint(iconColorSolid) + newView.requireViewById(R.id.icon).apply { + setImageDrawable(d) + contentDescription = element.type.getName(context) + } + val stringId = getStringIdForState(element.active) + val app = if (element.phoneCall) phonecall else element.applicationName + val appName = if (element.enterprise) { + TextUtils.concat(app, enterpriseText) + } else { + app + } + val firstLine = context.getString(stringId, appName, element.type.getName(context)) + val finalText = element.attribution?.let { + TextUtils.concat( + firstLine, + "\n", + context.getString(R.string.ongoing_privacy_dialog_attribution_text, it) + ) + } ?: firstLine + newView.requireViewById(R.id.text).text = finalText + newView.requireViewById(R.id.link).apply { + tag = element.type.permGroupName + setOnClickListener(clickListener) + } + return newView + } + + private fun getStringIdForState(active: Boolean): Int { + return if (active) { + R.string.ongoing_privacy_dialog_using_op + } else { + R.string.ongoing_privacy_dialog_recent_op + } + } + + private fun getDrawableForType(type: PrivacyType): LayerDrawable { + return context.getDrawable(when (type) { + PrivacyType.TYPE_LOCATION -> R.drawable.privacy_item_circle_location + PrivacyType.TYPE_CAMERA -> R.drawable.privacy_item_circle_camera + PrivacyType.TYPE_MICROPHONE -> R.drawable.privacy_item_circle_microphone + }) as LayerDrawable + } + + private val clickListener = View.OnClickListener { v -> + if (v.id == R.id.link) { + v.tag?.let { activityStarter(it as String) } + } + } + + /** */ + data class PrivacyElement( + val type: PrivacyType, + val applicationName: CharSequence, + val attribution: CharSequence?, + val lastActiveTimestamp: Long, + val active: Boolean, + val enterprise: Boolean, + val phoneCall: Boolean + ) { + private val builder = StringBuilder("PrivacyElement(") + + init { + builder.append("type=${type.logName}") + builder.append(", appName=$applicationName") + if (attribution != null) { + builder.append(", attribution=$attribution") + } + builder.append(", lastActive=$lastActiveTimestamp") + if (active) { + builder.append(", active") + } + if (enterprise) { + builder.append(", enterprise") + } + if (phoneCall) { + builder.append(", phoneCall") + } + builder.append(")") + } + + override fun toString(): String = builder.toString() + } + + /** */ + interface OnDialogDismissed { + fun onDialogDismissed() + } +} \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogController.kt b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogController.kt new file mode 100644 index 0000000000000..99e9bfc24e1d5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogController.kt @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2021 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.privacy + +import android.Manifest +import android.app.ActivityManager +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.UserHandle +import android.permission.PermGroupUsage +import android.permission.PermissionManager +import android.util.Log +import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.privacy.logging.PrivacyLogger +import com.android.systemui.settings.UserTracker +import com.android.systemui.statusbar.policy.KeyguardStateController +import java.util.concurrent.Executor +import javax.inject.Inject + +private val defaultDialogProvider = object : PrivacyDialogController.DialogProvider { + override fun makeDialog( + context: Context, + list: List, + starter: (String) -> Unit + ): PrivacyDialog { + return PrivacyDialog(context, list, starter) + } +} + +/** + * Controller for [PrivacyDialog]. + * + * This controller shows and dismissed the dialog, as well as determining the information to show in + * it. + */ +@SysUISingleton +class PrivacyDialogController( + private val permissionManager: PermissionManager, + private val packageManager: PackageManager, + private val privacyItemController: PrivacyItemController, + private val userTracker: UserTracker, + private val activityStarter: ActivityStarter, + private val backgroundExecutor: Executor, + private val uiExecutor: Executor, + private val privacyLogger: PrivacyLogger, + private val keyguardStateController: KeyguardStateController, + @VisibleForTesting private val dialogProvider: DialogProvider +) { + + @Inject + constructor( + permissionManager: PermissionManager, + packageManager: PackageManager, + privacyItemController: PrivacyItemController, + userTracker: UserTracker, + activityStarter: ActivityStarter, + @Background backgroundExecutor: Executor, + @Main uiExecutor: Executor, + privacyLogger: PrivacyLogger, + keyguardStateController: KeyguardStateController + ) : this( + permissionManager, + packageManager, + privacyItemController, + userTracker, + activityStarter, + backgroundExecutor, + uiExecutor, + privacyLogger, + keyguardStateController, + defaultDialogProvider + ) + + companion object { + private const val TAG = "PrivacyDialogController" + } + + private var dialog: Dialog? = null + + private val onDialogDismissed = object : PrivacyDialog.OnDialogDismissed { + override fun onDialogDismissed() { + privacyLogger.logPrivacyDialogDismissed() + dialog = null + } + } + + @MainThread + private fun startActivity(permGroupName: String) { + val intent = Intent(Intent.ACTION_MANAGE_PERMISSION_APPS) + intent.putExtra(Intent.EXTRA_PERMISSION_GROUP_NAME, permGroupName) + privacyLogger.logStartSettingsActivityFromDialog(permGroupName) + if (!keyguardStateController.isUnlocked) { + // If we are locked, hide the dialog so the user can unlock + dialog?.hide() + } + // startActivity calls internally startActivityDismissingKeyguard + activityStarter.startActivity(intent, true) { + if (ActivityManager.isStartResultSuccessful(it)) { + dismissDialog() + } else { + dialog?.show() + } + } + } + + @WorkerThread + private fun permGroupUsage(): List = permissionManager.indicatorAppOpUsageData + + /** + * Show the [PrivacyDialog] + * + * This retrieves the permission usage from [PermissionManager] and creates a new + * [PrivacyDialog] with a list of [PrivacyDialog.PrivacyElement] to show. + * + * This list will be filtered by [filterAndSelect]. Only types available by + * [PrivacyItemController] will be shown. + * + * @param context A context to use to create the dialog. + * @see filterAndSelect + */ + fun showDialog(context: Context) { + dismissDialog() + backgroundExecutor.execute { + val usage = permGroupUsage() + val userInfos = userTracker.userProfiles + privacyLogger.logUnfilteredPermGroupUsage(usage) + val items = usage.mapNotNull { + val type = filterType(permGroupToPrivacyType(it.permGroupName)) + val userInfo = userInfos.firstOrNull { ui -> ui.id == UserHandle.getUserId(it.uid) } + userInfo?.let { ui -> + type?.let { t -> + // Only try to get the app name if we actually need it + val appName = if (it.isPhoneCall) { + "" + } else { + getLabelForPackage(it.packageName, it.uid) + } + PrivacyDialog.PrivacyElement( + t, + appName, + it.attribution, + it.lastAccess, + it.isActive, + ui.isManagedProfile, + it.isPhoneCall + ) + } + } + } + uiExecutor.execute { + val elements = filterAndSelect(items) + val d = dialogProvider.makeDialog(context, elements, this::startActivity) + d.setShowForAllUsers(true) + d.addOnDismissListener(onDialogDismissed) + d.show() + privacyLogger.logShowDialogContents(elements) + dialog = d + } + } + } + + /** + * Dismisses the dialog + */ + fun dismissDialog() { + dialog?.dismiss() + } + + @WorkerThread + private fun getLabelForPackage(packageName: String, uid: Int): CharSequence { + return try { + packageManager + .getApplicationInfoAsUser(packageName, 0, UserHandle.getUserId(uid)) + .loadLabel(packageManager) + } catch (_: PackageManager.NameNotFoundException) { + Log.w(TAG, "Label not found for: $packageName") + packageName + } + } + + private fun permGroupToPrivacyType(group: String): PrivacyType? { + return when (group) { + Manifest.permission_group.CAMERA -> PrivacyType.TYPE_CAMERA + Manifest.permission_group.MICROPHONE -> PrivacyType.TYPE_MICROPHONE + Manifest.permission_group.LOCATION -> PrivacyType.TYPE_LOCATION + else -> null + } + } + + private fun filterType(type: PrivacyType?): PrivacyType? { + return type?.let { + if (privacyItemController.allIndicatorsAvailable) { + it + } else if ((it == PrivacyType.TYPE_CAMERA || it == PrivacyType.TYPE_MICROPHONE) && + privacyItemController.micCameraAvailable) { + it + } else if (it == PrivacyType.TYPE_LOCATION && privacyItemController.locationAvailable) { + it + } else { + null + } + } + } + + /** + * Filters the list of elements to show. + * + * * Return at most one element per [PrivacyType], sorted by the natural order of the + * [PrivacyType]. + * * If there are no active usages for a type, return the most recent + * * If there are multiple active usages for a type, return the most active recent. + */ + private fun filterAndSelect( + list: List + ): List { + return list.groupBy { it.type }.toSortedMap().mapNotNull { entry -> + if (entry.value.isEmpty()) { + null + } else { + val actives = entry.value.filter { it.active } + val out = if (actives.isNotEmpty()) actives else entry.value + out.maxByOrNull { it.lastActiveTimestamp } + } + } + } + + /** + * Interface to create a [PrivacyDialog]. + * + * Can be used to inject a mock creator. + */ + interface DialogProvider { + /** + * Create a [PrivacyDialog]. + */ + fun makeDialog( + context: Context, + list: List, + starter: (String) -> Unit + ): PrivacyDialog + } +} \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItem.kt b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItem.kt index 7359e79b26f59..8a429e2e424d4 100644 --- a/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItem.kt +++ b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyItem.kt @@ -19,21 +19,29 @@ import com.android.systemui.R typealias Privacy = PrivacyType -enum class PrivacyType(val nameId: Int, val iconId: Int, val logName: String) { - // This is uses the icons used by the corresponding permission groups in the AndroidManifest +enum class PrivacyType( + val nameId: Int, + val iconId: Int, + val permGroupName: String, + val logName: String +) { + // This uses the icons used by the corresponding permission groups in the AndroidManifest TYPE_CAMERA( R.string.privacy_type_camera, com.android.internal.R.drawable.perm_group_camera, + android.Manifest.permission_group.CAMERA, "camera" ), TYPE_MICROPHONE( R.string.privacy_type_microphone, com.android.internal.R.drawable.perm_group_microphone, + android.Manifest.permission_group.MICROPHONE, "microphone" ), TYPE_LOCATION( R.string.privacy_type_location, com.android.internal.R.drawable.perm_group_location, + android.Manifest.permission_group.LOCATION, "location" ); diff --git a/packages/SystemUI/src/com/android/systemui/privacy/logging/PrivacyLogger.kt b/packages/SystemUI/src/com/android/systemui/privacy/logging/PrivacyLogger.kt index f3b8d2e5fbc03..8059475dd832c 100644 --- a/packages/SystemUI/src/com/android/systemui/privacy/logging/PrivacyLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/privacy/logging/PrivacyLogger.kt @@ -16,10 +16,12 @@ package com.android.systemui.privacy.logging +import android.permission.PermGroupUsage import com.android.systemui.log.LogBuffer import com.android.systemui.log.LogLevel import com.android.systemui.log.LogMessage import com.android.systemui.log.dagger.PrivacyLog +import com.android.systemui.privacy.PrivacyDialog import javax.inject.Inject private const val TAG = "PrivacyLog" @@ -85,18 +87,48 @@ class PrivacyLogger @Inject constructor( fun logStatusBarIconsVisible( showCamera: Boolean, - showMichrophone: Boolean, + showMicrophone: Boolean, showLocation: Boolean ) { log(LogLevel.INFO, { bool1 = showCamera - bool2 = showMichrophone + bool2 = showMicrophone bool3 = showLocation }, { "Status bar icons visible: camera=$bool1, microphone=$bool2, location=$bool3" }) } + fun logUnfilteredPermGroupUsage(contents: List) { + log(LogLevel.DEBUG, { + str1 = contents.toString() + }, { + "Perm group usage: $str1" + }) + } + + fun logShowDialogContents(contents: List) { + log(LogLevel.INFO, { + str1 = contents.toString() + }, { + "Privacy dialog shown. Contents: $str1" + }) + } + + fun logPrivacyDialogDismissed() { + log(LogLevel.INFO, {}, { + "Privacy dialog dismissed" + }) + } + + fun logStartSettingsActivityFromDialog(permGroupName: String) { + log(LogLevel.INFO, { + str1 = permGroupName + }, { + "Start settings activity from dialog for perm group: $str1" + }) + } + private inline fun log( logLevel: LogLevel, initializer: LogMessage.() -> Unit, diff --git a/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeaderController.java b/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeaderController.java index c5b76feb7cb39..e3c39aa77402d 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeaderController.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeaderController.java @@ -20,8 +20,6 @@ import android.app.AlarmManager.AlarmClockInfo; import android.content.Intent; import android.media.AudioManager; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; import android.provider.AlarmClock; import android.provider.Settings; import android.service.notification.ZenModeConfig; @@ -43,6 +41,7 @@ import com.android.systemui.demomode.DemoModeController; import com.android.systemui.plugins.ActivityStarter; import com.android.systemui.privacy.OngoingPrivacyChip; import com.android.systemui.privacy.PrivacyChipEvent; +import com.android.systemui.privacy.PrivacyDialogController; import com.android.systemui.privacy.PrivacyItem; import com.android.systemui.privacy.PrivacyItemController; import com.android.systemui.privacy.logging.PrivacyLogger; @@ -94,6 +93,7 @@ class QuickStatusBarHeaderController extends ViewController { - mActivityStarter.postStartActivityDismissingKeyguard( - new Intent(Intent.ACTION_REVIEW_ONGOING_PERMISSION_USAGE), 0); - mQSTileHost.collapsePanels(); - }); + mPrivacyDialogController.showDialog(getContext()); } else if (v == mRingerContainer && mRingerContainer.isVisibleToUser()) { mActivityStarter.postStartActivityDismissingKeyguard(new Intent( Settings.ACTION_SOUND_SETTINGS), 0); @@ -222,7 +217,8 @@ class QuickStatusBarHeaderController extends ViewController + @Captor + private lateinit var activityStartedCaptor: ArgumentCaptor + @Captor + private lateinit var intentCaptor: ArgumentCaptor + + private val backgroundExecutor = FakeExecutor(FakeSystemClock()) + private val uiExecutor = FakeExecutor(FakeSystemClock()) + private lateinit var controller: PrivacyDialogController + private var nextUid: Int = 0 + + private val dialogProvider = object : PrivacyDialogController.DialogProvider { + var list: List? = null + var starter: ((String) -> Unit)? = null + + override fun makeDialog( + context: Context, + list: List, + starter: (String) -> Unit + ): PrivacyDialog { + this.list = list + this.starter = starter + return dialog + } + } + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + nextUid = 0 + + setUpDefaultMockResponses() + + controller = PrivacyDialogController( + permissionManager, + packageManager, + privacyItemController, + userTracker, + activityStarter, + backgroundExecutor, + uiExecutor, + privacyLogger, + keyguardStateController, + dialogProvider + ) + } + + @After + fun tearDown() { + FakeExecutor.exhaustExecutors(uiExecutor, backgroundExecutor) + dialogProvider.list = null + dialogProvider.starter = null + } + + @Test + fun testPermissionManagerOnlyCalledInBackgroundThread() { + controller.showDialog(context) + verify(permissionManager, never()).indicatorAppOpUsageData + backgroundExecutor.runAllReady() + verify(permissionManager).indicatorAppOpUsageData + } + + @Test + fun testPackageManagerOnlyCalledInBackgroundThread() { + val usage = createMockPermGroupUsage() + `when`(usage.isPhoneCall).thenReturn(false) + `when`(permissionManager.indicatorAppOpUsageData).thenReturn(listOf(usage)) + + controller.showDialog(context) + verify(packageManager, never()).getApplicationInfoAsUser(anyString(), anyInt(), anyInt()) + backgroundExecutor.runAllReady() + verify(packageManager, atLeastOnce()) + .getApplicationInfoAsUser(anyString(), anyInt(), anyInt()) + } + + @Test + fun testShowDialogShowsDialog() { + controller.showDialog(context) + exhaustExecutors() + + verify(dialog).show() + } + + @Test + fun testHideDialogDismissesDialogIfShown() { + controller.showDialog(context) + exhaustExecutors() + + controller.dismissDialog() + verify(dialog).dismiss() + } + + @Test + fun testHideDialogNoopIfNotShown() { + controller.dismissDialog() + verify(dialog, never()).dismiss() + } + + @Test + fun testHideDialogNoopAfterDismissed() { + controller.showDialog(context) + exhaustExecutors() + + verify(dialog).addOnDismissListener(capture(dialogDismissedCaptor)) + + dialogDismissedCaptor.value.onDialogDismissed() + controller.dismissDialog() + verify(dialog, never()).dismiss() + } + + @Test + fun testShowForAllUsers() { + controller.showDialog(context) + + exhaustExecutors() + verify(dialog).setShowForAllUsers(true) + } + + @Test + fun testSingleElementInList() { + val usage = createMockPermGroupUsage( + packageName = TEST_PACKAGE_NAME, + uid = generateUidForUser(0), + permGroupName = PERM_CAMERA, + lastAccess = 5L, + isActive = true, + isPhoneCall = false, + attribution = TEST_ATTRIBUTION + ) + `when`(permissionManager.indicatorAppOpUsageData).thenReturn(listOf(usage)) + + controller.showDialog(context) + exhaustExecutors() + + val expected = PrivacyDialog.PrivacyElement( + type = PrivacyType.TYPE_CAMERA, + applicationName = TEST_PACKAGE_NAME, + attribution = TEST_ATTRIBUTION, + lastActiveTimestamp = 5L, + active = true, + phoneCall = false, + enterprise = false + ) + assertThat(dialogProvider.list).containsExactly(expected) + } + + @Test + fun testTwoElementsDifferentType_sorted() { + val usage_camera = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_camera", + permGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_microphone", + permGroupName = PERM_MICROPHONE + ) + `when`(permissionManager.indicatorAppOpUsageData).thenReturn( + listOf(usage_microphone, usage_camera) + ) + + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.list?.let { list -> + assertThat(list).hasSize(2) + assertThat(list.get(0).type.compareTo(list.get(1).type)).isLessThan(0) + } + } + + @Test + fun testTwoElementsSameType_oneActive() { + val usage_active = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_active", + isActive = true + ) + val usage_recent = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_recent", + isActive = false + ) + `when`(permissionManager.indicatorAppOpUsageData).thenReturn( + listOf(usage_recent, usage_active) + ) + + controller.showDialog(context) + exhaustExecutors() + + assertThat(dialogProvider.list).hasSize(1) + assertThat(dialogProvider.list?.get(0)?.active).isTrue() + } + + @Test + fun testTwoElementsSameType_twoActive() { + val usage_active = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_active", + isActive = true, + lastAccess = 0L + ) + val usage_active_moreRecent = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_active_recent", + isActive = true, + lastAccess = 1L + ) + `when`(permissionManager.indicatorAppOpUsageData).thenReturn( + listOf(usage_active, usage_active_moreRecent) + ) + controller.showDialog(context) + exhaustExecutors() + assertThat(dialogProvider.list).hasSize(1) + assertThat(dialogProvider.list?.get(0)?.lastActiveTimestamp).isEqualTo(1L) + } + + @Test + fun testManyElementsSameType_bothRecent() { + val usage_recent = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_recent", + isActive = false, + lastAccess = 0L + ) + val usage_moreRecent = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_moreRecent", + isActive = false, + lastAccess = 1L + ) + val usage_mostRecent = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_mostRecent", + isActive = false, + lastAccess = 2L + ) + `when`(permissionManager.indicatorAppOpUsageData).thenReturn( + listOf(usage_recent, usage_mostRecent, usage_moreRecent) + ) + + controller.showDialog(context) + exhaustExecutors() + + assertThat(dialogProvider.list).hasSize(1) + assertThat(dialogProvider.list?.get(0)?.lastActiveTimestamp).isEqualTo(2L) + } + + @Test + fun testMicAndCameraDisabled() { + val usage_camera = createMockPermGroupUsage( + permGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + permGroupName = PERM_MICROPHONE + ) + val usage_location = createMockPermGroupUsage( + permGroupName = PERM_LOCATION + ) + + `when`(permissionManager.indicatorAppOpUsageData).thenReturn( + listOf(usage_camera, usage_location, usage_microphone) + ) + `when`(privacyItemController.micCameraAvailable).thenReturn(false) + + controller.showDialog(context) + exhaustExecutors() + + assertThat(dialogProvider.list).hasSize(1) + assertThat(dialogProvider.list?.get(0)?.type).isEqualTo(PrivacyType.TYPE_LOCATION) + } + + @Test + fun testLocationDisabled() { + val usage_camera = createMockPermGroupUsage( + permGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + permGroupName = PERM_MICROPHONE + ) + val usage_location = createMockPermGroupUsage( + permGroupName = PERM_LOCATION + ) + + `when`(permissionManager.indicatorAppOpUsageData).thenReturn( + listOf(usage_camera, usage_location, usage_microphone) + ) + `when`(privacyItemController.locationAvailable).thenReturn(false) + + controller.showDialog(context) + exhaustExecutors() + + assertThat(dialogProvider.list).hasSize(2) + dialogProvider.list?.forEach { + assertThat(it.type).isNotEqualTo(PrivacyType.TYPE_LOCATION) + } + } + + @Test + fun testAllIndicatorsAvailable() { + val usage_camera = createMockPermGroupUsage( + permGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + permGroupName = PERM_MICROPHONE + ) + val usage_location = createMockPermGroupUsage( + permGroupName = PERM_LOCATION + ) + + `when`(permissionManager.indicatorAppOpUsageData).thenReturn( + listOf(usage_camera, usage_location, usage_microphone) + ) + `when`(privacyItemController.micCameraAvailable).thenReturn(false) + `when`(privacyItemController.locationAvailable).thenReturn(false) + `when`(privacyItemController.allIndicatorsAvailable).thenReturn(true) + + controller.showDialog(context) + exhaustExecutors() + + assertThat(dialogProvider.list).hasSize(3) + } + + @Test + fun testNoIndicatorsAvailable() { + val usage_camera = createMockPermGroupUsage( + permGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + permGroupName = PERM_MICROPHONE + ) + val usage_location = createMockPermGroupUsage( + permGroupName = PERM_LOCATION + ) + + `when`(permissionManager.indicatorAppOpUsageData).thenReturn( + listOf(usage_camera, usage_location, usage_microphone) + ) + `when`(privacyItemController.micCameraAvailable).thenReturn(false) + `when`(privacyItemController.locationAvailable).thenReturn(false) + `when`(privacyItemController.allIndicatorsAvailable).thenReturn(false) + + controller.showDialog(context) + exhaustExecutors() + + assertThat(dialogProvider.list).isEmpty() + } + + @Test + fun testEnterpriseUser() { + val usage_enterprise = createMockPermGroupUsage( + uid = generateUidForUser(ENT_USER_ID) + ) + `when`(permissionManager.indicatorAppOpUsageData).thenReturn(listOf(usage_enterprise)) + + controller.showDialog(context) + exhaustExecutors() + + assertThat(dialogProvider.list?.single()?.enterprise).isTrue() + } + + @Test + fun testNotCurrentUser() { + val usage_other = createMockPermGroupUsage( + uid = generateUidForUser(ENT_USER_ID + 1) + ) + `when`(permissionManager.indicatorAppOpUsageData).thenReturn(listOf(usage_other)) + + controller.showDialog(context) + exhaustExecutors() + + assertThat(dialogProvider.list).isEmpty() + } + + @Test + fun testStartActivityCorrectIntent() { + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.starter?.invoke(PERM_MICROPHONE) + verify(activityStarter) + .startActivity(capture(intentCaptor), eq(true), any()) + + assertThat(intentCaptor.value.action).isEqualTo(Intent.ACTION_MANAGE_PERMISSION_APPS) + assertThat(intentCaptor.value.getStringExtra(Intent.EXTRA_PERMISSION_GROUP_NAME)) + .isEqualTo(PERM_MICROPHONE) + } + + @Test + fun testStartActivitySuccess() { + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.starter?.invoke(PERM_MICROPHONE) + verify(activityStarter).startActivity(any(), eq(true), capture(activityStartedCaptor)) + + activityStartedCaptor.value.onActivityStarted(ActivityManager.START_DELIVERED_TO_TOP) + + verify(dialog).dismiss() + } + + @Test + fun testStartActivityFailure() { + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.starter?.invoke(PERM_MICROPHONE) + verify(activityStarter).startActivity(any(), eq(true), capture(activityStartedCaptor)) + + activityStartedCaptor.value.onActivityStarted(ActivityManager.START_ABORTED) + + verify(dialog, never()).dismiss() + } + + private fun exhaustExecutors() { + FakeExecutor.exhaustExecutors(backgroundExecutor, uiExecutor) + } + + private fun setUpDefaultMockResponses() { + `when`(permissionManager.indicatorAppOpUsageData).thenReturn(emptyList()) + + `when`(packageManager.getApplicationInfoAsUser(anyString(), anyInt(), anyInt())) + .thenAnswer { + FakeApplicationInfo(it.getArgument(0)) + } + + `when`(privacyItemController.locationAvailable).thenReturn(true) + `when`(privacyItemController.micCameraAvailable).thenReturn(true) + `when`(privacyItemController.allIndicatorsAvailable).thenReturn(false) + + `when`(userTracker.userProfiles).thenReturn(listOf( + UserInfo(USER_ID, "", 0), + UserInfo(ENT_USER_ID, "", UserInfo.FLAG_MANAGED_PROFILE) + )) + + `when`(keyguardStateController.isUnlocked).thenReturn(true) + } + + private class FakeApplicationInfo(val label: CharSequence) : ApplicationInfo() { + override fun loadLabel(pm: PackageManager): CharSequence { + return label + } + } + + private fun generateUidForUser(user: Int): Int { + return user * UserHandle.PER_USER_RANGE + nextUid++ + } + + private fun createMockPermGroupUsage( + packageName: String = TEST_PACKAGE_NAME, + uid: Int = generateUidForUser(USER_ID), + permGroupName: String = PERM_CAMERA, + lastAccess: Long = 0L, + isActive: Boolean = false, + isPhoneCall: Boolean = false, + attribution: CharSequence? = null + ): PermGroupUsage { + val usage = mock(PermGroupUsage::class.java) + `when`(usage.packageName).thenReturn(packageName) + `when`(usage.uid).thenReturn(uid) + `when`(usage.permGroupName).thenReturn(permGroupName) + `when`(usage.lastAccess).thenReturn(lastAccess) + `when`(usage.isActive).thenReturn(isActive) + `when`(usage.isPhoneCall).thenReturn(isPhoneCall) + `when`(usage.attribution).thenReturn(attribution) + + return usage + } +} \ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogTest.kt b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogTest.kt new file mode 100644 index 0000000000000..9762ffffb02e3 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogTest.kt @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2021 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.privacy + +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper(setAsMainLooper = true) +class PrivacyDialogTest : SysuiTestCase() { + + @Mock + private lateinit var starter: (String) -> Unit + + private lateinit var dialog: PrivacyDialog + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + } + + @After + fun teardown() { + if (this::dialog.isInitialized) { + dialog.dismiss() + } + } + + @Test + fun testStarterCalledWithCorrectPermGroupName() { + val list = listOf( + PrivacyDialog.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + "App", + null, + 0L, + false, + false, + false + ) + ) + dialog = PrivacyDialog(context, list, starter) + dialog.show() + dialog.requireViewById(R.id.link).callOnClick() + verify(starter).invoke(PrivacyType.TYPE_MICROPHONE.permGroupName) + } + + @Test + fun testDismissListenerCalledOnDismiss() { + dialog = PrivacyDialog(context, emptyList(), starter) + val dismissListener = mock(PrivacyDialog.OnDialogDismissed::class.java) + dialog.addOnDismissListener(dismissListener) + dialog.show() + + verify(dismissListener, never()).onDialogDismissed() + dialog.dismiss() + verify(dismissListener).onDialogDismissed() + } + + @Test + fun testDismissListenerCalledImmediatelyIfDialogAlreadyDismissed() { + dialog = PrivacyDialog(context, emptyList(), starter) + val dismissListener = mock(PrivacyDialog.OnDialogDismissed::class.java) + dialog.show() + dialog.dismiss() + + dialog.addOnDismissListener(dismissListener) + verify(dismissListener).onDialogDismissed() + } + + @Test + fun testCorrectNumElements() { + val list = listOf( + PrivacyDialog.PrivacyElement( + PrivacyType.TYPE_CAMERA, + "App", + null, + 0L, + true, + false, + false + ), + PrivacyDialog.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + "App", + null, + 0L, + false, + false, + false + ) + ) + dialog = PrivacyDialog(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById(R.id.root).childCount).isEqualTo(2) + } + + @Test + fun testUsingText() { + val element = PrivacyDialog.PrivacyElement( + PrivacyType.TYPE_CAMERA, + "App", + null, + 0L, + true, + false, + false + ) + + val list = listOf(element) + dialog = PrivacyDialog(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById(R.id.text).text).isEqualTo( + context.getString( + R.string.ongoing_privacy_dialog_using_op, + element.applicationName, + element.type.getName(context) + ) + ) + } + + @Test + fun testRecentText() { + val element = PrivacyDialog.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + "App", + null, + 0L, + false, + false, + false + ) + + val list = listOf(element) + dialog = PrivacyDialog(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById(R.id.text).text).isEqualTo( + context.getString( + R.string.ongoing_privacy_dialog_recent_op, + element.applicationName, + element.type.getName(context) + ) + ) + } + + @Test + fun testEnterprise() { + val element = PrivacyDialog.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + "App", + null, + 0L, + false, + true, + false + ) + + val list = listOf(element) + dialog = PrivacyDialog(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById(R.id.text).text.toString()).contains( + context.getString(R.string.ongoing_privacy_dialog_enterprise) + ) + } + + @Test + fun testPhoneCall() { + val element = PrivacyDialog.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + "App", + null, + 0L, + false, + false, + true + ) + + val list = listOf(element) + dialog = PrivacyDialog(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById(R.id.text).text.toString()).contains( + context.getString(R.string.ongoing_privacy_dialog_phonecall) + ) + } + + @Test + fun testAttribution() { + val element = PrivacyDialog.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + "App", + "attribution", + 0L, + false, + false, + true + ) + + val list = listOf(element) + dialog = PrivacyDialog(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById(R.id.text).text.toString()).contains( + context.getString( + R.string.ongoing_privacy_dialog_attribution_text, + element.attribution + ) + ) + } +} \ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QuickStatusBarHeaderControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/QuickStatusBarHeaderControllerTest.kt index d1cc75708a197..97a8459161854 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QuickStatusBarHeaderControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QuickStatusBarHeaderControllerTest.kt @@ -27,6 +27,7 @@ import com.android.systemui.SysuiTestCase import com.android.systemui.demomode.DemoModeController import com.android.systemui.plugins.ActivityStarter import com.android.systemui.privacy.OngoingPrivacyChip +import com.android.systemui.privacy.PrivacyDialogController import com.android.systemui.privacy.PrivacyItemController import com.android.systemui.privacy.logging.PrivacyLogger import com.android.systemui.qs.carrier.QSCarrierGroup @@ -38,6 +39,7 @@ import com.android.systemui.statusbar.phone.StatusIconContainer import com.android.systemui.statusbar.policy.Clock import com.android.systemui.statusbar.policy.NextAlarmController import com.android.systemui.util.RingerModeTracker +import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.argumentCaptor import com.android.systemui.util.mockito.capture import com.android.systemui.utils.leaks.FakeZenModeController @@ -98,6 +100,8 @@ class QuickStatusBarHeaderControllerTest : SysuiTestCase() { @Mock private lateinit var privacyChip: OngoingPrivacyChip @Mock + private lateinit var privacyDialogController: PrivacyDialogController + @Mock private lateinit var clock: Clock @Mock private lateinit var mockView: View @@ -114,6 +118,7 @@ class QuickStatusBarHeaderControllerTest : SysuiTestCase() { `when`(qsCarrierGroupControllerBuilder.build()).thenReturn(qsCarrierGroupController) `when`(view.resources).thenReturn(mContext.resources) `when`(view.isAttachedToWindow).thenReturn(true) + `when`(view.context).thenReturn(context) controller = QuickStatusBarHeaderController( view, @@ -131,7 +136,8 @@ class QuickStatusBarHeaderControllerTest : SysuiTestCase() { quickQSPanelController, qsCarrierGroupControllerBuilder, privacyLogger, - colorExtractor + colorExtractor, + privacyDialogController ) } @@ -222,6 +228,18 @@ class QuickStatusBarHeaderControllerTest : SysuiTestCase() { assertThat(captor.value).containsExactly(cameraString, micString, locationString) } + @Test + fun testPrivacyChipClicked() { + controller.init() + + val captor = argumentCaptor() + verify(privacyChip).setOnClickListener(capture(captor)) + + captor.value.onClick(privacyChip) + + verify(privacyDialogController).showDialog(any(Context::class.java)) + } + private fun stubViews() { `when`(view.findViewById(anyInt())).thenReturn(mockView) `when`(view.findViewById(R.id.carrier_group)).thenReturn(qsCarrierGroup)