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