1 /*
2  * Copyright (C) 2024 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.carlauncher
18 
19 import android.app.Application
20 import android.os.Bundle
21 import android.os.SystemClock
22 import androidx.lifecycle.AbstractSavedStateViewModelFactory
23 import androidx.lifecycle.AndroidViewModel
24 import androidx.lifecycle.SavedStateHandle
25 import androidx.lifecycle.ViewModel
26 import androidx.lifecycle.viewModelScope
27 import androidx.preference.PreferenceManager
28 import androidx.savedstate.SavedStateRegistryOwner
29 import com.android.car.carlauncher.AppGridFragment.AppTypes.Companion.APP_TYPE_LAUNCHABLES
30 import com.android.car.carlauncher.AppGridFragment.Mode
31 import com.android.car.carlauncher.repositories.AppGridRepository
32 import java.time.Clock
33 import java.util.concurrent.TimeUnit
34 import kotlinx.coroutines.ExperimentalCoroutinesApi
35 import kotlinx.coroutines.flow.Flow
36 import kotlinx.coroutines.flow.MutableStateFlow
37 import kotlinx.coroutines.flow.SharingStarted
38 import kotlinx.coroutines.flow.distinctUntilChanged
39 import kotlinx.coroutines.flow.emitAll
40 import kotlinx.coroutines.flow.flowOf
41 import kotlinx.coroutines.flow.mapLatest
42 import kotlinx.coroutines.flow.shareIn
43 import kotlinx.coroutines.flow.transformLatest
44 import kotlinx.coroutines.launch
45 
46 /**
47  * This ViewModel manages the main application grid within the car launcher. It provides
48  * methods to retrieve app lists, handle app reordering, determine distraction
49  * optimization requirements, and manage the Terms of Service (TOS) banner display.
50  */
51 class AppGridViewModel(
52     private val appGridRepository: AppGridRepository,
53     private val application: Application
54 ) : AndroidViewModel(application) {
55 
56     /**
57      * A Kotlin Flow containing a complete list of applications obtained from the repository.
58      * This Flow is shared for efficiency within the ViewModel.
59      */
60     private val allAppsItemList = appGridRepository.getAllAppsList()
61         .shareIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIME_OUT_FLOW_SUBSCRIPTION), 1)
62 
63     /**
64      * A Kotlin Flow containing a list of media-focused applications obtained from the repository,
65      * shared for efficiency within the ViewModel.
66      */
67     private val mediaOnlyList = appGridRepository.getMediaAppsList()
68         .shareIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIME_OUT_FLOW_SUBSCRIPTION), 1)
69 
70     /**
71      * A MutableStateFlow indicating the current application display mode in the app grid.
72      */
73     private val appMode: MutableStateFlow<Mode> = MutableStateFlow(Mode.ALL_APPS)
74 
75     /**
76      * Provides a Flow of application lists (AppItem). The returned Flow dynamically switches
77      * between the complete app list (`allAppsItemList`) and a filtered list
78      * of media apps (`mediaOnlyList`) based on the current `appMode`.
79      *
80      * @return A Flow of AppItem lists
81      */
82     @OptIn(ExperimentalCoroutinesApi::class)
getAppListnull83     fun getAppList(): Flow<List<AppItem>> {
84         return appMode.transformLatest {
85             val sourceList = if (it.appTypes and APP_TYPE_LAUNCHABLES == 1) {
86                 allAppsItemList
87             } else {
88                 mediaOnlyList
89             }
90             emitAll(sourceList)
91         }.distinctUntilChanged()
92     }
93 
94     /**
95      * Updates the application order in the repository.
96      *
97      * @param newPosition The intended new index position for the app.
98      * @param appItem The AppItem to be repositioned.
99      */
saveAppOrdernull100     fun saveAppOrder(newPosition: Int, appItem: AppItem) {
101         viewModelScope.launch {
102             allAppsItemList.replayCache.lastOrNull()?.toMutableList()?.apply {
103                 // Remove original occurrence
104                 remove(appItem)
105                 // Add to new position
106                 add(newPosition, appItem)
107             }?.let {
108                 appGridRepository.saveAppOrder(it)
109             }
110         }
111     }
112 
113     /**
114      * Provides a flow indicating whether distraction optimization should be applied
115      * in the car launcher UI.
116      *
117      * @return A Flow emitting Boolean values where 'true' signifies a need for distraction optimization.
118      */
requiresDistractionOptimizationnull119     fun requiresDistractionOptimization(): Flow<Boolean> {
120         return appGridRepository.requiresDistractionOptimization()
121     }
122 
123     /**
124      * Returns a flow that determines whether the Terms of Service (TOS) banner should be displayed.
125      * The logic considers if the TOS requires acceptance and the banner resurfacing interval.
126      *
127      * @return A Flow emitting Boolean values where 'true' indicates the banner should be displayed.
128      */
129     @OptIn(ExperimentalCoroutinesApi::class)
getShouldShowTosBannernull130     fun getShouldShowTosBanner(): Flow<Boolean> {
131         if (Flags.tosRestrictionsEnabled()) {
132             val enableBanner = application.resources.getBoolean(R.bool.config_enable_tos_banner)
133             if (!enableBanner) {
134                 return flowOf(false)
135             }
136         }
137         return appGridRepository.getTosState().mapLatest {
138             if (!it.shouldBlockTosApps) {
139                 return@mapLatest false
140             }
141             return@mapLatest shouldShowTos()
142         }
143     }
144 
145     /**
146      * Checks if we need to show the Banner based when it was previously dismissed.
147      */
shouldShowTosnull148     private fun shouldShowTos(): Boolean {
149         // Convert days to seconds
150         val bannerResurfaceTimeInSeconds = TimeUnit.DAYS.toSeconds(
151             application.resources
152                 .getInteger(R.integer.config_tos_banner_resurface_time_days).toLong()
153         )
154         val bannerDismissTime = PreferenceManager.getDefaultSharedPreferences(application)
155             .getLong(TOS_BANNER_DISMISS_TIME_KEY, 0)
156 
157         val systemBootTime = Clock.systemUTC()
158             .instant().epochSecond - TimeUnit.MILLISECONDS.toSeconds(SystemClock.elapsedRealtime())
159         // Show on next drive / reboot, when banner has not been dismissed in current session
160         return if (bannerResurfaceTimeInSeconds == 0L) {
161             // If banner is dismissed in current drive session, it will have a timestamp greater
162             // than the system boot time timestamp.
163             bannerDismissTime < systemBootTime
164         } else {
165             Clock.systemUTC()
166             .instant().epochSecond - bannerDismissTime > bannerResurfaceTimeInSeconds
167         }
168     }
169 
170     /**
171      * Saves the current timestamp to Preferences, marking the time when the Terms of Service (TOS)
172      * banner was dismissed by the user.
173      */
saveTosBannerDismissalTimenull174     fun saveTosBannerDismissalTime() {
175         val dismissTime: Long = Clock.systemUTC().instant().epochSecond
176         PreferenceManager.getDefaultSharedPreferences(application)
177             .edit().putLong(TOS_BANNER_DISMISS_TIME_KEY, dismissTime).apply()
178     }
179 
180     /**
181      * Updates the current application display mode. This triggers UI updates in the app grid.
182      * @param mode The new Mode to set for the application grid.
183      */
updateModenull184     fun updateMode(mode: Mode) {
185         appMode.value = mode
186     }
187 
188     companion object {
189         const val TOS_BANNER_DISMISS_TIME_KEY = "TOS_BANNER_DISMISS_TIME"
190         const val STOP_TIME_OUT_FLOW_SUBSCRIPTION = 5_000L
provideFactorynull191         fun provideFactory(
192             myRepository: AppGridRepository,
193             application: Application,
194             owner: SavedStateRegistryOwner,
195             defaultArgs: Bundle? = null,
196         ): AbstractSavedStateViewModelFactory =
197             object : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
198                 override fun <T : ViewModel> create(
199                     key: String,
200                     modelClass: Class<T>,
201                     handle: SavedStateHandle
202                 ): T {
203                     return AppGridViewModel(myRepository, application) as T
204                 }
205             }
206     }
207 }
208