diff options
7 files changed, 1845 insertions, 5 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogControllerV2.kt b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogControllerV2.kt new file mode 100644 index 000000000000..805a102b1a1c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogControllerV2.kt @@ -0,0 +1,341 @@ +/* + * 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.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.UserHandle +import android.permission.PermissionGroupUsage +import android.permission.PermissionManager +import android.util.Log +import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import com.android.internal.logging.UiEventLogger +import com.android.systemui.appops.AppOpsController +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 : PrivacyDialogControllerV2.DialogProvider { + override fun makeDialog( + context: Context, + list: List<PrivacyDialogV2.PrivacyElement>, + starter: (String, Int, CharSequence?, Intent?) -> Unit + ): PrivacyDialogV2 { + return PrivacyDialogV2(context, list, starter) + } +} + +/** + * Controller for [PrivacyDialogV2]. + * + * This controller shows and dismissed the dialog, as well as determining the information to show in + * it. + */ +@SysUISingleton +class PrivacyDialogControllerV2( + 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, + private val appOpsController: AppOpsController, + private val uiEventLogger: UiEventLogger, + @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, + appOpsController: AppOpsController, + uiEventLogger: UiEventLogger + ) : this( + permissionManager, + packageManager, + privacyItemController, + userTracker, + activityStarter, + backgroundExecutor, + uiExecutor, + privacyLogger, + keyguardStateController, + appOpsController, + uiEventLogger, + defaultDialogProvider + ) + + companion object { + private const val TAG = "PrivacyDialogController" + } + + private var dialog: Dialog? = null + + private val onDialogDismissed = object : PrivacyDialogV2.OnDialogDismissed { + override fun onDialogDismissed() { + privacyLogger.logPrivacyDialogDismissed() + uiEventLogger.log(PrivacyDialogEvent.PRIVACY_DIALOG_DISMISSED) + dialog = null + } + } + + @MainThread + private fun startActivity( + packageName: String, + userId: Int, + attributionTag: CharSequence?, + navigationIntent: Intent? + ) { + val intent = if (navigationIntent == null) { + getDefaultManageAppPermissionsIntent(packageName, userId) + } else { + navigationIntent + } + uiEventLogger.log(PrivacyDialogEvent.PRIVACY_DIALOG_ITEM_CLICKED_TO_APP_SETTINGS, + userId, packageName) + privacyLogger.logStartSettingsActivityFromDialog(packageName, userId) + 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 getManagePermissionIntent( + packageName: String, + userId: Int, + permGroupName: CharSequence, + attributionTag: CharSequence?, + isAttributionSupported: Boolean + ): Intent + { + lateinit var intent: Intent + if (attributionTag != null && isAttributionSupported) { + intent = Intent(Intent.ACTION_MANAGE_PERMISSION_USAGE) + intent.setPackage(packageName) + intent.putExtra(Intent.EXTRA_PERMISSION_GROUP_NAME, permGroupName.toString()) + intent.putExtra(Intent.EXTRA_ATTRIBUTION_TAGS, arrayOf(attributionTag.toString())) + intent.putExtra(Intent.EXTRA_SHOWING_ATTRIBUTION, true) + val resolveInfo = packageManager.resolveActivity( + intent, PackageManager.ResolveInfoFlags.of(0)) + if (resolveInfo != null && resolveInfo.activityInfo != null && + resolveInfo.activityInfo.permission == + android.Manifest.permission.START_VIEW_PERMISSION_USAGE) { + intent.component = ComponentName(packageName, resolveInfo.activityInfo.name) + return intent + } + } + return getDefaultManageAppPermissionsIntent(packageName, userId) + } + + fun getDefaultManageAppPermissionsIntent(packageName: String, userId: Int): Intent { + val intent = Intent(Intent.ACTION_MANAGE_APP_PERMISSIONS) + intent.putExtra(Intent.EXTRA_PACKAGE_NAME, packageName) + intent.putExtra(Intent.EXTRA_USER, UserHandle.of(userId)) + return intent + } + + @WorkerThread + private fun permGroupUsage(): List<PermissionGroupUsage> { + return permissionManager.getIndicatorAppOpUsageData(appOpsController.isMicMuted) + } + + /** + * Show the [PrivacyDialogV2] + * + * This retrieves the permission usage from [PermissionManager] and creates a new + * [PrivacyDialogV2] with a list of [PrivacyDialogV2.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.permissionGroupName)) + val userInfo = userInfos.firstOrNull { ui -> ui.id == UserHandle.getUserId(it.uid) } + if (userInfo != null || it.isPhoneCall) { + 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) + } + val userId = UserHandle.getUserId(it.uid) + PrivacyDialogV2.PrivacyElement( + t, + it.packageName, + userId, + appName, + it.attributionTag, + it.attributionLabel, + it.proxyLabel, + it.lastAccessTimeMillis, + it.isActive, + // If there's no user info, we're in a phoneCall in secondary user + userInfo?.isManagedProfile ?: false, + it.isPhoneCall, + it.permissionGroupName, + getManagePermissionIntent( + it.packageName, + userId, + it.permissionGroupName, + it.attributionTag, + // attributionLabel is set only when subattribution policies + // are supported and satisfied + it.attributionLabel != null + ) + ) + } + } else { + // No matching user or phone call + null + } + } + uiExecutor.execute { + val elements = filterAndSelect(items) + if (elements.isNotEmpty()) { + val d = dialogProvider.makeDialog(context, elements, this::startActivity) + d.setShowForAllUsers(true) + d.addOnDismissListener(onDialogDismissed) + d.show() + privacyLogger.logShowDialogV2Contents(elements) + dialog = d + } else { + Log.w(TAG, "Trying to show empty dialog") + } + } + } + } + + /** + * 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 ((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. + * + * For each privacy type, it'll return all active elements. If there are no active elements, + * it'll return the most recent access + */ + private fun filterAndSelect( + list: List<PrivacyDialogV2.PrivacyElement> + ): List<PrivacyDialogV2.PrivacyElement> { + return list.groupBy { it.type }.toSortedMap().flatMap { (_, elements) -> + val actives = elements.filter { it.active } + if (actives.isNotEmpty()) { + actives.sortedByDescending { it.lastActiveTimestamp } + } else { + elements.maxByOrNull { it.lastActiveTimestamp }?.let { + listOf(it) + } ?: emptyList() + } + } + } + + /** + * Interface to create a [PrivacyDialogV2]. + * + * Can be used to inject a mock creator. + */ + interface DialogProvider { + /** + * Create a [PrivacyDialogV2]. + */ + fun makeDialog( + context: Context, + list: List<PrivacyDialogV2.PrivacyElement>, + starter: (String, Int, CharSequence?, Intent?) -> Unit + ): PrivacyDialogV2 + } +} diff --git a/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogV2.kt b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogV2.kt new file mode 100644 index 000000000000..a00775ad0e4b --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/privacy/PrivacyDialogV2.kt @@ -0,0 +1,234 @@ +/* + * 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.content.Intent +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.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 PrivacyDialogV2Controller + * @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 package name, user id, attributionTag and intent + */ +class PrivacyDialogV2( + context: Context, + private val list: List<PrivacyElement>, + activityStarter: (String, Int, CharSequence?, Intent?) -> Unit +) : SystemUIDialog(context, R.style.PrivacyDialog) { + + private val dismissListeners = mutableListOf<WeakReference<OnDialogDismissed>>() + 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() + attributes.receiveInsetsIgnoringZOrder = true + setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL) + } + setTitle(R.string.ongoing_privacy_dialog_a11y_title) + setContentView(R.layout.privacy_dialog) + rootView = requireViewById<ViewGroup>(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 stop() { + 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<ImageView>(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) + val finalText = getFinalText(firstLine, element.attributionLabel, element.proxyLabel) + newView.requireViewById<TextView>(R.id.text).text = finalText + if (element.phoneCall) { + newView.requireViewById<View>(R.id.chevron).visibility = View.GONE + } + newView.apply { + setTag(element) + if (!element.phoneCall) { + setOnClickListener(clickListener) + } + } + return newView + } + + private fun getFinalText( + firstLine: CharSequence, + attributionLabel: CharSequence?, + proxyLabel: CharSequence? + ): CharSequence { + var dialogText: CharSequence? = null + if (attributionLabel != null && proxyLabel != null) { + dialogText = context.getString(R.string.ongoing_privacy_dialog_attribution_proxy_label, + attributionLabel, proxyLabel) + } else if (attributionLabel != null) { + dialogText = context.getString(R.string.ongoing_privacy_dialog_attribution_label, + attributionLabel) + } else if (proxyLabel != null) { + dialogText = context.getString(R.string.ongoing_privacy_dialog_attribution_text, + proxyLabel) + } + return if (dialogText != null) TextUtils.concat(firstLine, " ", dialogText) else firstLine + } + + 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 + PrivacyType.TYPE_MEDIA_PROJECTION -> R.drawable.privacy_item_circle_media_projection + }) as LayerDrawable + } + + private val clickListener = View.OnClickListener { v -> + v.tag?.let { + val element = it as PrivacyElement + activityStarter(element.packageName, element.userId, + element.attributionTag, element.navigationIntent) + } + } + + /** */ + data class PrivacyElement( + val type: PrivacyType, + val packageName: String, + val userId: Int, + val applicationName: CharSequence, + val attributionTag: CharSequence?, + val attributionLabel: CharSequence?, + val proxyLabel: CharSequence?, + val lastActiveTimestamp: Long, + val active: Boolean, + val enterprise: Boolean, + val phoneCall: Boolean, + val permGroupName: CharSequence, + val navigationIntent: Intent? + ) { + private val builder = StringBuilder("PrivacyElement(") + + init { + builder.append("type=${type.logName}") + builder.append(", packageName=$packageName") + builder.append(", userId=$userId") + builder.append(", appName=$applicationName") + if (attributionTag != null) { + builder.append(", attributionTag=$attributionTag") + } + if (attributionLabel != null) { + builder.append(", attributionLabel=$attributionLabel") + } + if (proxyLabel != null) { + builder.append(", proxyLabel=$proxyLabel") + } + builder.append(", lastActive=$lastActiveTimestamp") + if (active) { + builder.append(", active") + } + if (enterprise) { + builder.append(", enterprise") + } + if (phoneCall) { + builder.append(", phoneCall") + } + builder.append(", permGroupName=$permGroupName)") + if (navigationIntent != null) { + builder.append(", navigationIntent=$navigationIntent") + } + } + + 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/logging/PrivacyLogger.kt b/packages/SystemUI/src/com/android/systemui/privacy/logging/PrivacyLogger.kt index f934346d9775..26c4df8d4536 100644 --- a/packages/SystemUI/src/com/android/systemui/privacy/logging/PrivacyLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/privacy/logging/PrivacyLogger.kt @@ -23,6 +23,7 @@ import com.android.systemui.log.LogBuffer import com.android.systemui.log.core.LogLevel import com.android.systemui.log.core.LogMessage import com.android.systemui.privacy.PrivacyDialog +import com.android.systemui.privacy.PrivacyDialogV2 import com.android.systemui.privacy.PrivacyItem import java.util.Locale import javax.inject.Inject @@ -126,6 +127,14 @@ class PrivacyLogger @Inject constructor( }) } + fun logShowDialogV2Contents(contents: List<PrivacyDialogV2.PrivacyElement>) { + log(LogLevel.INFO, { + str1 = contents.toString() + }, { + "Privacy dialog shown. Contents: $str1" + }) + } + fun logEmptyDialog() { log(LogLevel.WARNING, {}, { "Trying to show an empty dialog" diff --git a/packages/SystemUI/src/com/android/systemui/qs/HeaderPrivacyIconsController.kt b/packages/SystemUI/src/com/android/systemui/qs/HeaderPrivacyIconsController.kt index 995c6a476f0d..d41ae0bb026b 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/HeaderPrivacyIconsController.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/HeaderPrivacyIconsController.kt @@ -14,10 +14,13 @@ import com.android.internal.logging.UiEventLogger import com.android.systemui.animation.ActivityLaunchAnimator import com.android.systemui.appops.AppOpsController import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags 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.PrivacyDialogControllerV2 import com.android.systemui.privacy.PrivacyItem import com.android.systemui.privacy.PrivacyItemController import com.android.systemui.privacy.logging.PrivacyLogger @@ -49,6 +52,7 @@ class HeaderPrivacyIconsController @Inject constructor( private val uiEventLogger: UiEventLogger, @Named(SHADE_HEADER) private val privacyChip: OngoingPrivacyChip, private val privacyDialogController: PrivacyDialogController, + private val privacyDialogControllerV2: PrivacyDialogControllerV2, private val privacyLogger: PrivacyLogger, @Named(SHADE_HEADER) private val iconContainer: StatusIconContainer, private val permissionManager: PermissionManager, @@ -58,7 +62,8 @@ class HeaderPrivacyIconsController @Inject constructor( private val appOpsController: AppOpsController, private val broadcastDispatcher: BroadcastDispatcher, private val safetyCenterManager: SafetyCenterManager, - private val deviceProvisionedController: DeviceProvisionedController + private val deviceProvisionedController: DeviceProvisionedController, + private val featureFlags: FeatureFlags ) { var chipVisibilityListener: ChipVisibilityListener? = null @@ -143,7 +148,11 @@ class HeaderPrivacyIconsController @Inject constructor( // If the privacy chip is visible, it means there were some indicators uiEventLogger.log(PrivacyChipEvent.ONGOING_INDICATORS_CHIP_CLICK) if (safetyCenterEnabled) { - showSafetyCenter() + if (featureFlags.isEnabled(Flags.ENABLE_NEW_PRIVACY_DIALOG)) { + privacyDialogControllerV2.showDialog(privacyChip.context) + } else { + showSafetyCenter() + } } else { privacyDialogController.showDialog(privacyChip.context) } diff --git a/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogControllerV2Test.kt b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogControllerV2Test.kt new file mode 100644 index 000000000000..22ec26e35502 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogControllerV2Test.kt @@ -0,0 +1,798 @@ +/* + * 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.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ResolveInfoFlags +import android.content.pm.ResolveInfo +import android.content.pm.UserInfo +import android.os.Process.SYSTEM_UID +import android.os.UserHandle +import android.permission.PermissionGroupUsage +import android.permission.PermissionManager +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import com.android.internal.logging.UiEventLogger +import com.android.systemui.SysuiTestCase +import com.android.systemui.appops.AppOpsController +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 com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.mockito.capture +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.time.FakeSystemClock +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.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class PrivacyDialogControllerV2Test : SysuiTestCase() { + + companion object { + private const val USER_ID = 0 + private const val ENT_USER_ID = 10 + + private const val TEST_PACKAGE_NAME = "test package name" + private const val TEST_ATTRIBUTION_TAG = "test attribution tag" + private const val TEST_PROXY_LABEL = "test proxy label" + + private const val PERM_CAMERA = android.Manifest.permission_group.CAMERA + private const val PERM_MICROPHONE = android.Manifest.permission_group.MICROPHONE + private const val PERM_LOCATION = android.Manifest.permission_group.LOCATION + } + + @Mock + private lateinit var dialog: PrivacyDialogV2 + @Mock + private lateinit var permissionManager: PermissionManager + @Mock + private lateinit var packageManager: PackageManager + @Mock + private lateinit var privacyItemController: PrivacyItemController + @Mock + private lateinit var userTracker: UserTracker + @Mock + private lateinit var activityStarter: ActivityStarter + @Mock + private lateinit var privacyLogger: PrivacyLogger + @Mock + private lateinit var keyguardStateController: KeyguardStateController + @Mock + private lateinit var appOpsController: AppOpsController + @Captor + private lateinit var dialogDismissedCaptor: ArgumentCaptor<PrivacyDialogV2.OnDialogDismissed> + @Captor + private lateinit var activityStartedCaptor: ArgumentCaptor<ActivityStarter.Callback> + @Captor + private lateinit var intentCaptor: ArgumentCaptor<Intent> + @Mock + private lateinit var uiEventLogger: UiEventLogger + + private val backgroundExecutor = FakeExecutor(FakeSystemClock()) + private val uiExecutor = FakeExecutor(FakeSystemClock()) + private lateinit var controller: PrivacyDialogControllerV2 + private var nextUid: Int = 0 + + private val dialogProvider = object : PrivacyDialogControllerV2.DialogProvider { + var list: List<PrivacyDialogV2.PrivacyElement>? = null + var starter: ((String, Int, CharSequence?, Intent?) -> Unit)? = null + + override fun makeDialog( + context: Context, + list: List<PrivacyDialogV2.PrivacyElement>, + starter: (String, Int, CharSequence?, Intent?) -> Unit + ): PrivacyDialogV2 { + this.list = list + this.starter = starter + return dialog + } + } + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + nextUid = 0 + setUpDefaultMockResponses() + + controller = PrivacyDialogControllerV2( + permissionManager, + packageManager, + privacyItemController, + userTracker, + activityStarter, + backgroundExecutor, + uiExecutor, + privacyLogger, + keyguardStateController, + appOpsController, + uiEventLogger, + dialogProvider + ) + } + + @After + fun tearDown() { + FakeExecutor.exhaustExecutors(uiExecutor, backgroundExecutor) + dialogProvider.list = null + dialogProvider.starter = null + } + + @Test + fun testMicMutedParameter() { + `when`(appOpsController.isMicMuted).thenReturn(true) + controller.showDialog(context) + backgroundExecutor.runAllReady() + + verify(permissionManager).getIndicatorAppOpUsageData(true) + } + + @Test + fun testPermissionManagerOnlyCalledInBackgroundThread() { + controller.showDialog(context) + verify(permissionManager, never()).getIndicatorAppOpUsageData(anyBoolean()) + backgroundExecutor.runAllReady() + verify(permissionManager).getIndicatorAppOpUsageData(anyBoolean()) + } + + @Test + fun testPackageManagerOnlyCalledInBackgroundThread() { + val usage = createMockPermGroupUsage() + `when`(usage.isPhoneCall).thenReturn(false) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).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() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + + controller.showDialog(context) + exhaustExecutors() + + verify(dialog).show() + } + + @Test + fun testDontShowEmptyDialog() { + controller.showDialog(context) + exhaustExecutors() + + verify(dialog, never()).show() + } + + @Test + fun testHideDialogDismissesDialogIfShown() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + controller.showDialog(context) + exhaustExecutors() + + controller.dismissDialog() + verify(dialog).dismiss() + } + + @Test + fun testHideDialogNoopIfNotShown() { + controller.dismissDialog() + verify(dialog, never()).dismiss() + } + + @Test + fun testHideDialogNoopAfterDismissed() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + controller.showDialog(context) + exhaustExecutors() + + verify(dialog).addOnDismissListener(capture(dialogDismissedCaptor)) + + dialogDismissedCaptor.value.onDialogDismissed() + controller.dismissDialog() + verify(dialog, never()).dismiss() + } + + @Test + fun testShowForAllUsers() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + controller.showDialog(context) + + exhaustExecutors() + verify(dialog).setShowForAllUsers(true) + } + + @Test + fun testSingleElementInList() { + val usage = createMockPermGroupUsage( + packageName = TEST_PACKAGE_NAME, + uid = generateUidForUser(USER_ID), + permissionGroupName = PERM_CAMERA, + lastAccessTimeMillis = 5L, + isActive = true, + isPhoneCall = false, + attributionTag = null, + proxyLabel = TEST_PROXY_LABEL + ) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.list?.let { list -> + assertThat(list.get(0).type).isEqualTo(PrivacyType.TYPE_CAMERA) + assertThat(list.get(0).packageName).isEqualTo(TEST_PACKAGE_NAME) + assertThat(list.get(0).userId).isEqualTo(USER_ID) + assertThat(list.get(0).applicationName).isEqualTo(TEST_PACKAGE_NAME) + assertThat(list.get(0).attributionTag).isNull() + assertThat(list.get(0).attributionLabel).isNull() + assertThat(list.get(0).proxyLabel).isEqualTo(TEST_PROXY_LABEL) + assertThat(list.get(0).lastActiveTimestamp).isEqualTo(5L) + assertThat(list.get(0).active).isTrue() + assertThat(list.get(0).phoneCall).isFalse() + assertThat(list.get(0).enterprise).isFalse() + assertThat(list.get(0).permGroupName).isEqualTo(PERM_CAMERA) + assertThat(isIntentEqual(list.get(0).navigationIntent!!, + controller.getDefaultManageAppPermissionsIntent(TEST_PACKAGE_NAME, USER_ID))) + .isTrue() + } + } + + private fun isIntentEqual(actual: Intent, expected: Intent): Boolean { + return actual.action == expected.action && + actual.getStringExtra(Intent.EXTRA_PACKAGE_NAME) == + expected.getStringExtra(Intent.EXTRA_PACKAGE_NAME) && + actual.getParcelableExtra(Intent.EXTRA_USER) as? UserHandle == + expected.getParcelableExtra(Intent.EXTRA_USER) as? UserHandle + } + + @Test + fun testTwoElementsDifferentType_sorted() { + val usage_camera = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_camera", + permissionGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_microphone", + permissionGroupName = PERM_MICROPHONE + ) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).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.getIndicatorAppOpUsageData(anyBoolean())).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, + lastAccessTimeMillis = 0L + ) + val usage_active_moreRecent = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_active_recent", + isActive = true, + lastAccessTimeMillis = 1L + ) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn( + listOf(usage_active, usage_active_moreRecent) + ) + controller.showDialog(context) + exhaustExecutors() + assertThat(dialogProvider.list).hasSize(2) + assertThat(dialogProvider.list?.get(0)?.lastActiveTimestamp).isEqualTo(1L) + assertThat(dialogProvider.list?.get(1)?.lastActiveTimestamp).isEqualTo(0L) + } + + @Test + fun testManyElementsSameType_bothRecent() { + val usage_recent = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_recent", + isActive = false, + lastAccessTimeMillis = 0L + ) + val usage_moreRecent = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_moreRecent", + isActive = false, + lastAccessTimeMillis = 1L + ) + val usage_mostRecent = createMockPermGroupUsage( + packageName = "${TEST_PACKAGE_NAME}_mostRecent", + isActive = false, + lastAccessTimeMillis = 2L + ) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).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( + permissionGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + permissionGroupName = PERM_MICROPHONE + ) + val usage_location = createMockPermGroupUsage( + permissionGroupName = PERM_LOCATION + ) + + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).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( + permissionGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + permissionGroupName = PERM_MICROPHONE + ) + val usage_location = createMockPermGroupUsage( + permissionGroupName = PERM_LOCATION + ) + + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).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( + permissionGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + permissionGroupName = PERM_MICROPHONE + ) + val usage_location = createMockPermGroupUsage( + permissionGroupName = PERM_LOCATION + ) + + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn( + listOf(usage_camera, usage_location, usage_microphone) + ) + `when`(privacyItemController.micCameraAvailable).thenReturn(true) + `when`(privacyItemController.locationAvailable).thenReturn(true) + + controller.showDialog(context) + exhaustExecutors() + + assertThat(dialogProvider.list).hasSize(3) + } + + @Test + fun testNoIndicatorsAvailable() { + val usage_camera = createMockPermGroupUsage( + permissionGroupName = PERM_CAMERA + ) + val usage_microphone = createMockPermGroupUsage( + permissionGroupName = PERM_MICROPHONE + ) + val usage_location = createMockPermGroupUsage( + permissionGroupName = PERM_LOCATION + ) + + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn( + listOf(usage_camera, usage_location, usage_microphone) + ) + `when`(privacyItemController.micCameraAvailable).thenReturn(false) + `when`(privacyItemController.locationAvailable).thenReturn(false) + + controller.showDialog(context) + exhaustExecutors() + + verify(dialog, never()).show() + } + + @Test + fun testEnterpriseUser() { + val usage_enterprise = createMockPermGroupUsage( + uid = generateUidForUser(ENT_USER_ID) + ) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())) + .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.getIndicatorAppOpUsageData(anyBoolean())) + .thenReturn(listOf(usage_other)) + + controller.showDialog(context) + exhaustExecutors() + + verify(dialog, never()).show() + } + + @Test + fun testStartActivityCorrectIntent() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.starter?.invoke(TEST_PACKAGE_NAME, USER_ID, null, null) + verify(activityStarter) + .startActivity(capture(intentCaptor), eq(true), any<ActivityStarter.Callback>()) + + assertThat(intentCaptor.value.action).isEqualTo(Intent.ACTION_MANAGE_APP_PERMISSIONS) + assertThat(intentCaptor.value.getStringExtra(Intent.EXTRA_PACKAGE_NAME)) + .isEqualTo(TEST_PACKAGE_NAME) + assertThat(intentCaptor.value.getParcelableExtra(Intent.EXTRA_USER) as? UserHandle) + .isEqualTo(UserHandle.of(USER_ID)) + } + + @Test + fun testStartActivityCorrectIntent_enterpriseUser() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.starter?.invoke(TEST_PACKAGE_NAME, ENT_USER_ID, null, null) + verify(activityStarter) + .startActivity(capture(intentCaptor), eq(true), any<ActivityStarter.Callback>()) + + assertThat(intentCaptor.value.getParcelableExtra(Intent.EXTRA_USER) as? UserHandle) + .isEqualTo(UserHandle.of(ENT_USER_ID)) + } + + @Test + fun testStartActivitySuccess() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.starter?.invoke(TEST_PACKAGE_NAME, USER_ID, null, null) + verify(activityStarter).startActivity(any(), eq(true), capture(activityStartedCaptor)) + + activityStartedCaptor.value.onActivityStarted(ActivityManager.START_DELIVERED_TO_TOP) + + verify(dialog).dismiss() + } + + @Test + fun testStartActivityFailure() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.starter?.invoke(TEST_PACKAGE_NAME, USER_ID, null, null) + verify(activityStarter).startActivity(any(), eq(true), capture(activityStartedCaptor)) + + activityStartedCaptor.value.onActivityStarted(ActivityManager.START_ABORTED) + + verify(dialog, never()).dismiss() + } + + @Test + fun testCallOnSecondaryUser() { + // Calls happen in + val usage = createMockPermGroupUsage(uid = SYSTEM_UID, isPhoneCall = true) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + `when`(userTracker.userProfiles).thenReturn(listOf( + UserInfo(ENT_USER_ID, "", 0) + )) + + controller.showDialog(context) + exhaustExecutors() + + verify(dialog).show() + } + + @Test + fun testStartActivityLogs() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.starter?.invoke(TEST_PACKAGE_NAME, USER_ID, null, null) + verify(uiEventLogger).log(PrivacyDialogEvent.PRIVACY_DIALOG_ITEM_CLICKED_TO_APP_SETTINGS, + USER_ID, TEST_PACKAGE_NAME) + } + + @Test + fun testDismissedDialogLogs() { + val usage = createMockPermGroupUsage() + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + controller.showDialog(context) + exhaustExecutors() + + verify(dialog).addOnDismissListener(capture(dialogDismissedCaptor)) + + dialogDismissedCaptor.value.onDialogDismissed() + + controller.dismissDialog() + + verify(uiEventLogger, times(1)).log(PrivacyDialogEvent.PRIVACY_DIALOG_DISMISSED) + } + + @Test + fun testInvalidAttributionTag() { + val usage = createMockPermGroupUsage( + packageName = TEST_PACKAGE_NAME, + uid = generateUidForUser(USER_ID), + permissionGroupName = PERM_CAMERA, + lastAccessTimeMillis = 5L, + isActive = true, + isPhoneCall = false, + attributionTag = "INVALID_ATTRIBUTION_TAG", + proxyLabel = TEST_PROXY_LABEL + ) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.list?.let { list -> + assertThat(list.get(0).type).isEqualTo(PrivacyType.TYPE_CAMERA) + assertThat(list.get(0).packageName).isEqualTo(TEST_PACKAGE_NAME) + assertThat(list.get(0).userId).isEqualTo(USER_ID) + assertThat(list.get(0).applicationName).isEqualTo(TEST_PACKAGE_NAME) + assertThat(list.get(0).attributionTag).isEqualTo("INVALID_ATTRIBUTION_TAG") + assertThat(list.get(0).attributionLabel).isNull() + assertThat(list.get(0).proxyLabel).isEqualTo(TEST_PROXY_LABEL) + assertThat(list.get(0).lastActiveTimestamp).isEqualTo(5L) + assertThat(list.get(0).active).isTrue() + assertThat(list.get(0).phoneCall).isFalse() + assertThat(list.get(0).enterprise).isFalse() + assertThat(list.get(0).permGroupName).isEqualTo(PERM_CAMERA) + assertThat(isIntentEqual(list.get(0).navigationIntent!!, + controller.getDefaultManageAppPermissionsIntent(TEST_PACKAGE_NAME, USER_ID))) + .isTrue() + } + } + + @Test + fun testCorrectIntentSubAttribution() { + val usage = createMockPermGroupUsage( + attributionTag = TEST_ATTRIBUTION_TAG, + attributionLabel = "TEST_LABEL" + ) + + val activityInfo = createMockActivityInfo() + val resolveInfo = createMockResolveInfo(activityInfo) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + `when`(packageManager.resolveActivity(any(), any<ResolveInfoFlags>())) + .thenAnswer { resolveInfo } + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.list?.let { list -> + val navigationIntent = list.get(0).navigationIntent!! + assertThat(navigationIntent.action).isEqualTo(Intent.ACTION_MANAGE_PERMISSION_USAGE) + assertThat(navigationIntent.getStringExtra(Intent.EXTRA_PERMISSION_GROUP_NAME)) + .isEqualTo(PERM_CAMERA) + assertThat(navigationIntent.getStringArrayExtra(Intent.EXTRA_ATTRIBUTION_TAGS)) + .isEqualTo(arrayOf(TEST_ATTRIBUTION_TAG.toString())) + assertThat(navigationIntent.getBooleanExtra(Intent.EXTRA_SHOWING_ATTRIBUTION, false)) + .isTrue() + } + } + + @Test + fun testDefaultIntentOnMissingAttributionLabel() { + val usage = createMockPermGroupUsage( + attributionTag = TEST_ATTRIBUTION_TAG + ) + + val activityInfo = createMockActivityInfo() + val resolveInfo = createMockResolveInfo(activityInfo) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + `when`(packageManager.resolveActivity(any(), any<ResolveInfoFlags>())) + .thenAnswer { resolveInfo } + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.list?.let { list -> + assertThat(isIntentEqual(list.get(0).navigationIntent!!, + controller.getDefaultManageAppPermissionsIntent(TEST_PACKAGE_NAME, USER_ID))) + .isTrue() + } + } + + @Test + fun testDefaultIntentOnIncorrectPermission() { + val usage = createMockPermGroupUsage( + attributionTag = TEST_ATTRIBUTION_TAG + ) + + val activityInfo = createMockActivityInfo( + permission = "INCORRECT_PERMISSION" + ) + val resolveInfo = createMockResolveInfo(activityInfo) + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(listOf(usage)) + `when`(packageManager.resolveActivity(any(), any<ResolveInfoFlags>())) + .thenAnswer { resolveInfo } + controller.showDialog(context) + exhaustExecutors() + + dialogProvider.list?.let { list -> + assertThat(isIntentEqual(list.get(0).navigationIntent!!, + controller.getDefaultManageAppPermissionsIntent(TEST_PACKAGE_NAME, USER_ID))) + .isTrue() + } + } + + private fun exhaustExecutors() { + FakeExecutor.exhaustExecutors(backgroundExecutor, uiExecutor) + } + + private fun setUpDefaultMockResponses() { + `when`(permissionManager.getIndicatorAppOpUsageData(anyBoolean())).thenReturn(emptyList()) + `when`(appOpsController.isMicMuted).thenReturn(false) + + `when`(packageManager.getApplicationInfoAsUser(anyString(), anyInt(), anyInt())) + .thenAnswer { FakeApplicationInfo(it.getArgument(0)) } + + `when`(privacyItemController.locationAvailable).thenReturn(true) + `when`(privacyItemController.micCameraAvailable).thenReturn(true) + + `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 createMockResolveInfo( + activityInfo: ActivityInfo? = null + ): ResolveInfo { + val resolveInfo = mock(ResolveInfo::class.java) + resolveInfo.activityInfo = activityInfo + return resolveInfo + } + + private fun createMockActivityInfo( + permission: String = android.Manifest.permission.START_VIEW_PERMISSION_USAGE, + className: String = "TEST_CLASS_NAME" + ): ActivityInfo { + val activityInfo = mock(ActivityInfo::class.java) + activityInfo.permission = permission + activityInfo.name = className + return activityInfo + } + + private fun createMockPermGroupUsage( + packageName: String = TEST_PACKAGE_NAME, + uid: Int = generateUidForUser(USER_ID), + permissionGroupName: String = PERM_CAMERA, + lastAccessTimeMillis: Long = 0L, + isActive: Boolean = false, + isPhoneCall: Boolean = false, + attributionTag: CharSequence? = null, + attributionLabel: CharSequence? = null, + proxyLabel: CharSequence? = null + ): PermissionGroupUsage { + val usage = mock(PermissionGroupUsage::class.java) + `when`(usage.packageName).thenReturn(packageName) + `when`(usage.uid).thenReturn(uid) + `when`(usage.permissionGroupName).thenReturn(permissionGroupName) + `when`(usage.lastAccessTimeMillis).thenReturn(lastAccessTimeMillis) + `when`(usage.isActive).thenReturn(isActive) + `when`(usage.isPhoneCall).thenReturn(isPhoneCall) + `when`(usage.attributionTag).thenReturn(attributionTag) + `when`(usage.attributionLabel).thenReturn(attributionLabel) + `when`(usage.proxyLabel).thenReturn(proxyLabel) + return usage + } +}
\ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogV2Test.kt b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogV2Test.kt new file mode 100644 index 000000000000..62ce356d6cfa --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/privacy/PrivacyDialogV2Test.kt @@ -0,0 +1,404 @@ +/* + * 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 +import android.content.Intent +import android.text.TextUtils + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper(setAsMainLooper = true) +class PrivacyDialogV2Test : SysuiTestCase() { + + companion object { + private const val TEST_PACKAGE_NAME = "test_pkg" + private const val TEST_USER_ID = 0 + private const val TEST_PERM_GROUP = "test_perm_group" + } + + @Mock + private lateinit var starter: (String, Int, CharSequence?, Intent?) -> Unit + private lateinit var dialog: PrivacyDialogV2 + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + } + + @After + fun teardown() { + if (this::dialog.isInitialized) { + dialog.dismiss() + } + } + + @Test + fun testStarterCalledWithCorrectParams() { + val list = listOf( + PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + null, + 0L, + false, + false, + false, + TEST_PERM_GROUP, + null + ) + ) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + dialog.requireViewById<View>(R.id.privacy_item).callOnClick() + verify(starter).invoke(TEST_PACKAGE_NAME, TEST_USER_ID, null, null) + } + + @Test + fun testDismissListenerCalledOnDismiss() { + dialog = PrivacyDialogV2(context, emptyList(), starter) + val dismissListener = mock(PrivacyDialogV2.OnDialogDismissed::class.java) + dialog.addOnDismissListener(dismissListener) + dialog.show() + + verify(dismissListener, never()).onDialogDismissed() + dialog.dismiss() + verify(dismissListener).onDialogDismissed() + } + + @Test + fun testDismissListenerCalledImmediatelyIfDialogAlreadyDismissed() { + dialog = PrivacyDialogV2(context, emptyList(), starter) + val dismissListener = mock(PrivacyDialogV2.OnDialogDismissed::class.java) + dialog.show() + dialog.dismiss() + + dialog.addOnDismissListener(dismissListener) + verify(dismissListener).onDialogDismissed() + } + + @Test + fun testCorrectNumElements() { + val list = listOf( + PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_CAMERA, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + null, + 0L, + true, + false, + false, + TEST_PERM_GROUP, + null + ), + PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + null, + 0L, + false, + false, + false, + TEST_PERM_GROUP, + null + ) + ) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById<ViewGroup>(R.id.root).childCount).isEqualTo(2) + } + + @Test + fun testUsingText() { + val element = PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_CAMERA, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + null, + 0L, + true, + false, + false, + TEST_PERM_GROUP, + null + ) + + val list = listOf(element) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById<TextView>(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 = PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + null, + 0L, + false, + false, + false, + TEST_PERM_GROUP, + null + ) + + val list = listOf(element) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById<TextView>(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 = PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + null, + 0L, + false, + true, + false, + TEST_PERM_GROUP, + null + ) + + val list = listOf(element) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById<TextView>(R.id.text).text.toString()).contains( + context.getString(R.string.ongoing_privacy_dialog_enterprise) + ) + } + + @Test + fun testPhoneCall() { + val element = PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + null, + 0L, + false, + false, + true, + TEST_PERM_GROUP, + null + ) + + val list = listOf(element) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById<TextView>(R.id.text).text.toString()).contains( + context.getString(R.string.ongoing_privacy_dialog_phonecall) + ) + } + + @Test + fun testPhoneCallNotClickable() { + val element = PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + null, + 0L, + false, + false, + true, + TEST_PERM_GROUP, + null + ) + + val list = listOf(element) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById<View>(R.id.privacy_item).isClickable).isFalse() + assertThat(dialog.requireViewById<View>(R.id.chevron).visibility).isEqualTo(View.GONE) + } + + @Test + fun testProxyLabel() { + val element = PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + "proxyLabel", + 0L, + false, + false, + true, + TEST_PERM_GROUP, + null + ) + + val list = listOf(element) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById<TextView>(R.id.text).text.toString()).contains( + context.getString( + R.string.ongoing_privacy_dialog_attribution_text, + element.proxyLabel + ) + ) + } + + @Test + fun testSubattribution() { + val element = PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + "For subattribution", + null, + 0L, + true, + false, + false, + TEST_PERM_GROUP, + null + ) + + val list = listOf(element) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById<TextView>(R.id.text).text.toString()).contains( + context.getString( + R.string.ongoing_privacy_dialog_attribution_label, + element.attributionLabel + ) + ) + } + + @Test + fun testSubattributionAndProxyLabel() { + val element = PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + "For subattribution", + "proxy label", + 0L, + true, + false, + false, + TEST_PERM_GROUP, + null + ) + + val list = listOf(element) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + assertThat(dialog.requireViewById<TextView>(R.id.text).text.toString()).contains( + context.getString( + R.string.ongoing_privacy_dialog_attribution_proxy_label, + element.attributionLabel, element.proxyLabel + ) + ) + } + + @Test + fun testDialogHasTitle() { + // Dialog must have a non-empty title for a11y purposes. + + val list = listOf( + PrivacyDialogV2.PrivacyElement( + PrivacyType.TYPE_MICROPHONE, + TEST_PACKAGE_NAME, + TEST_USER_ID, + "App", + null, + null, + null, + 0L, + false, + false, + false, + TEST_PERM_GROUP, + null + ) + ) + dialog = PrivacyDialogV2(context, list, starter) + dialog.show() + + assertThat(TextUtils.isEmpty(dialog.window?.attributes?.title)).isFalse() + } +}
\ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/HeaderPrivacyIconsControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/HeaderPrivacyIconsControllerTest.kt index 3620233fc9df..a6e471b57f75 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/HeaderPrivacyIconsControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/HeaderPrivacyIconsControllerTest.kt @@ -13,9 +13,12 @@ import com.android.internal.logging.UiEventLogger import com.android.systemui.SysuiTestCase import com.android.systemui.appops.AppOpsController import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags import com.android.systemui.plugins.ActivityStarter import com.android.systemui.privacy.OngoingPrivacyChip import com.android.systemui.privacy.PrivacyDialogController +import com.android.systemui.privacy.PrivacyDialogControllerV2 import com.android.systemui.privacy.PrivacyItemController import com.android.systemui.privacy.logging.PrivacyLogger import com.android.systemui.statusbar.phone.StatusIconContainer @@ -54,6 +57,8 @@ class HeaderPrivacyIconsControllerTest : SysuiTestCase() { @Mock private lateinit var privacyDialogController: PrivacyDialogController @Mock + private lateinit var privacyDialogControllerV2: PrivacyDialogControllerV2 + @Mock private lateinit var privacyLogger: PrivacyLogger @Mock private lateinit var iconContainer: StatusIconContainer @@ -69,6 +74,8 @@ class HeaderPrivacyIconsControllerTest : SysuiTestCase() { private lateinit var safetyCenterManager: SafetyCenterManager @Mock private lateinit var deviceProvisionedController: DeviceProvisionedController + @Mock + private lateinit var featureFlags: FeatureFlags private val uiExecutor = FakeExecutor(FakeSystemClock()) private val backgroundExecutor = FakeExecutor(FakeSystemClock()) @@ -94,6 +101,7 @@ class HeaderPrivacyIconsControllerTest : SysuiTestCase() { uiEventLogger, privacyChip, privacyDialogController, + privacyDialogControllerV2, privacyLogger, iconContainer, permissionManager, @@ -103,7 +111,8 @@ class HeaderPrivacyIconsControllerTest : SysuiTestCase() { appOpsController, broadcastDispatcher, safetyCenterManager, - deviceProvisionedController + deviceProvisionedController, + featureFlags ) backgroundExecutor.runAllReady() @@ -154,17 +163,52 @@ class HeaderPrivacyIconsControllerTest : SysuiTestCase() { } @Test - fun testPrivacyChipClicked() { + fun testPrivacyChipClickedWhenNewDialogDisabledAndSafetyCenterDisabled() { + whenever(featureFlags.isEnabled(Flags.ENABLE_NEW_PRIVACY_DIALOG)).thenReturn(false) + whenever(safetyCenterManager.isSafetyCenterEnabled).thenReturn(false) + controller.onParentVisible() + val captor = argumentCaptor<View.OnClickListener>() + verify(privacyChip).setOnClickListener(capture(captor)) + captor.value.onClick(privacyChip) + verify(privacyDialogController).showDialog(any(Context::class.java)) + verify(privacyDialogControllerV2, never()).showDialog(any(Context::class.java)) + } + + @Test + fun testPrivacyChipClickedWhenNewDialogEnabledAndSafetyCenterDisabled() { + whenever(featureFlags.isEnabled(Flags.ENABLE_NEW_PRIVACY_DIALOG)).thenReturn(true) whenever(safetyCenterManager.isSafetyCenterEnabled).thenReturn(false) controller.onParentVisible() val captor = argumentCaptor<View.OnClickListener>() verify(privacyChip).setOnClickListener(capture(captor)) captor.value.onClick(privacyChip) verify(privacyDialogController).showDialog(any(Context::class.java)) + verify(privacyDialogControllerV2, never()).showDialog(any(Context::class.java)) + } + + @Test + fun testPrivacyChipClickedWhenNewDialogDisabledAndSafetyCenterEnabled() { + whenever(featureFlags.isEnabled(Flags.ENABLE_NEW_PRIVACY_DIALOG)).thenReturn(false) + val receiverCaptor = argumentCaptor<BroadcastReceiver>() + whenever(safetyCenterManager.isSafetyCenterEnabled).thenReturn(true) + verify(broadcastDispatcher).registerReceiver(capture(receiverCaptor), + any(), any(), nullable(), anyInt(), nullable()) + receiverCaptor.value.onReceive( + context, + Intent(SafetyCenterManager.ACTION_SAFETY_CENTER_ENABLED_CHANGED) + ) + backgroundExecutor.runAllReady() + controller.onParentVisible() + val captor = argumentCaptor<View.OnClickListener>() + verify(privacyChip).setOnClickListener(capture(captor)) + captor.value.onClick(privacyChip) + verify(privacyDialogController, never()).showDialog(any(Context::class.java)) + verify(privacyDialogControllerV2, never()).showDialog(any(Context::class.java)) } @Test - fun testSafetyCenterFlag() { + fun testPrivacyChipClickedWhenNewDialogEnabledAndSafetyCenterEnabled() { + whenever(featureFlags.isEnabled(Flags.ENABLE_NEW_PRIVACY_DIALOG)).thenReturn(true) val receiverCaptor = argumentCaptor<BroadcastReceiver>() whenever(safetyCenterManager.isSafetyCenterEnabled).thenReturn(true) verify(broadcastDispatcher).registerReceiver(capture(receiverCaptor), @@ -178,6 +222,7 @@ class HeaderPrivacyIconsControllerTest : SysuiTestCase() { val captor = argumentCaptor<View.OnClickListener>() verify(privacyChip).setOnClickListener(capture(captor)) captor.value.onClick(privacyChip) + verify(privacyDialogControllerV2).showDialog(any(Context::class.java)) verify(privacyDialogController, never()).showDialog(any(Context::class.java)) } |