1 /*
<lambda>null2  * Copyright (C) 2021 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 @file:Suppress("DEPRECATION")
17 
18 package com.android.permissioncontroller.permission.ui
19 
20 import android.Manifest.permission_group
21 import android.app.AlertDialog
22 import android.app.Application
23 import android.app.Dialog
24 import android.content.Intent
25 import android.icu.text.MessageFormat
26 import android.os.Bundle
27 import android.os.Handler
28 import android.os.Looper
29 import android.os.UserHandle
30 import android.util.Log
31 import android.view.LayoutInflater
32 import android.view.View
33 import android.view.ViewGroup
34 import androidx.fragment.app.DialogFragment
35 import androidx.fragment.app.Fragment
36 import androidx.lifecycle.Observer
37 import androidx.lifecycle.ViewModelProvider
38 import androidx.preference.Preference
39 import androidx.preference.PreferenceCategory
40 import androidx.preference.PreferenceFragmentCompat
41 import androidx.preference.PreferenceScreen
42 import com.android.permissioncontroller.Constants.EXTRA_SESSION_ID
43 import com.android.permissioncontroller.Constants.INVALID_SESSION_ID
44 import com.android.permissioncontroller.R
45 import com.android.permissioncontroller.hibernation.isHibernationEnabled
46 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel
47 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel.UnusedPackageInfo
48 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel.UnusedPeriod
49 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel.UnusedPeriod.Companion.allPeriods
50 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModelFactory
51 import com.android.permissioncontroller.permission.utils.KotlinUtils
52 import java.text.Collator
53 
54 /**
55  * A fragment displaying all applications that are unused as well as the option to remove them and
56  * to open them.
57  */
58 class UnusedAppsFragment<PF, UnusedAppPref> : Fragment() where
59 PF : PreferenceFragmentCompat,
60 PF : UnusedAppsFragment.Parent<UnusedAppPref>,
61 UnusedAppPref : Preference,
62 UnusedAppPref : RemovablePref {
63 
64     private lateinit var viewModel: UnusedAppsViewModel
65     private lateinit var collator: Collator
66     private var sessionId: Long = 0L
67     private var isFirstLoad = false
68 
69     companion object {
70         const val INFO_MSG_CATEGORY = "info_msg_category"
71         private const val SHOW_LOAD_DELAY_MS = 200L
72         private const val INFO_MSG_KEY = "info_msg"
73         private const val ELEVATION_HIGH = 8f
74         private val LOG_TAG = UnusedAppsFragment::class.java.simpleName
75 
76         @JvmStatic
77         fun <PF, UnusedAppPref> newInstance(): UnusedAppsFragment<PF, UnusedAppPref> where
78         PF : PreferenceFragmentCompat,
79         PF : UnusedAppsFragment.Parent<UnusedAppPref>,
80         UnusedAppPref : Preference,
81         UnusedAppPref : RemovablePref {
82             return UnusedAppsFragment()
83         }
84 
85         /**
86          * Create the args needed for this fragment
87          *
88          * @param sessionId The current session Id
89          * @return A bundle containing the session Id
90          */
91         @JvmStatic
92         fun createArgs(sessionId: Long): Bundle {
93             val bundle = Bundle()
94             bundle.putLong(EXTRA_SESSION_ID, sessionId)
95             return bundle
96         }
97     }
98 
99     override fun onCreateView(
100         inflater: LayoutInflater,
101         container: ViewGroup?,
102         savedInstanceState: Bundle?,
103     ): View? {
104         val preferenceFragment: PF = requirePreferenceFragment()
105         isFirstLoad = true
106 
107         collator =
108             Collator.getInstance(context!!.getResources().getConfiguration().getLocales().get(0))
109         sessionId = arguments!!.getLong(EXTRA_SESSION_ID, INVALID_SESSION_ID)
110         val factory = UnusedAppsViewModelFactory(activity!!.application, sessionId)
111         viewModel = ViewModelProvider(this, factory).get(UnusedAppsViewModel::class.java)
112         viewModel.unusedPackageCategoriesLiveData.observe(
113             this,
114             Observer {
115                 it?.let { pkgs ->
116                     updatePackages(pkgs)
117                     preferenceFragment.setLoadingState(loading = false, animate = true)
118                 }
119             }
120         )
121 
122         activity?.getActionBar()?.setDisplayHomeAsUpEnabled(true)
123 
124         if (!viewModel.unusedPackageCategoriesLiveData.isInitialized) {
125             val handler = Handler(Looper.getMainLooper())
126             handler.postDelayed(
127                 {
128                     if (!viewModel.unusedPackageCategoriesLiveData.isInitialized) {
129                         preferenceFragment.setLoadingState(loading = true, animate = true)
130                     } else {
131                         updatePackages(viewModel.unusedPackageCategoriesLiveData.value!!)
132                     }
133                 },
134                 SHOW_LOAD_DELAY_MS
135             )
136         } else {
137             updatePackages(viewModel.unusedPackageCategoriesLiveData.value!!)
138         }
139         return super.onCreateView(inflater, container, savedInstanceState)
140     }
141 
142     override fun onStart() {
143         super.onStart()
144         activity?.actionBar?.setElevation(ELEVATION_HIGH)
145     }
146 
147     override fun onActivityCreated(savedInstanceState: Bundle?) {
148         super.onActivityCreated(savedInstanceState)
149         val preferenceFragment: PF = requirePreferenceFragment()
150         if (isHibernationEnabled()) {
151             preferenceFragment.setTitle(getString(R.string.unused_apps_page_title))
152         } else {
153             preferenceFragment.setTitle(getString(R.string.permission_removed_page_title))
154         }
155     }
156 
157     @Suppress("UNCHECKED_CAST")
158     private fun requirePreferenceFragment(): PF {
159         return requireParentFragment() as PF
160     }
161 
162     /** Create [PreferenceScreen] in the parent fragment. */
163     private fun createPreferenceScreen() {
164         val preferenceFragment: PF = requirePreferenceFragment()
165         val preferenceScreen =
166             preferenceFragment.preferenceManager.inflateFromResource(
167                 context!!,
168                 R.xml.unused_app_categories,
169                 /* rootPreferences= */ null
170             )
171 
172         for (period in allPeriods) {
173             val periodCat = PreferenceCategory(context!!)
174             periodCat.key = period.name
175             periodCat.order = 0
176             preferenceScreen.addPreference(periodCat)
177         }
178         preferenceFragment.preferenceScreen = preferenceScreen
179 
180         val infoMsgCategory = preferenceScreen.findPreference<PreferenceCategory>(INFO_MSG_CATEGORY)
181         val footerPreference = preferenceFragment.createFooterPreference()
182         footerPreference.key = INFO_MSG_KEY
183         infoMsgCategory?.addPreference(footerPreference)
184     }
185 
186     @Suppress("UNCHECKED_CAST")
187     private fun updatePackages(categorizedPackages: Map<UnusedPeriod, List<UnusedPackageInfo>>) {
188         val preferenceFragment: PF = requirePreferenceFragment()
189         if (preferenceFragment.preferenceScreen == null) {
190             createPreferenceScreen()
191         }
192         val preferenceScreen: PreferenceScreen = preferenceFragment.preferenceScreen
193 
194         // Remove stale preferences
195         val removedPrefs = mutableMapOf<String, UnusedAppPref>()
196         for (period in allPeriods) {
197             val category = preferenceScreen.findPreference<PreferenceCategory>(period.name)!!
198             for (i in 0 until category.preferenceCount) {
199                 val pref = category.getPreference(i) as UnusedAppPref
200                 val contains =
201                     categorizedPackages[period]?.any { (pkgName, user, _) ->
202                         val key = createKey(pkgName, user)
203                         pref.key == key
204                     }
205                 if (contains != true) {
206                     removedPrefs[pref.key] = pref
207                 }
208             }
209 
210             for ((_, pref) in removedPrefs) {
211                 category.removePreference(pref)
212             }
213         }
214 
215         var allCategoriesEmpty = true
216         for ((period, packages) in categorizedPackages) {
217             val category = preferenceScreen.findPreference<PreferenceCategory>(period.name)!!
218             val months = period.months
219             category.title =
220                 MessageFormat.format(
221                     getString(R.string.last_opened_category_title),
222                     mapOf("count" to months)
223                 )
224             category.isVisible = packages.isNotEmpty()
225             if (packages.isNotEmpty()) {
226                 allCategoriesEmpty = false
227             }
228 
229             for ((pkgName, user, isSystemApp, permSet) in packages) {
230                 val revokedPerms = permSet.toList()
231                 val key = createKey(pkgName, user)
232 
233                 var pref = category.findPreference<UnusedAppPref>(key)
234                 if (pref == null) {
235                     pref =
236                         removedPrefs[key]
237                             ?: preferenceFragment.createUnusedAppPref(
238                                 activity!!.application,
239                                 pkgName,
240                                 user
241                             )
242                     pref.key = key
243                     pref.title = KotlinUtils.getPackageLabel(activity!!.application, pkgName, user)
244                 }
245 
246                 pref.setRemoveClickRunnable { viewModel.requestUninstallApp(this, pkgName, user) }
247                 pref.setRemoveComponentEnabled(!isSystemApp)
248 
249                 pref.onPreferenceClickListener =
250                     Preference.OnPreferenceClickListener { _ ->
251                         viewModel.navigateToAppInfo(pkgName, user, sessionId)
252                         true
253                     }
254 
255                 val mostImportant = getMostImportantGroup(revokedPerms)
256                 val importantLabel = KotlinUtils.getPermGroupLabel(context!!, mostImportant)
257                 pref.summary =
258                     when {
259                         revokedPerms.isEmpty() -> null
260                         revokedPerms.size == 1 ->
261                             getString(R.string.auto_revoked_app_summary_one, importantLabel)
262                         revokedPerms.size == 2 -> {
263                             val otherLabel =
264                                 if (revokedPerms[0] == mostImportant) {
265                                     KotlinUtils.getPermGroupLabel(context!!, revokedPerms[1])
266                                 } else {
267                                     KotlinUtils.getPermGroupLabel(context!!, revokedPerms[0])
268                                 }
269                             getString(
270                                 R.string.auto_revoked_app_summary_two,
271                                 importantLabel,
272                                 otherLabel
273                             )
274                         }
275                         else ->
276                             getString(
277                                 R.string.auto_revoked_app_summary_many,
278                                 importantLabel,
279                                 "${revokedPerms.size - 1}"
280                             )
281                     }
282                 category.addPreference(pref)
283                 KotlinUtils.sortPreferenceGroup(category, this::comparePreference, false)
284             }
285         }
286 
287         preferenceFragment.setEmptyState(allCategoriesEmpty)
288 
289         if (isFirstLoad) {
290             if (categorizedPackages.any { (_, packages) -> packages.isNotEmpty() }) {
291                 isFirstLoad = false
292             }
293             Log.i(LOG_TAG, "sessionId: $sessionId Showed Auto Revoke Page")
294             for (period in allPeriods) {
295                 Log.i(
296                     LOG_TAG,
297                     "sessionId: $sessionId $period unused: " + "${categorizedPackages[period]}"
298                 )
299                 for (revokedPackageInfo in categorizedPackages[period]!!) {
300                     for (groupName in revokedPackageInfo.revokedGroups) {
301                         val isNewlyRevoked = period.isNewlyUnused()
302                         viewModel.logAppView(
303                             revokedPackageInfo.packageName,
304                             revokedPackageInfo.user,
305                             groupName,
306                             isNewlyRevoked
307                         )
308                     }
309                 }
310             }
311         }
312     }
313 
314     private fun comparePreference(lhs: Preference, rhs: Preference): Int {
315         var result = collator.compare(lhs.title.toString(), rhs.title.toString())
316         if (result == 0) {
317             result = lhs.key.compareTo(rhs.key)
318         }
319         return result
320     }
321 
322     private fun createKey(packageName: String, user: UserHandle): String {
323         return "$packageName:${user.identifier}"
324     }
325 
326     private fun getMostImportantGroup(groupNames: List<String>): String {
327         return when {
328             groupNames.contains(permission_group.LOCATION) -> permission_group.LOCATION
329             groupNames.contains(permission_group.MICROPHONE) -> permission_group.MICROPHONE
330             groupNames.contains(permission_group.CAMERA) -> permission_group.CAMERA
331             groupNames.contains(permission_group.CONTACTS) -> permission_group.CONTACTS
332             groupNames.contains(permission_group.STORAGE) -> permission_group.STORAGE
333             groupNames.contains(permission_group.CALENDAR) -> permission_group.CALENDAR
334             groupNames.isNotEmpty() -> groupNames[0]
335             else -> ""
336         }
337     }
338 
339     private fun createDisableDialog(packageName: String, user: UserHandle) {
340         val dialog = DisableDialog()
341 
342         val args = Bundle()
343         args.putString(Intent.EXTRA_PACKAGE_NAME, packageName)
344         args.putParcelable(Intent.EXTRA_USER, user)
345         dialog.arguments = args
346 
347         dialog.isCancelable = true
348 
349         dialog.show(childFragmentManager.beginTransaction(), DisableDialog::class.java.name)
350     }
351 
352     class DisableDialog : DialogFragment() {
353         override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
354             val fragment = parentFragment as UnusedAppsFragment<*, *>
355             val packageName = arguments!!.getString(Intent.EXTRA_PACKAGE_NAME)!!
356             val user = arguments!!.getParcelable<UserHandle>(Intent.EXTRA_USER)!!
357             val b =
358                 AlertDialog.Builder(context!!)
359                     .setMessage(R.string.app_disable_dlg_text)
360                     .setPositiveButton(R.string.app_disable_dlg_positive) { _, _ ->
361                         fragment.viewModel.disableApp(packageName, user)
362                     }
363                     .setNegativeButton(R.string.cancel, null)
364             val d: Dialog = b.create()
365             d.setCanceledOnTouchOutside(true)
366             return d
367         }
368     }
369 
370     /** Interface that the parent fragment must implement. */
371     interface Parent<UnusedAppPref> where
372     UnusedAppPref : Preference,
373     UnusedAppPref : RemovablePref {
374 
375         /**
376          * Set the title of the current settings page.
377          *
378          * @param title the title of the current settings page
379          */
380         fun setTitle(title: CharSequence)
381 
382         /**
383          * Creates the footer preference that explains why permissions have been re-used and how an
384          * app can re-request them.
385          */
386         fun createFooterPreference(): Preference
387 
388         /**
389          * Sets the loading state of the view.
390          *
391          * @param loading whether the view is loading
392          * @param animate whether the load state should change with a fade animation
393          */
394         fun setLoadingState(loading: Boolean, animate: Boolean)
395 
396         /**
397          * Creates a preference which represents an app that is unused. Has the app icon and label,
398          * as well as a button to uninstall/disable the app, and a button to open the app.
399          *
400          * @param app The current application
401          * @param packageName The name of the package whose icon this preference will retrieve
402          * @param user The user whose package icon will be retrieved
403          */
404         fun createUnusedAppPref(
405             app: Application,
406             packageName: String,
407             user: UserHandle,
408         ): UnusedAppPref
409 
410         /**
411          * Updates the state based on whether the content is empty.
412          *
413          * @param empty whether the content is empty
414          */
415         fun setEmptyState(empty: Boolean)
416     }
417 }
418