From 8b95658eb161b2ecd2eb4c413a60035d814a0ea6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 22:39:58 +0200 Subject: [PATCH 1/4] #2137 feat: capture getevent output during trigger recording --- .../keymapper/base/BaseSingletonHiltModule.kt | 6 +++ .../keymapper/base/BaseViewModelHiltModule.kt | 6 --- .../base/trigger/RecordTriggerController.kt | 53 +++++++++++++++---- 3 files changed, 50 insertions(+), 15 deletions(-) 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..7a1696d4f8 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.GetEventOutputUseCase +import io.github.sds100.keymapper.base.debug.GetEventOutputUseCaseImpl 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: GetEventOutputUseCaseImpl): GetEventOutputUseCase + @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/trigger/RecordTriggerController.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt index b8e2b97424..b6affe2480 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.GetEventOutputUseCase 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 @@ -20,6 +23,7 @@ import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -28,6 +32,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import timber.log.Timber @Singleton @@ -35,6 +40,8 @@ class RecordTriggerControllerImpl @Inject constructor( private val coroutineScope: CoroutineScope, private val inputEventHub: InputEventHub, private val accessibilityServiceAdapter: AccessibilityServiceAdapter, + private val getEventOutputUseCase: GetEventOutputUseCase, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, ) : RecordTriggerController, InputEventHubCallback { companion object { @@ -255,18 +262,46 @@ 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. + // Launch on the outer coroutineScope so the capture lifecycle is independent of this + // job's cancellation state and we can drain its output even when the user stops early. + val geteventCaptureJob: Job? = if (systemBridgeConnectionManager.isConnected()) { + coroutineScope.launch(Dispatchers.IO) { + runCatching { getEventOutputUseCase.refreshDeviceInfo() } + .onFailure { Timber.w(it, "Failed to refresh getevent device info") } + runCatching { getEventOutputUseCase.recordEvents() } + .onFailure { Timber.w(it, "Failed to record getevent events") } + } + } else { + null + } - state.update { RecordTriggerState.CountingDown(timeLeft) } + try { + repeat(RECORD_TRIGGER_TIMER_LENGTH) { iteration -> + val timeLeft = RECORD_TRIGGER_TIMER_LENGTH - iteration - delay(1000) - } + state.update { RecordTriggerState.CountingDown(timeLeft) } - downKeyEvents.clear() - dpadMotionEventTracker.reset() - inputEventHub.unregisterClient(INPUT_EVENT_HUB_ID) - state.update { RecordTriggerState.Completed(recordedKeys) } + delay(1000) + } + } finally { + downKeyEvents.clear() + dpadMotionEventTracker.reset() + inputEventHub.unregisterClient(INPUT_EVENT_HUB_ID) + state.update { RecordTriggerState.Completed(recordedKeys) } + + if (geteventCaptureJob != null) { + // Stop the getevent shell process so the parallel capture job exits and + // its output is persisted to the existing preference keys. Run on + // NonCancellable so we still kill getevent when this job is cancelled. + withContext(NonCancellable) { + runCatching { getEventOutputUseCase.stopRecording() } + .onFailure { Timber.w(it, "Failed to stop parallel getevent capture") } + geteventCaptureJob.join() + } + } + } } } From e20e3401237439260d8644cf414c3dcd86b58174 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 10 May 2026 14:10:06 +0100 Subject: [PATCH 2/4] bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From ef110d40899c4b6e30efb712db5f1f37f0f78243 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 10 May 2026 15:14:07 +0100 Subject: [PATCH 3/4] show text when getevent output is empty --- .../keymapper/base/debug/GetEventScreen.kt | 42 ++++++++++++------- base/src/main/res/values/strings.xml | 3 +- 2 files changed, 29 insertions(+), 16 deletions(-) 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/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,\ From 06298c82c8e548c5dd26f54f97701d1337d57bc5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 10 May 2026 15:14:21 +0100 Subject: [PATCH 4/4] fix various bugs with recording getevent while recording --- .../keymapper/base/BaseSingletonHiltModule.kt | 6 +- ...ntOutputUseCase.kt => GetEventRecorder.kt} | 74 +++++++++++++------ .../keymapper/base/debug/GetEventViewModel.kt | 47 ++++++++---- .../base/trigger/RecordTriggerController.kt | 46 +++++------- 4 files changed, 104 insertions(+), 69 deletions(-) rename base/src/main/java/io/github/sds100/keymapper/base/debug/{GetEventOutputUseCase.kt => GetEventRecorder.kt} (65%) 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 7a1696d4f8..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,8 +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.GetEventOutputUseCase -import io.github.sds100.keymapper.base.debug.GetEventOutputUseCaseImpl +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 @@ -137,7 +137,7 @@ abstract class BaseSingletonHiltModule { @Binds @Singleton - abstract fun bindGetEventOutputUseCase(impl: GetEventOutputUseCaseImpl): GetEventOutputUseCase + abstract fun bindGetEventOutputUseCase(impl: GetEventRecorderImpl): GetEventRecorder @Binds @Singleton 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/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 b6affe2480..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,7 +1,7 @@ package io.github.sds100.keymapper.base.trigger import android.view.KeyEvent -import io.github.sds100.keymapper.base.debug.GetEventOutputUseCase +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 @@ -23,7 +23,6 @@ import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -32,7 +31,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import timber.log.Timber @Singleton @@ -40,7 +38,7 @@ class RecordTriggerControllerImpl @Inject constructor( private val coroutineScope: CoroutineScope, private val inputEventHub: InputEventHub, private val accessibilityServiceAdapter: AccessibilityServiceAdapter, - private val getEventOutputUseCase: GetEventOutputUseCase, + private val getEventRecorder: GetEventRecorder, private val systemBridgeConnectionManager: SystemBridgeConnectionManager, ) : RecordTriggerController, InputEventHubCallback { @@ -122,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, + ) + }", ) } @@ -200,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) } @@ -264,17 +265,11 @@ class RecordTriggerControllerImpl @Inject constructor( // 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. - // Launch on the outer coroutineScope so the capture lifecycle is independent of this - // job's cancellation state and we can drain its output even when the user stops early. - val geteventCaptureJob: Job? = if (systemBridgeConnectionManager.isConnected()) { - coroutineScope.launch(Dispatchers.IO) { - runCatching { getEventOutputUseCase.refreshDeviceInfo() } - .onFailure { Timber.w(it, "Failed to refresh getevent device info") } - runCatching { getEventOutputUseCase.recordEvents() } - .onFailure { Timber.w(it, "Failed to record getevent events") } - } - } else { - null + if (systemBridgeConnectionManager.isConnected()) { + runCatching { getEventRecorder.refreshDeviceInfo() } + .onFailure { Timber.w(it, "Failed to refresh getevent device info") } + + getEventRecorder.recordEvents() } try { @@ -286,21 +281,14 @@ class RecordTriggerControllerImpl @Inject constructor( 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) } - - if (geteventCaptureJob != null) { - // Stop the getevent shell process so the parallel capture job exits and - // its output is persisted to the existing preference keys. Run on - // NonCancellable so we still kill getevent when this job is cancelled. - withContext(NonCancellable) { - runCatching { getEventOutputUseCase.stopRecording() } - .onFailure { Timber.w(it, "Failed to stop parallel getevent capture") } - geteventCaptureJob.join() - } - } } } }