• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * 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.systemui.inputdevice.tutorial.ui.viewmodel
18 
19 import androidx.lifecycle.AbstractSavedStateViewModelFactory
20 import androidx.lifecycle.DefaultLifecycleObserver
21 import androidx.lifecycle.LifecycleOwner
22 import androidx.lifecycle.SavedStateHandle
23 import androidx.lifecycle.ViewModel
24 import androidx.lifecycle.viewModelScope
25 import com.android.app.tracing.coroutines.launchTraced as launch
26 import com.android.systemui.inputdevice.tutorial.InputDeviceTutorialLogger
27 import com.android.systemui.inputdevice.tutorial.domain.interactor.ConnectionState
28 import com.android.systemui.inputdevice.tutorial.domain.interactor.KeyboardTouchpadConnectionInteractor
29 import com.android.systemui.inputdevice.tutorial.ui.view.KeyboardTouchpadTutorialActivity.Companion.INTENT_TUTORIAL_SCOPE_ALL
30 import com.android.systemui.inputdevice.tutorial.ui.view.KeyboardTouchpadTutorialActivity.Companion.INTENT_TUTORIAL_SCOPE_KEY
31 import com.android.systemui.inputdevice.tutorial.ui.view.KeyboardTouchpadTutorialActivity.Companion.INTENT_TUTORIAL_SCOPE_KEYBOARD
32 import com.android.systemui.inputdevice.tutorial.ui.view.KeyboardTouchpadTutorialActivity.Companion.INTENT_TUTORIAL_SCOPE_TOUCHPAD
33 import com.android.systemui.inputdevice.tutorial.ui.view.KeyboardTouchpadTutorialActivity.Companion.INTENT_TUTORIAL_SCOPE_TOUCHPAD_BACK
34 import com.android.systemui.inputdevice.tutorial.ui.view.KeyboardTouchpadTutorialActivity.Companion.INTENT_TUTORIAL_SCOPE_TOUCHPAD_HOME
35 import com.android.systemui.inputdevice.tutorial.ui.viewmodel.RequiredHardware.KEYBOARD
36 import com.android.systemui.inputdevice.tutorial.ui.viewmodel.RequiredHardware.TOUCHPAD
37 import com.android.systemui.inputdevice.tutorial.ui.viewmodel.Screen.ACTION_KEY
38 import com.android.systemui.inputdevice.tutorial.ui.viewmodel.Screen.BACK_GESTURE
39 import com.android.systemui.inputdevice.tutorial.ui.viewmodel.Screen.HOME_GESTURE
40 import com.android.systemui.touchpad.tutorial.domain.interactor.TouchpadGesturesInteractor
41 import dagger.assisted.Assisted
42 import dagger.assisted.AssistedFactory
43 import dagger.assisted.AssistedInject
44 import java.util.Optional
45 import kotlin.time.Duration.Companion.seconds
46 import kotlinx.coroutines.delay
47 import kotlinx.coroutines.flow.Flow
48 import kotlinx.coroutines.flow.MutableStateFlow
49 import kotlinx.coroutines.flow.StateFlow
50 import kotlinx.coroutines.flow.filter
51 import kotlinx.coroutines.flow.filterNot
52 import kotlinx.coroutines.flow.runningFold
53 
54 @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
55 class KeyboardTouchpadTutorialViewModel(
56     private val gesturesInteractor: Optional<TouchpadGesturesInteractor>,
57     private val keyboardTouchpadConnectionInteractor: KeyboardTouchpadConnectionInteractor,
58     private val hasTouchpadTutorialScreens: Boolean,
59     private val logger: InputDeviceTutorialLogger,
60     handle: SavedStateHandle,
61 ) : ViewModel(), DefaultLifecycleObserver {
62 
63     private val _screen = MutableStateFlow(startingScreen(handle))
64     val screen: Flow<Screen> = _screen.filter { it.canBeShown() }
65 
66     private val _closeActivity: MutableStateFlow<Boolean> = MutableStateFlow(false)
67     val closeActivity: StateFlow<Boolean> = _closeActivity
68 
69     private val screenSequence: ScreenSequence = chooseScreenSequence(handle)
70 
71     private val screensBackStack = ArrayDeque(listOf(_screen.value))
72 
73     private var connectionState: ConnectionState =
74         ConnectionState(keyboardConnected = false, touchpadConnected = false)
75 
76     init {
77         viewModelScope.launch {
78             keyboardTouchpadConnectionInteractor.connectionState.collect {
79                 logger.logNewConnectionState(connectionState)
80                 connectionState = it
81             }
82         }
83 
84         viewModelScope.launch {
85             screen
86                 .runningFold<Screen, Pair<Screen?, Screen?>>(null to null) {
87                     previousScreensPair,
88                     currentScreen ->
89                     previousScreensPair.second to currentScreen
90                 }
91                 .collect { (previousScreen, currentScreen) ->
92                     // ignore first empty emission
93                     if (currentScreen != null) {
94                         setupDeviceState(previousScreen, currentScreen)
95                     }
96                 }
97         }
98 
99         viewModelScope.launch {
100             // close activity if screen requires touchpad but we don't have it. This can only happen
101             // when current sysui build doesn't contain touchpad module dependency
102             _screen
103                 .filterNot { it.canBeShown() }
104                 .collect {
105                     logger.e(
106                         "Touchpad is connected but touchpad module is missing, something went wrong"
107                     )
108                     _closeActivity.value = true
109                 }
110         }
111     }
112 
113     private fun startingScreen(handle: SavedStateHandle): Screen {
114         val scope: String? = handle[INTENT_TUTORIAL_SCOPE_KEY]
115         return when (scope) {
116             INTENT_TUTORIAL_SCOPE_KEYBOARD -> ACTION_KEY
117             INTENT_TUTORIAL_SCOPE_TOUCHPAD_HOME -> HOME_GESTURE
118             INTENT_TUTORIAL_SCOPE_TOUCHPAD,
119             INTENT_TUTORIAL_SCOPE_ALL,
120             INTENT_TUTORIAL_SCOPE_TOUCHPAD_BACK -> BACK_GESTURE
121             else -> {
122                 logger.w("Intent didn't specify tutorial scope, starting with default")
123                 BACK_GESTURE
124             }
125         }
126     }
127 
128     private fun chooseScreenSequence(handle: SavedStateHandle): ScreenSequence {
129         val scope: String? = handle[INTENT_TUTORIAL_SCOPE_KEY]
130         return if (
131             scope == INTENT_TUTORIAL_SCOPE_TOUCHPAD_HOME ||
132                 scope == INTENT_TUTORIAL_SCOPE_TOUCHPAD_BACK
133         ) {
134             SingleScreenOnly
135         } else {
136             AllSupportedScreens
137         }
138     }
139 
140     override fun onCleared() {
141         // this shouldn't be needed as onTutorialInvisible should already clear device state but
142         // it'd be really bad if we'd block gestures/shortcuts after leaving tutorial so just to be
143         // extra sure...
144         clearDeviceStateForScreen(_screen.value)
145     }
146 
147     override fun onStart(owner: LifecycleOwner) {
148         setupDeviceState(previousScreen = null, currentScreen = _screen.value)
149     }
150 
151     override fun onStop(owner: LifecycleOwner) {
152         clearDeviceStateForScreen(_screen.value)
153     }
154 
155     suspend fun onAutoProceed() {
156         delay(AUTO_PROCEED_DELAY)
157         progressToNextScreen()
158     }
159 
160     fun onDoneButtonClicked() {
161         progressToNextScreen()
162     }
163 
164     private fun progressToNextScreen() {
165         var nextScreen = screenSequence.nextScreen(_screen.value)
166         while (nextScreen != null) {
167             if (requiredHardwarePresent(nextScreen)) {
168                 break
169             }
170             logger.logNextScreenMissingHardware(nextScreen)
171             nextScreen = screenSequence.nextScreen(nextScreen)
172         }
173         if (nextScreen == null) {
174             logger.d("Final screen reached, closing tutorial")
175             _closeActivity.value = true
176         } else {
177             logger.logNextScreen(nextScreen)
178             _screen.value = nextScreen
179             screensBackStack.add(nextScreen)
180         }
181     }
182 
183     private fun Screen.canBeShown() = requiredHardware != TOUCHPAD || hasTouchpadTutorialScreens
184 
185     private fun setupDeviceState(previousScreen: Screen?, currentScreen: Screen) {
186         logger.logMovingBetweenScreens(previousScreen, currentScreen)
187         if (previousScreen?.requiredHardware == currentScreen.requiredHardware) return
188         previousScreen?.let { clearDeviceStateForScreen(it) }
189         when (currentScreen.requiredHardware) {
190             TOUCHPAD -> gesturesInteractor.get().disableGestures()
191             KEYBOARD -> {} // TODO(b/358587037) disabled keyboard shortcuts
192         }
193     }
194 
195     private fun clearDeviceStateForScreen(screen: Screen) {
196         when (screen.requiredHardware) {
197             TOUCHPAD -> gesturesInteractor.get().enableGestures()
198             KEYBOARD -> {} // TODO(b/358587037) enable keyboard shortcuts
199         }
200     }
201 
202     private fun requiredHardwarePresent(screen: Screen): Boolean =
203         when (screen.requiredHardware) {
204             KEYBOARD -> connectionState.keyboardConnected
205             TOUCHPAD -> connectionState.touchpadConnected
206         }
207 
208     fun onBack() {
209         if (screensBackStack.size <= 1) {
210             _closeActivity.value = true
211         } else {
212             screensBackStack.removeLast()
213             logger.logGoingBack(screensBackStack.last())
214             _screen.value = screensBackStack.last()
215         }
216     }
217 
218     class Factory
219     @AssistedInject
220     constructor(
221         private val gesturesInteractor: Optional<TouchpadGesturesInteractor>,
222         private val keyboardTouchpadConnected: KeyboardTouchpadConnectionInteractor,
223         private val logger: InputDeviceTutorialLogger,
224         @Assisted private val hasTouchpadTutorialScreens: Boolean,
225     ) : AbstractSavedStateViewModelFactory() {
226 
227         @AssistedFactory
228         fun interface ViewModelFactoryAssistedProvider {
229             fun create(@Assisted hasTouchpadTutorialScreens: Boolean): Factory
230         }
231 
232         @Suppress("UNCHECKED_CAST")
233         override fun <T : ViewModel> create(
234             key: String,
235             modelClass: Class<T>,
236             handle: SavedStateHandle,
237         ): T =
238             KeyboardTouchpadTutorialViewModel(
239                 gesturesInteractor,
240                 keyboardTouchpadConnected,
241                 hasTouchpadTutorialScreens,
242                 logger,
243                 handle,
244             )
245                 as T
246     }
247 
248     private interface ScreenSequence {
249         fun nextScreen(current: Screen): Screen?
250     }
251 
252     private object AllSupportedScreens : ScreenSequence {
253         override fun nextScreen(current: Screen): Screen? {
254             return when (current) {
255                 BACK_GESTURE -> HOME_GESTURE
256                 HOME_GESTURE -> ACTION_KEY
257                 ACTION_KEY -> null
258             }
259         }
260     }
261 
262     private object SingleScreenOnly : ScreenSequence {
263         override fun nextScreen(current: Screen): Screen? = null
264     }
265 
266     companion object {
267         private val AUTO_PROCEED_DELAY = 3.seconds
268     }
269 }
270 
271 enum class RequiredHardware {
272     TOUCHPAD,
273     KEYBOARD,
274 }
275 
276 enum class Screen(val requiredHardware: RequiredHardware) {
277     BACK_GESTURE(requiredHardware = TOUCHPAD),
278     HOME_GESTURE(requiredHardware = TOUCHPAD),
279     ACTION_KEY(requiredHardware = KEYBOARD),
280 }
281