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