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