From 022cdd7f0063f71ee2960f8b31896449389f8d5f Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 1 Jan 2026 16:06:46 +0100 Subject: [PATCH 01/13] chore: bump version to 4.0.0-beta.6 --- app/version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/version.properties b/app/version.properties index 6d3cc998f5..ef95f78163 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=223 From 454fc6a6137f12d227cc59ab255f6e7320165dba Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 1 Jan 2026 16:06:56 +0100 Subject: [PATCH 02/13] chore: set release date for beta 5 in changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e80b40e6..61af6c48a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## [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 From b917429eb60eea6f86b332fdf4248f3a2c1a475b Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 2 Jan 2026 15:52:39 +0100 Subject: [PATCH 03/13] fix: stop logging pairing code node text --- .../base/expertmode/SystemBridgeSetupAssistantController.kt | 1 - 1 file changed, 1 deletion(-) 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 From 36952b1bca291f09cad170658ff73d4be7eee334 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 15:50:54 +0100 Subject: [PATCH 04/13] #1968 fix: device controls action no longer works on Android 16+ so it has been disabled on new Android versions --- CHANGELOG.md | 8 ++++++++ .../github/sds100/keymapper/base/actions/ActionUtils.kt | 2 ++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61af6c48a5..9671eea75c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [4.0.0 Beta 6](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.06) + +#### TO BE RELEASED + +## Bug fixes + +- #1968 Device controls action no longer works on Android 16+ so it has been disabled on new Android versions. + ## [4.0.0 Beta 5](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.05) #### 1 January 2026 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 } From 4d092bc58cf13a220df8813ca6cabf55ea5eae76 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 16:26:42 +0100 Subject: [PATCH 05/13] #1967 fix: still connect to the system bridge if granting WRITE_SECURE_SETTINGS fails --- .../base/actions/PerformActionsUseCase.kt | 15 ++++-- .../base/expertmode/ExpertModeScreen.kt | 25 +++++++-- .../base/expertmode/ExpertModeViewModel.kt | 8 ++- .../expertmode/SystemBridgeSetupUseCase.kt | 21 +++++++- .../ui/compose/SwitchPreferenceCompose.kt | 7 ++- base/src/main/res/values/strings.xml | 1 + .../manager/SystemBridgeConnectionManager.kt | 52 ++++++++++++------- .../permissions/AndroidPermissionAdapter.kt | 10 ++++ 8 files changed, 105 insertions(+), 34 deletions(-) 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..27dc484a4d 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 } @@ -949,7 +954,6 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } ActionData.DeviceControls -> { - @Suppress("ktlint:standard:max-line-length") result = intentAdapter.send( IntentTarget.ACTIVITY, uri = "#Intent;action=android.intent.action.MAIN;package=com.android.systemui;component=com.android.systemui/.controls.ui.ControlsActivity;end", @@ -1040,10 +1044,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..4ecca387b9 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 @@ -79,6 +79,7 @@ import io.github.sds100.keymapper.common.utils.State fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewModel) { val expertModeWarningState by viewModel.warningState.collectAsStateWithLifecycle() val expertModeSetupState by viewModel.setupState.collectAsStateWithLifecycle() + val autoStartBootChecked by viewModel.autoStartBootChecked.collectAsStateWithLifecycle() val autoStartBootEnabled by viewModel.autoStartBootEnabled.collectAsStateWithLifecycle() ExpertModeScreen( @@ -98,8 +99,9 @@ fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewMod onRootButtonClick = viewModel::onRootButtonClick, onSetupWithKeyMapperClick = viewModel::onSetupWithKeyMapperClick, onRequestNotificationPermissionClick = viewModel::onRequestNotificationPermissionClick, - autoStartAtBoot = autoStartBootEnabled, + autoStartAtBoot = autoStartBootChecked, onAutoStartAtBootToggled = { viewModel.onAutoStartBootToggled() }, + autoStartAtBootEnabled = autoStartBootEnabled, ) } } @@ -181,6 +183,7 @@ private fun Content( onRequestNotificationPermissionClick: () -> Unit = {}, autoStartAtBoot: Boolean, onAutoStartAtBootToggled: (Boolean) -> Unit = {}, + autoStartAtBootEnabled: Boolean, ) { Column(modifier = modifier.verticalScroll(rememberScrollState())) { AnimatedVisibility( @@ -229,6 +232,7 @@ private fun Content( onRequestNotificationPermissionClick = onRequestNotificationPermissionClick, autoStartAtBoot = autoStartAtBoot, onAutoStartAtBootToggled = onAutoStartAtBootToggled, + autoStartAtBootEnabled = autoStartAtBootEnabled, ) } } @@ -253,6 +257,7 @@ private fun LoadedContent( onRequestNotificationPermissionClick: () -> Unit = {}, autoStartAtBoot: Boolean, onAutoStartAtBootToggled: (Boolean) -> Unit = {}, + autoStartAtBootEnabled: Boolean, ) { Column(modifier) { OptionsHeaderRow( @@ -420,8 +425,8 @@ private fun LoadedContent( buttonText = setupKeyMapperText, onButtonClick = onSetupWithKeyMapperClick, enabled = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - state.isNotificationPermissionGranted, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + state.isNotificationPermissionGranted, isLoading = state.isStarting, ) } @@ -441,10 +446,17 @@ private fun LoadedContent( 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), + text = if (autoStartAtBootEnabled) { + 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 = autoStartAtBoot, onCheckedChange = onAutoStartAtBootToggled, + isEnabled = autoStartAtBootEnabled, ) Spacer(modifier = Modifier.height(8.dp)) @@ -751,6 +763,7 @@ private fun Preview() { onInfoCardDismiss = {}, autoStartAtBoot = false, onAutoStartAtBootToggled = {}, + autoStartAtBootEnabled = true, ) } } @@ -768,6 +781,7 @@ private fun PreviewDark() { onInfoCardDismiss = {}, autoStartAtBoot = true, onAutoStartAtBootToggled = {}, + autoStartAtBootEnabled = true, ) } } @@ -787,6 +801,7 @@ private fun PreviewCountingDown() { onInfoCardDismiss = {}, autoStartAtBoot = false, onAutoStartAtBootToggled = {}, + autoStartAtBootEnabled = true, ) } } @@ -806,6 +821,7 @@ private fun PreviewStarted() { onInfoCardDismiss = {}, autoStartAtBoot = false, onAutoStartAtBootToggled = {}, + autoStartAtBootEnabled = true, ) } } @@ -830,6 +846,7 @@ private fun PreviewNotificationPermissionNotGranted() { onInfoCardDismiss = {}, autoStartAtBoot = false, onAutoStartAtBootToggled = {}, + autoStartAtBootEnabled = true, ) } } 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..bbb43e2a7b 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 @@ -13,7 +13,6 @@ 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.valueOrNull -import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -26,6 +25,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class ExpertModeViewModel @Inject constructor( @@ -64,10 +64,14 @@ class ExpertModeViewModel @Inject constructor( ::buildSetupState, ).stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) - val autoStartBootEnabled: StateFlow = + val autoStartBootChecked: StateFlow = useCase.isAutoStartBootEnabled .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val autoStartBootEnabled: StateFlow = + useCase.isAutoStartBootAllowed + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + var showInfoCard by mutableStateOf(!useCase.isInfoDismissed()) private set 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..805856592d 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( @@ -210,9 +211,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) { @@ -273,6 +289,7 @@ interface SystemBridgeSetupUseCase { fun dismissInfo() val isAutoStartBootEnabled: Flow + val isAutoStartBootAllowed: Flow fun toggleAutoStartBoot() val isSetupAssistantEnabled: Flow 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..404fe25798 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1683,6 +1683,7 @@ 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 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..9f6b246986 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 @@ -66,7 +66,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( time = SystemClock.elapsedRealtime(), // Get whether the user previously stopped the system bridge. isStoppedByUser = - preferences.get(Keys.isSystemBridgeStoppedByUser).firstBlocking() ?: false, + preferences.get(Keys.isSystemBridgeStoppedByUser).firstBlocking() ?: false, ), ) private var isExpectedDeath: Boolean = false @@ -118,11 +118,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 +208,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, @@ -247,6 +243,22 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( } } + 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() { starter.startWithRoot() } 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) { From eb02e33dfebd00861612ef2324ddb1493dd84d80 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 16:35:18 +0100 Subject: [PATCH 06/13] show loading indicator in center of expert mode screen rather than in the start service button --- .../base/expertmode/ExpertModeSetupScreen.kt | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) 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) } } From 466805803d7d61bda92afd1c51712b0b3f108bc9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 17:09:39 +0100 Subject: [PATCH 07/13] #1965 fix: better system bridge support on Xiaomi devices and ask to enable "USB debugging security settings" in developer options --- CHANGELOG.md | 2 + .../base/actions/PerformActionsUseCase.kt | 1 + .../base/expertmode/ExpertModeScreen.kt | 187 ++++++++++++------ .../base/expertmode/ExpertModeViewModel.kt | 100 ++++++---- .../expertmode/SystemBridgeSetupUseCase.kt | 15 ++ base/src/main/res/values/strings.xml | 5 +- .../manager/SystemBridgeConnectionManager.kt | 2 +- .../service/SystemBridgeSetupController.kt | 86 ++++++++ 8 files changed, 290 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9671eea75c..6595dfc68a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ## 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) 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 27dc484a4d..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 @@ -954,6 +954,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } ActionData.DeviceControls -> { + @Suppress("ktlint:standard:max-line-length") result = intentAdapter.send( IntentTarget.ACTIVITY, uri = "#Intent;action=android.intent.action.MAIN;package=com.android.systemui;component=com.android.systemui/.controls.ui.ControlsActivity;end", 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 4ecca387b9..33d268aa83 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 @@ -33,7 +33,6 @@ import androidx.compose.material.icons.rounded.Close 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 @@ -78,9 +77,7 @@ import io.github.sds100.keymapper.common.utils.State @Composable fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewModel) { val expertModeWarningState by viewModel.warningState.collectAsStateWithLifecycle() - val expertModeSetupState by viewModel.setupState.collectAsStateWithLifecycle() - val autoStartBootChecked by viewModel.autoStartBootChecked.collectAsStateWithLifecycle() - val autoStartBootEnabled by viewModel.autoStartBootEnabled.collectAsStateWithLifecycle() + val expertModeState by viewModel.state.collectAsStateWithLifecycle() ExpertModeScreen( modifier = modifier, @@ -90,7 +87,7 @@ fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewMod ) { Content( warningState = expertModeWarningState, - setupState = expertModeSetupState, + setupState = expertModeState, showInfoCard = viewModel.showInfoCard, onInfoCardDismiss = { viewModel.hideInfoCard() }, onWarningButtonClick = viewModel::onWarningButtonClick, @@ -99,9 +96,8 @@ fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewMod onRootButtonClick = viewModel::onRootButtonClick, onSetupWithKeyMapperClick = viewModel::onSetupWithKeyMapperClick, onRequestNotificationPermissionClick = viewModel::onRequestNotificationPermissionClick, - autoStartAtBoot = autoStartBootChecked, onAutoStartAtBootToggled = { viewModel.onAutoStartBootToggled() }, - autoStartAtBootEnabled = autoStartBootEnabled, + onLaunchDeveloperOptionsClick = viewModel::onLaunchDeveloperOptionsClick, ) } } @@ -181,9 +177,8 @@ private fun Content( onRootButtonClick: () -> Unit = {}, onSetupWithKeyMapperClick: () -> Unit = {}, onRequestNotificationPermissionClick: () -> Unit = {}, - autoStartAtBoot: Boolean, - onAutoStartAtBootToggled: (Boolean) -> Unit = {}, - autoStartAtBootEnabled: Boolean, + onAutoStartAtBootToggled: () -> Unit = {}, + onLaunchDeveloperOptionsClick: () -> Unit = {}, ) { Column(modifier = modifier.verticalScroll(rememberScrollState())) { AnimatedVisibility( @@ -230,9 +225,8 @@ private fun Content( onRootButtonClick = onRootButtonClick, onSetupWithKeyMapperClick = onSetupWithKeyMapperClick, onRequestNotificationPermissionClick = onRequestNotificationPermissionClick, - autoStartAtBoot = autoStartAtBoot, onAutoStartAtBootToggled = onAutoStartAtBootToggled, - autoStartAtBootEnabled = autoStartAtBootEnabled, + onLaunchDeveloperOptionsClick = onLaunchDeveloperOptionsClick, ) } } @@ -255,9 +249,8 @@ private fun LoadedContent( onStopServiceClick: () -> Unit, onSetupWithKeyMapperClick: () -> Unit, onRequestNotificationPermissionClick: () -> Unit = {}, - autoStartAtBoot: Boolean, - onAutoStartAtBootToggled: (Boolean) -> Unit = {}, - autoStartAtBootEnabled: Boolean, + onAutoStartAtBootToggled: () -> Unit = {}, + onLaunchDeveloperOptionsClick: () -> Unit = {}, ) { Column(modifier) { OptionsHeaderRow( @@ -306,6 +299,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 @@ -316,11 +318,33 @@ 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, ) } @@ -425,41 +449,14 @@ private fun LoadedContent( buttonText = setupKeyMapperText, onButtonClick = onSetupWithKeyMapperClick, enabled = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - state.isNotificationPermissionGranted, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + state.isNotificationPermissionGranted, isLoading = state.isStarting, ) + + Spacer(modifier = Modifier.height(8.dp)) } } - - // 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)) - - SwitchPreferenceCompose( - modifier = Modifier.padding(horizontal = 8.dp), - title = stringResource(R.string.title_pref_expert_mode_auto_start), - text = if (autoStartAtBootEnabled) { - 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 = autoStartAtBoot, - onCheckedChange = onAutoStartAtBootToggled, - isEnabled = autoStartAtBootEnabled, - ) - - Spacer(modifier = Modifier.height(8.dp)) } } @@ -501,6 +498,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, @@ -761,9 +791,8 @@ private fun Preview() { ), showInfoCard = true, onInfoCardDismiss = {}, - autoStartAtBoot = false, onAutoStartAtBootToggled = {}, - autoStartAtBootEnabled = true, + onLaunchDeveloperOptionsClick = {}, ) } } @@ -776,12 +805,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 = {}, - autoStartAtBootEnabled = true, + onLaunchDeveloperOptionsClick = {}, ) } } @@ -799,9 +834,8 @@ private fun PreviewCountingDown() { setupState = State.Loading, showInfoCard = true, onInfoCardDismiss = {}, - autoStartAtBoot = false, onAutoStartAtBootToggled = {}, - autoStartAtBootEnabled = true, + onLaunchDeveloperOptionsClick = {}, ) } } @@ -815,13 +849,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 = {}, - autoStartAtBootEnabled = true, + onLaunchDeveloperOptionsClick = {}, ) } } @@ -844,9 +882,32 @@ private fun PreviewNotificationPermissionNotGranted() { ), showInfoCard = false, onInfoCardDismiss = {}, - autoStartAtBoot = false, onAutoStartAtBootToggled = {}, - autoStartAtBootEnabled = true, + 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 = {}, ) } } 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 bbb43e2a7b..16e00cf484 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 @@ -13,6 +13,7 @@ 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.valueOrNull +import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -25,7 +26,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class ExpertModeViewModel @Inject constructor( @@ -54,23 +54,15 @@ 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) - - val autoStartBootChecked: StateFlow = - useCase.isAutoStartBootEnabled - .stateIn(viewModelScope, SharingStarted.Eagerly, false) - - val autoStartBootEnabled: StateFlow = - useCase.isAutoStartBootAllowed - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + @OptIn(ExperimentalCoroutinesApi::class) + val state: StateFlow> = + useCase.isSystemBridgeConnected.flatMapLatest { isSystemBridgeConnected -> + if (isSystemBridgeConnected) { + startedStateFlow() + } else { + stoppedStateFlow() + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) var showInfoCard by mutableStateOf(!useCase.isInfoDismissed()) private set @@ -155,30 +147,47 @@ 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() + } + + private fun stoppedStateFlow(): Flow> = combine( + useCase.isRootGranted, + useCase.shizukuSetupState, + useCase.isNotificationPermissionGranted, + useCase.isSystemBridgeStarting, + ) { + isRootGranted, + shizukuSetupState, + isNotificationPermissionGranted, + isSystemBridgeStarting, + -> + + State.Data( + ExpertModeState.Stopped( + isRootGranted = isRootGranted, + shizukuSetupState = shizukuSetupState, + isNotificationPermissionGranted = isNotificationPermissionGranted, + isStarting = isSystemBridgeStarting, + ), + ) + } + + private fun startedStateFlow(): Flow> = combine( + useCase.isAutoStartBootEnabled, + useCase.isAutoStartBootAllowed, + useCase.isAdbInputSecurityEnabled, + ) { autoStartBootChecked, autoStartBootEnabled, isAdbInputSecurityEnabled -> + State.Data( + ExpertModeState.Started( + isDefaultUsbModeCompatible = + useCase.isCompatibleUsbModeSelected().valueOrNull() + ?: false, + autoStartBootChecked = autoStartBootChecked, + autoStartBootEnabled = autoStartBootEnabled, + isAdbInputSecurityEnabled = isAdbInputSecurityEnabled, + ), + ) } } @@ -196,5 +205,10 @@ sealed class ExpertModeState { val isStarting: Boolean, ) : ExpertModeState() - data class Started(val isDefaultUsbModeCompatible: Boolean) : ExpertModeState() + data class Started( + val isDefaultUsbModeCompatible: Boolean, + val autoStartBootChecked: Boolean, + val autoStartBootEnabled: Boolean, + val isAdbInputSecurityEnabled: Boolean?, + ) : ExpertModeState() } 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 805856592d..a1614e9cda 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 @@ -166,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() } @@ -311,6 +323,7 @@ interface SystemBridgeSetupUseCase { fun stopSystemBridge() fun enableAccessibilityService() fun enableDeveloperOptions() + fun launchDeveloperOptions() fun connectWifiNetwork() fun enableWirelessDebugging() fun pairWirelessAdb() @@ -319,5 +332,7 @@ interface SystemBridgeSetupUseCase { fun startSystemBridgeWithAdb() fun autoStartSystemBridgeWithAdb() + val isAdbInputSecurityEnabled: Flow + fun isCompatibleUsbModeSelected(): KMResult } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 404fe25798..c814645b25 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1674,7 +1674,6 @@ 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. @@ -1726,6 +1725,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 9f6b246986..c4bf66fcf8 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 @@ -66,7 +66,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( time = SystemClock.elapsedRealtime(), // Get whether the user previously stopped the system bridge. isStoppedByUser = - preferences.get(Keys.isSystemBridgeStoppedByUser).firstBlocking() ?: false, + preferences.get(Keys.isSystemBridgeStoppedByUser).firstBlocking() ?: false, ), ) private var isExpectedDeath: Boolean = false 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..15fe5f4b0d 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() } @@ -418,4 +496,12 @@ 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() } From b71dba8debf5835e8a08f8e5bbf2c4a4a675b0e6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 17:13:35 +0100 Subject: [PATCH 08/13] #1965 disable miui_optimisation when system bridge starts --- .../sysbridge/manager/SystemBridgeConnectionManager.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 c4bf66fcf8..dcd1430fab 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() @@ -66,7 +67,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( time = SystemClock.elapsedRealtime(), // Get whether the user previously stopped the system bridge. isStoppedByUser = - preferences.get(Keys.isSystemBridgeStoppedByUser).firstBlocking() ?: false, + preferences.get(Keys.isSystemBridgeStoppedByUser).firstBlocking() ?: false, ), ) private var isExpectedDeath: Boolean = false @@ -241,6 +242,13 @@ 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) { From 75952ab724129e652ebb46202995d8d2ddabf659 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 17:30:15 +0100 Subject: [PATCH 09/13] #1964 feat: show the command to start Expert Mode with a shell command --- CHANGELOG.md | 4 + app/version.properties | 2 +- .../base/expertmode/ExpertModeScreen.kt | 192 +++++++++++++++++- .../base/expertmode/ExpertModeViewModel.kt | 68 +++++-- .../expertmode/SystemBridgeSetupUseCase.kt | 6 + base/src/main/res/values/strings.xml | 7 + .../manager/SystemBridgeConnectionManager.kt | 10 + .../service/SystemBridgeSetupController.kt | 6 + .../sysbridge/starter/SystemBridgeStarter.kt | 168 +++++++-------- sysbridge/src/main/res/raw/start.sh | 2 +- 10 files changed, 342 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6595dfc68a..61a7d7fb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ #### TO BE RELEASED +## 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. diff --git a/app/version.properties b/app/version.properties index ef95f78163..e64fe0f3ae 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.0.0-beta.06 -VERSION_CODE=223 +VERSION_CODE=224 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 33d268aa83..2a4a140b51 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,6 +32,7 @@ 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 @@ -53,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 @@ -73,6 +80,7 @@ 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) { @@ -98,6 +106,7 @@ fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewMod onRequestNotificationPermissionClick = viewModel::onRequestNotificationPermissionClick, onAutoStartAtBootToggled = { viewModel.onAutoStartBootToggled() }, onLaunchDeveloperOptionsClick = viewModel::onLaunchDeveloperOptionsClick, + onGetShellStartCommandClick = viewModel::onGetShellStartCommandClick, ) } } @@ -179,6 +188,7 @@ private fun Content( onRequestNotificationPermissionClick: () -> Unit = {}, onAutoStartAtBootToggled: () -> Unit = {}, onLaunchDeveloperOptionsClick: () -> Unit = {}, + onGetShellStartCommandClick: () -> Unit = {}, ) { Column(modifier = modifier.verticalScroll(rememberScrollState())) { AnimatedVisibility( @@ -227,6 +237,7 @@ private fun Content( onRequestNotificationPermissionClick = onRequestNotificationPermissionClick, onAutoStartAtBootToggled = onAutoStartAtBootToggled, onLaunchDeveloperOptionsClick = onLaunchDeveloperOptionsClick, + onGetShellStartCommandClick = onGetShellStartCommandClick, ) } } @@ -251,6 +262,7 @@ private fun LoadedContent( onRequestNotificationPermissionClick: () -> Unit = {}, onAutoStartAtBootToggled: () -> Unit = {}, onLaunchDeveloperOptionsClick: () -> Unit = {}, + onGetShellStartCommandClick: () -> Unit = {}, ) { Column(modifier) { OptionsHeaderRow( @@ -345,7 +357,9 @@ private fun LoadedContent( isChecked = state.autoStartBootChecked, onCheckedChange = { onAutoStartAtBootToggled() }, isEnabled = state.autoStartBootEnabled, + ) + Spacer(modifier = Modifier.height(8.dp)) } is ExpertModeState.Stopped -> { @@ -449,12 +463,22 @@ private fun LoadedContent( buttonText = setupKeyMapperText, onButtonClick = onSetupWithKeyMapperClick, enabled = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - state.isNotificationPermissionGranted, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + state.isNotificationPermissionGranted, isLoading = state.isStarting, ) Spacer(modifier = Modifier.height(8.dp)) + + ShellStartCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + shellStartCommandState = state.shellStartCommandState, + onGetShellStartCommandClick = onGetShellStartCommandClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) } } } @@ -718,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( @@ -787,6 +946,7 @@ private fun Preview() { shizukuSetupState = ShizukuSetupState.PERMISSION_GRANTED, isNotificationPermissionGranted = true, isStarting = false, + shellStartCommandState = ShellStartCommandState.Idle, ), ), showInfoCard = true, @@ -878,6 +1038,7 @@ private fun PreviewNotificationPermissionNotGranted() { shizukuSetupState = ShizukuSetupState.PERMISSION_GRANTED, isNotificationPermissionGranted = false, isStarting = false, + shellStartCommandState = ShellStartCommandState.Idle, ), ), showInfoCard = false, @@ -912,3 +1073,30 @@ private fun PreviewUsbDebuggingSecuritySettingsCard() { } } } + +@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 = {}, + onAutoStartAtBootToggled = {}, + onLaunchDeveloperOptionsClick = {}, + ) + } + } +} 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 16e00cf484..23b90ace30 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,12 @@ 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,8 +25,10 @@ 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 +import javax.inject.Inject @HiltViewModel class ExpertModeViewModel @Inject constructor( @@ -54,13 +57,16 @@ class ExpertModeViewModel @Inject constructor( ), ) + private val shellStartCommandState: MutableStateFlow = + MutableStateFlow(ShellStartCommandState.Idle) + @OptIn(ExperimentalCoroutinesApi::class) val state: StateFlow> = useCase.isSystemBridgeConnected.flatMapLatest { isSystemBridgeConnected -> if (isSystemBridgeConnected) { - startedStateFlow() + startedStateFlow().map { State.Data(it) } } else { - stoppedStateFlow() + stoppedStateFlow().map { State.Data(it) } } }.stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) @@ -151,42 +157,56 @@ class ExpertModeViewModel @Inject constructor( useCase.launchDeveloperOptions() } - private fun stoppedStateFlow(): Flow> = combine( + 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, -> - - State.Data( - ExpertModeState.Stopped( - isRootGranted = isRootGranted, - shizukuSetupState = shizukuSetupState, - isNotificationPermissionGranted = isNotificationPermissionGranted, - isStarting = isSystemBridgeStarting, - ), + ExpertModeState.Stopped( + isRootGranted = isRootGranted, + shizukuSetupState = shizukuSetupState, + isNotificationPermissionGranted = isNotificationPermissionGranted, + isStarting = isSystemBridgeStarting, + shellStartCommandState = shellStartCommandState, ) } - private fun startedStateFlow(): Flow> = combine( + private fun startedStateFlow(): Flow = combine( useCase.isAutoStartBootEnabled, useCase.isAutoStartBootAllowed, useCase.isAdbInputSecurityEnabled, ) { autoStartBootChecked, autoStartBootEnabled, isAdbInputSecurityEnabled -> - State.Data( - ExpertModeState.Started( - isDefaultUsbModeCompatible = + ExpertModeState.Started( + isDefaultUsbModeCompatible = useCase.isCompatibleUsbModeSelected().valueOrNull() ?: false, - autoStartBootChecked = autoStartBootChecked, - autoStartBootEnabled = autoStartBootEnabled, - isAdbInputSecurityEnabled = isAdbInputSecurityEnabled, - ), + autoStartBootChecked = autoStartBootChecked, + autoStartBootEnabled = autoStartBootEnabled, + isAdbInputSecurityEnabled = isAdbInputSecurityEnabled, ) } } @@ -203,6 +223,7 @@ sealed class ExpertModeState { val shizukuSetupState: ShizukuSetupState, val isNotificationPermissionGranted: Boolean, val isStarting: Boolean, + val shellStartCommandState: ShellStartCommandState, ) : ExpertModeState() data class Started( @@ -212,3 +233,10 @@ sealed class ExpertModeState { 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/SystemBridgeSetupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt index a1614e9cda..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 @@ -291,6 +291,10 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( else -> SystemBridgeSetupStep.START_SERVICE } } + + override suspend fun getShellStartCommand(): KMResult { + return systemBridgeSetupController.getShellStartCommand() + } } interface SystemBridgeSetupUseCase { @@ -335,4 +339,6 @@ interface SystemBridgeSetupUseCase { val isAdbInputSecurityEnabled: Flow fun isCompatibleUsbModeSelected(): KMResult + + suspend fun getShellStartCommand(): KMResult } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index c814645b25..c362120442 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1679,6 +1679,13 @@ These settings are unavailable until you acknowledge the warning. Expert Mode service is running Stop + Start with shell + The other methods above are recommended. If you are using ADB then make sure you do \'adb shell\' first. + 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. 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 dcd1430fab..746d71d100 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 @@ -24,6 +24,8 @@ import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.common.utils.then +import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.sysbridge.ISystemBridge @@ -39,6 +41,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import timber.log.Timber /** @@ -274,6 +277,10 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( override fun startWithShizuku() { starter.startWithShizuku() } + + override suspend fun getShellStartCommand(): KMResult { + return starter.getStartCommand() + } } @SuppressLint("ObsoleteSdkInt") @@ -298,6 +305,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 15fe5f4b0d..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 @@ -470,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") @@ -504,4 +508,6 @@ interface SystemBridgeSetupController { 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" From f14d5a7c689944fc479aa748ce3fd11a49f027b9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 20:31:33 +0100 Subject: [PATCH 10/13] #1964 feat: show the command to start Expert Mode with a shell command --- .../sds100/keymapper/base/expertmode/ExpertModeScreen.kt | 4 ++-- .../sds100/keymapper/base/expertmode/ExpertModeViewModel.kt | 6 +++--- .../sysbridge/manager/SystemBridgeConnectionManager.kt | 5 +---- 3 files changed, 6 insertions(+), 9 deletions(-) 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 2a4a140b51..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 @@ -463,8 +463,8 @@ private fun LoadedContent( buttonText = setupKeyMapperText, onButtonClick = onSetupWithKeyMapperClick, enabled = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - state.isNotificationPermissionGranted, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + state.isNotificationPermissionGranted, isLoading = state.isStarting, ) 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 23b90ace30..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 @@ -14,6 +14,7 @@ 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 @@ -28,7 +29,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class ExpertModeViewModel @Inject constructor( @@ -202,8 +202,8 @@ class ExpertModeViewModel @Inject constructor( ) { autoStartBootChecked, autoStartBootEnabled, isAdbInputSecurityEnabled -> ExpertModeState.Started( isDefaultUsbModeCompatible = - useCase.isCompatibleUsbModeSelected().valueOrNull() - ?: false, + useCase.isCompatibleUsbModeSelected().valueOrNull() + ?: false, autoStartBootChecked = autoStartBootChecked, autoStartBootEnabled = autoStartBootEnabled, isAdbInputSecurityEnabled = isAdbInputSecurityEnabled, 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 746d71d100..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 @@ -24,8 +24,6 @@ import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.common.utils.onFailure -import io.github.sds100.keymapper.common.utils.then -import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.sysbridge.ISystemBridge @@ -41,7 +39,6 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import timber.log.Timber /** @@ -70,7 +67,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( time = SystemClock.elapsedRealtime(), // Get whether the user previously stopped the system bridge. isStoppedByUser = - preferences.get(Keys.isSystemBridgeStoppedByUser).firstBlocking() ?: false, + preferences.get(Keys.isSystemBridgeStoppedByUser).firstBlocking() ?: false, ), ) private var isExpectedDeath: Boolean = false From 1115a44ea4da9b517997112989a99c3dcbfc0d3e Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 20:41:39 +0100 Subject: [PATCH 11/13] update strings --- base/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index c362120442..dceb055592 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1679,8 +1679,8 @@ These settings are unavailable until you acknowledge the warning. Expert Mode service is running Stop - Start with shell - The other methods above are recommended. If you are using ADB then make sure you do \'adb shell\' first. + 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. From 2b3c8d8ec06619af58f32aedae6d535b177ed5f2 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 20:41:52 +0100 Subject: [PATCH 12/13] chore: bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index e64fe0f3ae..c4fdc8c42a 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.0.0-beta.06 -VERSION_CODE=224 +VERSION_CODE=226 From 2a990749d821679d469d8e6e5c5e6d3556d47d41 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 4 Jan 2026 20:42:57 +0100 Subject: [PATCH 13/13] chore: update changelog release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a7d7fb12..db78ba37e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [4.0.0 Beta 6](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.06) -#### TO BE RELEASED +#### 4 January 2026 ## Added