diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index 0b22cc858e..1927e46269 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -25,6 +25,7 @@ import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementViewModel import io.github.sds100.keymapper.base.constraints.ChooseConstraintScreen import io.github.sds100.keymapper.base.constraints.ChooseConstraintViewModel +import io.github.sds100.keymapper.base.debug.GetEventScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupScreen import io.github.sds100.keymapper.base.logging.LogScreen @@ -165,6 +166,14 @@ fun BaseMainNavHost( ) } + composable { + GetEventScreen( + modifier = Modifier.fillMaxSize(), + viewModel = hiltViewModel(), + onBackClick = { navController.popBackStack() }, + ) + } + composable { ChooseSettingScreen( modifier = Modifier.fillMaxSize(), 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 75ebbc90c7..6c5a9240bf 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,6 +25,8 @@ 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 @@ -185,6 +187,10 @@ 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/GetEventOutputUseCase.kt new file mode 100644 index 0000000000..86054205a9 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventOutputUseCase.kt @@ -0,0 +1,110 @@ +package io.github.sds100.keymapper.base.debug + +import android.content.Context +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.base.actions.ExecuteShellCommandUseCase +import io.github.sds100.keymapper.base.utils.ShareUtils +import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.utils.handle +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.system.clipboard.ClipboardAdapter +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +interface GetEventOutputUseCase { + val deviceInfoOutput: Flow + val eventsOutput: Flow + + suspend fun refreshDeviceInfo() + suspend fun recordEvents() + suspend fun stopRecording() + fun copyOutput(output: String) + suspend fun shareOutput(output: String) +} + +class GetEventOutputUseCaseImpl @Inject constructor( + @ApplicationContext private val context: Context, + 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 { + + companion object { + private const val MAX_COPY_OUTPUT_LENGTH = 150_000 + } + + override val deviceInfoOutput: Flow = preferenceRepository + .get(Keys.getEventDeviceInfoOutput) + .map { it.orEmpty() } + + override val eventsOutput: Flow = preferenceRepository + .get(Keys.getEventEventsOutput) + .map { it.orEmpty() } + + override suspend fun refreshDeviceInfo() { + val output = executeShellCommandUseCase.execute( + command = "getevent -il", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 30_000L, + ).handle( + onSuccess = { it.stdout }, + onError = { "Error: ${it.getFullMessage(resourceProvider)}" }, + ) + 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 suspend fun stopRecording() { + executeShellCommandUseCase.execute( + command = "pkill -x getevent || true", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 5_000L, + ) + } + + override fun copyOutput(output: String) { + clipboardAdapter.copy( + "getevent output", + output.takeLast(MAX_COPY_OUTPUT_LENGTH), + ) + } + + override suspend fun shareOutput(output: String) { + withContext(Dispatchers.IO) { + val fileName = "getevent/key_mapper_getevent_${FileUtils.createFileDate()}.txt" + val file: IFile = fileAdapter.getPrivateFile(fileName) + file.createFile() + file.outputStream()?.bufferedWriter()?.use { it.write(output) } + + val publicUri = fileAdapter.getPublicUriForPrivateFile(file).toUri() + ShareUtils.shareFile(context, publicUri, buildConfigProvider.packageName) + } + } +} 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 new file mode 100644 index 0000000000..3322defce5 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -0,0 +1,513 @@ +package io.github.sds100.keymapper.base.debug + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.rounded.FiberManualRecord +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ExpertModeStatus +import kotlinx.coroutines.launch + +@Composable +fun GetEventScreen( + modifier: Modifier = Modifier, + viewModel: GetEventViewModel = hiltViewModel(), + onBackClick: () -> Unit, +) { + GetEventScreen( + modifier = modifier, + state = viewModel.state, + onBackClick = onBackClick, + onToggleRecordClick = viewModel::onToggleRecordClick, + onRefreshDeviceInfoClick = viewModel::onRefreshDeviceInfoClick, + onCopyToClipboardClick = { tab -> viewModel.onCopyToClipboardClick(tab.toOutputTab()) }, + onSaveToFileClick = { tab -> viewModel.onSaveToFileClick(tab.toOutputTab()) }, + onSetupExpertModeClick = viewModel::onSetupExpertModeClick, + ) +} + +private enum class GetEventTab { + INFO, + EVENTS, +} + +private enum class RefreshButtonState { + REFRESH_INFO, + START_RECORDING, + STOP, +} + +private fun GetEventTab.toOutputTab(): GetEventViewModel.OutputTab = when (this) { + GetEventTab.INFO -> GetEventViewModel.OutputTab.INFO + GetEventTab.EVENTS -> GetEventViewModel.OutputTab.EVENTS +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GetEventScreen( + modifier: Modifier = Modifier, + state: GetEventViewModel.State, + onBackClick: () -> Unit = {}, + onToggleRecordClick: () -> Unit = {}, + onRefreshDeviceInfoClick: () -> Unit = {}, + onCopyToClipboardClick: (GetEventTab) -> Unit = {}, + onSaveToFileClick: (GetEventTab) -> Unit = {}, + onSetupExpertModeClick: () -> Unit = {}, +) { + val pagerState = rememberPagerState(pageCount = { 2 }) + val scope = rememberCoroutineScope() + val isExpertModeEnabled = state.expertModeStatus == ExpertModeStatus.ENABLED + val selectedTab = if (pagerState.currentPage == 0) GetEventTab.INFO else GetEventTab.EVENTS + val hasOutputForSelectedTab = when (selectedTab) { + GetEventTab.INFO -> state.deviceInfoOutput.isNotEmpty() + GetEventTab.EVENTS -> state.recordingOutput.isNotEmpty() + } + val refreshButtonState = when { + selectedTab == GetEventTab.INFO -> RefreshButtonState.REFRESH_INFO + state.isRecording -> RefreshButtonState.STOP + else -> RefreshButtonState.START_RECORDING + } + + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_debug_getevent)) }, + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + val containerColor = if (refreshButtonState == RefreshButtonState.STOP) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + val contentColor = if (refreshButtonState == RefreshButtonState.STOP) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + FloatingActionButton( + modifier = if (isExpertModeEnabled) Modifier else Modifier.alpha(0.5f), + onClick = { + if (!isExpertModeEnabled) { + return@FloatingActionButton + } + if (selectedTab == GetEventTab.INFO) { + onRefreshDeviceInfoClick() + } else { + if (!state.isRecording) { + scope.launch { pagerState.animateScrollToPage(1) } + } + onToggleRecordClick() + } + }, + containerColor = containerColor, + contentColor = contentColor, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + ) { + AnimatedContent( + targetState = refreshButtonState, + transitionSpec = { + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(200)) + }, + label = "refresh_button_state", + ) { buttonState -> + when (buttonState) { + RefreshButtonState.REFRESH_INFO -> { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = stringResource( + R.string.debug_getevent_refresh, + ), + ) + } + + RefreshButtonState.START_RECORDING -> { + Icon( + imageVector = Icons.Rounded.FiberManualRecord, + contentDescription = stringResource( + R.string.debug_getevent_start_recording, + ), + ) + } + + RefreshButtonState.STOP -> { + Icon( + imageVector = Icons.Rounded.Stop, + contentDescription = stringResource( + R.string.debug_getevent_stop_recording, + ), + ) + } + } + } + } + }, + actions = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = { onCopyToClipboardClick(selectedTab) }, + enabled = hasOutputForSelectedTab, + ) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = stringResource(R.string.debug_getevent_copy), + ) + } + IconButton( + onClick = { onSaveToFileClick(selectedTab) }, + enabled = hasOutputForSelectedTab, + ) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = stringResource(R.string.debug_getevent_save), + ) + } + }, + ) + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + Column(modifier = Modifier.fillMaxSize()) { + if (state.expertModeStatus != ExpertModeStatus.ENABLED) { + ExpertModeSetupCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + onSetupExpertModeClick = onSetupExpertModeClick, + ) + } + PrimaryTabRow( + selectedTabIndex = pagerState.targetPage, + modifier = Modifier.fillMaxWidth(), + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Tab( + selected = pagerState.targetPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.debug_getevent_tab_info)) }, + ) + Tab( + selected = pagerState.targetPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.debug_getevent_tab_events)) }, + ) + } + + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + ) { pageIndex -> + when (pageIndex) { + 0 -> InfoContent( + modifier = Modifier.fillMaxSize(), + state = state, + ) + + 1 -> EventsContent( + modifier = Modifier.fillMaxSize(), + state = state, + ) + } + } + } + } + } +} + +@Composable +private fun ExpertModeSetupCard( + modifier: Modifier = Modifier, + onSetupExpertModeClick: () -> Unit, +) { + ElevatedCard(modifier = modifier) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.debug_getevent_expert_mode_required), + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton( + onClick = onSetupExpertModeClick, + colors = ButtonDefaults.outlinedButtonColors(), + ) { + Text(stringResource(R.string.action_shell_command_setup_expert_mode)) + } + } + } +} + +@Composable +private fun InfoContent(modifier: Modifier = Modifier, state: GetEventViewModel.State) { + Column(modifier = modifier) { + if (state.isLoadingDeviceInfo) { + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) + } + + if (state.deviceInfoOutput.isNotEmpty()) { + SelectionContainer( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), + ) { + Text( + modifier = Modifier.padding(16.dp), + text = state.deviceInfoOutput, + softWrap = false, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ), + lineHeight = 13.sp, + ) + } + } + } +} + +@Composable +private fun EventsContent(modifier: Modifier = Modifier, state: GetEventViewModel.State) { + val verticalScrollState = rememberScrollState() + + LaunchedEffect(state.recordingOutput) { + if (state.recordingOutput.isNotEmpty()) { + verticalScrollState.animateScrollTo(verticalScrollState.maxValue) + } + } + + Column(modifier = modifier) { + if (state.isRecording) { + Spacer(Modifier.height(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.recordingOutput.isNotEmpty()) { + SelectionContainer( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()) + .verticalScroll(verticalScrollState), + ) { + Text( + modifier = Modifier.padding(16.dp), + text = state.recordingOutput, + softWrap = false, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ), + lineHeight = 13.sp, + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewInfoTab() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + deviceInfoOutput = """add device 1: /dev/input/event0 + bus: 0019 + vendor 0001 + product 0001 + version 0100 + name: "gpio-keys" + location: "gpio-keys/input0" + id: "" + version: 1.0.1 + events: + KEY (0001): KEY_POWER + input props: + +add device 2: /dev/input/event1 + bus: 0006 + vendor 0000 + product 0000 + version 0000 + name: "virtio_input_multi_touch_1" + location: "virtio10/input0" + id: "" + version: 1.0.1 + events: + KEY (0001): BTN_TOOL_RUBBER BTN_STYLUS + ABS (0003): ABS_X : value 0, min 0, max 32767, fuzz 0, flat 0, resolution 0""", + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewInfoTabLoading() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + isLoadingDeviceInfo = true, + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewInfoTabEmptyOutput() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewRecording() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + recordingOutput = """/dev/input/event1: EV_KEY KEY_VOLUMEDOWN DOWN +/dev/input/event1: EV_SYN SYN_REPORT 00""", + isRecording = true, + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewEventsContentOutputIdle() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + recordingOutput = """/dev/input/event2: EV_KEY KEY_VOLUMEUP DOWN +/dev/input/event2: EV_SYN SYN_REPORT 00""", + isRecording = false, + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewInfoContentOutputAndLoading() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + deviceInfoOutput = """add device 3: /dev/input/event2 + bus: 0019 + vendor 0001 + product 0001 + version 0100 + name: "gpio-keys-2" + location: "gpio-keys/input1\"""", + isLoadingDeviceInfo = true, + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewExpertModeDisabled() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + 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 new file mode 100644 index 0000000000..aa4846544b --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -0,0 +1,134 @@ +package io.github.sds100.keymapper.base.debug + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.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.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import javax.inject.Inject +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@HiltViewModel +class GetEventViewModel @Inject constructor( + private val outputUseCase: GetEventOutputUseCase, + private val navigationProvider: NavigationProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, +) : ViewModel(), + NavigationProvider by navigationProvider { + + enum class OutputTab { + INFO, + EVENTS, + } + + data class State( + val deviceInfoOutput: String = "", + val recordingOutput: String = "", + val isLoadingDeviceInfo: Boolean = false, + val isRecording: Boolean = false, + val expertModeStatus: ExpertModeStatus = ExpertModeStatus.DISABLED, + ) + + var state: State by mutableStateOf(State()) + private set + + init { + viewModelScope.launch { + outputUseCase.deviceInfoOutput.collect { output -> + state = state.copy(deviceInfoOutput = output) + } + } + + viewModelScope.launch { + outputUseCase.eventsOutput.collect { output -> + state = state.copy(recordingOutput = output) + } + } + + viewModelScope.launch { + systemBridgeConnectionManager.connectionState.map { connectionState -> + when (connectionState) { + is SystemBridgeConnectionState.Connected -> ExpertModeStatus.ENABLED + is SystemBridgeConnectionState.Disconnected -> ExpertModeStatus.DISABLED + } + }.collect { status -> + val wasDisabled = state.expertModeStatus != ExpertModeStatus.ENABLED + state = state.copy(expertModeStatus = status) + if (status == ExpertModeStatus.ENABLED && + wasDisabled && + state.deviceInfoOutput.isEmpty() + ) { + onRefreshDeviceInfoClick() + } + } + } + } + + fun onRefreshDeviceInfoClick() { + viewModelScope.launch { + state = state.copy(isLoadingDeviceInfo = true) + outputUseCase.refreshDeviceInfo() + state = state.copy(isLoadingDeviceInfo = false) + } + } + + fun onToggleRecordClick() { + if (state.isRecording) { + stopRecording() + } else { + startRecording() + } + } + + private fun startRecording() { + viewModelScope.launch { + state = state.copy(isRecording = true) + outputUseCase.recordEvents() + state = state.copy(isRecording = false) + } + } + + private fun stopRecording() { + viewModelScope.launch { + outputUseCase.stopRecording() + } + } + + fun onCopyToClipboardClick(tab: OutputTab) { + outputUseCase.copyOutput(getOutputForTab(tab)) + } + + fun onSaveToFileClick(tab: OutputTab) { + viewModelScope.launch { + outputUseCase.shareOutput(getOutputForTab(tab)) + } + } + + private fun getOutputForTab(tab: OutputTab): String = when (tab) { + OutputTab.INFO -> state.deviceInfoOutput + OutputTab.EVENTS -> state.recordingOutput + } + + fun onBackClick() { + viewModelScope.launch { + navigationProvider.popBackStack() + } + } + + fun onSetupExpertModeClick() { + viewModelScope.launch { + navigationProvider.navigate( + "getevent_setup_expert_mode", + NavDestination.ExpertModeSetup, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt index 5d79cd6838..b4435f13e2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt @@ -152,6 +152,7 @@ fun SettingsScreen(modifier: Modifier = Modifier, viewModel: SettingsViewModel) onForceVibrateToggled = viewModel::onForceVibrateToggled, onLoggingToggled = viewModel::onLoggingToggled, onViewLogClick = viewModel::onViewLogClick, + onGetEventClick = viewModel::onGetEventClick, onHideHomeScreenAlertsToggled = viewModel::onHideHomeScreenAlertsToggled, onShowDeviceDescriptorsToggled = viewModel::onShowDeviceDescriptorsToggled, onAutomaticBackupClick = { @@ -235,6 +236,7 @@ private fun Content( onForceVibrateToggled: (Boolean) -> Unit = { }, onLoggingToggled: (Boolean) -> Unit = { }, onViewLogClick: () -> Unit = { }, + onGetEventClick: () -> Unit = { }, onShareLogcatClick: () -> Unit = { }, onHideHomeScreenAlertsToggled: (Boolean) -> Unit = { }, onShowDeviceDescriptorsToggled: (Boolean) -> Unit = { }, @@ -398,6 +400,13 @@ private fun Content( onClick = onShareLogcatClick, ) + OptionPageButton( + title = stringResource(R.string.title_pref_get_event_debug), + text = stringResource(R.string.summary_pref_get_event_debug), + icon = Icons.Rounded.Keyboard, + onClick = onGetEventClick, + ) + Spacer(modifier = Modifier.height(8.dp)) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt index 7628eb1edc..1a2ef5dbda 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt @@ -291,6 +291,12 @@ class SettingsViewModel @Inject constructor( } } + fun onGetEventClick() { + viewModelScope.launch { + navigate("get_event_debug", NavDestination.GetEvent) + } + } + fun onShareLogcatClick() { viewModelScope.launch { if (shareLogcatUseCase.isPermissionGranted()) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index f13888bccd..6aae4a0db8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -45,6 +45,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_EXPERT_MODE = "expert_mode" const val ID_LOG = "log" const val ID_ADVANCED_TRIGGERS = "advanced_triggers" + const val ID_GET_EVENT = "get_event" } @Serializable @@ -211,4 +212,9 @@ abstract class NavDestination(val isCompose: Boolean = false) { data object AdvancedTriggers : NavDestination(isCompose = true) { override val id: String = ID_ADVANCED_TRIGGERS } + + @Serializable + data object GetEvent : NavDestination(isCompose = true) { + override val id: String = ID_GET_EVENT + } } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index d90aef6edc..9d6bb9862b 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -635,6 +635,21 @@ Share the entire system log Sharing logcat failed + Expert Mode debug + View raw input device info and events + Expert Mode Debug + Expert Mode is required to use getevent. Start Expert Mode and then return to this screen. + Record + Stop Recording + Clear output + Refresh + Loading device info\u2026 + Copy to clipboard + Save to file + Info + Events + Output appears after recording stops. + Report issue Delete sound files diff --git a/base/src/main/res/xml/provider_paths.xml b/base/src/main/res/xml/provider_paths.xml index 81fcea4ee0..72b9380231 100644 --- a/base/src/main/res/xml/provider_paths.xml +++ b/base/src/main/res/xml/provider_paths.xml @@ -4,6 +4,10 @@ name="backups" path="backups" /> + + diff --git a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt index 65f9615f5f..59e88eb0a4 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt @@ -153,6 +153,8 @@ object Keys { booleanPreferencesKey("key_key_event_actions_use_system_bridge") val shellCommandScriptText = stringPreferencesKey("key_shell_command_script_text") + val getEventDeviceInfoOutput = stringPreferencesKey("key_getevent_device_info_output") + val getEventEventsOutput = stringPreferencesKey("key_getevent_events_output") /** * This is stored as true when PRO Mode has been auto started after updating