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.car.docklib 18 19 import android.app.ActivityManager 20 import android.app.ActivityTaskManager 21 import android.car.content.pm.CarPackageManager 22 import android.content.ComponentName 23 import android.content.Context 24 import android.content.pm.PackageItemInfo 25 import android.content.pm.PackageManager 26 import android.graphics.drawable.Drawable 27 import android.os.Build 28 import android.util.Log 29 import android.view.Display 30 import android.widget.Toast 31 import androidx.annotation.VisibleForTesting 32 import androidx.lifecycle.MutableLiveData 33 import androidx.lifecycle.Observer 34 import com.android.car.docklib.data.DockAppItem 35 import com.android.car.docklib.data.DockItemId 36 import com.android.car.docklib.data.DockProtoDataController 37 import com.android.car.docklib.media.MediaUtils 38 import com.android.car.docklib.task.TaskUtils 39 import com.android.launcher3.icons.BaseIconFactory 40 import com.android.launcher3.icons.ColorExtractor 41 import com.android.launcher3.icons.IconFactory 42 import java.util.Collections 43 import java.util.UUID 44 45 /** 46 * This class contains a live list of dock app items. All changes to dock items will go through it 47 * and will be observed by the view layer. 48 */ 49 open class DockViewModel( 50 private val maxItemsInDock: Int, 51 private val context: Context, 52 private val packageManager: PackageManager, 53 private var carPackageManager: CarPackageManager? = null, 54 private val userId: Int = context.userId, 55 private var launcherActivities: MutableSet<ComponentName>, 56 defaultPinnedItems: List<ComponentName>, 57 private val isPackageExcluded: (pkg: String) -> Boolean, 58 private val isComponentExcluded: (component: ComponentName) -> Boolean, 59 private val iconFactory: IconFactory = IconFactory.obtain(context), 60 private val dockProtoDataController: DockProtoDataController, 61 private val observer: Observer<List<DockAppItem>>, 62 ) { 63 64 private companion object { 65 private const val TAG = "DockViewModel" 66 private val DEBUG = Build.isDebuggable() 67 private const val MAX_UNIQUE_ID_TRIES = 20 68 private const val MAX_TASKS_TO_FETCH = 20 69 } 70 71 private val noSpotAvailableToPinToastMsg = context.getString(R.string.pin_failed_no_spots) 72 private val defaultIconColor = context.resources.getColor( 73 R.color.icon_default_color, 74 context.theme // theme 75 ) 76 private val currentItems = MutableLiveData<List<DockAppItem>>() 77 private val mediaServiceComponents = MediaUtils.fetchMediaServiceComponents(packageManager) 78 79 /* 80 * Maintain a mapping of dock index to dock item, with the order of addition, 81 * so it's easier to find least recently updated position. 82 * The order goes from least recently updated item to most recently updated item. 83 * The key in each mapping is the index/position of the item being shown in Dock. 84 */ 85 @VisibleForTesting 86 val internalItems: MutableMap<Int, DockAppItem> = 87 Collections.synchronizedMap(LinkedHashMap<Int, DockAppItem>()) 88 89 init { 90 initializeDockItems(defaultPinnedItems) 91 currentItems.value = createDockList() 92 currentItems.observeForever(observer) 93 } 94 95 private fun initializeDockItems(defaultPinnedItems: List<ComponentName>) { 96 dockProtoDataController.loadFromFile()?.let { savedPinnedDockItems -> 97 if (DEBUG) Log.d(TAG, "Initialized using saved items") 98 savedPinnedDockItems.forEach { (index, component) -> 99 createDockItem(component, DockAppItem.Type.STATIC, isMediaApp(component))?.let { 100 internalItems[index] = it 101 } 102 } 103 } ?: run { 104 if (DEBUG) Log.d(TAG, "Initialized using default items") 105 for (index in 0..<minOf(maxItemsInDock, defaultPinnedItems.size)) { 106 createDockItem( 107 defaultPinnedItems[index], 108 DockAppItem.Type.STATIC, 109 isMediaApp(defaultPinnedItems[index]) 110 )?.let { 111 internalItems[index] = it 112 } 113 } 114 } 115 } 116 117 /** Pin an existing dock item with given [id]. It is assumed the item is not pinned/static. */ 118 fun pinItem(@DockItemId id: UUID) { 119 if (DEBUG) Log.d(TAG, "Pin Item, id: $id") 120 internalItems 121 .filter { mapEntry -> mapEntry.value.id == id } 122 .firstNotNullOfOrNull { it } 123 ?.let { mapEntry -> 124 if (DEBUG) { 125 Log.d(TAG, "Pinning ${mapEntry.value.component} at ${mapEntry.key}") 126 } 127 internalItems[mapEntry.key] = 128 mapEntry.value.copy(type = DockAppItem.Type.STATIC) 129 } 130 // update list regardless to update the listeners 131 currentItems.value = createDockList() 132 savePinnedItemsToProto() 133 } 134 135 /** 136 * Pin a new item that is not previously present in the dock. It is assumed the item is not 137 * pinned/static. 138 * 139 * @param component [ComponentName] of the pinned item. 140 * @param indexToPin the index to pin the item at. For null value, a suitable index is searched 141 * to pin to. If no index is suitable the user is notified. 142 */ 143 fun pinItem(component: ComponentName, indexToPin: Int? = null) { 144 if (DEBUG) Log.d(TAG, "Pin Item, component: $component, indexToPin: $indexToPin") 145 createDockItem( 146 component, 147 DockAppItem.Type.STATIC, 148 isMediaApp(component) 149 )?.let { dockItem -> 150 if (indexToPin != null) { 151 if (indexToPin in 0..<maxItemsInDock) { 152 if (DEBUG) Log.d(TAG, "Pinning $component at $indexToPin") 153 internalItems[indexToPin] = dockItem 154 } else { 155 if (DEBUG) Log.d(TAG, "Invalid index provided") 156 } 157 } else { 158 val index = findIndexToPin() 159 if (index == null) { 160 if (DEBUG) Log.d(TAG, "No dynamic or empty spots available to pin") 161 // if no dynamic or empty spots available, notify the user 162 showToast(noSpotAvailableToPinToastMsg) 163 return@pinItem 164 } 165 if (DEBUG) Log.d(TAG, "Pinning $component at $index") 166 internalItems[index] = dockItem 167 } 168 } 169 // update list regardless to update the listeners 170 currentItems.value = createDockList() 171 savePinnedItemsToProto() 172 } 173 174 /** Removes item with the given [id] from the dock. */ 175 fun removeItem(id: UUID) { 176 if (DEBUG) Log.d(TAG, "Unpin Item, id: $id") 177 internalItems 178 .filter { mapEntry -> mapEntry.value.id == id } 179 .firstNotNullOfOrNull { it } 180 ?.let { mapEntry -> 181 if (DEBUG) { 182 Log.d(TAG, "Unpinning ${mapEntry.value.component} at ${mapEntry.key}") 183 } 184 internalItems.remove(mapEntry.key) 185 } 186 // update list regardless to update the listeners 187 currentItems.value = createDockList() 188 savePinnedItemsToProto() 189 } 190 191 /** Removes all items of the given [packageName] from the dock. */ 192 fun removeItems(packageName: String) { 193 internalItems.entries.removeAll { it.value.component.packageName == packageName } 194 val areMediaComponentsRemoved = 195 mediaServiceComponents.removeIf { it.packageName == packageName } 196 if (areMediaComponentsRemoved && DEBUG) { 197 Log.d(TAG, "Media components were removed for $packageName") 198 } 199 launcherActivities.removeAll { it.packageName == packageName } 200 currentItems.value = createDockList() 201 savePinnedItemsToProto() 202 } 203 204 /** Adds all media service components for the given [packageName]. */ 205 fun addMediaComponents(packageName: String) { 206 val components = MediaUtils.fetchMediaServiceComponents(packageManager, packageName) 207 if (DEBUG) Log.d(TAG, "Added media components: $components") 208 mediaServiceComponents.addAll(components) 209 } 210 211 /** Adds all launcher components. */ 212 fun addLauncherComponents(components: List<ComponentName>) { 213 launcherActivities.addAll(components) 214 } 215 216 fun getMediaServiceComponents(): Set<ComponentName> = mediaServiceComponents 217 218 /** 219 * Add a new app to the dock. If the app is already in the dock, the recency of the app is 220 * refreshed. If not, and the dock has dynamic item(s) to update, then it will replace the least 221 * recent dynamic item. 222 */ 223 fun addDynamicItem(component: ComponentName) { 224 if (DEBUG) Log.d(TAG, "Add dynamic item, component: $component") 225 if (isItemExcluded(component)) { 226 if (DEBUG) Log.d(TAG, "Dynamic item is excluded") 227 return 228 } 229 if (isItemInDock(component, DockAppItem.Type.STATIC)) { 230 if (DEBUG) Log.d(TAG, "Dynamic item is already present in the dock as static item") 231 return 232 } 233 val indexToUpdate = 234 indexOfItemWithPackageName(component.packageName) 235 ?: indexOfLeastRecentDynamicItemInDock() 236 if (indexToUpdate == null || indexToUpdate >= maxItemsInDock) return 237 238 createDockItem( 239 component, 240 DockAppItem.Type.DYNAMIC, 241 isMediaApp(component) 242 )?.let { newDockItem -> 243 if (DEBUG) Log.d(TAG, "Updating $component at $indexToUpdate") 244 internalItems.remove(indexToUpdate) 245 internalItems[indexToUpdate] = newDockItem 246 currentItems.value = createDockList() 247 } 248 } 249 250 fun getIconColorWithScrim(componentName: ComponentName): Int { 251 return DockAppItem.getIconColorWithScrim(getIconColor(componentName)) 252 } 253 254 fun destroy() { 255 currentItems.removeObserver(observer) 256 } 257 258 fun setCarPackageManager(carPackageManager: CarPackageManager) { 259 this.carPackageManager = carPackageManager 260 internalItems.forEach { mapEntry -> 261 val item = mapEntry.value 262 internalItems[mapEntry.key] = item.copy( 263 isDistractionOptimized = item.isMediaApp || 264 carPackageManager.isActivityDistractionOptimized( 265 item.component.packageName, 266 item.component.className 267 ) 268 ) 269 } 270 currentItems.value = createDockList() 271 } 272 273 @VisibleForTesting 274 fun createDockList(): List<DockAppItem> { 275 if (DEBUG) Log.d(TAG, "createDockList called") 276 // todo(b/312718542): hidden api(ActivityTaskManager.getTasks) usage 277 val runningTaskList = getRunningTasks().filter { it.userId == userId } 278 279 for (index in 0..<maxItemsInDock) { 280 if (internalItems.contains(index)) continue 281 282 var isItemFound = false 283 for (component in runningTaskList.mapNotNull { TaskUtils.getComponentName(it) }) { 284 if (!isItemExcluded(component) && !isItemInDock(component)) { 285 createDockItem( 286 component, 287 DockAppItem.Type.DYNAMIC, 288 isMediaApp(component) 289 )?.let { dockItem -> 290 if (DEBUG) { 291 Log.d(TAG, "Adding recent item(${dockItem.component}) at $index") 292 } 293 internalItems[index] = dockItem 294 isItemFound = true 295 } 296 } 297 if (isItemFound) break 298 } 299 300 if (isItemFound) continue 301 302 for (component in launcherActivities.shuffled()) { 303 if (!isItemExcluded(component) && !isItemInDock(component)) { 304 createDockItem( 305 componentName = component, 306 DockAppItem.Type.DYNAMIC, 307 isMediaApp(component) 308 )?.let { dockItem -> 309 if (DEBUG) { 310 Log.d(TAG, "Adding recommended item(${dockItem.component}) at $index") 311 } 312 internalItems[index] = dockItem 313 isItemFound = true 314 } 315 } 316 if (isItemFound) break 317 } 318 319 if (!isItemFound) { 320 throw IllegalStateException("Cannot find enough apps to place in the dock") 321 } 322 } 323 return convertMapToList(internalItems) 324 } 325 326 private fun savePinnedItemsToProto() { 327 dockProtoDataController.savePinnedItemsToFile( 328 internalItems.filter { entry -> entry.value.type == DockAppItem.Type.STATIC } 329 .mapValues { entry -> entry.value.component } 330 ) 331 } 332 333 /** Use the mapping index->item to create the ordered list of Dock items */ 334 private fun convertMapToList(map: Map<Int, DockAppItem>): List<DockAppItem> = 335 List(maxItemsInDock) { index -> map[index] }.filterNotNull() 336 // TODO b/314409899: use a default DockItem when a position is empty 337 338 private fun findIndexToPin(): Int? { 339 var index: Int? = null 340 for (i in 0..<maxItemsInDock) { 341 if (!internalItems.contains(i)) { 342 index = i 343 break 344 } 345 if (internalItems[i]?.type == DockAppItem.Type.DYNAMIC) { 346 index = i 347 break 348 } 349 } 350 return index 351 } 352 353 private fun indexOfLeastRecentDynamicItemInDock(): Int? { 354 if (DEBUG) { 355 Log.d( 356 TAG, 357 "internalItems.size = ${internalItems.size}, maxItemsInDock= $maxItemsInDock" 358 ) 359 } 360 if (internalItems.size < maxItemsInDock) return internalItems.size 361 // since map is ordered from least recent to most recent, return first dynamic entry found 362 internalItems.forEach { appItemEntry -> 363 if (appItemEntry.value.type == DockAppItem.Type.DYNAMIC) return appItemEntry.key 364 } 365 // there is no dynamic item in dock to be replaced 366 return null 367 } 368 369 private fun indexOfItemWithPackageName(packageName: String): Int? { 370 internalItems.forEach { appItemEntry -> 371 if (appItemEntry.value.component.packageName == packageName) { 372 return appItemEntry.key 373 } 374 } 375 return null 376 } 377 378 private fun isItemExcluded(component: ComponentName): Boolean = 379 (isPackageExcluded(component.packageName) || isComponentExcluded(component)) 380 381 private fun isItemInDock(component: ComponentName, ofType: DockAppItem.Type? = null): Boolean { 382 return internalItems.values 383 .filter { (ofType == null) || (it.type == ofType) } 384 .map { it.component.packageName } 385 .contains(component.packageName) 386 } 387 388 /* Creates Dock item from a ComponentName. */ 389 private fun createDockItem( 390 componentName: ComponentName, 391 itemType: DockAppItem.Type, 392 isMediaApp: Boolean, 393 ): DockAppItem? { 394 // TODO: Compare the component against LauncherApps to make sure the component 395 // is launchable, similar to what app grid has 396 397 val ai = getPackageItemInfo(componentName) ?: return null 398 // todo(b/315210225): handle getting icon lazily 399 val icon = ai.loadIcon(packageManager) 400 val iconColor = getIconColor(icon) 401 return DockAppItem( 402 id = getUniqueDockItemId(), 403 type = itemType, 404 component = componentName, 405 name = ai.loadLabel(packageManager).toString(), 406 icon = icon, 407 iconColor = iconColor, 408 isDistractionOptimized = 409 isMediaApp || (carPackageManager?.isActivityDistractionOptimized( 410 componentName.packageName, 411 componentName.className 412 ) ?: false), 413 isMediaApp = isMediaApp 414 ) 415 } 416 417 private fun getPackageItemInfo(componentName: ComponentName): PackageItemInfo? { 418 try { 419 val isMediaApp = isMediaApp(componentName) 420 val pkgInfo = packageManager.getPackageInfo( 421 componentName.packageName, 422 PackageManager.PackageInfoFlags.of( 423 (if (isMediaApp) PackageManager.GET_SERVICES else PackageManager.GET_ACTIVITIES) 424 .toLong() 425 ) 426 ) 427 return if (isMediaApp) { 428 pkgInfo.services?.find { it.componentName == componentName } 429 } else { 430 pkgInfo.activities?.find { it.componentName == componentName } 431 } 432 } catch (e: PackageManager.NameNotFoundException) { 433 if (DEBUG) { 434 // don't need to crash for this failure, log error instead 435 Log.e(TAG, "Component $componentName not found", e) 436 } 437 } 438 return null 439 } 440 441 private fun getIconColor(componentName: ComponentName): Int { 442 val ai = getPackageItemInfo(componentName) ?: return defaultIconColor 443 return getIconColor(ai.loadIcon(packageManager)) 444 } 445 446 private fun getIconColor(icon: Drawable) = ColorExtractor.findDominantColorByHue( 447 iconFactory.createScaledBitmap(icon, BaseIconFactory.MODE_DEFAULT) 448 ) 449 450 private fun getUniqueDockItemId(): @DockItemId UUID { 451 val existingKeys = internalItems.values.map { it.id }.toSet() 452 for (i in 0..MAX_UNIQUE_ID_TRIES) { 453 val id = UUID.randomUUID() 454 if (!existingKeys.contains(id)) return id 455 } 456 return UUID.randomUUID() 457 } 458 459 private fun isMediaApp(component: ComponentName) = mediaServiceComponents.contains(component) 460 461 /** To be disabled for tests since [Toast] cannot be shown on that process */ 462 @VisibleForTesting 463 fun showToast(message: String) { 464 Toast.makeText(context, message, Toast.LENGTH_SHORT).show() 465 } 466 467 /** To be overridden in tests to pass mock values for RunningTasks */ 468 @VisibleForTesting 469 fun getRunningTasks(): List<ActivityManager.RunningTaskInfo> { 470 return ActivityTaskManager.getInstance().getTasks( 471 MAX_TASKS_TO_FETCH, 472 false, // filterOnlyVisibleRecents 473 false, // keepIntentExtra 474 Display.DEFAULT_DISPLAY // displayId 475 ) 476 } 477 } 478