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