• 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.domain.interactor
18 
19 import android.os.SystemProperties
20 import com.android.systemui.dagger.SysUISingleton
21 import com.android.systemui.dagger.qualifiers.Background
22 import com.android.systemui.inputdevice.tutorial.InputDeviceTutorialLogger
23 import com.android.systemui.inputdevice.tutorial.data.repository.DeviceType
24 import com.android.systemui.inputdevice.tutorial.data.repository.DeviceType.KEYBOARD
25 import com.android.systemui.inputdevice.tutorial.data.repository.DeviceType.TOUCHPAD
26 import com.android.systemui.inputdevice.tutorial.data.repository.TutorialSchedulerRepository
27 import com.android.systemui.inputdevice.tutorial.domain.interactor.TutorialSchedulerInteractor.Companion.LAUNCH_DELAY
28 import com.android.systemui.keyboard.data.repository.KeyboardRepository
29 import com.android.systemui.statusbar.commandline.Command
30 import com.android.systemui.statusbar.commandline.CommandRegistry
31 import com.android.systemui.touchpad.data.repository.TouchpadRepository
32 import java.io.PrintWriter
33 import java.time.Duration
34 import java.time.Instant
35 import javax.inject.Inject
36 import kotlin.time.Duration.Companion.hours
37 import kotlin.time.toKotlinDuration
38 import kotlinx.coroutines.CoroutineScope
39 import kotlinx.coroutines.delay
40 import kotlinx.coroutines.flow.Flow
41 import kotlinx.coroutines.flow.MutableStateFlow
42 import kotlinx.coroutines.flow.combine
43 import kotlinx.coroutines.flow.drop
44 import kotlinx.coroutines.flow.filter
45 import kotlinx.coroutines.flow.first
46 import kotlinx.coroutines.flow.flow
47 import kotlinx.coroutines.flow.map
48 import kotlinx.coroutines.flow.merge
49 import kotlinx.coroutines.launch
50 import kotlinx.coroutines.runBlocking
51 
52 /**
53  * When the first time a keyboard or touchpad is connected, wait for [LAUNCH_DELAY], and as soon as
54  * there's a connected device, show a notification to launch the tutorial.
55  */
56 @SysUISingleton
57 class TutorialSchedulerInteractor
58 @Inject
59 constructor(
60     keyboardRepository: KeyboardRepository,
61     touchpadRepository: TouchpadRepository,
62     private val repo: TutorialSchedulerRepository,
63     private val logger: InputDeviceTutorialLogger,
64     commandRegistry: CommandRegistry,
65     @Background private val backgroundScope: CoroutineScope,
66 ) {
67     init {
68         commandRegistry.registerCommand(COMMAND) { TutorialCommand() }
69     }
70 
71     private val isAnyDeviceConnected =
72         mapOf(
73             KEYBOARD to keyboardRepository.isAnyKeyboardConnected,
74             TOUCHPAD to touchpadRepository.isAnyTouchpadConnected,
75         )
76 
77     private val touchpadScheduleFlow = flow {
78         if (!repo.isNotified(TOUCHPAD)) {
79             schedule(TOUCHPAD)
80             emit(TOUCHPAD)
81         }
82     }
83 
84     private val keyboardScheduleFlow = flow {
85         if (!repo.isNotified(KEYBOARD)) {
86             schedule(KEYBOARD)
87             emit(KEYBOARD)
88         }
89     }
90 
91     private suspend fun schedule(deviceType: DeviceType) {
92         if (!repo.wasEverConnected(deviceType)) {
93             logger.d("Waiting for $deviceType to connect")
94             waitForDeviceConnection(deviceType)
95             logger.logDeviceFirstConnection(deviceType)
96             repo.setFirstConnectionTime(deviceType, Instant.now())
97         }
98         val remainingTime = remainingTime(start = repo.getFirstConnectionTime(deviceType)!!)
99         logger.d("Tutorial is scheduled in ${remainingTime.inWholeSeconds} seconds")
100         delay(remainingTime)
101         waitForDeviceConnection(deviceType)
102     }
103 
104     // This flow is used by the notification updater once an initial notification is launched. It
105     // listens to the changes of keyboard and touchpad connection and resolve the tutorial type base
106     // on the latest connection state.
107     // Dropping the initial state because it represents the existing notification.
108     val tutorialTypeUpdates: Flow<TutorialType> =
109         keyboardRepository.isAnyKeyboardConnected
110             .combine(touchpadRepository.isAnyTouchpadConnected, ::Pair)
111             .map { (keyboardConnected, touchpadConnected) ->
112                 when {
113                     keyboardConnected && touchpadConnected -> TutorialType.BOTH
114                     keyboardConnected -> TutorialType.KEYBOARD
115                     touchpadConnected -> TutorialType.TOUCHPAD
116                     else -> TutorialType.NONE
117                 }
118             }
119             .drop(1)
120 
121     private suspend fun waitForDeviceConnection(deviceType: DeviceType) =
122         isAnyDeviceConnected[deviceType]!!.filter { it }.first()
123 
124     // Only for testing notifications. This should behave independently from scheduling
125     val commandTutorials = MutableStateFlow(TutorialType.NONE)
126 
127     // Merging two flows ensures that tutorial is launched consecutively to avoid race condition
128     val tutorials: Flow<TutorialType> =
129         merge(touchpadScheduleFlow, keyboardScheduleFlow).map {
130             val tutorialType = resolveTutorialType(it)
131 
132             if (tutorialType == TutorialType.KEYBOARD || tutorialType == TutorialType.BOTH)
133                 repo.setNotifiedTime(KEYBOARD, Instant.now())
134             if (tutorialType == TutorialType.TOUCHPAD || tutorialType == TutorialType.BOTH)
135                 repo.setNotifiedTime(TOUCHPAD, Instant.now())
136 
137             logger.logTutorialLaunched(tutorialType)
138             tutorialType
139         }
140 
141     private suspend fun resolveTutorialType(deviceType: DeviceType): TutorialType {
142         // Resolve the type of tutorial depending on which device are connected when the tutorial is
143         // launched. E.g. when the keyboard is connected for [LAUNCH_DELAY], both keyboard and
144         // touchpad are connected, we launch the tutorial for both.
145         if (repo.isNotified(deviceType)) return TutorialType.NONE
146         val otherDevice = if (deviceType == KEYBOARD) TOUCHPAD else KEYBOARD
147         val isOtherDeviceConnected = isAnyDeviceConnected[otherDevice]!!.first()
148         if (!repo.isNotified(otherDevice) && isOtherDeviceConnected) return TutorialType.BOTH
149         return if (deviceType == KEYBOARD) TutorialType.KEYBOARD else TutorialType.TOUCHPAD
150     }
151 
152     private fun remainingTime(start: Instant): kotlin.time.Duration {
153         val elapsed = Duration.between(start, Instant.now())
154         return LAUNCH_DELAY.minus(elapsed).toKotlinDuration()
155     }
156 
157     fun updateLaunchInfo(tutorialType: TutorialType) {
158         backgroundScope.launch {
159             if (tutorialType == TutorialType.KEYBOARD || tutorialType == TutorialType.BOTH)
160                 repo.setScheduledTutorialLaunchTime(KEYBOARD, Instant.now())
161             if (tutorialType == TutorialType.TOUCHPAD || tutorialType == TutorialType.BOTH)
162                 repo.setScheduledTutorialLaunchTime(TOUCHPAD, Instant.now())
163         }
164     }
165 
166     inner class TutorialCommand : Command {
167         override fun execute(pw: PrintWriter, args: List<String>) {
168             if (args.isEmpty()) {
169                 help(pw)
170                 return
171             }
172             when (args[0]) {
173                 "clear" ->
174                     runBlocking {
175                         repo.clear()
176                         pw.println("Tutorial scheduler reset")
177                     }
178                 "info" ->
179                     runBlocking {
180                         pw.println(
181                             "Keyboard connect time = ${repo.getFirstConnectionTime(KEYBOARD)}"
182                         )
183                         pw.println("         notified = ${repo.isNotified(KEYBOARD)}")
184                         pw.println(
185                             "         launch time = ${repo.getScheduledTutorialLaunchTime(KEYBOARD)}"
186                         )
187                         pw.println(
188                             "Touchpad connect time = ${repo.getFirstConnectionTime(TOUCHPAD)}"
189                         )
190                         pw.println("         notified = ${repo.isNotified(TOUCHPAD)}")
191                         pw.println(
192                             "         launch time = ${repo.getScheduledTutorialLaunchTime(TOUCHPAD)}"
193                         )
194                         pw.println("Delay time = ${LAUNCH_DELAY.seconds} sec")
195                     }
196                 "notify" -> {
197                     if (args.size != 2) help(pw)
198                     when (args[1]) {
199                         "keyboard" -> commandTutorials.value = TutorialType.KEYBOARD
200                         "touchpad" -> commandTutorials.value = TutorialType.TOUCHPAD
201                         "both" -> commandTutorials.value = TutorialType.BOTH
202                         else -> help(pw)
203                     }
204                 }
205                 else -> help(pw)
206             }
207         }
208 
209         override fun help(pw: PrintWriter) {
210             pw.println("Usage: adb shell cmd statusbar $COMMAND <command>")
211             pw.println("Available commands:")
212             pw.println("  clear")
213             pw.println("  info")
214             pw.println("  notify [keyboard|touchpad|both]")
215         }
216     }
217 
218     companion object {
219         const val TAG = "TutorialSchedulerInteractor"
220         const val COMMAND = "peripheral_tutorial"
221         private val DEFAULT_LAUNCH_DELAY_SEC = 72.hours.inWholeSeconds
222         private val LAUNCH_DELAY: Duration
223             get() =
224                 Duration.ofSeconds(
225                     SystemProperties.getLong(
226                         "persist.peripheral_tutorial_delay_sec",
227                         DEFAULT_LAUNCH_DELAY_SEC,
228                     )
229                 )
230     }
231 
232     enum class TutorialType {
233         KEYBOARD,
234         TOUCHPAD,
235         BOTH,
236         NONE,
237     }
238 }
239