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