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