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