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..acc0092714 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 @@ -27,6 +27,7 @@ import io.github.sds100.keymapper.base.constraints.ChooseConstraintScreen import io.github.sds100.keymapper.base.constraints.ChooseConstraintViewModel import io.github.sds100.keymapper.base.expertmode.ExpertModeScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupScreen +import io.github.sds100.keymapper.base.debug.GetEventScreen import io.github.sds100.keymapper.base.logging.LogScreen import io.github.sds100.keymapper.base.onboarding.HandleAccessibilityServiceDialogs import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegateImpl @@ -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/debug/GetEventScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt new file mode 100644 index 0000000000..d904202a6d --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -0,0 +1,331 @@ +package io.github.sds100.keymapper.base.debug + +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.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.Delete +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.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +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 + +@Composable +fun GetEventScreen( + modifier: Modifier = Modifier, + viewModel: GetEventViewModel = hiltViewModel(), + onBackClick: () -> Unit, +) { + GetEventScreen( + modifier = modifier, + state = viewModel.state, + onBackClick = onBackClick, + onToggleRecordClick = viewModel::onToggleRecordClick, + onClearClick = viewModel::onClearClick, + onCopyToClipboardClick = viewModel::onCopyToClipboardClick, + onSaveToFileClick = viewModel::onSaveToFileClick, + onSetupExpertModeClick = viewModel::onSetupExpertModeClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GetEventScreen( + modifier: Modifier = Modifier, + state: GetEventViewModel.State, + onBackClick: () -> Unit = {}, + onToggleRecordClick: () -> Unit = {}, + onClearClick: () -> Unit = {}, + onCopyToClipboardClick: () -> Unit = {}, + onSaveToFileClick: () -> Unit = {}, + onSetupExpertModeClick: () -> Unit = {}, +) { + val hasOutput = state.output.isNotEmpty() + + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_debug_getevent)) }, + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + if (state.expertModeStatus == ExpertModeStatus.ENABLED) { + val containerColor = if (state.isRecording) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + val contentColor = if (state.isRecording) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + FloatingActionButton( + onClick = onToggleRecordClick, + containerColor = containerColor, + contentColor = contentColor, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + ) { + if (state.isRecording) { + Icon( + imageVector = Icons.Rounded.Stop, + contentDescription = stringResource( + R.string.debug_getevent_stop_recording, + ), + ) + } else { + Icon( + imageVector = Icons.Rounded.FiberManualRecord, + contentDescription = stringResource( + R.string.debug_getevent_start_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, + enabled = hasOutput, + ) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = stringResource(R.string.debug_getevent_copy), + ) + } + IconButton( + onClick = onSaveToFileClick, + enabled = hasOutput, + ) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = stringResource(R.string.debug_getevent_save), + ) + } + IconButton( + onClick = onClearClick, + enabled = hasOutput, + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.debug_getevent_clear), + ) + } + }, + ) + }, + ) { 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, + ), + ) { + Content( + modifier = Modifier.fillMaxSize(), + state = state, + onSetupExpertModeClick = onSetupExpertModeClick, + ) + } + } +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + state: GetEventViewModel.State, + onSetupExpertModeClick: () -> Unit, +) { + val scrollState = rememberScrollState() + + LaunchedEffect(state.output) { + if (state.output.isNotEmpty()) { + scrollState.animateScrollTo(scrollState.maxValue) + } + } + + Column(modifier = modifier) { + if (state.expertModeStatus != ExpertModeStatus.ENABLED) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + 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)) + } + } + } + } + + if (state.isLoadingDeviceInfo) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + + if (state.isRecording) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + + if (state.output.isNotEmpty()) { + SelectionContainer( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = state.output, + softWrap = false, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ), + lineHeight = 13.sp, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Preview +@Composable +private fun PreviewWithOutput() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + output = """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 PreviewRecording() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + output = "add device 1: /dev/input/event0\n name: \"gpio-keys\"", + isRecording = 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..42ca58ae17 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -0,0 +1,157 @@ +package io.github.sds100.keymapper.base.debug + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.base.actions.ExecuteShellCommandUseCase +import io.github.sds100.keymapper.base.utils.ExpertModeStatus +import io.github.sds100.keymapper.base.utils.ShareUtils +import io.github.sds100.keymapper.base.utils.getFullMessage +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.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.system.clipboard.ClipboardAdapter +import io.github.sds100.keymapper.system.files.FileAdapter +import io.github.sds100.keymapper.system.files.FileUtils +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@HiltViewModel +class GetEventViewModel @Inject constructor( + private val executeShellCommandUseCase: ExecuteShellCommandUseCase, + private val navigationProvider: NavigationProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val clipboardAdapter: ClipboardAdapter, + private val fileAdapter: FileAdapter, + private val buildConfigProvider: BuildConfigProvider, + @ApplicationContext private val context: Context, + resourceProvider: ResourceProvider, +) : ViewModel(), NavigationProvider by navigationProvider, ResourceProvider by resourceProvider { + + data class State( + val output: 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 { + 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.output.isEmpty()) { + loadDeviceInfo() + } + } + } + } + + private fun loadDeviceInfo() { + viewModelScope.launch { + state = state.copy(isLoadingDeviceInfo = true) + val result = executeShellCommandUseCase.execute( + command = "getevent -il", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 30_000L, + ) + val output = result.handle( + onSuccess = { it.stdout }, + onError = { "Error: ${it.getFullMessage(this@GetEventViewModel)}" }, + ) + state = state.copy(output = output, isLoadingDeviceInfo = false) + } + } + + fun onToggleRecordClick() { + if (state.isRecording) { + stopRecording() + } else { + startRecording() + } + } + + private fun startRecording() { + state = state.copy(isRecording = true) + viewModelScope.launch { + val result = executeShellCommandUseCase.execute( + command = "getevent -lt", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 300_000L, + ) + val newOutput = result.handle( + onSuccess = { it.stdout }, + onError = { "" }, + ) + if (newOutput.isNotEmpty()) { + state = state.copy( + output = state.output + "\n--- Recording ---\n" + newOutput, + ) + } + state = state.copy(isRecording = false) + } + } + + private fun stopRecording() { + viewModelScope.launch { + executeShellCommandUseCase.execute( + command = "pkill -x getevent || true", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 5_000L, + ) + } + } + + fun onClearClick() { + state = state.copy(output = "") + } + + fun onCopyToClipboardClick() { + clipboardAdapter.copy("getevent output", state.output) + } + + fun onSaveToFileClick() { + viewModelScope.launch(Dispatchers.IO) { + val fileName = "getevent/key_mapper_getevent_${FileUtils.createFileDate()}.txt" + val file = fileAdapter.getPrivateFile(fileName) + file.createFile() + file.outputStream()?.bufferedWriter()?.use { it.write(state.output) } + val publicUri = fileAdapter.getPublicUriForPrivateFile(file).toUri() + ShareUtils.shareFile(context, publicUri, buildConfigProvider.packageName) + } + } + + 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..3fa56c873e 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -635,6 +635,17 @@ 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 + Loading device info\u2026 + Copy to clipboard + Save to file + Report issue Delete sound files