1 /* <lambda>null2 * Copyright (C) 2023 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.wear 18 19 import android.Manifest 20 import android.content.Context 21 import android.os.Bundle 22 import android.os.Handler 23 import android.os.Looper 24 import android.os.UserHandle 25 import android.util.Log 26 import android.view.LayoutInflater 27 import android.view.View 28 import android.view.ViewGroup 29 import androidx.compose.ui.platform.ComposeView 30 import androidx.fragment.app.Fragment 31 import androidx.fragment.app.FragmentActivity 32 import androidx.lifecycle.Observer 33 import androidx.lifecycle.ViewModelProvider 34 import com.android.permissioncontroller.Constants.EXTRA_SESSION_ID 35 import com.android.permissioncontroller.Constants.INVALID_SESSION_ID 36 import com.android.permissioncontroller.R 37 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel 38 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel.UnusedPackageInfo 39 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel.UnusedPeriod 40 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModel.UnusedPeriod.Companion.allPeriods 41 import com.android.permissioncontroller.permission.ui.model.UnusedAppsViewModelFactory 42 import com.android.permissioncontroller.permission.ui.wear.model.WearUnusedAppsViewModel 43 import com.android.permissioncontroller.permission.ui.wear.model.WearUnusedAppsViewModel.UnusedAppChip 44 import com.android.permissioncontroller.permission.ui.wear.theme.WearPermissionTheme 45 import com.android.permissioncontroller.permission.utils.KotlinUtils 46 import com.android.settingslib.utils.applications.AppUtils 47 import java.text.Collator 48 49 /** 50 * This is a condensed version of 51 * [com.android.permissioncontroller.permission.ui.UnusedAppsFragment.kt], tailored for Wear. 52 * 53 * A fragment displaying all applications that are unused as well as the option to remove them and 54 * to open them. 55 */ 56 class WearUnusedAppsFragment : Fragment() { 57 private lateinit var activity: FragmentActivity 58 private lateinit var context: Context 59 private lateinit var viewModel: UnusedAppsViewModel 60 private lateinit var wearViewModel: WearUnusedAppsViewModel 61 private lateinit var collator: Collator 62 private var sessionId: Long = 0L 63 private var isFirstLoad = false 64 private var categoryVisibilities: MutableList<Boolean> = 65 MutableList(UnusedPeriod.values().size) { false } 66 private var unusedAppsMap: MutableMap<UnusedPeriod, MutableMap<String, UnusedAppChip>> = 67 initUnusedAppsMap() 68 69 companion object { 70 private const val SHOW_LOAD_DELAY_MS = 200L 71 private val LOG_TAG = WearUnusedAppsFragment::class.java.simpleName 72 73 /** 74 * Create the args needed for this fragment 75 * 76 * @param sessionId The current session Id 77 * @return A bundle containing the session Id 78 */ 79 @JvmStatic 80 fun createArgs(sessionId: Long): Bundle { 81 val bundle = Bundle() 82 bundle.putLong(EXTRA_SESSION_ID, sessionId) 83 return bundle 84 } 85 } 86 87 override fun onCreateView( 88 inflater: LayoutInflater, 89 container: ViewGroup?, 90 savedInstanceState: Bundle? 91 ): View? { 92 isFirstLoad = true 93 context = requireContext() 94 collator = 95 Collator.getInstance(context.getResources().getConfiguration().getLocales().get(0)) 96 activity = requireActivity() 97 val application = activity.getApplication() 98 sessionId = arguments!!.getLong(EXTRA_SESSION_ID, INVALID_SESSION_ID) 99 val factory = UnusedAppsViewModelFactory(activity.application, sessionId) 100 viewModel = ViewModelProvider(this, factory).get(UnusedAppsViewModel::class.java) 101 wearViewModel = 102 ViewModelProvider( 103 this, 104 ViewModelProvider.AndroidViewModelFactory.getInstance(application) 105 ) 106 .get(WearUnusedAppsViewModel::class.java) 107 viewModel.unusedPackageCategoriesLiveData.observe( 108 this, 109 Observer { 110 it?.let { pkgs -> 111 updatePackages(pkgs) 112 updateWearViewModel(false) 113 } 114 } 115 ) 116 117 if (!viewModel.unusedPackageCategoriesLiveData.isInitialized) { 118 val handler = Handler(Looper.getMainLooper()) 119 handler.postDelayed( 120 { 121 if (!viewModel.unusedPackageCategoriesLiveData.isInitialized) { 122 wearViewModel.loadingLiveData.value = true 123 } else { 124 updatePackages(viewModel.unusedPackageCategoriesLiveData.value!!) 125 updateWearViewModel(false) 126 } 127 }, 128 SHOW_LOAD_DELAY_MS 129 ) 130 } else { 131 updatePackages(viewModel.unusedPackageCategoriesLiveData.value!!) 132 updateWearViewModel(false) 133 } 134 135 return ComposeView(activity).apply { 136 setContent { WearPermissionTheme { WearUnusedAppsScreen(wearViewModel) } } 137 } 138 } 139 140 private fun initUnusedAppsMap(): MutableMap<UnusedPeriod, MutableMap<String, UnusedAppChip>> { 141 val res = mutableMapOf<UnusedPeriod, MutableMap<String, UnusedAppChip>>() 142 for (period in allPeriods) { 143 res.put(period, mutableMapOf()) 144 } 145 return res 146 } 147 148 private fun updatePackages(categorizedPackages: Map<UnusedPeriod, List<UnusedPackageInfo>>) { 149 // Remove stale unused app chips 150 for (period in allPeriods) { 151 val unUsedAppsInAPeriod = unusedAppsMap[period] ?: continue 152 val categorizedPackagesOfAPeriod = categorizedPackages[period] 153 if (categorizedPackagesOfAPeriod == null) { 154 unUsedAppsInAPeriod.clear() 155 continue 156 } 157 val categorizedPackageKeys = 158 categorizedPackagesOfAPeriod.map { createKey(it.packageName, it.user) } 159 // Do not remove apps that are still in the unused category 160 val keysToRemove = unUsedAppsInAPeriod.keys.filterNot { it in categorizedPackageKeys } 161 for (key in keysToRemove) { 162 unUsedAppsInAPeriod.remove(key) 163 } 164 } 165 166 var allCategoriesEmpty = true 167 for ((period, packages) in categorizedPackages) { 168 categoryVisibilities.set(periodToIndex(period), packages.isNotEmpty()) 169 if (packages.isNotEmpty()) { 170 allCategoriesEmpty = false 171 } 172 173 for ((pkgName, user, _, permSet) in packages) { 174 val revokedPerms = permSet.toList() 175 val key = createKey(pkgName, user) 176 177 if (!unusedAppsMap[period]!!.containsKey(key)) { 178 val mostImportant = getMostImportantGroup(revokedPerms) 179 val importantLabel = KotlinUtils.getPermGroupLabel(context, mostImportant) 180 val summary = 181 when { 182 revokedPerms.isEmpty() -> null 183 revokedPerms.size == 1 -> 184 getString(R.string.auto_revoked_app_summary_one, importantLabel) 185 revokedPerms.size == 2 -> { 186 val otherLabel = 187 if (revokedPerms[0] == mostImportant) { 188 KotlinUtils.getPermGroupLabel(context, revokedPerms[1]) 189 } else { 190 KotlinUtils.getPermGroupLabel(context, revokedPerms[0]) 191 } 192 getString( 193 R.string.auto_revoked_app_summary_two, 194 importantLabel, 195 otherLabel 196 ) 197 } 198 else -> 199 getString( 200 R.string.auto_revoked_app_summary_many, 201 importantLabel, 202 "${revokedPerms.size - 1}" 203 ) 204 } 205 206 val onChipClicked: () -> Unit = { 207 run { viewModel.navigateToAppInfo(pkgName, user, sessionId) } 208 } 209 210 val chip = 211 UnusedAppChip( 212 KotlinUtils.getPackageLabel(activity.application, pkgName, user), 213 summary, 214 KotlinUtils.getBadgedPackageIcon(activity.application, pkgName, user), 215 AppUtils.getAppContentDescription( 216 context, 217 pkgName, 218 user.getIdentifier() 219 ), 220 onChipClicked 221 ) 222 unusedAppsMap[period]!!.put(key, chip) 223 } 224 } 225 226 // Sort the chips 227 unusedAppsMap[period] = 228 unusedAppsMap[period]!! 229 .toList() 230 .sortedWith(Comparator { lhs, rhs -> compareUnusedApps(lhs, rhs) }) 231 .toMap() 232 .toMutableMap() 233 } 234 235 wearViewModel.infoMsgCategoryVisibilityLiveData.value = !allCategoriesEmpty 236 237 if (isFirstLoad) { 238 if (categorizedPackages.any { (_, packages) -> packages.isNotEmpty() }) { 239 isFirstLoad = false 240 } 241 Log.i(LOG_TAG, "sessionId: $sessionId Showed Auto Revoke Page") 242 for (period in allPeriods) { 243 Log.i( 244 LOG_TAG, 245 "sessionId: $sessionId $period unused: " + "${categorizedPackages[period]}" 246 ) 247 for (revokedPackageInfo in categorizedPackages[period]!!) { 248 for (groupName in revokedPackageInfo.revokedGroups) { 249 val isNewlyRevoked = period.isNewlyUnused() 250 viewModel.logAppView( 251 revokedPackageInfo.packageName, 252 revokedPackageInfo.user, 253 groupName, 254 isNewlyRevoked 255 ) 256 } 257 } 258 } 259 } 260 } 261 262 private fun createKey(packageName: String, user: UserHandle): String { 263 return "$packageName:${user.identifier}" 264 } 265 266 private fun periodToIndex(period: UnusedPeriod): Int { 267 when (period) { 268 UnusedPeriod.ONE_MONTH -> return 0 269 UnusedPeriod.THREE_MONTHS -> return 1 270 UnusedPeriod.SIX_MONTHS -> return 2 271 } 272 } 273 274 private fun getMostImportantGroup(groupNames: List<String>): String { 275 return when { 276 groupNames.contains(Manifest.permission_group.LOCATION) -> 277 Manifest.permission_group.LOCATION 278 groupNames.contains(Manifest.permission_group.MICROPHONE) -> 279 Manifest.permission_group.MICROPHONE 280 groupNames.contains(Manifest.permission_group.CAMERA) -> 281 Manifest.permission_group.CAMERA 282 groupNames.contains(Manifest.permission_group.CONTACTS) -> 283 Manifest.permission_group.CONTACTS 284 groupNames.contains(Manifest.permission_group.STORAGE) -> 285 Manifest.permission_group.STORAGE 286 groupNames.contains(Manifest.permission_group.CALENDAR) -> 287 Manifest.permission_group.CALENDAR 288 groupNames.isNotEmpty() -> groupNames[0] 289 else -> "" 290 } 291 } 292 293 private fun compareUnusedApps( 294 lhs: Pair<String, UnusedAppChip>, 295 rhs: Pair<String, UnusedAppChip> 296 ): Int { 297 var result = collator.compare(lhs.second.label, rhs.second.label) 298 if (result == 0) { 299 result = collator.compare(lhs.first, rhs.first) 300 } 301 return result 302 } 303 304 private fun updateWearViewModel(isLoading: Boolean) { 305 wearViewModel.loadingLiveData.value = isLoading 306 wearViewModel.unusedPeriodCategoryVisibilitiesLiveData.setValue(categoryVisibilities) 307 308 // Need to copy to non mutable maps or compose will not update correctly 309 val map = mutableMapOf<UnusedPeriod, Map<String, UnusedAppChip>>() 310 for (period in allPeriods) { 311 map.put(period, unusedAppsMap[period]!!.toMap()) 312 } 313 314 wearViewModel.unusedAppChipsLiveData.setValue(map.toMap()) 315 } 316 } 317