diff --git a/app/version.properties b/app/version.properties index 8712175313..09ff3bbe63 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.1.0 -VERSION_CODE=248 +VERSION_CODE=250 diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt index 435a7e3265..cd89501ae5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt @@ -14,6 +14,8 @@ import io.github.sds100.keymapper.base.backup.BackupManager import io.github.sds100.keymapper.base.backup.BackupManagerImpl import io.github.sds100.keymapper.base.constraints.GetConstraintErrorUseCase import io.github.sds100.keymapper.base.constraints.GetConstraintErrorUseCaseImpl +import io.github.sds100.keymapper.base.debug.GetEventRecorder +import io.github.sds100.keymapper.base.debug.GetEventRecorderImpl import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.input.InputEventHubImpl import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState @@ -133,6 +135,10 @@ abstract class BaseSingletonHiltModule { impl: RecordTriggerControllerImpl, ): RecordTriggerController + @Binds + @Singleton + abstract fun bindGetEventOutputUseCase(impl: GetEventRecorderImpl): GetEventRecorder + @Binds @Singleton abstract fun bindFingerprintGesturesSupportedUseCase( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt index 6c5a9240bf..75ebbc90c7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt @@ -25,8 +25,6 @@ import io.github.sds100.keymapper.base.constraints.ConfigConstraintsUseCaseImpl import io.github.sds100.keymapper.base.constraints.CreateConstraintUseCase import io.github.sds100.keymapper.base.constraints.CreateConstraintUseCaseImpl import io.github.sds100.keymapper.base.constraints.DisplayConstraintUseCase -import io.github.sds100.keymapper.base.debug.GetEventOutputUseCase -import io.github.sds100.keymapper.base.debug.GetEventOutputUseCaseImpl import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupDelegateImpl import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupDelegate import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupUseCase @@ -187,10 +185,6 @@ abstract class BaseViewModelHiltModule { @ViewModelScoped abstract fun bindShareLogcatUseCase(impl: ShareLogcatUseCaseImpl): ShareLogcatUseCase - @Binds - @ViewModelScoped - abstract fun bindGetEventOutputUseCase(impl: GetEventOutputUseCaseImpl): GetEventOutputUseCase - @Binds @ViewModelScoped abstract fun bindOnboardingTipDelegate(impl: OnboardingTipDelegateImpl): OnboardingTipDelegate diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventOutputUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventRecorder.kt similarity index 65% rename from base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventOutputUseCase.kt rename to base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventRecorder.kt index 86054205a9..815dcea45d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventOutputUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventRecorder.kt @@ -17,36 +17,49 @@ import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.files.FileUtils import io.github.sds100.keymapper.system.files.IFile import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -interface GetEventOutputUseCase { +interface GetEventRecorder { + val isRecording: Flow val deviceInfoOutput: Flow val eventsOutput: Flow suspend fun refreshDeviceInfo() - suspend fun recordEvents() - suspend fun stopRecording() + fun recordEvents() + fun stopRecording() fun copyOutput(output: String) suspend fun shareOutput(output: String) } -class GetEventOutputUseCaseImpl @Inject constructor( +@Singleton +class GetEventRecorderImpl @Inject constructor( @ApplicationContext private val context: Context, + private val coroutineScope: CoroutineScope, private val executeShellCommandUseCase: ExecuteShellCommandUseCase, private val preferenceRepository: PreferenceRepository, private val clipboardAdapter: ClipboardAdapter, private val fileAdapter: FileAdapter, private val buildConfigProvider: BuildConfigProvider, private val resourceProvider: ResourceProvider, -) : GetEventOutputUseCase { +) : GetEventRecorder { companion object { private const val MAX_COPY_OUTPUT_LENGTH = 150_000 } + private val recordingJobState: MutableStateFlow = MutableStateFlow(null) + + override val isRecording: Flow = recordingJobState.map { it != null && it.isActive } + override val deviceInfoOutput: Flow = preferenceRepository .get(Keys.getEventDeviceInfoOutput) .map { it.orEmpty() } @@ -59,7 +72,7 @@ class GetEventOutputUseCaseImpl @Inject constructor( val output = executeShellCommandUseCase.execute( command = "getevent -il", executionMode = ShellExecutionMode.ADB, - timeoutMillis = 30_000L, + timeoutMillis = 5_000L, ).handle( onSuccess = { it.stdout }, onError = { "Error: ${it.getFullMessage(resourceProvider)}" }, @@ -67,26 +80,41 @@ class GetEventOutputUseCaseImpl @Inject constructor( preferenceRepository.set(Keys.getEventDeviceInfoOutput, output) } - override suspend fun recordEvents() { - val output = executeShellCommandUseCase.execute( - command = "getevent -lt", - executionMode = ShellExecutionMode.ADB, - timeoutMillis = 300_000L, - ).handle( - onSuccess = { it.stdout }, - onError = { "" }, - ) - if (output.isNotEmpty()) { - preferenceRepository.set(Keys.getEventEventsOutput, output) + override fun recordEvents() { + recordingJobState.update { oldJob -> + oldJob?.cancel() + + coroutineScope.launch { + val output = executeShellCommandUseCase.execute( + command = "getevent -lt", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 60_000L, + ).handle( + onSuccess = { it.stdout }, + onError = { "" }, + ) + + if (output.isNotEmpty()) { + preferenceRepository.set(Keys.getEventEventsOutput, output) + } + } } } - override suspend fun stopRecording() { - executeShellCommandUseCase.execute( - command = "pkill -x getevent || true", - executionMode = ShellExecutionMode.ADB, - timeoutMillis = 5_000L, - ) + override fun stopRecording() { + coroutineScope.launch { + executeShellCommandUseCase.execute( + command = "pkill -x getevent || true", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 5_000L, + ) + + recordingJobState.update { oldJob -> + oldJob?.join() + oldJob?.cancel() + null + } + } } override fun copyOutput(output: String) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt index 253f2d3b18..953bd24e9e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -95,7 +95,7 @@ private enum class RefreshButtonState { @Composable private fun GetEventScreen( modifier: Modifier = Modifier, - state: GetEventViewModel.State, + state: GetEventState, onBackClick: () -> Unit = {}, onToggleRecordClick: () -> Unit = {}, onRefreshDeviceInfoClick: () -> Unit = {}, @@ -309,11 +309,15 @@ private fun ExpertModeSetupCard( } @Composable -private fun InfoContent(modifier: Modifier = Modifier, state: GetEventViewModel.State) { +private fun InfoContent(modifier: Modifier = Modifier, state: GetEventState) { Column(modifier = modifier) { if (state.isLoadingDeviceInfo) { Spacer(Modifier.height(16.dp)) - LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) } if (state.deviceInfoOutput.isNotEmpty()) { @@ -339,7 +343,7 @@ private fun InfoContent(modifier: Modifier = Modifier, state: GetEventViewModel. } @Composable -private fun EventsContent(modifier: Modifier = Modifier, state: GetEventViewModel.State) { +private fun EventsContent(modifier: Modifier = Modifier, state: GetEventState) { val verticalScrollState = rememberScrollState() LaunchedEffect(state.recordingOutput) { @@ -351,15 +355,23 @@ private fun EventsContent(modifier: Modifier = Modifier, state: GetEventViewMode Column(modifier = modifier) { if (state.isRecording) { Spacer(Modifier.height(16.dp)) - LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) Text( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), text = stringResource(R.string.debug_getevent_events_output_after_recording), style = MaterialTheme.typography.bodySmall, ) - } - - if (!state.isRecording && state.recordingOutput.isNotEmpty()) { + } else if (state.recordingOutput.isEmpty()) { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(R.string.debug_getevent_events_empty), + style = MaterialTheme.typography.bodySmall, + ) + } else { SelectionContainer( modifier = Modifier .weight(1f) @@ -386,7 +398,7 @@ private fun EventsContent(modifier: Modifier = Modifier, state: GetEventViewMode private fun PreviewInfoTab() { KeyMapperTheme { GetEventScreen( - state = GetEventViewModel.State( + state = GetEventState( deviceInfoOutput = """add device 1: /dev/input/event0 bus: 0019 vendor 0001 @@ -423,7 +435,7 @@ add device 2: /dev/input/event1 private fun PreviewInfoTabLoading() { KeyMapperTheme { GetEventScreen( - state = GetEventViewModel.State( + state = GetEventState( isLoadingDeviceInfo = true, expertModeStatus = ExpertModeStatus.ENABLED, ), @@ -436,7 +448,7 @@ private fun PreviewInfoTabLoading() { private fun PreviewInfoTabEmptyOutput() { KeyMapperTheme { GetEventScreen( - state = GetEventViewModel.State( + state = GetEventState( expertModeStatus = ExpertModeStatus.ENABLED, ), ) @@ -448,7 +460,7 @@ private fun PreviewInfoTabEmptyOutput() { private fun PreviewRecording() { KeyMapperTheme { GetEventScreen( - state = GetEventViewModel.State( + state = GetEventState( recordingOutput = """/dev/input/event1: EV_KEY KEY_VOLUMEDOWN DOWN /dev/input/event1: EV_SYN SYN_REPORT 00""", isRecording = true, @@ -463,7 +475,7 @@ private fun PreviewRecording() { private fun PreviewEventsContentOutputIdle() { KeyMapperTheme { GetEventScreen( - state = GetEventViewModel.State( + state = GetEventState( recordingOutput = """/dev/input/event2: EV_KEY KEY_VOLUMEUP DOWN /dev/input/event2: EV_SYN SYN_REPORT 00""", isRecording = false, @@ -478,7 +490,7 @@ private fun PreviewEventsContentOutputIdle() { private fun PreviewInfoContentOutputAndLoading() { KeyMapperTheme { GetEventScreen( - state = GetEventViewModel.State( + state = GetEventState( deviceInfoOutput = """add device 3: /dev/input/event2 bus: 0019 vendor 0001 @@ -498,7 +510,7 @@ private fun PreviewInfoContentOutputAndLoading() { private fun PreviewExpertModeDisabled() { KeyMapperTheme { GetEventScreen( - state = GetEventViewModel.State( + state = GetEventState( expertModeStatus = ExpertModeStatus.DISABLED, ), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt index b3b6719afa..78860c5adb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -6,10 +6,12 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.base.utils.ExpertModeStatus import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate +import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import javax.inject.Inject @@ -18,21 +20,16 @@ import kotlinx.coroutines.launch @HiltViewModel class GetEventViewModel @Inject constructor( - private val outputUseCase: GetEventOutputUseCase, + private val outputUseCase: GetEventRecorder, private val navigationProvider: NavigationProvider, private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val pauseKeyMapsUseCase: PauseKeyMapsUseCase, ) : ViewModel(), NavigationProvider by navigationProvider { - data class State( - val deviceInfoOutput: String = "", - val recordingOutput: String = "", - val isLoadingDeviceInfo: Boolean = false, - val isRecording: Boolean = false, - val expertModeStatus: ExpertModeStatus = ExpertModeStatus.DISABLED, - ) + private var resumeKeyMapsOnStop = false - var state: State by mutableStateOf(State()) + var state: GetEventState by mutableStateOf(GetEventState()) private set init { @@ -48,6 +45,17 @@ class GetEventViewModel @Inject constructor( } } + viewModelScope.launch { + outputUseCase.isRecording.collect { isRecording -> + if (!isRecording && resumeKeyMapsOnStop) { + pauseKeyMapsUseCase.resume() + resumeKeyMapsOnStop = false + } + + state = state.copy(isRecording = isRecording) + } + } + viewModelScope.launch { systemBridgeConnectionManager.connectionState.map { connectionState -> when (connectionState) { @@ -84,11 +92,14 @@ class GetEventViewModel @Inject constructor( } private fun startRecording() { - viewModelScope.launch { - state = state.copy(isRecording = true) - outputUseCase.recordEvents() - state = state.copy(isRecording = false) - } + // Only unpause key maps when recording stops if the user hadn't previously paused it + // themselves. + resumeKeyMapsOnStop = !pauseKeyMapsUseCase.isPaused.firstBlocking() + + // Key maps should be paused while recording because any events from grabbed devices + // will not appear in the getevent log. + pauseKeyMapsUseCase.pause() + outputUseCase.recordEvents() } private fun stopRecording() { @@ -135,3 +146,11 @@ class GetEventViewModel @Inject constructor( } } } + +data class GetEventState( + val deviceInfoOutput: String = "", + val recordingOutput: String = "", + val isLoadingDeviceInfo: Boolean = false, + val isRecording: Boolean = false, + val expertModeStatus: ExpertModeStatus = ExpertModeStatus.DISABLED, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt index b8e2b97424..0ef3002cf4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.trigger import android.view.KeyEvent +import io.github.sds100.keymapper.base.debug.GetEventRecorder import io.github.sds100.keymapper.base.detection.DpadMotionEventTracker import io.github.sds100.keymapper.base.input.InputEventDetectionSource import io.github.sds100.keymapper.base.input.InputEventHub @@ -8,6 +9,8 @@ import io.github.sds100.keymapper.base.input.InputEventHubCallback import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.isError +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.isConnected import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent import io.github.sds100.keymapper.system.inputevents.KMEvdevEvent @@ -35,6 +38,8 @@ class RecordTriggerControllerImpl @Inject constructor( private val coroutineScope: CoroutineScope, private val inputEventHub: InputEventHub, private val accessibilityServiceAdapter: AccessibilityServiceAdapter, + private val getEventRecorder: GetEventRecorder, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, ) : RecordTriggerController, InputEventHubCallback { companion object { @@ -115,9 +120,11 @@ class RecordTriggerControllerImpl @Inject constructor( } else if (event.isUpEvent) { onRecordKey(createEvdevRecordedKey(event)) Timber.d( - "Recorded evdev event ${event.code} ${KeyEvent.keyCodeToString( - event.androidCode, - )}", + "Recorded evdev event ${event.code} ${ + KeyEvent.keyCodeToString( + event.androidCode, + ) + }", ) } @@ -193,6 +200,7 @@ class RecordTriggerControllerImpl @Inject constructor( recordingTriggerJob?.cancel() recordingTriggerJob = null + getEventRecorder.stopRecording() dpadMotionEventTracker.reset() inputEventHub.unregisterClient(INPUT_EVENT_HUB_ID) state.update { RecordTriggerState.Completed(recordedKeys) } @@ -255,18 +263,33 @@ class RecordTriggerControllerImpl @Inject constructor( inputEventHub.grabAllEvdevDevices(INPUT_EVENT_HUB_ID) } - repeat(RECORD_TRIGGER_TIMER_LENGTH) { iteration -> - val timeLeft = RECORD_TRIGGER_TIMER_LENGTH - iteration + // Capture getevent output in parallel for the bug report and getevent debug screen. + // ADB shell is only available when expert mode (system bridge) is connected. + if (systemBridgeConnectionManager.isConnected()) { + runCatching { getEventRecorder.refreshDeviceInfo() } + .onFailure { Timber.w(it, "Failed to refresh getevent device info") } - state.update { RecordTriggerState.CountingDown(timeLeft) } - - delay(1000) + getEventRecorder.recordEvents() } - downKeyEvents.clear() - dpadMotionEventTracker.reset() - inputEventHub.unregisterClient(INPUT_EVENT_HUB_ID) - state.update { RecordTriggerState.Completed(recordedKeys) } + try { + repeat(RECORD_TRIGGER_TIMER_LENGTH) { iteration -> + val timeLeft = RECORD_TRIGGER_TIMER_LENGTH - iteration + + state.update { RecordTriggerState.CountingDown(timeLeft) } + + delay(1000) + } + } finally { + // Stop the getevent shell process so the parallel capture job exits and + // its output is persisted to the existing preference keys. + getEventRecorder.stopRecording() + + downKeyEvents.clear() + dpadMotionEventTracker.reset() + inputEventHub.unregisterClient(INPUT_EVENT_HUB_ID) + state.update { RecordTriggerState.Completed(recordedKeys) } + } } } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index bf84f418b3..a6cb758233 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -650,6 +650,7 @@ Info Events Output appears after recording stops. + Record events by tapping the record button below. All key maps will be temporarily paused. Report issue @@ -1462,7 +1463,7 @@ Key Mapper refund query Key Mapper Bug report Please fill the following information so I can help you.\n\n1. Device model: %s\n2. Android version: %s\n3. Key Mapper version: %s\n4, Key maps (make a backup in the home screen menu)\n6. Screenshot of Key Mapper home screen\n6. Describe the problem you are having - The advanced triggers are paid feature but you downloaded the FOSS build of Key Mapper that does not include this closed source module or Google Play billing. Please download Key Mapper from Google Play to get access to this feature. + The advanced triggers are paid feature but you downloaded the FOSS build of Key Mapper that does not include this closed source module or Google Play billing. Please download Key Mapper from Google Play to get access to this feature. Key Mapper FOSS support Floating Buttons and Assistant trigger aren\'t sold in this FOSS build because RevenueCat and Google Play in-app billing aren\'t included.\n\nYou can still support development on Ko-fi. If you donate and later want those products,\