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