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