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
18 
19 import android.content.Context
20 import android.content.Intent
21 import android.content.Intent.ACTION_SAFETY_CENTER
22 import android.os.Build
23 import android.os.Bundle
24 import android.safetycenter.SafetyCenterIssue
25 import android.safetycenter.SafetyCenterIssue.ISSUE_SEVERITY_LEVEL_OK
26 import androidx.annotation.RequiresApi
27 import androidx.fragment.app.FragmentManager
28 import androidx.preference.PreferenceGroup
29 import com.android.permissioncontroller.R
30 import com.android.permissioncontroller.safetycenter.SafetyCenterConstants.EXPAND_ISSUE_GROUP_QS_FRAGMENT_KEY
31 import com.android.permissioncontroller.safetycenter.ui.model.ActionId
32 import com.android.permissioncontroller.safetycenter.ui.model.IssueId
33 import com.android.permissioncontroller.safetycenter.ui.model.SafetyCenterViewModel
34 import com.android.safetycenter.internaldata.SafetyCenterIds
35 import com.android.safetycenter.internaldata.SafetyCenterIssueKey
36 import kotlin.math.max
37 
38 /**
39  * Helper class to hide issue cards if over a predefined limit and handle revealing hidden issue
40  * cards when the more issues preference is clicked
41  */
42 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
43 class CollapsableIssuesCardHelper(
44     val safetyCenterViewModel: SafetyCenterViewModel,
45     val sameTaskIssueIds: List<String>
46 ) {
47     private var isQuickSettingsFragment: Boolean = false
48     private var issueCardsExpanded: Boolean = false
49     private var focusedSafetyCenterIssueKey: SafetyCenterIssueKey? = null
50     private var previousMoreIssuesCardData: MoreIssuesCardData? = null
51 
52     fun setFocusedIssueKey(safetyCenterIssueKey: SafetyCenterIssueKey?) {
53         focusedSafetyCenterIssueKey = safetyCenterIssueKey
54     }
55 
56     /**
57      * Sets QuickSetting specific state for use to determine correct issue section expansion state
58      * as well ass more issues card icon values
59      *
60      * <p> Note the issueCardsExpanded value set here may be overridden here by calls to
61      * restoreState
62      *
63      * @param isQuickSettingsFragment {@code true} if CollapsableIssuesCardHelper is being used in
64      *   quick settings fragment
65      * @param issueCardsExpanded Whether issue cards should be expanded or not when added to
66      *   preference screen
67      */
68     fun setQuickSettingsState(isQuickSettingsFragment: Boolean, issueCardsExpanded: Boolean) {
69         this.isQuickSettingsFragment = isQuickSettingsFragment
70         this.issueCardsExpanded = issueCardsExpanded
71     }
72 
73     /** Restore previously saved state from [Bundle] */
74     fun restoreState(state: Bundle?) {
75         if (state == null) {
76             return
77         }
78         // Apply the previously saved state
79         issueCardsExpanded = state.getBoolean(EXPAND_ISSUE_GROUP_SAVED_INSTANCE_STATE_KEY, false)
80     }
81 
82     /** Save current state to provided [Bundle] */
83     fun saveState(outState: Bundle) =
84         outState.putBoolean(EXPAND_ISSUE_GROUP_SAVED_INSTANCE_STATE_KEY, issueCardsExpanded)
85 
86     /**
87      * Add the [IssueCardPreference] managed by this helper to the specified [PreferenceGroup]
88      *
89      * @param context Current context
90      * @param safetyCenterViewModel {@link SafetyCenterViewModel} used when executing issue actions
91      * @param dialogFragmentManager fragment manager use for issue dismissal
92      * @param issuesPreferenceGroup Preference group to add preference to
93      * @param issues {@link List} of {@link SafetyCenterIssue} to add to the preference fragment
94      * @param dismissedIssues {@link List} of dismissed {@link SafetyCenterIssue} to add
95      * @param resolvedIssues {@link Map} of issue id to action ids of resolved issues
96      */
97     fun addIssues(
98         context: Context,
99         safetyCenterViewModel: SafetyCenterViewModel,
100         dialogFragmentManager: FragmentManager,
101         issuesPreferenceGroup: PreferenceGroup,
102         issues: List<SafetyCenterIssue>?,
103         dismissedIssues: List<SafetyCenterIssue>?,
104         resolvedIssues: Map<IssueId, ActionId>,
105         launchTaskId: Int
106     ) {
107         val (reorderedIssues, numberOfIssuesToShowWhenCollapsed) =
108             maybeReorderFocusedSafetyCenterIssueInList(issues)
109 
110         val onlyDismissedIssuesAreCollapsed =
111             reorderedIssues.size <= numberOfIssuesToShowWhenCollapsed
112 
113         val issueCardPreferences: List<IssueCardPreference> =
114             reorderedIssues.mapToIssueCardPreferences(
115                 resolvedIssues,
116                 launchTaskId,
117                 context,
118                 safetyCenterViewModel,
119                 dialogFragmentManager,
120                 areDismissed = false
121             ) { index ->
122                 when (index) {
123                     in 0 until numberOfIssuesToShowWhenCollapsed ->
124                         PositionInCardList.LIST_START_END
125                     this.size - 1 -> PositionInCardList.CARD_START_LIST_END
126                     else -> PositionInCardList.CARD_START_END
127                 }
128             }
129 
130         val dismissedIssueCardPreferences: List<IssueCardPreference> =
131             dismissedIssues.mapToIssueCardPreferences(
132                 resolvedIssues,
133                 launchTaskId,
134                 context,
135                 safetyCenterViewModel,
136                 dialogFragmentManager,
137                 areDismissed = true
138             ) { index ->
139                 when {
140                     onlyDismissedIssuesAreCollapsed && index == size - 1 ->
141                         PositionInCardList.CARD_START_LIST_END
142                     onlyDismissedIssuesAreCollapsed -> PositionInCardList.CARD_START_END
143                     size == 1 -> PositionInCardList.LIST_START_END
144                     index == 0 -> PositionInCardList.LIST_START_CARD_END
145                     index == size - 1 -> PositionInCardList.CARD_START_LIST_END
146                     else -> PositionInCardList.CARD_START_END
147                 }
148             }
149 
150         val nextMoreIssuesCardData =
151             createMoreIssuesCardData(
152                 issueCardPreferences,
153                 dismissedIssueCardPreferences,
154                 numberOfIssuesToShowWhenCollapsed
155             )
156 
157         val moreIssuesCardPreference =
158             createMoreIssuesCardPreference(
159                 context,
160                 dismissedOnly = onlyDismissedIssuesAreCollapsed,
161                 staticHeader = false,
162                 issuesPreferenceGroup,
163                 previousMoreIssuesCardData,
164                 nextMoreIssuesCardData,
165                 numberOfIssuesToShowWhenCollapsed
166             )
167 
168         val dismissedIssuesHeaderCardPreference =
169             if (!onlyDismissedIssuesAreCollapsed && dismissedIssueCardPreferences.isNotEmpty()) {
170                 createMoreIssuesCardPreference(
171                     context,
172                     dismissedOnly = false,
173                     staticHeader = true,
174                     issuesPreferenceGroup,
175                     previousMoreIssuesCardData,
176                     nextMoreIssuesCardData,
177                     numberOfIssuesToShowWhenCollapsed
178                 )
179             } else {
180                 null
181             }
182 
183         // Keep track of previously presented more issues data to assist with transitions
184         previousMoreIssuesCardData = nextMoreIssuesCardData
185 
186         addIssuesToPreferenceGroupAndSetVisibility(
187             issuesPreferenceGroup,
188             issueCardPreferences,
189             dismissedIssueCardPreferences,
190             moreIssuesCardPreference,
191             dismissedIssuesHeaderCardPreference,
192             numberOfIssuesToShowWhenCollapsed,
193             issueCardsExpanded
194         )
195     }
196 
197     private fun List<SafetyCenterIssue>?.mapToIssueCardPreferences(
198         resolvedIssues: Map<IssueId, ActionId>,
199         launchTaskId: Int,
200         context: Context,
201         safetyCenterViewModel: SafetyCenterViewModel,
202         dialogFragmentManager: FragmentManager,
203         areDismissed: Boolean,
204         position: List<SafetyCenterIssue>.(index: Int) -> PositionInCardList
205     ): List<IssueCardPreference> =
206         this?.mapIndexed { index: Int, issue: SafetyCenterIssue ->
207             val resolvedActionId: ActionId? = resolvedIssues[issue.id]
208             val resolvedTaskId = getLaunchTaskIdForIssue(issue, launchTaskId)
209             val positionInCardList = position(index)
210             IssueCardPreference(
211                 context,
212                 safetyCenterViewModel,
213                 issue,
214                 resolvedActionId,
215                 dialogFragmentManager,
216                 resolvedTaskId,
217                 areDismissed,
218                 positionInCardList
219             )
220         }
221             ?: emptyList()
222 
223     data class ReorderedSafetyCenterIssueList(
224         val issues: List<SafetyCenterIssue>,
225         val numberOfIssuesToShowWhenCollapsed: Int
226     )
227     private fun maybeReorderFocusedSafetyCenterIssueInList(
228         issues: List<SafetyCenterIssue>?
229     ): ReorderedSafetyCenterIssueList {
230         if (issues == null) {
231             return ReorderedSafetyCenterIssueList(
232                 emptyList(),
233                 numberOfIssuesToShowWhenCollapsed = 0
234             )
235         }
236         val mutableIssuesList = issues.toMutableList()
237         val focusedIssue: SafetyCenterIssue? =
238             focusedSafetyCenterIssueKey?.let { findAndRemoveIssueInList(it, mutableIssuesList) }
239 
240         // If focused issue preference found, place at/near top of list and return new list and
241         // correct number of issue to show while collapsed
242         if (focusedIssue != null) {
243             val focusedIssuePlacement = getFocusedIssuePlacement(focusedIssue, mutableIssuesList)
244             mutableIssuesList.add(focusedIssuePlacement.index, focusedIssue)
245             return ReorderedSafetyCenterIssueList(
246                 mutableIssuesList.toList(),
247                 focusedIssuePlacement.numberForShownIssuesCollapsed
248             )
249         }
250 
251         return ReorderedSafetyCenterIssueList(issues, DEFAULT_NUMBER_SHOWN_ISSUES_COLLAPSED)
252     }
253 
254     private fun findAndRemoveIssueInList(
255         focusedIssueKey: SafetyCenterIssueKey,
256         issues: MutableList<SafetyCenterIssue>
257     ): SafetyCenterIssue? {
258         issues.forEachIndexed { index, issue ->
259             val issueKey = SafetyCenterIds.issueIdFromString(issue.id).safetyCenterIssueKey
260             if (focusedIssueKey == issueKey) {
261                 // Remove focused issue from current placement in list and exit loop
262                 issues.removeAt(index)
263                 return issue
264             }
265         }
266 
267         return null
268     }
269 
270     /** Defines indices and number of shown issues for use when prioritizing focused issues */
271     private enum class FocusedIssuePlacement(
272         val index: Int,
273         val numberForShownIssuesCollapsed: Int
274     ) {
275         FOCUSED_ISSUE_INDEX_0(0, DEFAULT_NUMBER_SHOWN_ISSUES_COLLAPSED),
276         FOCUSED_ISSUE_INDEX_1(1, DEFAULT_NUMBER_SHOWN_ISSUES_COLLAPSED + 1)
277     }
278 
279     private fun getFocusedIssuePlacement(
280         issue: SafetyCenterIssue,
281         issueList: List<SafetyCenterIssue>
282     ): FocusedIssuePlacement {
283         return if (issueList.isEmpty() || issueList[0].severityLevel <= issue.severityLevel) {
284             FocusedIssuePlacement.FOCUSED_ISSUE_INDEX_0
285         } else {
286             FocusedIssuePlacement.FOCUSED_ISSUE_INDEX_1
287         }
288     }
289 
290     private fun createMoreIssuesCardData(
291         issueCardPreferences: List<IssueCardPreference>,
292         dismissedIssueCardPreferences: List<IssueCardPreference>,
293         numberOfIssuesToShowWhenCollapsed: Int
294     ): MoreIssuesCardData {
295         val numberOfHiddenIssue: Int =
296             getNumberOfHiddenIssues(
297                 issueCardPreferences,
298                 dismissedIssueCardPreferences,
299                 numberOfIssuesToShowWhenCollapsed
300             )
301         val firstHiddenIssueSeverityLevel: Int =
302             if (issueCardPreferences.size <= numberOfIssuesToShowWhenCollapsed) {
303                 getFirstHiddenIssueSeverityLevel(dismissedIssueCardPreferences, 0)
304             } else {
305                 getFirstHiddenIssueSeverityLevel(
306                     issueCardPreferences,
307                     numberOfIssuesToShowWhenCollapsed
308                 )
309             }
310 
311         return MoreIssuesCardData(
312             firstHiddenIssueSeverityLevel,
313             numberOfHiddenIssue,
314             issueCardsExpanded
315         )
316     }
317 
318     private fun createMoreIssuesCardPreference(
319         context: Context,
320         dismissedOnly: Boolean,
321         staticHeader: Boolean,
322         issuesPreferenceGroup: PreferenceGroup,
323         previousMoreIssuesCardData: MoreIssuesCardData?,
324         nextMoreIssuesCardData: MoreIssuesCardData,
325         numberOfIssuesToShowWhenCollapsed: Int
326     ): MoreIssuesCardPreference {
327         val overrideChevronIconResId =
328             if (isQuickSettingsFragment) R.drawable.ic_chevron_right else null
329 
330         return MoreIssuesCardPreference(
331             context,
332             overrideChevronIconResId,
333             previousMoreIssuesCardData,
334             nextMoreIssuesCardData,
335             dismissedOnly,
336             staticHeader
337         ) {
338             if (isQuickSettingsFragment) {
339                 goToSafetyCenter(context)
340             } else {
341                 setExpanded(
342                     issuesPreferenceGroup,
343                     !issueCardsExpanded,
344                     numberOfIssuesToShowWhenCollapsed
345                 )
346             }
347             safetyCenterViewModel.interactionLogger.record(Action.MORE_ISSUES_CLICKED)
348         }
349     }
350 
351     private fun setExpanded(
352         issuesPreferenceGroup: PreferenceGroup,
353         isExpanded: Boolean,
354         numberOfIssuesToShowWhenCollapsed: Int
355     ) {
356         if (issueCardsExpanded == isExpanded) {
357             return
358         }
359 
360         val numberOfPreferences = issuesPreferenceGroup.preferenceCount
361         for (i in 0 until numberOfPreferences) {
362             when (val preference = issuesPreferenceGroup.getPreference(i)) {
363                 // IssueCardPreference can all be visible now
364                 is IssueCardPreference ->
365                     preference.isVisible = isExpanded || i < numberOfIssuesToShowWhenCollapsed
366                 // MoreIssuesCardPreference must be hidden after expansion of issues
367                 is MoreIssuesCardPreference -> {
368                     if (preference.isStaticHeader) {
369                         preference.isVisible = isExpanded
370                     } else {
371                         previousMoreIssuesCardData?.let {
372                             val newMoreIssuesCardData = it.copy(isExpanded = isExpanded)
373                             preference.setNewMoreIssuesCardData(newMoreIssuesCardData)
374                             previousMoreIssuesCardData = newMoreIssuesCardData
375                         }
376                     }
377                     preference.isVisible = isExpanded || !preference.isStaticHeader
378                 }
379                 // Other types are undefined, no-op
380                 else -> continue
381             }
382         }
383         issueCardsExpanded = isExpanded
384     }
385 
386     private fun goToSafetyCenter(context: Context) {
387         // Navigate to Safety center with issues expanded
388         val safetyCenterIntent = Intent(ACTION_SAFETY_CENTER)
389         safetyCenterIntent.putExtra(EXPAND_ISSUE_GROUP_QS_FRAGMENT_KEY, true)
390         NavigationSource.QUICK_SETTINGS_TILE.addToIntent(safetyCenterIntent)
391         context.startActivity(safetyCenterIntent)
392     }
393 
394     companion object {
395         private const val EXPAND_ISSUE_GROUP_SAVED_INSTANCE_STATE_KEY =
396             "expand_issue_group_saved_instance_state_key"
397         private const val DEFAULT_NUMBER_SHOWN_ISSUES_COLLAPSED = 1
398 
399         private fun getNumberOfHiddenIssues(
400             issueCardPreferences: List<IssueCardPreference>,
401             dismissedIssueCardPreferences: List<IssueCardPreference>,
402             numberOfIssuesToShowWhenCollapsed: Int
403         ): Int =
404             max(0, issueCardPreferences.size - numberOfIssuesToShowWhenCollapsed) +
405                 dismissedIssueCardPreferences.size
406 
407         private fun getFirstHiddenIssueSeverityLevel(
408             issueCardPreferences: List<IssueCardPreference>,
409             numberOfIssuesToShowWhenCollapsed: Int
410         ): Int {
411             // Index of first hidden issue (zero based) is equal to number of shown issues when
412             // collapsed
413             val indexOfFirstHiddenIssue: Int = numberOfIssuesToShowWhenCollapsed
414             val firstHiddenIssue: IssueCardPreference? =
415                 issueCardPreferences.getOrNull(indexOfFirstHiddenIssue)
416             // If no first hidden issue, default to ISSUE_SEVERITY_LEVEL_OK
417             return firstHiddenIssue?.severityLevel ?: ISSUE_SEVERITY_LEVEL_OK
418         }
419 
420         private fun addIssuesToPreferenceGroupAndSetVisibility(
421             issuesPreferenceGroup: PreferenceGroup,
422             issueCardPreferences: List<IssueCardPreference>,
423             dismissedIssueCardPreferences: List<IssueCardPreference>,
424             moreIssuesCardPreference: MoreIssuesCardPreference,
425             dismissedIssuesHeaderPreference: MoreIssuesCardPreference?,
426             numberOfIssuesToShowWhenCollapsed: Int,
427             issueCardsExpanded: Boolean
428         ) {
429             // Index of first hidden issue (zero based) is equal to number of shown issues when
430             // collapsed
431             val indexOfFirstHiddenIssue: Int = numberOfIssuesToShowWhenCollapsed
432             issueCardPreferences.forEachIndexed { index, issueCardPreference ->
433                 if (index == indexOfFirstHiddenIssue) {
434                     issuesPreferenceGroup.addPreference(moreIssuesCardPreference)
435                 }
436                 issueCardPreference.isVisible =
437                     index < indexOfFirstHiddenIssue || issueCardsExpanded
438                 issuesPreferenceGroup.addPreference(issueCardPreference)
439             }
440             if (dismissedIssueCardPreferences.isNotEmpty()) {
441                 if (issueCardPreferences.size <= numberOfIssuesToShowWhenCollapsed) {
442                     issuesPreferenceGroup.addPreference(moreIssuesCardPreference)
443                 }
444                 dismissedIssuesHeaderPreference?.let {
445                     it.isVisible = issueCardsExpanded
446                     issuesPreferenceGroup.addPreference(it)
447                 }
448                 dismissedIssueCardPreferences.forEach {
449                     it.isVisible = issueCardsExpanded
450                     issuesPreferenceGroup.addPreference(it)
451                 }
452             }
453         }
454     }
455 
456     private fun getLaunchTaskIdForIssue(issue: SafetyCenterIssue, taskId: Int): Int? {
457         val issueId: String =
458             SafetyCenterIds.issueIdFromString(issue.id)
459                 .getSafetyCenterIssueKey()
460                 .getSafetySourceId()
461         return if (sameTaskIssueIds.contains(issueId)) taskId else null
462     }
463 }
464