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.permissioncontroller.permission.ui.wear
18 
19 import android.Manifest
20 import android.content.Context
21 import android.os.Bundle
22 import android.os.Handler
23 import android.os.Looper
24 import android.os.UserHandle
25 import android.util.Log
26 import android.view.LayoutInflater
27 import android.view.View
28 import android.view.ViewGroup
29 import androidx.compose.ui.platform.ComposeView
30 import androidx.fragment.app.Fragment
31 import androidx.fragment.app.FragmentActivity
32 import androidx.lifecycle.Observer
33 import androidx.lifecycle.ViewModelProvider
34 import com.android.permissioncontroller.Constants.EXTRA_SESSION_ID
35 import com.android.permissioncontroller.Constants.INVALID_SESSION_ID
36 import com.android.permissioncontroller.R
37 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel
38 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel.UnusedPackageInfo
39 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel.UnusedPeriod
40 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel.UnusedPeriod.Companion.allPeriods
41 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModelFactory
42 import com.android.permissioncontroller.permission.ui.wear.model.WearUnusedAppsViewModel
43 import com.android.permissioncontroller.permission.ui.wear.model.WearUnusedAppsViewModel.UnusedAppChip
44 import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionTheme
45 import com.android.permissioncontroller.permission.utils.KotlinUtils
46 import com.android.settingslib.utils.applications.AppUtils
47 import java.text.Collator
48 
49 /**
50  * This is a condensed version of
51  * [com.android.permissioncontroller.permission.ui.UnusedAppsFragment.kt], tailored for Wear.
52  *
53  * A fragment displaying all applications that are unused as well as the option to remove them and
54  * to open them.
55  */
56 class WearUnusedAppsFragment : Fragment() {
57     private lateinit var activity: FragmentActivity
58     private lateinit var context: Context
59     private lateinit var viewModel: UnusedAppsViewModel
60     private lateinit var wearViewModel: WearUnusedAppsViewModel
61     private lateinit var collator: Collator
62     private var sessionId: Long = 0L
63     private var isFirstLoad = false
64     private var categoryVisibilities: MutableList<Boolean> =
65         MutableList(UnusedPeriod.values().size) { false }
66     private var unusedAppsMap: MutableMap<UnusedPeriod, MutableMap<String, UnusedAppChip>> =
67         initUnusedAppsMap()
68 
69     companion object {
70         private const val SHOW_LOAD_DELAY_MS = 200L
71         private val LOG_TAG = WearUnusedAppsFragment::class.java.simpleName
72 
73         /**
74          * Create the args needed for this fragment
75          *
76          * @param sessionId The current session Id
77          * @return A bundle containing the session Id
78          */
79         @JvmStatic
80         fun createArgs(sessionId: Long): Bundle {
81             val bundle = Bundle()
82             bundle.putLong(EXTRA_SESSION_ID, sessionId)
83             return bundle
84         }
85     }
86 
87     override fun onCreateView(
88         inflater: LayoutInflater,
89         container: ViewGroup?,
90         savedInstanceState: Bundle?
91     ): View? {
92         isFirstLoad = true
93         context = requireContext()
94         collator =
95             Collator.getInstance(context.getResources().getConfiguration().getLocales().get(0))
96         activity = requireActivity()
97         val application = activity.getApplication()
98         sessionId = arguments!!.getLong(EXTRA_SESSION_ID, INVALID_SESSION_ID)
99         val factory = UnusedAppsViewModelFactory(activity.application, sessionId)
100         viewModel = ViewModelProvider(this, factory).get(UnusedAppsViewModel::class.java)
101         wearViewModel =
102             ViewModelProvider(
103                     this,
104                     ViewModelProvider.AndroidViewModelFactory.getInstance(application)
105                 )
106                 .get(WearUnusedAppsViewModel::class.java)
107         viewModel.unusedPackageCategoriesLiveData.observe(
108             this,
109             Observer {
110                 it?.let { pkgs ->
111                     updatePackages(pkgs)
112                     updateWearViewModel(false)
113                 }
114             }
115         )
116 
117         if (!viewModel.unusedPackageCategoriesLiveData.isInitialized) {
118             val handler = Handler(Looper.getMainLooper())
119             handler.postDelayed(
120                 {
121                     if (!viewModel.unusedPackageCategoriesLiveData.isInitialized) {
122                         wearViewModel.loadingLiveData.value = true
123                     } else {
124                         updatePackages(viewModel.unusedPackageCategoriesLiveData.value!!)
125                         updateWearViewModel(false)
126                     }
127                 },
128                 SHOW_LOAD_DELAY_MS
129             )
130         } else {
131             updatePackages(viewModel.unusedPackageCategoriesLiveData.value!!)
132             updateWearViewModel(false)
133         }
134 
135         return ComposeView(activity).apply {
136             setContent { WearPermissionTheme { WearUnusedAppsScreen(wearViewModel) } }
137         }
138     }
139 
140     private fun initUnusedAppsMap(): MutableMap<UnusedPeriod, MutableMap<String, UnusedAppChip>> {
141         val res = mutableMapOf<UnusedPeriod, MutableMap<String, UnusedAppChip>>()
142         for (period in allPeriods) {
143             res.put(period, mutableMapOf())
144         }
145         return res
146     }
147 
148     private fun updatePackages(categorizedPackages: Map<UnusedPeriod, List<UnusedPackageInfo>>) {
149         // Remove stale unused app chips
150         for (period in allPeriods) {
151             val unUsedAppsInAPeriod = unusedAppsMap[period] ?: continue
152             val categorizedPackagesOfAPeriod = categorizedPackages[period]
153             if (categorizedPackagesOfAPeriod == null) {
154                 unUsedAppsInAPeriod.clear()
155                 continue
156             }
157             val categorizedPackageKeys =
158                 categorizedPackagesOfAPeriod.map { createKey(it.packageName, it.user) }
159             // Do not remove apps that are still in the unused category
160             val keysToRemove = unUsedAppsInAPeriod.keys.filterNot { it in categorizedPackageKeys }
161             for (key in keysToRemove) {
162                 unUsedAppsInAPeriod.remove(key)
163             }
164         }
165 
166         var allCategoriesEmpty = true
167         for ((period, packages) in categorizedPackages) {
168             categoryVisibilities.set(periodToIndex(period), packages.isNotEmpty())
169             if (packages.isNotEmpty()) {
170                 allCategoriesEmpty = false
171             }
172 
173             for ((pkgName, user, _, permSet) in packages) {
174                 val revokedPerms = permSet.toList()
175                 val key = createKey(pkgName, user)
176 
177                 if (!unusedAppsMap[period]!!.containsKey(key)) {
178                     val mostImportant = getMostImportantGroup(revokedPerms)
179                     val importantLabel = KotlinUtils.getPermGroupLabel(context, mostImportant)
180                     val summary =
181                         when {
182                             revokedPerms.isEmpty() -> null
183                             revokedPerms.size == 1 ->
184                                 getString(R.string.auto_revoked_app_summary_one, importantLabel)
185                             revokedPerms.size == 2 -> {
186                                 val otherLabel =
187                                     if (revokedPerms[0] == mostImportant) {
188                                         KotlinUtils.getPermGroupLabel(context, revokedPerms[1])
189                                     } else {
190                                         KotlinUtils.getPermGroupLabel(context, revokedPerms[0])
191                                     }
192                                 getString(
193                                     R.string.auto_revoked_app_summary_two,
194                                     importantLabel,
195                                     otherLabel
196                                 )
197                             }
198                             else ->
199                                 getString(
200                                     R.string.auto_revoked_app_summary_many,
201                                     importantLabel,
202                                     "${revokedPerms.size - 1}"
203                                 )
204                         }
205 
206                     val onChipClicked: () -> Unit = {
207                         run { viewModel.navigateToAppInfo(pkgName, user, sessionId) }
208                     }
209 
210                     val chip =
211                         UnusedAppChip(
212                             KotlinUtils.getPackageLabel(activity.application, pkgName, user),
213                             summary,
214                             KotlinUtils.getBadgedPackageIcon(activity.application, pkgName, user),
215                             AppUtils.getAppContentDescription(
216                                 context,
217                                 pkgName,
218                                 user.getIdentifier()
219                             ),
220                             onChipClicked
221                         )
222                     unusedAppsMap[period]!!.put(key, chip)
223                 }
224             }
225 
226             // Sort the chips
227             unusedAppsMap[period] =
228                 unusedAppsMap[period]!!
229                     .toList()
230                     .sortedWith(Comparator { lhs, rhs -> compareUnusedApps(lhs, rhs) })
231                     .toMap()
232                     .toMutableMap()
233         }
234 
235         wearViewModel.infoMsgCategoryVisibilityLiveData.value = !allCategoriesEmpty
236 
237         if (isFirstLoad) {
238             if (categorizedPackages.any { (_, packages) -> packages.isNotEmpty() }) {
239                 isFirstLoad = false
240             }
241             Log.i(LOG_TAG, "sessionId: $sessionId Showed Auto Revoke Page")
242             for (period in allPeriods) {
243                 Log.i(
244                     LOG_TAG,
245                     "sessionId: $sessionId $period unused: " + "${categorizedPackages[period]}"
246                 )
247                 for (revokedPackageInfo in categorizedPackages[period]!!) {
248                     for (groupName in revokedPackageInfo.revokedGroups) {
249                         val isNewlyRevoked = period.isNewlyUnused()
250                         viewModel.logAppView(
251                             revokedPackageInfo.packageName,
252                             revokedPackageInfo.user,
253                             groupName,
254                             isNewlyRevoked
255                         )
256                     }
257                 }
258             }
259         }
260     }
261 
262     private fun createKey(packageName: String, user: UserHandle): String {
263         return "$packageName:${user.identifier}"
264     }
265 
266     private fun periodToIndex(period: UnusedPeriod): Int {
267         when (period) {
268             UnusedPeriod.ONE_MONTH -> return 0
269             UnusedPeriod.THREE_MONTHS -> return 1
270             UnusedPeriod.SIX_MONTHS -> return 2
271         }
272     }
273 
274     private fun getMostImportantGroup(groupNames: List<String>): String {
275         return when {
276             groupNames.contains(Manifest.permission_group.LOCATION) ->
277                 Manifest.permission_group.LOCATION
278             groupNames.contains(Manifest.permission_group.MICROPHONE) ->
279                 Manifest.permission_group.MICROPHONE
280             groupNames.contains(Manifest.permission_group.CAMERA) ->
281                 Manifest.permission_group.CAMERA
282             groupNames.contains(Manifest.permission_group.CONTACTS) ->
283                 Manifest.permission_group.CONTACTS
284             groupNames.contains(Manifest.permission_group.STORAGE) ->
285                 Manifest.permission_group.STORAGE
286             groupNames.contains(Manifest.permission_group.CALENDAR) ->
287                 Manifest.permission_group.CALENDAR
288             groupNames.isNotEmpty() -> groupNames[0]
289             else -> ""
290         }
291     }
292 
293     private fun compareUnusedApps(
294         lhs: Pair<String, UnusedAppChip>,
295         rhs: Pair<String, UnusedAppChip>
296     ): Int {
297         var result = collator.compare(lhs.second.label, rhs.second.label)
298         if (result == 0) {
299             result = collator.compare(lhs.first, rhs.first)
300         }
301         return result
302     }
303 
304     private fun updateWearViewModel(isLoading: Boolean) {
305         wearViewModel.loadingLiveData.value = isLoading
306         wearViewModel.unusedPeriodCategoryVisibilitiesLiveData.setValue(categoryVisibilities)
307 
308         // Need to copy to non mutable maps or compose will not update correctly
309         val map = mutableMapOf<UnusedPeriod, Map<String, UnusedAppChip>>()
310         for (period in allPeriods) {
311             map.put(period, unusedAppsMap[period]!!.toMap())
312         }
313 
314         wearViewModel.unusedAppChipsLiveData.setValue(map.toMap())
315     }
316 }
317