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