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