1 /* <lambda>null2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.privacy 18 19 import android.content.Context 20 import android.content.Intent 21 import android.content.pm.PackageItemInfo 22 import android.content.pm.PackageManager 23 import android.content.pm.PackageManager.NameNotFoundException 24 import android.content.res.Resources.NotFoundException 25 import android.graphics.drawable.Drawable 26 import android.graphics.drawable.LayerDrawable 27 import android.os.Bundle 28 import android.text.TextUtils 29 import android.util.Log 30 import android.view.Gravity 31 import android.view.LayoutInflater 32 import android.view.View 33 import android.view.ViewGroup 34 import android.widget.Button 35 import android.widget.ImageView 36 import android.widget.TextView 37 import androidx.annotation.ColorInt 38 import androidx.annotation.DrawableRes 39 import androidx.annotation.WorkerThread 40 import androidx.core.view.ViewCompat 41 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat 42 import com.android.settingslib.Utils 43 import com.android.systemui.res.R 44 import com.android.systemui.animation.ViewHierarchyAnimator 45 import com.android.systemui.statusbar.phone.SystemUIDialog 46 import com.android.systemui.util.maybeForceFullscreen 47 import java.lang.ref.WeakReference 48 import java.util.concurrent.atomic.AtomicBoolean 49 50 /** 51 * Dialog to show ongoing and recent app ops element. 52 * 53 * @param context A context to create the dialog 54 * @param list list of elements to show in the dialog. The elements will show in the same order they 55 * appear in the list 56 * @param manageApp a callback to start an activity for a given package name, user id, and intent 57 * @param closeApp a callback to close an app for a given package name, user id 58 * @param openPrivacyDashboard a callback to open the privacy dashboard 59 * @see PrivacyDialogControllerV2 60 */ 61 class PrivacyDialogV2( 62 context: Context, 63 private val list: List<PrivacyElement>, 64 private val manageApp: (String, Int, Intent) -> Unit, 65 private val closeApp: (String, Int) -> Unit, 66 private val openPrivacyDashboard: () -> Unit 67 ) : SystemUIDialog(context, R.style.Theme_PrivacyDialog) { 68 69 private val dismissListeners = mutableListOf<WeakReference<OnDialogDismissed>>() 70 private val dismissed = AtomicBoolean(false) 71 // Note: this will call the dialog create method during init 72 private val decorViewLayoutListener = maybeForceFullscreen()?.component2() 73 74 /** 75 * Add a listener that will be called when the dialog is dismissed. 76 * 77 * If the dialog has already been dismissed, the listener will be called immediately, in the 78 * same thread. 79 */ 80 fun addOnDismissListener(listener: OnDialogDismissed) { 81 if (dismissed.get()) { 82 listener.onDialogDismissed() 83 } else { 84 dismissListeners.add(WeakReference(listener)) 85 } 86 } 87 88 override fun stop() { 89 dismissed.set(true) 90 val iterator = dismissListeners.iterator() 91 while (iterator.hasNext()) { 92 val el = iterator.next() 93 iterator.remove() 94 el.get()?.onDialogDismissed() 95 } 96 // Remove the layout change listener we may have added to the DecorView. 97 if (decorViewLayoutListener != null) { 98 window!!.decorView.removeOnLayoutChangeListener(decorViewLayoutListener) 99 } 100 } 101 102 override fun onCreate(savedInstanceState: Bundle?) { 103 super.onCreate(savedInstanceState) 104 window!!.setGravity(Gravity.CENTER) 105 setTitle(R.string.privacy_dialog_title) 106 setContentView(R.layout.privacy_dialog_v2) 107 108 val closeButton = requireViewById<Button>(R.id.privacy_dialog_close_button) 109 closeButton.setOnClickListener { dismiss() } 110 111 val moreButton = requireViewById<Button>(R.id.privacy_dialog_more_button) 112 moreButton.setOnClickListener { openPrivacyDashboard() } 113 114 val itemsContainer = requireViewById<ViewGroup>(R.id.privacy_dialog_items_container) 115 list.forEach { itemsContainer.addView(createView(it, itemsContainer)) } 116 } 117 118 private fun createView(element: PrivacyElement, itemsContainer: ViewGroup): View { 119 val itemCard = 120 LayoutInflater.from(context) 121 .inflate(R.layout.privacy_dialog_item_v2, itemsContainer, false) as ViewGroup 122 123 updateItemHeader(element, itemCard) 124 125 if (element.isPhoneCall) { 126 return itemCard 127 } 128 129 setItemExpansionBehavior(itemCard) 130 131 configureIndicatorActionButtons(element, itemCard) 132 133 return itemCard 134 } 135 136 private fun updateItemHeader(element: PrivacyElement, itemCard: View) { 137 val itemHeader = itemCard.findViewById<ViewGroup>(R.id.privacy_dialog_item_header)!! 138 val permGroupLabel = context.packageManager.getDefaultPermGroupLabel(element.permGroupName) 139 140 val iconView = itemHeader.findViewById<ImageView>(R.id.privacy_dialog_item_header_icon)!! 141 val indicatorIcon = context.getPermGroupIcon(element.permGroupName) 142 updateIconView(iconView, indicatorIcon, element.isActive) 143 iconView.contentDescription = permGroupLabel 144 145 val titleView = itemHeader.findViewById<TextView>(R.id.privacy_dialog_item_header_title)!! 146 titleView.text = permGroupLabel 147 titleView.contentDescription = permGroupLabel 148 149 val usageText = getUsageText(element) 150 val summaryView = 151 itemHeader.findViewById<TextView>(R.id.privacy_dialog_item_header_summary)!! 152 summaryView.text = usageText 153 summaryView.contentDescription = usageText 154 } 155 156 private fun configureIndicatorActionButtons(element: PrivacyElement, itemCard: View) { 157 val expandedLayout = 158 itemCard.findViewById<ViewGroup>(R.id.privacy_dialog_item_header_expanded_layout)!! 159 160 val buttons: MutableList<View> = mutableListOf() 161 configureCloseAppButton(element, expandedLayout)?.also { buttons.add(it) } 162 buttons.add(configureManageButton(element, expandedLayout)) 163 164 val backgroundColor = getBackgroundColor(element.isActive) 165 when (buttons.size) { 166 0 -> return 167 1 -> { 168 val background = 169 getMutableDrawable(R.drawable.privacy_dialog_background_large_top_large_bottom) 170 background.setTint(backgroundColor) 171 buttons[0].background = background 172 } 173 else -> { 174 val firstBackground = 175 getMutableDrawable(R.drawable.privacy_dialog_background_large_top_small_bottom) 176 val middleBackground = 177 getMutableDrawable(R.drawable.privacy_dialog_background_small_top_small_bottom) 178 val lastBackground = 179 getMutableDrawable(R.drawable.privacy_dialog_background_small_top_large_bottom) 180 firstBackground.setTint(backgroundColor) 181 middleBackground.setTint(backgroundColor) 182 lastBackground.setTint(backgroundColor) 183 buttons.forEach { it.background = middleBackground } 184 buttons.first().background = firstBackground 185 buttons.last().background = lastBackground 186 } 187 } 188 } 189 190 private fun configureCloseAppButton(element: PrivacyElement, expandedLayout: ViewGroup): View? { 191 if (element.isService || !element.isActive) { 192 return null 193 } 194 val closeAppButton = 195 checkNotNull(window).layoutInflater.inflate( 196 R.layout.privacy_dialog_card_button, 197 expandedLayout, 198 false 199 ) as Button 200 expandedLayout.addView(closeAppButton) 201 closeAppButton.id = R.id.privacy_dialog_close_app_button 202 closeAppButton.setText(R.string.privacy_dialog_close_app_button) 203 closeAppButton.setTextColor(getForegroundColor(true)) 204 closeAppButton.tag = element 205 closeAppButton.setOnClickListener { v -> 206 v.tag?.let { 207 val element = it as PrivacyElement 208 closeApp(element.packageName, element.userId) 209 closeAppTransition(element.packageName, element.userId) 210 } 211 } 212 return closeAppButton 213 } 214 215 private fun closeAppTransition(packageName: String, userId: Int) { 216 val itemsContainer = requireViewById<ViewGroup>(R.id.privacy_dialog_items_container) 217 var shouldTransition = false 218 for (i in 0 until itemsContainer.getChildCount()) { 219 val itemCard = itemsContainer.getChildAt(i) 220 val button = itemCard.findViewById<Button>(R.id.privacy_dialog_close_app_button) 221 if (button == null || button.tag == null) { 222 continue 223 } 224 val element = button.tag as PrivacyElement 225 if (element.packageName != packageName || element.userId != userId) { 226 continue 227 } 228 229 itemCard.setEnabled(false) 230 231 val expandToggle = 232 itemCard.findViewById<ImageView>(R.id.privacy_dialog_item_header_expand_toggle)!! 233 expandToggle.visibility = View.GONE 234 235 disableIndicatorCardUi(itemCard, element.applicationName) 236 237 val expandedLayout = 238 itemCard.findViewById<View>(R.id.privacy_dialog_item_header_expanded_layout)!! 239 if (expandedLayout.visibility == View.VISIBLE) { 240 expandedLayout.visibility = View.GONE 241 shouldTransition = true 242 } 243 } 244 if (shouldTransition) { 245 ViewHierarchyAnimator.animateNextUpdate(window!!.decorView) 246 } 247 } 248 249 private fun configureManageButton(element: PrivacyElement, expandedLayout: ViewGroup): View { 250 val manageButton = 251 checkNotNull(window).layoutInflater.inflate( 252 R.layout.privacy_dialog_card_button, 253 expandedLayout, 254 false 255 ) as Button 256 expandedLayout.addView(manageButton) 257 manageButton.id = R.id.privacy_dialog_manage_app_button 258 manageButton.setText( 259 if (element.isService) R.string.privacy_dialog_manage_service 260 else R.string.privacy_dialog_manage_permissions 261 ) 262 manageButton.setTextColor(getForegroundColor(element.isActive)) 263 manageButton.tag = element 264 manageButton.setOnClickListener { v -> 265 v.tag?.let { 266 val element = it as PrivacyElement 267 manageApp(element.packageName, element.userId, element.navigationIntent) 268 } 269 } 270 return manageButton 271 } 272 273 private fun disableIndicatorCardUi(itemCard: View, applicationName: CharSequence) { 274 val iconView = itemCard.findViewById<ImageView>(R.id.privacy_dialog_item_header_icon)!! 275 val indicatorIcon = getMutableDrawable(R.drawable.privacy_dialog_check_icon) 276 updateIconView(iconView, indicatorIcon, false) 277 278 val closedAppText = 279 context.getString(R.string.privacy_dialog_close_app_message, applicationName) 280 val summaryView = itemCard.findViewById<TextView>(R.id.privacy_dialog_item_header_summary)!! 281 summaryView.text = closedAppText 282 summaryView.contentDescription = closedAppText 283 } 284 285 private fun setItemExpansionBehavior(itemCard: ViewGroup) { 286 val itemHeader = itemCard.findViewById<ViewGroup>(R.id.privacy_dialog_item_header)!! 287 288 val expandToggle = 289 itemHeader.findViewById<ImageView>(R.id.privacy_dialog_item_header_expand_toggle)!! 290 expandToggle.setImageResource(R.drawable.privacy_dialog_expand_toggle_down) 291 expandToggle.visibility = View.VISIBLE 292 293 ViewCompat.replaceAccessibilityAction( 294 itemCard, 295 AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK, 296 context.getString(R.string.privacy_dialog_expand_action), 297 null 298 ) 299 300 val expandedLayout = 301 itemCard.findViewById<View>(R.id.privacy_dialog_item_header_expanded_layout)!! 302 expandedLayout.setOnClickListener { 303 // Stop clicks from propagating 304 } 305 306 itemCard.setOnClickListener { 307 if (expandedLayout.visibility == View.VISIBLE) { 308 expandedLayout.visibility = View.GONE 309 expandToggle.setImageResource(R.drawable.privacy_dialog_expand_toggle_down) 310 ViewCompat.replaceAccessibilityAction( 311 it!!, 312 AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK, 313 context.getString(R.string.privacy_dialog_expand_action), 314 null 315 ) 316 } else { 317 expandedLayout.visibility = View.VISIBLE 318 expandToggle.setImageResource(R.drawable.privacy_dialog_expand_toggle_up) 319 ViewCompat.replaceAccessibilityAction( 320 it!!, 321 AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK, 322 context.getString(R.string.privacy_dialog_collapse_action), 323 null 324 ) 325 } 326 ViewHierarchyAnimator.animateNextUpdate( 327 rootView = window!!.decorView, 328 excludedViews = setOf(expandedLayout) 329 ) 330 } 331 } 332 333 private fun updateIconView(iconView: ImageView, indicatorIcon: Drawable, active: Boolean) { 334 indicatorIcon.setTint(getForegroundColor(active)) 335 val backgroundIcon = getMutableDrawable(R.drawable.privacy_dialog_background_circle) 336 backgroundIcon.setTint(getBackgroundColor(active)) 337 val backgroundSize = 338 context.resources.getDimension(R.dimen.ongoing_appops_dialog_circle_size).toInt() 339 val indicatorSize = 340 context.resources.getDimension(R.dimen.ongoing_appops_dialog_icon_size).toInt() 341 iconView.setImageDrawable( 342 constructLayeredIcon(indicatorIcon, indicatorSize, backgroundIcon, backgroundSize) 343 ) 344 } 345 346 @ColorInt 347 private fun getForegroundColor(active: Boolean) = 348 Utils.getColorAttrDefaultColor( 349 context, 350 if (active) com.android.internal.R.attr.materialColorOnPrimaryFixed 351 else com.android.internal.R.attr.materialColorOnSurface 352 ) 353 354 @ColorInt 355 private fun getBackgroundColor(active: Boolean) = 356 Utils.getColorAttrDefaultColor( 357 context, 358 if (active) com.android.internal.R.attr.materialColorPrimaryFixed 359 else com.android.internal.R.attr.materialColorSurfaceContainerHigh 360 ) 361 362 private fun getMutableDrawable(@DrawableRes resId: Int) = context.getDrawable(resId)!!.mutate() 363 364 private fun getUsageText(element: PrivacyElement) = 365 if (element.isPhoneCall) { 366 val phoneCallResId = 367 if (element.isActive) R.string.privacy_dialog_active_call_usage 368 else R.string.privacy_dialog_recent_call_usage 369 context.getString(phoneCallResId) 370 } else if (element.attributionLabel == null && element.proxyLabel == null) { 371 val usageResId: Int = 372 if (element.isActive) R.string.privacy_dialog_active_app_usage 373 else R.string.privacy_dialog_recent_app_usage 374 context.getString(usageResId, element.applicationName) 375 } else if (element.attributionLabel == null || element.proxyLabel == null) { 376 val singleUsageResId: Int = 377 if (element.isActive) R.string.privacy_dialog_active_app_usage_1 378 else R.string.privacy_dialog_recent_app_usage_1 379 context.getString( 380 singleUsageResId, 381 element.applicationName, 382 element.attributionLabel ?: element.proxyLabel 383 ) 384 } else { 385 val doubleUsageResId: Int = 386 if (element.isActive) R.string.privacy_dialog_active_app_usage_2 387 else R.string.privacy_dialog_recent_app_usage_2 388 context.getString( 389 doubleUsageResId, 390 element.applicationName, 391 element.attributionLabel, 392 element.proxyLabel 393 ) 394 } 395 396 companion object { 397 private const val LOG_TAG = "PrivacyDialogV2" 398 private const val REVIEW_PERMISSION_USAGE = "android.intent.action.REVIEW_PERMISSION_USAGE" 399 400 /** 401 * Gets a permission group's icon from the system. 402 * 403 * @param groupName The name of the permission group whose icon we want 404 * @return The permission group's icon, the privacy_dialog_default_permission_icon icon if 405 * the group has no icon, or the group does not exist 406 */ 407 @WorkerThread 408 private fun Context.getPermGroupIcon(groupName: String): Drawable { 409 val groupInfo = packageManager.getGroupInfo(groupName) 410 if (groupInfo != null && groupInfo.icon != 0) { 411 val icon = packageManager.loadDrawable(groupInfo.packageName, groupInfo.icon) 412 if (icon != null) { 413 return icon 414 } 415 } 416 417 return getDrawable(R.drawable.privacy_dialog_default_permission_icon)!!.mutate() 418 } 419 420 /** 421 * Gets a permission group's label from the system. 422 * 423 * @param groupName The name of the permission group whose label we want 424 * @return The permission group's label, or the group name, if the group is invalid 425 */ 426 @WorkerThread 427 private fun PackageManager.getDefaultPermGroupLabel(groupName: String): CharSequence { 428 val groupInfo = getGroupInfo(groupName) ?: return groupName 429 return groupInfo.loadSafeLabel( 430 this, 431 0f, 432 TextUtils.SAFE_STRING_FLAG_FIRST_LINE or TextUtils.SAFE_STRING_FLAG_TRIM 433 ) 434 } 435 436 /** 437 * Get the [infos][PackageItemInfo] for the given permission group. 438 * 439 * @param groupName the group 440 * @return The info of permission group or null if the group does not have runtime 441 * permissions. 442 */ 443 @WorkerThread 444 private fun PackageManager.getGroupInfo(groupName: String): PackageItemInfo? { 445 try { 446 return getPermissionGroupInfo(groupName, 0) 447 } catch (e: NameNotFoundException) { 448 /* ignore */ 449 } 450 try { 451 return getPermissionInfo(groupName, 0) 452 } catch (e: NameNotFoundException) { 453 /* ignore */ 454 } 455 return null 456 } 457 458 @WorkerThread 459 private fun PackageManager.loadDrawable(pkg: String, @DrawableRes resId: Int): Drawable? { 460 return try { 461 getResourcesForApplication(pkg).getDrawable(resId, null)?.mutate() 462 } catch (e: NotFoundException) { 463 Log.w(LOG_TAG, "Couldn't get resource", e) 464 null 465 } catch (e: NameNotFoundException) { 466 Log.w(LOG_TAG, "Couldn't get resource", e) 467 null 468 } 469 } 470 471 private fun constructLayeredIcon( 472 icon: Drawable, 473 iconSize: Int, 474 background: Drawable, 475 backgroundSize: Int 476 ): Drawable { 477 val layered = LayerDrawable(arrayOf(background, icon)) 478 layered.setLayerSize(0, backgroundSize, backgroundSize) 479 layered.setLayerGravity(0, Gravity.CENTER) 480 layered.setLayerSize(1, iconSize, iconSize) 481 layered.setLayerGravity(1, Gravity.CENTER) 482 return layered 483 } 484 } 485 486 /** */ 487 data class PrivacyElement( 488 val type: PrivacyType, 489 val packageName: String, 490 val userId: Int, 491 val applicationName: CharSequence, 492 val attributionTag: CharSequence?, 493 val attributionLabel: CharSequence?, 494 val proxyLabel: CharSequence?, 495 val lastActiveTimestamp: Long, 496 val isActive: Boolean, 497 val isPhoneCall: Boolean, 498 val isService: Boolean, 499 val permGroupName: String, 500 val navigationIntent: Intent 501 ) { 502 private val builder = StringBuilder("PrivacyElement(") 503 504 init { 505 builder.append("type=${type.logName}") 506 builder.append(", packageName=$packageName") 507 builder.append(", userId=$userId") 508 builder.append(", appName=$applicationName") 509 if (attributionTag != null) { 510 builder.append(", attributionTag=$attributionTag") 511 } 512 if (attributionLabel != null) { 513 builder.append(", attributionLabel=$attributionLabel") 514 } 515 if (proxyLabel != null) { 516 builder.append(", proxyLabel=$proxyLabel") 517 } 518 builder.append(", lastActive=$lastActiveTimestamp") 519 if (isActive) { 520 builder.append(", active") 521 } 522 if (isPhoneCall) { 523 builder.append(", phoneCall") 524 } 525 if (isService) { 526 builder.append(", service") 527 } 528 builder.append(", permGroupName=$permGroupName") 529 builder.append(", navigationIntent=$navigationIntent)") 530 } 531 532 override fun toString(): String = builder.toString() 533 } 534 535 /** */ 536 interface OnDialogDismissed { 537 fun onDialogDismissed() 538 } 539 } 540