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 }