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 }