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