1 /*
<lambda>null2  * Copyright (C) 2022 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.permissioncontroller.safetycenter.ui.view
18 
19 import android.content.Context
20 import android.graphics.drawable.Animatable2.AnimationCallback
21 import android.graphics.drawable.AnimatedVectorDrawable
22 import android.graphics.drawable.Drawable
23 import android.os.Build
24 import android.safetycenter.SafetyCenterEntry.ENTRY_SEVERITY_LEVEL_RECOMMENDATION
25 import android.safetycenter.SafetyCenterEntryGroup
26 import android.util.AttributeSet
27 import android.view.Gravity
28 import android.view.View
29 import android.view.ViewGroup
30 import android.widget.ImageView
31 import android.widget.LinearLayout
32 import android.widget.TextView
33 import androidx.annotation.DrawableRes
34 import androidx.annotation.RequiresApi
35 import androidx.core.view.ViewCompat
36 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
37 import androidx.transition.AutoTransition
38 import androidx.transition.TransitionManager
39 import com.android.permissioncontroller.R
40 import com.android.permissioncontroller.safetycenter.ui.PositionInCardList
41 import com.android.permissioncontroller.safetycenter.ui.model.SafetyCenterViewModel
42 
43 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
44 internal class SafetyEntryGroupView
45 @JvmOverloads
46 constructor(
47     context: Context?,
48     attrs: AttributeSet? = null,
49     defStyleAttr: Int = 0,
50     defStyleRes: Int = 0
51 ) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) {
52 
53     private companion object {
54         const val EXPAND_COLLAPSE_ANIMATION_DURATION_MS = 183L
55     }
56 
57     init {
58         inflate(context, R.layout.safety_center_group, this)
59     }
60 
61     private val groupHeaderView: LinearLayout? by lazyView(R.id.group_header)
62 
63     private val expandedHeaderView: ViewGroup? by lazyView(R.id.expanded_header)
64     private val expandedTitleView: TextView? by lazyView {
65         expandedHeaderView?.findViewById(R.id.title)
66     }
67 
68     private val collapsedHeaderView: ViewGroup? by lazyView(R.id.collapsed_header)
69     private val commonEntryView: SafetyEntryCommonViewsManager? by lazyView {
70         SafetyEntryCommonViewsManager(collapsedHeaderView)
71     }
72 
73     private val chevronIconView: ImageView? by lazyView(R.id.chevron_icon)
74     private val entriesContainerView: LinearLayout? by lazyView(R.id.entries_container)
75 
76     private var isExpanded: Boolean? = null
77 
78     fun showGroup(
79         group: SafetyCenterEntryGroup,
80         initiallyExpanded: (String) -> Boolean,
81         isFirstCard: Boolean,
82         isLastCard: Boolean,
83         getTaskIdForEntry: (String) -> Int,
84         viewModel: SafetyCenterViewModel,
85         onGroupExpanded: (String) -> Unit,
86         onGroupCollapsed: (String) -> Unit
87     ) {
88         applyPosition(isFirstCard, isLastCard)
89         showGroupDetails(group)
90         showGroupEntries(group, getTaskIdForEntry, viewModel)
91         setupExpandedState(group, initiallyExpanded(group.id))
92         setOnClickListener { toggleExpandedState(group, onGroupExpanded, onGroupCollapsed) }
93     }
94 
95     private fun applyPosition(isFirstCard: Boolean, isLastCard: Boolean) {
96         val position =
97             when {
98                 isFirstCard && isLastCard -> PositionInCardList.LIST_START_END
99                 isFirstCard && !isLastCard -> PositionInCardList.LIST_START_CARD_END
100                 !isFirstCard && isLastCard -> PositionInCardList.CARD_START_LIST_END
101                 /* !isFirstCard && !isLastCard */ else -> PositionInCardList.CARD_START_END
102             }
103         setBackgroundResource(position.backgroundDrawableResId)
104         val topMargin: Int = position.getTopMargin(context)
105 
106         val params = layoutParams as MarginLayoutParams
107         if (params.topMargin != topMargin) {
108             params.topMargin = topMargin
109         }
110 
111         if (isLastCard) {
112             params.bottomMargin = context.resources.getDimensionPixelSize(R.dimen.sc_spacing_large)
113         } else {
114             params.bottomMargin = 0
115         }
116 
117         layoutParams = params
118     }
119 
120     private fun showGroupDetails(group: SafetyCenterEntryGroup) {
121         expandedTitleView?.text = group.title
122         commonEntryView?.showDetails(
123             group.id,
124             group.title,
125             group.summary,
126             group.severityLevel,
127             group.severityUnspecifiedIconType
128         )
129     }
130 
131     private fun setupExpandedState(group: SafetyCenterEntryGroup, shouldBeExpanded: Boolean) {
132         if (isExpanded == shouldBeExpanded) {
133             return
134         }
135 
136         collapsedHeaderView?.visibility = if (shouldBeExpanded) View.GONE else View.VISIBLE
137         expandedHeaderView?.visibility = if (shouldBeExpanded) View.VISIBLE else View.GONE
138         entriesContainerView?.visibility = if (shouldBeExpanded) View.VISIBLE else View.GONE
139 
140         if (shouldBeExpanded) {
141             groupHeaderView?.gravity = Gravity.TOP
142         } else {
143             groupHeaderView?.gravity = Gravity.CENTER_VERTICAL
144         }
145 
146         if (isExpanded == null) {
147             chevronIconView?.setImageResource(
148                 if (shouldBeExpanded) {
149                     R.drawable.ic_safety_group_collapse
150                 } else {
151                     R.drawable.ic_safety_group_expand
152                 }
153             )
154         } else if (shouldBeExpanded) {
155             chevronIconView?.animate(
156                 R.drawable.safety_center_group_expand_anim,
157                 R.drawable.ic_safety_group_collapse
158             )
159         } else {
160             chevronIconView?.animate(
161                 R.drawable.safety_center_group_collapse_anim,
162                 R.drawable.ic_safety_group_expand
163             )
164         }
165 
166         isExpanded = shouldBeExpanded
167 
168         val newPaddingTop =
169             context.resources.getDimensionPixelSize(
170                 if (shouldBeExpanded) {
171                     R.dimen.sc_entry_group_expanded_padding_top
172                 } else {
173                     R.dimen.sc_entry_group_collapsed_padding_top
174                 }
175             )
176         val newPaddingBottom =
177             context.resources.getDimensionPixelSize(
178                 if (shouldBeExpanded) {
179                     R.dimen.sc_entry_group_expanded_padding_bottom
180                 } else {
181                     R.dimen.sc_entry_group_collapsed_padding_bottom
182                 }
183             )
184         setPaddingRelative(paddingStart, newPaddingTop, paddingEnd, newPaddingBottom)
185 
186         // accessibility attributes depend on the expanded state
187         // and should be updated every time this state changes
188         setAccessibilityAttributes(group)
189     }
190 
191     private fun ImageView.animate(@DrawableRes animationRes: Int, @DrawableRes imageRes: Int) {
192         (drawable as? AnimatedVectorDrawable)?.clearAnimationCallbacks()
193         setImageResource(animationRes)
194         (drawable as? AnimatedVectorDrawable)?.apply {
195             registerAnimationCallback(
196                 object : AnimationCallback() {
197                     override fun onAnimationEnd(drawable: Drawable?) {
198                         setImageResource(imageRes)
199                     }
200                 }
201             )
202             start()
203         }
204     }
205 
206     private fun showGroupEntries(
207         group: SafetyCenterEntryGroup,
208         getTaskIdForEntry: (String) -> Int,
209         viewModel: SafetyCenterViewModel
210     ) {
211         val entriesCount = group.entries.size
212         val existingViewsCount = entriesContainerView?.childCount ?: 0
213         if (entriesCount > existingViewsCount) {
214             for (i in 1..(entriesCount - existingViewsCount)) {
215                 inflate(context, R.layout.safety_center_group_entry, entriesContainerView)
216             }
217         } else if (entriesCount < existingViewsCount) {
218             for (i in 1..(existingViewsCount - entriesCount)) {
219                 entriesContainerView?.removeViewAt(0)
220             }
221         }
222 
223         group.entries.forEachIndexed { index, entry ->
224             val childAt = entriesContainerView?.getChildAt(index)
225             val entryView = childAt as? SafetyEntryView
226             entryView?.showEntry(
227                 entry,
228                 PositionInCardList.INSIDE_GROUP,
229                 getTaskIdForEntry(entry.id),
230                 viewModel
231             )
232         }
233     }
234 
235     private fun setAccessibilityAttributes(group: SafetyCenterEntryGroup) {
236         // When status is yellow/red, adding an "Actions needed" before the summary is read.
237         contentDescription =
238             if (isExpanded == true) {
239                 null
240             } else {
241                 val isActionNeeded = group.severityLevel >= ENTRY_SEVERITY_LEVEL_RECOMMENDATION
242                 val contentDescriptionResId =
243                     if (isActionNeeded) {
244                         R.string.safety_center_entry_group_with_actions_needed_content_description
245                     } else {
246                         R.string.safety_center_entry_group_content_description
247                     }
248                 context.getString(contentDescriptionResId, group.title, group.summary)
249             }
250 
251         // Replacing the on-click label to indicate the expand/collapse action. The on-click command
252         // is set to null so that it uses the existing expand/collapse behaviour.
253         val accessibilityActionResId =
254             if (isExpanded == true) {
255                 R.string.safety_center_entry_group_collapse_action
256             } else {
257                 R.string.safety_center_entry_group_expand_action
258             }
259         ViewCompat.replaceAccessibilityAction(
260             this,
261             AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK,
262             context.getString(accessibilityActionResId),
263             null
264         )
265     }
266 
267     private fun toggleExpandedState(
268         group: SafetyCenterEntryGroup,
269         onGroupExpanded: (String) -> Unit,
270         onGroupCollapsed: (String) -> Unit
271     ) {
272         val transition = AutoTransition()
273         transition.duration = EXPAND_COLLAPSE_ANIMATION_DURATION_MS
274         TransitionManager.beginDelayedTransition(rootView as ViewGroup, transition)
275 
276         val shouldBeExpanded = isExpanded != true
277         setupExpandedState(group, shouldBeExpanded)
278 
279         if (shouldBeExpanded) {
280             onGroupExpanded(group.id)
281         } else {
282             onGroupCollapsed(group.id)
283         }
284     }
285 }
286