• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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