diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e80b40e6..db78ba37e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ +## [4.0.0 Beta 6](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.06) + +#### 4 January 2026 + +## Added + +- #1964 show the command to start Expert Mode with a shell command. + +## Bug fixes + +- #1968 Device controls action no longer works on Android 16+ so it has been disabled on new Android versions. +- #1967 Still start system bridge if granting WRITE_SECURE_SETTINGS fails. +- #1965 Better system bridge support on Xiaomi devices and ask to enable "USB debugging security settings" in developer options. + ## [4.0.0 Beta 5](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.05) -#### TO BE RELEASED +#### 1 January 2026 + +Happy new year! ## Added - #1947 show tip to use expert mode where the old option for screen off remapping used to be diff --git a/app/version.properties b/app/version.properties index 6d3cc998f5..c4fdc8c42a 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.0.0-beta.05 -VERSION_CODE=220 +VERSION_NAME=4.0.0-beta.06 +VERSION_CODE=226 diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index a9b7fa1402..9bd390fccc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -694,6 +694,8 @@ object ActionUtils { // is not marked as deprecated even though it doesn't work. ActionId.TOGGLE_SPLIT_SCREEN -> Build.VERSION_CODES.S + ActionId.DEVICE_CONTROLS -> Build.VERSION_CODES.VANILLA_ICE_CREAM + else -> Constants.MAX_API } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index 8b1dd81c9d..77171ed65a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -689,6 +689,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val actionType = when (action.direction) { ActionData.MoveCursor.Direction.START -> AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY + ActionData.MoveCursor.Direction.END -> AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY } @@ -696,12 +697,16 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val granularity = when (action.moveType) { ActionData.MoveCursor.Type.CHAR -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER + ActionData.MoveCursor.Type.WORD -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD + ActionData.MoveCursor.Type.LINE -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE + ActionData.MoveCursor.Type.PARAGRAPH -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH + ActionData.MoveCursor.Type.PAGE -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE } @@ -1040,10 +1045,13 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is Success -> Timber.d( "Performed action $action, input event type: $inputEventAction, key meta state: $keyMetaState", ) + is KMError -> Timber.d( - "Failed to perform action $action, reason: ${result.getFullMessage( - resourceProvider, - )}, action: $action, input event type: $inputEventAction, key meta state: $keyMetaState", + "Failed to perform action $action, reason: ${ + result.getFullMessage( + resourceProvider, + ) + }, action: $action, input event type: $inputEventAction, key meta state: $keyMetaState", ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt index 496c22c917..5275f2fbc9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.expertmode +import android.content.ClipData import android.os.Build import android.provider.Settings import androidx.compose.animation.AnimatedVisibility @@ -9,6 +10,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,10 +32,10 @@ import androidx.compose.material.icons.automirrored.rounded.HelpOutline import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Numbers import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Usb import androidx.compose.material.icons.rounded.WarningAmber import androidx.compose.material3.BottomAppBar @@ -54,12 +56,16 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -74,12 +80,12 @@ import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcon import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.State +import kotlinx.coroutines.launch @Composable fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewModel) { val expertModeWarningState by viewModel.warningState.collectAsStateWithLifecycle() - val expertModeSetupState by viewModel.setupState.collectAsStateWithLifecycle() - val autoStartBootEnabled by viewModel.autoStartBootEnabled.collectAsStateWithLifecycle() + val expertModeState by viewModel.state.collectAsStateWithLifecycle() ExpertModeScreen( modifier = modifier, @@ -89,7 +95,7 @@ fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewMod ) { Content( warningState = expertModeWarningState, - setupState = expertModeSetupState, + setupState = expertModeState, showInfoCard = viewModel.showInfoCard, onInfoCardDismiss = { viewModel.hideInfoCard() }, onWarningButtonClick = viewModel::onWarningButtonClick, @@ -98,8 +104,9 @@ fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewMod onRootButtonClick = viewModel::onRootButtonClick, onSetupWithKeyMapperClick = viewModel::onSetupWithKeyMapperClick, onRequestNotificationPermissionClick = viewModel::onRequestNotificationPermissionClick, - autoStartAtBoot = autoStartBootEnabled, onAutoStartAtBootToggled = { viewModel.onAutoStartBootToggled() }, + onLaunchDeveloperOptionsClick = viewModel::onLaunchDeveloperOptionsClick, + onGetShellStartCommandClick = viewModel::onGetShellStartCommandClick, ) } } @@ -179,8 +186,9 @@ private fun Content( onRootButtonClick: () -> Unit = {}, onSetupWithKeyMapperClick: () -> Unit = {}, onRequestNotificationPermissionClick: () -> Unit = {}, - autoStartAtBoot: Boolean, - onAutoStartAtBootToggled: (Boolean) -> Unit = {}, + onAutoStartAtBootToggled: () -> Unit = {}, + onLaunchDeveloperOptionsClick: () -> Unit = {}, + onGetShellStartCommandClick: () -> Unit = {}, ) { Column(modifier = modifier.verticalScroll(rememberScrollState())) { AnimatedVisibility( @@ -227,8 +235,9 @@ private fun Content( onRootButtonClick = onRootButtonClick, onSetupWithKeyMapperClick = onSetupWithKeyMapperClick, onRequestNotificationPermissionClick = onRequestNotificationPermissionClick, - autoStartAtBoot = autoStartAtBoot, onAutoStartAtBootToggled = onAutoStartAtBootToggled, + onLaunchDeveloperOptionsClick = onLaunchDeveloperOptionsClick, + onGetShellStartCommandClick = onGetShellStartCommandClick, ) } } @@ -251,8 +260,9 @@ private fun LoadedContent( onStopServiceClick: () -> Unit, onSetupWithKeyMapperClick: () -> Unit, onRequestNotificationPermissionClick: () -> Unit = {}, - autoStartAtBoot: Boolean, - onAutoStartAtBootToggled: (Boolean) -> Unit = {}, + onAutoStartAtBootToggled: () -> Unit = {}, + onLaunchDeveloperOptionsClick: () -> Unit = {}, + onGetShellStartCommandClick: () -> Unit = {}, ) { Column(modifier) { OptionsHeaderRow( @@ -301,6 +311,15 @@ private fun LoadedContent( when (state) { is ExpertModeState.Started -> { + ExpertModeStartedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + onStopClick = onStopServiceClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + if (!state.isDefaultUsbModeCompatible) { IncompatibleUsbModeCard( modifier = Modifier @@ -311,12 +330,36 @@ private fun LoadedContent( Spacer(Modifier.height(8.dp)) } - ExpertModeStartedCard( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - onStopClick = onStopServiceClick, + // Only show auto-start options and warnings when Expert Mode is started + // Show USB debugging security settings warning if disabled + if (state.isAdbInputSecurityEnabled == false) { + UsbDebuggingSecuritySettingsCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + onLaunchDeveloperOptionsClick = onLaunchDeveloperOptionsClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + SwitchPreferenceCompose( + modifier = Modifier.padding(horizontal = 8.dp), + title = stringResource(R.string.title_pref_expert_mode_auto_start), + text = if (state.autoStartBootEnabled) { + stringResource(R.string.summary_pref_expert_mode_auto_start) + } else { + stringResource( + R.string.summary_pref_expert_mode_auto_start_disabled, + ) + }, + icon = Icons.Rounded.RestartAlt, + isChecked = state.autoStartBootChecked, + onCheckedChange = { onAutoStartAtBootToggled() }, + isEnabled = state.autoStartBootEnabled, + ) + Spacer(modifier = Modifier.height(8.dp)) } is ExpertModeState.Stopped -> { @@ -424,30 +467,20 @@ private fun LoadedContent( state.isNotificationPermissionGranted, isLoading = state.isStarting, ) - } - } - - // Options section - Spacer(modifier = Modifier.height(16.dp)) - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Rounded.Tune, - text = stringResource(R.string.expert_mode_options_title), - ) - - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - SwitchPreferenceCompose( - modifier = Modifier.padding(horizontal = 8.dp), - title = stringResource(R.string.title_pref_expert_mode_auto_start), - text = stringResource(R.string.summary_pref_expert_mode_auto_start), - icon = Icons.Rounded.RestartAlt, - isChecked = autoStartAtBoot, - onCheckedChange = onAutoStartAtBootToggled, - ) + ShellStartCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + shellStartCommandState = state.shellStartCommandState, + onGetShellStartCommandClick = onGetShellStartCommandClick, + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) + } + } } } @@ -489,6 +522,39 @@ private fun IncompatibleUsbModeCard(modifier: Modifier = Modifier) { ) } +@Composable +private fun UsbDebuggingSecuritySettingsCard( + modifier: Modifier = Modifier, + onLaunchDeveloperOptionsClick: () -> Unit = {}, +) { + SetupCard( + modifier = modifier, + color = MaterialTheme.colorScheme.errorContainer, + icon = { + Icon( + imageVector = Icons.Rounded.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + }, + title = stringResource( + R.string.expert_mode_usb_debugging_security_settings_title, + ), + content = { + Text( + text = stringResource( + R.string.expert_mode_usb_debugging_security_settings_description, + ), + style = MaterialTheme.typography.bodyMedium, + ) + }, + buttonText = stringResource( + R.string.expert_mode_usb_debugging_security_settings_button, + ), + onButtonClick = onLaunchDeveloperOptionsClick, + ) +} + @Composable private fun WarningCard( modifier: Modifier = Modifier, @@ -676,6 +742,141 @@ private fun SetupCard( } } +@Composable +private fun ShellStartCard( + modifier: Modifier = Modifier, + shellStartCommandState: ShellStartCommandState, + onGetShellStartCommandClick: () -> Unit, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + OutlinedCard(modifier = modifier) { + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.padding(horizontal = 16.dp)) { + Icon( + imageVector = Icons.Rounded.Usb, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(R.string.expert_mode_shell_start_title), + style = MaterialTheme.typography.titleMedium, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.expert_mode_shell_start_description), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + when (shellStartCommandState) { + is ShellStartCommandState.Idle -> { + FilledTonalButton( + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), + onClick = onGetShellStartCommandClick, + ) { + Text(stringResource(R.string.expert_mode_shell_start_get_command)) + } + } + + is ShellStartCommandState.Loading -> { + FilledTonalButton( + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), + onClick = {}, + enabled = false, + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = LocalContentColor.current, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.expert_mode_shell_start_get_command)) + } + } + + is ShellStartCommandState.Loaded -> { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End, + ) { + Text( + modifier = Modifier.weight(1f), + text = shellStartCommandState.command, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val clipEntry = ClipEntry( + ClipData.newPlainText( + stringResource( + R.string.expert_mode_shell_start_clipboard_label, + ), + shellStartCommandState.command, + ), + ) + + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry(clipEntry) + } + }, + ) { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = stringResource( + R.string.expert_mode_shell_start_copy_content_description, + ), + ) + } + } + } + + is ShellStartCommandState.Error -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = stringResource(R.string.expert_mode_shell_start_error), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + FilledTonalButton( + modifier = Modifier.align(Alignment.End), + onClick = onGetShellStartCommandClick, + ) { + Text(stringResource(R.string.expert_mode_shell_start_retry)) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + @Composable private fun ExpertModeInfoCard(modifier: Modifier = Modifier, onDismiss: () -> Unit = {}) { OutlinedCard( @@ -745,12 +946,13 @@ private fun Preview() { shizukuSetupState = ShizukuSetupState.PERMISSION_GRANTED, isNotificationPermissionGranted = true, isStarting = false, + shellStartCommandState = ShellStartCommandState.Idle, ), ), showInfoCard = true, onInfoCardDismiss = {}, - autoStartAtBoot = false, onAutoStartAtBootToggled = {}, + onLaunchDeveloperOptionsClick = {}, ) } } @@ -763,11 +965,18 @@ private fun PreviewDark() { ExpertModeScreen { Content( warningState = ExpertModeWarningState.Understood, - setupState = State.Data(ExpertModeState.Started(isDefaultUsbModeCompatible = true)), + setupState = State.Data( + ExpertModeState.Started( + isDefaultUsbModeCompatible = true, + autoStartBootChecked = true, + autoStartBootEnabled = true, + isAdbInputSecurityEnabled = null, + ), + ), showInfoCard = false, onInfoCardDismiss = {}, - autoStartAtBoot = true, onAutoStartAtBootToggled = {}, + onLaunchDeveloperOptionsClick = {}, ) } } @@ -785,8 +994,8 @@ private fun PreviewCountingDown() { setupState = State.Loading, showInfoCard = true, onInfoCardDismiss = {}, - autoStartAtBoot = false, onAutoStartAtBootToggled = {}, + onLaunchDeveloperOptionsClick = {}, ) } } @@ -800,12 +1009,17 @@ private fun PreviewStarted() { Content( warningState = ExpertModeWarningState.Understood, setupState = State.Data( - ExpertModeState.Started(isDefaultUsbModeCompatible = false), + ExpertModeState.Started( + isDefaultUsbModeCompatible = false, + autoStartBootChecked = false, + autoStartBootEnabled = true, + isAdbInputSecurityEnabled = null, + ), ), showInfoCard = false, onInfoCardDismiss = {}, - autoStartAtBoot = false, onAutoStartAtBootToggled = {}, + onLaunchDeveloperOptionsClick = {}, ) } } @@ -824,12 +1038,64 @@ private fun PreviewNotificationPermissionNotGranted() { shizukuSetupState = ShizukuSetupState.PERMISSION_GRANTED, isNotificationPermissionGranted = false, isStarting = false, + shellStartCommandState = ShellStartCommandState.Idle, + ), + ), + showInfoCard = false, + onInfoCardDismiss = {}, + onAutoStartAtBootToggled = {}, + onLaunchDeveloperOptionsClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewUsbDebuggingSecuritySettingsCard() { + KeyMapperTheme { + ExpertModeScreen { + Content( + warningState = ExpertModeWarningState.Understood, + setupState = State.Data( + ExpertModeState.Started( + isDefaultUsbModeCompatible = true, + autoStartBootChecked = false, + autoStartBootEnabled = true, + isAdbInputSecurityEnabled = false, + ), + ), + showInfoCard = false, + onInfoCardDismiss = {}, + onAutoStartAtBootToggled = {}, + onLaunchDeveloperOptionsClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellStartCard() { + KeyMapperTheme { + ExpertModeScreen { + Content( + warningState = ExpertModeWarningState.Understood, + setupState = State.Data( + ExpertModeState.Stopped( + isRootGranted = false, + shizukuSetupState = ShizukuSetupState.NOT_FOUND, + isNotificationPermissionGranted = true, + isStarting = false, + shellStartCommandState = ShellStartCommandState.Loaded( + "sh /storage/emulated/0/Android/data/io.github.sds100.keymapper/files/start.sh", + ), ), ), showInfoCard = false, onInfoCardDismiss = {}, - autoStartAtBoot = false, onAutoStartAtBootToggled = {}, + onLaunchDeveloperOptionsClick = {}, ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt index aba3e9398d..89670020a0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -238,12 +237,18 @@ private fun StepContent( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( - modifier = Modifier.size(64.dp), - imageVector = stepContent.icon, - contentDescription = null, - tint = iconTint, - ) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + ) + } else { + Icon( + modifier = Modifier.size(64.dp), + imageVector = stepContent.icon, + contentDescription = null, + tint = iconTint, + ) + } Spacer(modifier = Modifier.height(16.dp)) @@ -276,14 +281,6 @@ private fun StepContent( onClick = onButtonClick, enabled = !isLoading, ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = LocalContentColor.current, - ) - Spacer(modifier = Modifier.width(8.dp)) - } Text(text = stepContent.buttonText) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt index 87dfc97116..04680bc924 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt @@ -12,11 +12,13 @@ import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.valueOrNull import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -54,19 +57,18 @@ class ExpertModeViewModel @Inject constructor( ), ) - val setupState: StateFlow> = - combine( - useCase.isSystemBridgeConnected, - useCase.isRootGranted, - useCase.shizukuSetupState, - useCase.isNotificationPermissionGranted, - useCase.isSystemBridgeStarting, - ::buildSetupState, - ).stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) + private val shellStartCommandState: MutableStateFlow = + MutableStateFlow(ShellStartCommandState.Idle) - val autoStartBootEnabled: StateFlow = - useCase.isAutoStartBootEnabled - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + @OptIn(ExperimentalCoroutinesApi::class) + val state: StateFlow> = + useCase.isSystemBridgeConnected.flatMapLatest { isSystemBridgeConnected -> + if (isSystemBridgeConnected) { + startedStateFlow().map { State.Data(it) } + } else { + stoppedStateFlow().map { State.Data(it) } + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) var showInfoCard by mutableStateOf(!useCase.isInfoDismissed()) private set @@ -151,31 +153,62 @@ class ExpertModeViewModel @Inject constructor( useCase.toggleAutoStartBoot() } - private fun buildSetupState( - isSystemBridgeConnected: Boolean, - isRootGranted: Boolean, - shizukuSetupState: ShizukuSetupState, - isNotificationPermissionGranted: Boolean, - isSystemBridgeStarting: Boolean, - ): State { - if (isSystemBridgeConnected) { - return State.Data( - ExpertModeState.Started( - isDefaultUsbModeCompatible = - useCase.isCompatibleUsbModeSelected().valueOrNull() ?: false, - ), - ) - } else { - return State.Data( - ExpertModeState.Stopped( - isRootGranted = isRootGranted, - shizukuSetupState = shizukuSetupState, - isNotificationPermissionGranted = isNotificationPermissionGranted, - isStarting = isSystemBridgeStarting, - ), - ) + fun onLaunchDeveloperOptionsClick() { + useCase.launchDeveloperOptions() + } + + fun onGetShellStartCommandClick() { + viewModelScope.launch { + if (shellStartCommandState.value != ShellStartCommandState.Idle) { + // Do not get the command if not idle. + return@launch + } + + shellStartCommandState.value = ShellStartCommandState.Loading + val result = useCase.getShellStartCommand() + shellStartCommandState.value = when (result) { + is Success -> ShellStartCommandState.Loaded(result.value) + else -> ShellStartCommandState.Error + } } } + + private fun stoppedStateFlow(): Flow = combine( + useCase.isRootGranted, + useCase.shizukuSetupState, + useCase.isNotificationPermissionGranted, + useCase.isSystemBridgeStarting, + shellStartCommandState, + ) { + isRootGranted, + shizukuSetupState, + isNotificationPermissionGranted, + isSystemBridgeStarting, + shellStartCommandState, + -> + ExpertModeState.Stopped( + isRootGranted = isRootGranted, + shizukuSetupState = shizukuSetupState, + isNotificationPermissionGranted = isNotificationPermissionGranted, + isStarting = isSystemBridgeStarting, + shellStartCommandState = shellStartCommandState, + ) + } + + private fun startedStateFlow(): Flow = combine( + useCase.isAutoStartBootEnabled, + useCase.isAutoStartBootAllowed, + useCase.isAdbInputSecurityEnabled, + ) { autoStartBootChecked, autoStartBootEnabled, isAdbInputSecurityEnabled -> + ExpertModeState.Started( + isDefaultUsbModeCompatible = + useCase.isCompatibleUsbModeSelected().valueOrNull() + ?: false, + autoStartBootChecked = autoStartBootChecked, + autoStartBootEnabled = autoStartBootEnabled, + isAdbInputSecurityEnabled = isAdbInputSecurityEnabled, + ) + } } sealed class ExpertModeWarningState { @@ -190,7 +223,20 @@ sealed class ExpertModeState { val shizukuSetupState: ShizukuSetupState, val isNotificationPermissionGranted: Boolean, val isStarting: Boolean, + val shellStartCommandState: ShellStartCommandState, ) : ExpertModeState() - data class Started(val isDefaultUsbModeCompatible: Boolean) : ExpertModeState() + data class Started( + val isDefaultUsbModeCompatible: Boolean, + val autoStartBootChecked: Boolean, + val autoStartBootEnabled: Boolean, + val isAdbInputSecurityEnabled: Boolean?, + ) : ExpertModeState() +} + +sealed class ShellStartCommandState { + data object Idle : ShellStartCommandState() + data object Loading : ShellStartCommandState() + data class Loaded(val command: String) : ShellStartCommandState() + data object Error : ShellStartCommandState() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt index 94b5bdc966..73f14b3120 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt @@ -256,7 +256,6 @@ class SystemBridgeSetupAssistantController @AssistedInject constructor( // and trying to find the clickable node. This can change subtly between // Android devices and ROMs. val textNode = rootNode.findNodeRecursively { node -> - Timber.e(node.text?.toString()) PAIRING_CODE_BUTTON_TEXT_FILTER.any { text -> node.text?.contains(text) == true } } ?: return diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt index c8ea53a633..3ae7dbe92d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +@OptIn(ExperimentalCoroutinesApi::class) @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) @ViewModelScoped class SystemBridgeSetupUseCaseImpl @Inject constructor( @@ -165,6 +166,18 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( systemBridgeSetupController.enableDeveloperOptions() } + override fun launchDeveloperOptions() { + systemBridgeSetupController.launchDeveloperOptions() + } + + @RequiresApi(Build.VERSION_CODES.R) + override val isAdbInputSecurityEnabled: Flow = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + systemBridgeSetupController.isAdbInputSecurityEnabled + } else { + flowOf(null) + } + override fun connectWifiNetwork() { networkAdapter.connectWifiNetwork() } @@ -210,9 +223,24 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( preferences.set(Keys.isExpertModeInfoDismissed, true) } + override val isAutoStartBootAllowed: Flow = + permissionAdapter.isGrantedFlow(Permission.ROOT).flatMapLatest { isRooted -> + if (isRooted) { + flowOf(true) + } else { + permissionAdapter.isGrantedFlow(Permission.WRITE_SECURE_SETTINGS) + } + } + override val isAutoStartBootEnabled: Flow = - preferences.get(Keys.isSystemBridgeKeepAliveEnabled) - .map { it ?: PreferenceDefaults.EXPERT_MODE_KEEP_ALIVE } + isAutoStartBootAllowed.flatMapLatest { isAllowed -> + if (isAllowed) { + preferences.get(Keys.isSystemBridgeKeepAliveEnabled) + .map { it ?: PreferenceDefaults.EXPERT_MODE_KEEP_ALIVE } + } else { + flowOf(false) + } + } override fun toggleAutoStartBoot() { preferences.update(Keys.isSystemBridgeKeepAliveEnabled) { @@ -263,6 +291,10 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( else -> SystemBridgeSetupStep.START_SERVICE } } + + override suspend fun getShellStartCommand(): KMResult { + return systemBridgeSetupController.getShellStartCommand() + } } interface SystemBridgeSetupUseCase { @@ -273,6 +305,7 @@ interface SystemBridgeSetupUseCase { fun dismissInfo() val isAutoStartBootEnabled: Flow + val isAutoStartBootAllowed: Flow fun toggleAutoStartBoot() val isSetupAssistantEnabled: Flow @@ -294,6 +327,7 @@ interface SystemBridgeSetupUseCase { fun stopSystemBridge() fun enableAccessibilityService() fun enableDeveloperOptions() + fun launchDeveloperOptions() fun connectWifiNetwork() fun enableWirelessDebugging() fun pairWirelessAdb() @@ -302,5 +336,9 @@ interface SystemBridgeSetupUseCase { fun startSystemBridgeWithAdb() fun autoStartSystemBridgeWithAdb() + val isAdbInputSecurityEnabled: Flow + fun isCompatibleUsbModeSelected(): KMResult + + suspend fun getShellStartCommand(): KMResult } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt index 1060a84f53..3f124629ef 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt @@ -24,6 +24,7 @@ fun SwitchPreferenceCompose( icon: ImageVector, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean = true, ) { Surface( modifier = modifier, @@ -31,9 +32,10 @@ fun SwitchPreferenceCompose( onClick = { onCheckedChange(!isChecked) }, + enabled = isEnabled, ) { Row( - modifier = Modifier.Companion + modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -45,7 +47,7 @@ fun SwitchPreferenceCompose( tint = MaterialTheme.colorScheme.onSurface, ) - Column(modifier = Modifier.Companion.weight(1f)) { + Column(modifier = Modifier.weight(1f)) { Text(text = title, style = MaterialTheme.typography.bodyLarge) if (text != null) { Text( @@ -59,6 +61,7 @@ fun SwitchPreferenceCompose( Switch( checked = isChecked, onCheckedChange = onCheckedChange, + enabled = isEnabled, ) } } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 0a1c49f101..dceb055592 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1674,15 +1674,22 @@ Set up with Key Mapper Continue (Requires Android 11+) - Options Enable Expert Mode for all key maps Key Mapper will use the ADB Shell for remapping These settings are unavailable until you acknowledge the warning. Expert Mode service is running Stop + Start manually with ADB + The other methods above are recommended. You must do \'adb shell\' before executing this command. + Get command + Retry + Failed to get command. Please try again. + System Bridge Start Command + Copy command to clipboard Auto start and keep alive Expert Mode will start itself whenever you boot your device or it dies unexpectedly. + Expert Mode will start itself whenever you boot your device or it dies unexpectedly. This requires WRITE_SECURE_SETTINGS permission. Key event actions Select how key event actions are performed @@ -1725,6 +1732,10 @@ Incompatible USB configuration You must select \'No data transfer\' as your default USB configuration so that Expert Mode is not killed every time you lock your device. + USB debugging security settings disabled + You need to enable \"USB debugging security settings\" in Developer options for Expert Mode to work properly. This is mainly required on Xiaomi devices. + Open Developer options + Setup assistant Expert Mode is running diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt index 98a6cdefd9..66274d0e96 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt @@ -55,6 +55,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( companion object { private const val TAG = "SystemBridgeConnectionManagerImpl" + private const val MIUI_OPTIMIZATION_SETTING = "miui_optimization" } private val systemBridgeLock: Any = Any() @@ -118,11 +119,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( this.systemBridgeFlow.update { systemBridge } - // Only turn on the ADB options to prevent killing if it is running under - // the ADB shell user - if (systemBridge.processUid == Process.SHELL_UID) { - preventSystemBridgeKilling(systemBridge) - } + preventSystemBridgeKilling(systemBridge) connectionState.update { SystemBridgeConnectionState.Connected( @@ -212,24 +209,24 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( } private fun preventSystemBridgeKilling(systemBridge: ISystemBridge) { - val deviceId: Int = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ctx.deviceId - } else { - -1 - } - // WARNING! Granting some permissions (e.g READ_LOGS) will cause the system to kill // the app process and restart it. This is normal, expected behavior and can not be // worked around. Do not grant any other permissions automatically here. - systemBridge.grantPermission(Manifest.permission.WRITE_SECURE_SETTINGS, deviceId) - Timber.i("Granted WRITE_SECURE_SETTINGS permission with System Bridge") - if (ContextCompat.checkSelfPermission( - ctx, - Manifest.permission.WRITE_SECURE_SETTINGS, - ) == PERMISSION_GRANTED - ) { + val isWriteSecureSettingsGranted = ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.WRITE_SECURE_SETTINGS, + ) == PERMISSION_GRANTED + + if (!isWriteSecureSettingsGranted) { + grantWriteSecureSettings(systemBridge) + } + + val isShellProcess = systemBridge.processUid == Process.SHELL_UID + + // Only turn on the ADB options to prevent killing if it is running under + // the ADB shell user + if (isWriteSecureSettingsGranted && isShellProcess) { // Disable automatic revoking of ADB pairings SettingsUtils.putGlobalSetting( ctx, @@ -245,6 +242,29 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( 1, ) } + + val isMiuiOptimisationEnabled = + SettingsUtils.getGlobalSetting(ctx, MIUI_OPTIMIZATION_SETTING) == 1 + + if (isWriteSecureSettingsGranted && isMiuiOptimisationEnabled) { + SettingsUtils.putGlobalSetting(ctx, MIUI_OPTIMIZATION_SETTING, 0) + } + } + + private fun grantWriteSecureSettings(systemBridge: ISystemBridge) { + val deviceId: Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ctx.deviceId + } else { + -1 + } + + try { + systemBridge.grantPermission(Manifest.permission.WRITE_SECURE_SETTINGS, deviceId) + Timber.i("Granted WRITE_SECURE_SETTINGS permission with System Bridge") + } catch (e: Exception) { + Timber.w("Failed to grant WRITE_SECURE_SETTINGS: $e") + } } override suspend fun startWithRoot() { @@ -254,6 +274,10 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( override fun startWithShizuku() { starter.startWithShizuku() } + + override suspend fun getShellStartCommand(): KMResult { + return starter.getStartCommand() + } } @SuppressLint("ObsoleteSdkInt") @@ -278,6 +302,9 @@ interface SystemBridgeConnectionManager { @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) suspend fun startWithAdb() + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) + suspend fun getShellStartCommand(): KMResult } fun SystemBridgeConnectionManager.isConnected(): Boolean { diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt index 5c46b23560..a5c3f66e45 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt @@ -19,12 +19,15 @@ import io.github.sds100.keymapper.common.KeyMapperClassProvider import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.SettingsUtils +import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.isSuccess import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.sysbridge.adb.AdbManager import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState.Connected import io.github.sds100.keymapper.sysbridge.manager.awaitConnected +import io.github.sds100.keymapper.sysbridge.manager.isConnected import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -78,6 +81,9 @@ class SystemBridgeSetupControllerImpl @Inject constructor( private val isAdbPairedResult: MutableStateFlow = MutableStateFlow(null) private var isAdbPairedJob: Job? = null + override val isAdbInputSecurityEnabled: MutableStateFlow = MutableStateFlow(null) + private var checkAdbInputSecurityJob: Job? = null + init { // Automatically go back to the Key Mapper app when turning on wireless debugging coroutineScope.launch { @@ -88,6 +94,7 @@ class SystemBridgeSetupControllerImpl @Inject constructor( // Do not automatically go back to Key Mapper after this step because // some devices show a dialog that will be auto dismissed resulting in wireless // ADB being immediately disabled. E.g OnePlus 6T Oxygen OS 11 + // Note: ADB input security check is handled by monitoring isWirelessDebuggingEnabled flow } } @@ -103,6 +110,27 @@ class SystemBridgeSetupControllerImpl @Inject constructor( } } } + + // Automatically check ADB input security when SystemBridge is connected + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + coroutineScope.launch { + // Check when SystemBridge becomes connected + connectionManager.connectionState.collect { connectionState -> + when (connectionState) { + is Connected -> { + // Delay a bit to ensure SystemBridge is ready + kotlinx.coroutines.delay(1000L) + checkAdbInputSecurityEnabled() + } + + is SystemBridgeConnectionState.Disconnected -> { + // Reset to null when SystemBridge is disconnected + isAdbInputSecurityEnabled.value = null + } + } + } + } + } } override fun startWithRoot() { @@ -356,6 +384,56 @@ class SystemBridgeSetupControllerImpl @Inject constructor( ) } + override fun launchDeveloperOptions() { + SettingsUtils.launchSettingsScreen( + ctx, + Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS, + null, + ) + } + + private fun checkAdbInputSecurityEnabled() { + if (!connectionManager.isConnected()) { + isAdbInputSecurityEnabled.value = null + return + } + + // Only run one check at a time + if (checkAdbInputSecurityJob == null || checkAdbInputSecurityJob?.isCompleted == true) { + checkAdbInputSecurityJob?.cancel() + + checkAdbInputSecurityJob = coroutineScope.launch { + try { + val result = connectionManager.run { systemBridge -> + systemBridge.executeCommand("getprop persist.security.adbinput", 5000L) + } + + val isEnabled = when (result) { + is Success -> { + val stdout = result.value.stdout.trim() + + when (stdout) { + "1" -> true + + "0" -> false + + // If it is empty or anything else then set the value to null + // because what we are expecting does not exist. + else -> null + } + } + + else -> null + } + isAdbInputSecurityEnabled.value = isEnabled + } catch (_: Exception) { + // If check fails, set to null + isAdbInputSecurityEnabled.value = null + } + } + } + } + fun invalidateSettings() { isDeveloperOptionsEnabled.update { getDeveloperOptionsEnabled() } isWirelessDebuggingEnabled.update { getWirelessDebuggingEnabled() } @@ -392,6 +470,10 @@ class SystemBridgeSetupControllerImpl @Inject constructor( Manifest.permission.WRITE_SECURE_SETTINGS, ) == PackageManager.PERMISSION_GRANTED } + + override suspend fun getShellStartCommand(): KMResult { + return connectionManager.getShellStartCommand() + } } @SuppressLint("ObsoleteSdkInt") @@ -418,4 +500,14 @@ interface SystemBridgeSetupController { fun startWithShizuku() fun startWithAdb() fun autoStartWithAdb() + + /** + * If this value is null then the option does not exist or can not be checked + * because the system bridge is disconnected. + */ + val isAdbInputSecurityEnabled: StateFlow + + fun launchDeveloperOptions() + + suspend fun getShellStartCommand(): KMResult } diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt index 317b87ecbd..d95b6623e6 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt @@ -28,10 +28,8 @@ import java.io.BufferedReader import java.io.DataInputStream import java.io.File import java.io.FileOutputStream -import java.io.FileWriter import java.io.IOException import java.io.InputStreamReader -import java.io.PrintWriter import java.util.zip.ZipEntry import java.util.zip.ZipFile import javax.inject.Inject @@ -172,106 +170,78 @@ class SystemBridgeStarter @Inject constructor( } } - private suspend fun startSystemBridge( - commandExecutor: suspend (String) -> KMResult, - ): KMResult { - val externalFilesParent = try { - ctx.getExternalFilesDir(null)?.parentFile - } catch (e: IOException) { - return KMError.UnknownIOError - } - - Timber.i("Copy starter files to ${externalFilesParent?.absolutePath}") - - val outputStarterBinary = File(externalFilesParent, "starter") - val outputStarterScript = File(externalFilesParent, "start.sh") - - val copyFilesResult = withContext(Dispatchers.IO) { - copyNativeLibrary(outputStarterBinary).then { - // Create the start.sh shell script - writeStarterScript( - outputStarterScript, - outputStarterBinary.absolutePath, - ) - Success(Unit) + /** + * Get the shell command that can be used to start the system bridge manually. + * This command should be executed with 'adb shell'. + */ + suspend fun getStartCommand(): KMResult { + val directory = if (buildConfigProvider.sdkInt > Build.VERSION_CODES.R) { + try { + ctx.getExternalFilesDir(null)?.parentFile + } catch (e: IOException) { + return KMError.UnknownIOError } - } + } else { + // Adb on Android 11 has no permission to access Android/data so use /data/user_de. + val protectedStorageDir = + ctx.createDeviceProtectedStorageContext().filesDir.parentFile!! - val startCommand = - "sh ${outputStarterScript.absolutePath} --apk=$baseApkPath --lib=$libPath --package=$packageName --version=${buildConfigProvider.versionCode}" - - return copyFilesResult - .then { commandExecutor(startCommand) } - .then { output -> - // Adb on Android 11 has no permission to access Android/data so use /data/user_de. - if (output.contains( - "/Android/data/${ctx.packageName}/start.sh: Permission denied", - ) - ) { - Timber.w( - "ADB has no permission to access Android/data/${ctx.packageName}/start.sh. Trying to use /data/user_de instead...", - ) - - startSystemBridgeFromProtectedStorage(commandExecutor) - } else { - Success(output) - } + try { + // 0711 + Os.chmod(protectedStorageDir.absolutePath, 457) + } catch (e: ErrnoException) { + e.printStackTrace() } - } - - private suspend fun startSystemBridgeFromProtectedStorage( - executeCommand: suspend (String) -> KMResult, - ): KMResult { - val protectedStorageDir = - ctx.createDeviceProtectedStorageContext().filesDir.parentFile!! - Timber.i("Protected storage dir: ${protectedStorageDir.absolutePath}") - - try { - // 0711 - Os.chmod(protectedStorageDir.absolutePath, 457) - } catch (e: ErrnoException) { - e.printStackTrace() + protectedStorageDir } - Timber.i("Copy starter files to ${protectedStorageDir.absolutePath}") + return copyStarterFiles(directory!!).then { starterPath -> Success("sh $starterPath") } + } - try { - val outputStarterBinary = File(protectedStorageDir, "starter") - val outputStarterScript = File(protectedStorageDir, "start.sh") + /** + * @return The path to the starter script. + */ + private suspend fun copyStarterFiles(directory: File): KMResult { + Timber.i("Copy starter files to ${directory.absolutePath}") - withContext(Dispatchers.IO) { - copyNativeLibrary(outputStarterBinary) + val outputStarterBinary = File(directory, "starter") + val outputStarterScript = File(directory, "start.sh") + return withContext(Dispatchers.IO) { + copyNativeLibrary(outputStarterBinary).then { + // Create the start.sh shell script writeStarterScript( outputStarterScript, outputStarterBinary.absolutePath, ) - } - val startCommand = - "sh ${outputStarterScript.absolutePath} --apk=$baseApkPath --lib=$libPath --package=$packageName --version=${buildConfigProvider.versionCode}" + // Make starter binary executable + try { + // 0644 + Os.chmod(outputStarterBinary.absolutePath, 420) + } catch (e: ErrnoException) { + e.printStackTrace() + } - // Make starter binary executable - try { - // 0644 - Os.chmod(outputStarterBinary.absolutePath, 420) - } catch (e: ErrnoException) { - e.printStackTrace() - } + // Make starter script executable + try { + // 0644 + Os.chmod(outputStarterScript.absolutePath, 420) + } catch (e: ErrnoException) { + e.printStackTrace() + } - // Make starter script executable - try { - // 0644 - Os.chmod(outputStarterScript.absolutePath, 420) - } catch (e: ErrnoException) { - e.printStackTrace() + Success(outputStarterScript.absolutePath) } + } + } - return executeCommand(startCommand) - } catch (e: IOException) { - Timber.e(e) - return KMError.UnknownIOError + private suspend fun startSystemBridge( + commandExecutor: suspend (String) -> KMResult, + ): KMResult { + return getStartCommand().then { scriptPath -> + commandExecutor(scriptPath) } } @@ -323,28 +293,28 @@ class SystemBridgeStarter @Inject constructor( } /** - * Write the start.sh shell script to the specified [out] file. The path to the starter - * binary will be substituted in the script with the [starterPath]. + * Write the start.sh shell script to the specified [out] file. The placeholders in the script + * will be substituted with the provided values. */ private fun writeStarterScript(out: File, starterPath: String) { - if (!out.exists()) { - out.createNewFile() - } + out.createNewFile() val scriptInputStream = ctx.resources.openRawResource(R.raw.start) - with(scriptInputStream) { - val reader = BufferedReader(InputStreamReader(this)) - - val outputWriter = PrintWriter(FileWriter(out)) - var line: String? + with(BufferedReader(InputStreamReader(scriptInputStream))) { + val text = readText() + .replace("%%%STARTER_PATH%%%", starterPath) + .replace("%%%APK_PATH%%%", baseApkPath) + .replace("%%%LIB_PATH%%%", libPath ?: "") + .replace("%%%PACKAGE_NAME%%%", packageName) + .replace( + "%%%VERSION_CODE%%%", + buildConfigProvider.versionCode.toString(), + ) - while (reader.readLine().also { line = it } != null) { - outputWriter.println(line!!.replace("%%%STARTER_PATH%%%", starterPath)) + with(out) { + writeText(text) } - - outputWriter.flush() - outputWriter.close() } } } diff --git a/sysbridge/src/main/res/raw/start.sh b/sysbridge/src/main/res/raw/start.sh index f8357e65d5..a4515488cd 100644 --- a/sysbridge/src/main/res/raw/start.sh +++ b/sysbridge/src/main/res/raw/start.sh @@ -43,7 +43,7 @@ chgrp 2000 $STARTER_PATH if [ -f $STARTER_PATH ]; then echo "info: exec $STARTER_PATH" # Pass apk path, library path, package name, version code - $STARTER_PATH "$1" "$2" "$3" "$4" + $STARTER_PATH --apk="%%%APK_PATH%%%" --lib="%%%LIB_PATH%%%" --package="%%%PACKAGE_NAME%%%" --version="%%%VERSION_CODE%%%" result=$? if [ ${result} -ne 0 ]; then echo "info: keymapper_sysbridge_starter exit with non-zero value $result" diff --git a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt index bef0c78cef..3ee62e2f59 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt @@ -39,6 +39,7 @@ import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -137,6 +138,15 @@ class AndroidPermissionAdapter @Inject constructor( .drop(1) .onEach { onPermissionsChanged() } .launchIn(coroutineScope) + + coroutineScope.launch { + systemBridgeConnectionManager.connectionState.collect { + // Invalidate the permissions in case WRITE_SECURE_SETTINGS or other + // permissions were granted when the user started the system bridge. + delay(1000) + onPermissionsChanged() + } + } } override fun request(permission: Permission) {