From 4e0e7071868fa59d70e45c4bde58913e576ec1c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:21:37 +0000 Subject: [PATCH 01/62] Initial plan From 841feaa662279946de49a9ef6d60ff57209c2082 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:36:57 +0000 Subject: [PATCH 02/62] Add core implementation for modify system settings actions Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../keymapper/base/actions/ActionData.kt | 45 ++++++++++++++++ .../base/actions/ActionDataEntityMapper.kt | 39 ++++++++++++++ .../sds100/keymapper/base/actions/ActionId.kt | 4 ++ .../keymapper/base/actions/ActionUtils.kt | 18 +++++++ .../base/actions/PerformActionsUseCase.kt | 12 +++++ base/src/main/res/values/strings.xml | 3 ++ .../keymapper/data/entities/ActionEntity.kt | 1 + .../keymapper/sysbridge/ISystemBridge.aidl | 4 ++ .../sysbridge/service/SystemBridge.kt | 36 +++++++++++++ .../system/display/AndroidDisplayAdapter.kt | 52 +++++++++++++++++++ .../system/display/DisplayAdapter.kt | 4 ++ 11 files changed, 218 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 35feedbca9..4ab5bda852 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -949,4 +949,49 @@ sealed class ActionData : Comparable { data object ClearRecentApp : ActionData() { override val id: ActionId = ActionId.CLEAR_RECENT_APP } + + @Serializable + sealed class ModifySetting : ActionData() { + abstract val settingKey: String + abstract val value: String + + @Serializable + data class System( + override val settingKey: String, + override val value: String, + ) : ModifySetting() { + override val id: ActionId = ActionId.MODIFY_SYSTEM_SETTING + + override fun compareTo(other: ActionData) = when (other) { + is System -> compareValuesBy(this, other, { it.settingKey }, { it.value }) + else -> super.compareTo(other) + } + } + + @Serializable + data class Secure( + override val settingKey: String, + override val value: String, + ) : ModifySetting() { + override val id: ActionId = ActionId.MODIFY_SECURE_SETTING + + override fun compareTo(other: ActionData) = when (other) { + is Secure -> compareValuesBy(this, other, { it.settingKey }, { it.value }) + else -> super.compareTo(other) + } + } + + @Serializable + data class Global( + override val settingKey: String, + override val value: String, + ) : ModifySetting() { + override val id: ActionId = ActionId.MODIFY_GLOBAL_SETTING + + override fun compareTo(other: ActionData) = when (other) { + is Global -> compareValuesBy(this, other, { it.settingKey }, { it.value }) + else -> super.compareTo(other) + } + } + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index b13b0757e3..d4bb5ce0a6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -723,6 +723,36 @@ object ActionDataEntityMapper { ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp + + ActionId.MODIFY_SYSTEM_SETTING -> { + val value = entity.extras.getData(ActionEntity.EXTRA_SETTING_VALUE) + .valueOrNull() ?: return null + + ActionData.ModifySetting.System( + settingKey = entity.data, + value = value, + ) + } + + ActionId.MODIFY_SECURE_SETTING -> { + val value = entity.extras.getData(ActionEntity.EXTRA_SETTING_VALUE) + .valueOrNull() ?: return null + + ActionData.ModifySetting.Secure( + settingKey = entity.data, + value = value, + ) + } + + ActionId.MODIFY_GLOBAL_SETTING -> { + val value = entity.extras.getData(ActionEntity.EXTRA_SETTING_VALUE) + .valueOrNull() ?: return null + + ActionData.ModifySetting.Global( + settingKey = entity.data, + value = value, + ) + } } } @@ -825,6 +855,7 @@ object ActionDataEntityMapper { is ActionData.ControlMedia.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! is ActionData.ControlMedia.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!! is ActionData.GoBack -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ModifySetting -> data.settingKey else -> SYSTEM_ACTION_ID_MAP[data.id]!! } @@ -1105,6 +1136,10 @@ object ActionDataEntityMapper { EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMillis.toString()), ) + is ActionData.ModifySetting -> listOf( + EntityExtra(ActionEntity.EXTRA_SETTING_VALUE, data.value), + ) + else -> emptyList() } @@ -1279,5 +1314,9 @@ object ActionDataEntityMapper { ActionId.HTTP_REQUEST to "http_request", ActionId.FORCE_STOP_APP to "force_stop_app", ActionId.CLEAR_RECENT_APP to "clear_recent_app", + + ActionId.MODIFY_SYSTEM_SETTING to "modify_system_setting", + ActionId.MODIFY_SECURE_SETTING to "modify_secure_setting", + ActionId.MODIFY_GLOBAL_SETTING to "modify_global_setting", ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index 481dc59c5b..fcd70dea63 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -147,4 +147,8 @@ enum class ActionId { FORCE_STOP_APP, CLEAR_RECENT_APP, + + MODIFY_SYSTEM_SETTING, + MODIFY_SECURE_SETTING, + MODIFY_GLOBAL_SETTING, } 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 0902ef74db..14131285f3 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 @@ -254,6 +254,10 @@ object ActionUtils { ActionId.FORCE_STOP_APP -> ActionCategory.APPS ActionId.CLEAR_RECENT_APP -> ActionCategory.APPS + ActionId.MODIFY_SYSTEM_SETTING -> ActionCategory.DISPLAY + ActionId.MODIFY_SECURE_SETTING -> ActionCategory.DISPLAY + ActionId.MODIFY_GLOBAL_SETTING -> ActionCategory.DISPLAY + ActionId.CONSUME_KEY_EVENT -> ActionCategory.SPECIAL } @@ -383,6 +387,10 @@ object ActionUtils { ActionId.INTERACT_UI_ELEMENT -> R.string.action_interact_ui_element_title ActionId.FORCE_STOP_APP -> R.string.action_force_stop_app ActionId.CLEAR_RECENT_APP -> R.string.action_clear_recent_app + + ActionId.MODIFY_SYSTEM_SETTING -> R.string.action_modify_system_setting + ActionId.MODIFY_SECURE_SETTING -> R.string.action_modify_secure_setting + ActionId.MODIFY_GLOBAL_SETTING -> R.string.action_modify_global_setting } @DrawableRes @@ -760,6 +768,12 @@ object ActionUtils { return listOf(Permission.FIND_NEARBY_DEVICES) } + ActionId.MODIFY_SYSTEM_SETTING -> return listOf(Permission.WRITE_SETTINGS) + + ActionId.MODIFY_SECURE_SETTING, + ActionId.MODIFY_GLOBAL_SETTING, + -> return emptyList() // System bridge is required for these + else -> return emptyList() } @@ -890,6 +904,10 @@ object ActionUtils { ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit + + ActionId.MODIFY_SYSTEM_SETTING -> Icons.Outlined.Settings + ActionId.MODIFY_SECURE_SETTING -> Icons.Outlined.Settings + ActionId.MODIFY_GLOBAL_SETTING -> Icons.Outlined.Settings } } 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 ab1fe35d3b..be71b8df87 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 @@ -1016,6 +1016,18 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = SdkVersionTooLow(minSdk = Constants.SYSTEM_BRIDGE_MIN_API) } } + + is ActionData.ModifySetting.System -> { + result = displayAdapter.modifySystemSetting(action.settingKey, action.value) + } + + is ActionData.ModifySetting.Secure -> { + result = displayAdapter.modifySecureSetting(action.settingKey, action.value) + } + + is ActionData.ModifySetting.Global -> { + result = displayAdapter.modifyGlobalSetting(action.settingKey, action.value) + } } when (result) { diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 188a2d3f59..f5c8cd0256 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1160,6 +1160,9 @@ Force stop app Close and clear app from recents + Modify system setting + Modify secure setting + Modify global setting diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 5d33801ee2..02a3ff8507 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -140,6 +140,7 @@ data class ActionEntity( const val EXTRA_DELAY_BEFORE_NEXT_ACTION = "extra_delay_before_next_action" const val EXTRA_HOLD_DOWN_DURATION = "extra_hold_down_duration" const val EXTRA_REPEAT_LIMIT = "extra_repeat_limit" + const val EXTRA_SETTING_VALUE = "extra_setting_value" val DESERIALIZER = jsonDeserializer { val typeString by it.json.byString(NAME_ACTION_TYPE) diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl index 602310a084..d7fcc36c79 100644 --- a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl @@ -42,4 +42,8 @@ interface ISystemBridge { void removeTasks(String packageName) = 17; void setRingerMode(int ringerMode) = 18; + + boolean putSystemSetting(String key, String value) = 19; + boolean putSecureSetting(String key, String value) = 20; + boolean putGlobalSetting(String key, String value) = 21; } \ No newline at end of file diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index fdb9d4cc66..8d042ccd1f 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -667,4 +667,40 @@ internal class SystemBridge : ISystemBridge.Stub() { audioService.setRingerModeInternal(ringerMode, processPackageName) } + + override fun putSystemSetting(key: String?, value: String?): Boolean { + if (key == null || value == null) { + return false + } + + return try { + Settings.System.putString(contentResolver, key, value) + } catch (e: Exception) { + false + } + } + + override fun putSecureSetting(key: String?, value: String?): Boolean { + if (key == null || value == null) { + return false + } + + return try { + Settings.Secure.putString(contentResolver, key, value) + } catch (e: Exception) { + false + } + } + + override fun putGlobalSetting(key: String?, value: String?): Boolean { + if (key == null || value == null) { + return false + } + + return try { + Settings.Global.putString(contentResolver, key, value) + } catch (e: Exception) { + false + } + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt index 9067b25632..770894e74d 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt @@ -18,6 +18,7 @@ import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.getRealDisplaySize +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -32,6 +33,7 @@ import javax.inject.Singleton class AndroidDisplayAdapter @Inject constructor( @ApplicationContext private val context: Context, private val coroutineScope: CoroutineScope, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, ) : DisplayAdapter { companion object { @@ -253,4 +255,54 @@ class AndroidDisplayAdapter @Inject constructor( private fun isAodEnabled(): Boolean { return SettingsUtils.getSecureSetting(ctx, "doze_always_on") == 1 } + + override fun modifySystemSetting(key: String, value: String): KMResult<*> { + // Try to parse value as different types and use the appropriate method + val success = when { + value.toIntOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toInt()) + value.toLongOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toLong()) + value.toFloatOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toFloat()) + else -> SettingsUtils.putSystemSetting(ctx, key, value) + } + + return if (success) { + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(key) + } + } + + override fun modifySecureSetting(key: String, value: String): KMResult<*> { + return systemBridgeConnectionManager.run { bridge -> + val success = when { + value.toIntOrNull() != null -> bridge.putSecureSetting(key, value) + value.toLongOrNull() != null -> bridge.putSecureSetting(key, value) + value.toFloatOrNull() != null -> bridge.putSecureSetting(key, value) + else -> bridge.putSecureSetting(key, value) + } + + if (success) { + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(key) + } + } + } + + override fun modifyGlobalSetting(key: String, value: String): KMResult<*> { + return systemBridgeConnectionManager.run { bridge -> + val success = when { + value.toIntOrNull() != null -> bridge.putGlobalSetting(key, value) + value.toLongOrNull() != null -> bridge.putGlobalSetting(key, value) + value.toFloatOrNull() != null -> bridge.putGlobalSetting(key, value) + else -> bridge.putGlobalSetting(key, value) + } + + if (success) { + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(key) + } + } + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt index a38fe5a177..2881c423fd 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt @@ -27,4 +27,8 @@ interface DisplayAdapter { fun decreaseBrightness(): KMResult<*> fun enableAutoBrightness(): KMResult<*> fun disableAutoBrightness(): KMResult<*> + + fun modifySystemSetting(key: String, value: String): KMResult<*> + fun modifySecureSetting(key: String, value: String): KMResult<*> + fun modifyGlobalSetting(key: String, value: String): KMResult<*> } From d0d21d3190f708fd1c403b02dfaba847273ca8ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:41:53 +0000 Subject: [PATCH 03/62] Add UI bottom sheet for modifying system settings Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../base/actions/ChooseActionScreen.kt | 1 + .../base/actions/CreateActionDelegate.kt | 63 +++++++ .../actions/ModifySettingActionBottomSheet.kt | 161 ++++++++++++++++++ base/src/main/res/values/strings.xml | 5 + 4 files changed, 230 insertions(+) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt index c3365db29f..a7c1a1868d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt @@ -56,6 +56,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { HttpRequestBottomSheet(delegate) SmsActionBottomSheet(delegate) VolumeActionBottomSheet(delegate) + ModifySettingActionBottomSheet(delegate) } @Composable diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index ad6ed0c4f3..c7d562a326 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -54,6 +54,7 @@ class CreateActionDelegate( var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) + var modifySettingActionBottomSheetState: ModifySettingActionBottomSheetState? by mutableStateOf(null) init { coroutineScope.launch { @@ -196,6 +197,11 @@ class CreateActionDelegate( } } + fun onDoneModifySettingClick(action: ActionData.ModifySetting) { + modifySettingActionBottomSheetState = null + actionResult.update { action } + } + suspend fun editAction(oldData: ActionData) { if (!oldData.isEditable()) { throw IllegalArgumentException("This action ${oldData.javaClass.name} can't be edited!") @@ -927,6 +933,63 @@ class CreateActionDelegate( ActionId.MOVE_CURSOR -> return createMoverCursorAction() ActionId.FORCE_STOP_APP -> return ActionData.ForceStopApp ActionId.CLEAR_RECENT_APP -> return ActionData.ClearRecentApp + + ActionId.MODIFY_SYSTEM_SETTING -> { + val settingKey = when (oldData) { + is ActionData.ModifySetting.System -> oldData.settingKey + else -> "" + } + + val value = when (oldData) { + is ActionData.ModifySetting.System -> oldData.value + else -> "" + } + + modifySettingActionBottomSheetState = ModifySettingActionBottomSheetState.System( + settingKey = settingKey, + value = value, + ) + + return null + } + + ActionId.MODIFY_SECURE_SETTING -> { + val settingKey = when (oldData) { + is ActionData.ModifySetting.Secure -> oldData.settingKey + else -> "" + } + + val value = when (oldData) { + is ActionData.ModifySetting.Secure -> oldData.value + else -> "" + } + + modifySettingActionBottomSheetState = ModifySettingActionBottomSheetState.Secure( + settingKey = settingKey, + value = value, + ) + + return null + } + + ActionId.MODIFY_GLOBAL_SETTING -> { + val settingKey = when (oldData) { + is ActionData.ModifySetting.Global -> oldData.settingKey + else -> "" + } + + val value = when (oldData) { + is ActionData.ModifySetting.Global -> oldData.value + else -> "" + } + + modifySettingActionBottomSheetState = ModifySettingActionBottomSheetState.Global( + settingKey = settingKey, + value = value, + ) + + return null + } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt new file mode 100644 index 0000000000..c6811719e3 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -0,0 +1,161 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.utils.ui.compose.BottomSheet +import io.github.sds100.keymapper.base.utils.ui.compose.BottomSheetDefaults +import kotlinx.coroutines.launch + +sealed class ModifySettingActionBottomSheetState( + open val settingKey: String, + open val value: String, +) { + data class System( + override val settingKey: String, + override val value: String, + ) : ModifySettingActionBottomSheetState(settingKey, value) + + data class Secure( + override val settingKey: String, + override val value: String, + ) : ModifySettingActionBottomSheetState(settingKey, value) + + data class Global( + override val settingKey: String, + override val value: String, + ) : ModifySettingActionBottomSheetState(settingKey, value) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModifySettingActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.modifySettingActionBottomSheetState != null) { + ModalBottomSheet( + onDismissRequest = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + delegate.modifySettingActionBottomSheetState = null + } + }, + sheetState = sheetState, + ) { + ModifySettingActionBottomSheetContent( + state = delegate.modifySettingActionBottomSheetState!!, + onDismiss = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + delegate.modifySettingActionBottomSheetState = null + } + }, + onComplete = { action -> + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + delegate.onDoneModifySettingClick(action) + } + }, + ) + } + } +} + +@Composable +private fun ModifySettingActionBottomSheetContent( + state: ModifySettingActionBottomSheetState, + onDismiss: () -> Unit, + onComplete: (ActionData.ModifySetting) -> Unit, +) { + var settingKey by remember(state) { mutableStateOf(state.settingKey) } + var value by remember(state) { mutableStateOf(state.value) } + + val titleRes = when (state) { + is ModifySettingActionBottomSheetState.System -> R.string.action_modify_system_setting + is ModifySettingActionBottomSheetState.Secure -> R.string.action_modify_secure_setting + is ModifySettingActionBottomSheetState.Global -> R.string.action_modify_global_setting + } + + BottomSheet( + title = stringResource(titleRes), + onDismiss = onDismiss, + positiveButton = BottomSheetDefaults.OkButton { + val action = when (state) { + is ModifySettingActionBottomSheetState.System -> + ActionData.ModifySetting.System(settingKey, value) + is ModifySettingActionBottomSheetState.Secure -> + ActionData.ModifySetting.Secure(settingKey, value) + is ModifySettingActionBottomSheetState.Global -> + ActionData.ModifySetting.Global(settingKey, value) + } + onComplete(action) + }, + positiveButtonEnabled = settingKey.isNotBlank() && value.isNotBlank(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = settingKey, + onValueChange = { settingKey = it }, + label = { Text(stringResource(R.string.modify_setting_key_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(stringResource(R.string.modify_setting_value_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val exampleText = when (state) { + is ModifySettingActionBottomSheetState.System -> + stringResource(R.string.modify_setting_example_system) + is ModifySettingActionBottomSheetState.Secure -> + stringResource(R.string.modify_setting_example_secure) + is ModifySettingActionBottomSheetState.Global -> + stringResource(R.string.modify_setting_example_global) + } + + Text( + text = exampleText, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index f5c8cd0256..bde72c8fbe 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1163,6 +1163,11 @@ Modify system setting Modify secure setting Modify global setting + Setting Key + Value + Example: screen_off_timeout = 2147483647 (System settings use WRITE_SETTINGS permission) + Example: accessibility_enabled = 1 (Secure settings require system bridge) + Example: airplane_mode_on = 1 (Global settings require system bridge) From 4cc6ed90ae4a4f99b33ce347f6639f88c05ba3bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:44:38 +0000 Subject: [PATCH 04/62] Add action title and description display for modify settings Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../keymapper/base/actions/ActionUiHelper.kt | 15 +++++++++++++++ base/src/main/res/values/strings.xml | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 7659de15ef..be36a5c640 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -651,6 +651,21 @@ class ActionUiHelper( ActionData.Microphone.Mute -> getString(R.string.action_mute_microphone) ActionData.Microphone.Toggle -> getString(R.string.action_toggle_mute_microphone) ActionData.Microphone.Unmute -> getString(R.string.action_unmute_microphone) + + is ActionData.ModifySetting.System -> getString( + R.string.modify_setting_description, + arrayOf(action.settingKey, action.value, getString(R.string.modify_setting_type_system)), + ) + + is ActionData.ModifySetting.Secure -> getString( + R.string.modify_setting_description, + arrayOf(action.settingKey, action.value, getString(R.string.modify_setting_type_secure)), + ) + + is ActionData.ModifySetting.Global -> getString( + R.string.modify_setting_description, + arrayOf(action.settingKey, action.value, getString(R.string.modify_setting_type_global)), + ) } fun getIcon(action: ActionData): ComposeIconInfo = when (action) { diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index bde72c8fbe..fef9f998be 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1079,6 +1079,10 @@ Send SMS: "%s"" to %s Compose SMS Compose SMS: "%s" to %s + Set %3$s: %1$s = %2$s + System Setting + Secure Setting + Global Setting Play sound Dismiss most recent notification Dismiss all notifications From 6237188b0212aa7a3173c6047cfbabbc6987e64b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:47:19 +0000 Subject: [PATCH 05/62] Fix SystemBridge settings implementation with proper ContentResolver access Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../keymapper/sysbridge/service/SystemBridge.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index 8d042ccd1f..60baba88d2 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -26,6 +26,7 @@ import android.os.Process import android.os.ServiceManager import android.permission.IPermissionManager import android.permission.PermissionManagerApis +import android.provider.Settings import android.util.Log import android.view.InputEvent import com.android.internal.telephony.ITelephony @@ -674,8 +675,13 @@ internal class SystemBridge : ISystemBridge.Stub() { } return try { + // SystemBridge runs as shell/root user, so we can directly access Settings + // We need to use the system context's content resolver + val contentResolver = android.app.ActivityThread.systemMain() + .getSystemContext().contentResolver Settings.System.putString(contentResolver, key, value) } catch (e: Exception) { + Log.e(TAG, "Failed to put system setting: $key=$value", e) false } } @@ -686,8 +692,11 @@ internal class SystemBridge : ISystemBridge.Stub() { } return try { + val contentResolver = android.app.ActivityThread.systemMain() + .getSystemContext().contentResolver Settings.Secure.putString(contentResolver, key, value) } catch (e: Exception) { + Log.e(TAG, "Failed to put secure setting: $key=$value", e) false } } @@ -698,8 +707,11 @@ internal class SystemBridge : ISystemBridge.Stub() { } return try { + val contentResolver = android.app.ActivityThread.systemMain() + .getSystemContext().contentResolver Settings.Global.putString(contentResolver, key, value) } catch (e: Exception) { + Log.e(TAG, "Failed to put global setting: $key=$value", e) false } } From 927a3d6c2d4a024bd2e170dc59e2a96137f5e3f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:58:37 +0000 Subject: [PATCH 06/62] Refactor to use single ActionId with SettingType enum - Replace 3 separate ActionIds with single MODIFY_SETTING - Add SettingType enum (SYSTEM, SECURE, GLOBAL) - Update ActionData.ModifySetting to use enum instead of sealed class - Add dropdown for setting type in UI - Default to SYSTEM type (no PRO mode required) - Update all mappers and handlers accordingly Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../keymapper/base/actions/ActionData.kt | 55 +++------ .../base/actions/ActionDataEntityMapper.kt | 35 ++---- .../sds100/keymapper/base/actions/ActionId.kt | 4 +- .../keymapper/base/actions/ActionUiHelper.kt | 28 ++--- .../keymapper/base/actions/ActionUtils.kt | 18 +-- .../base/actions/CreateActionDelegate.kt | 48 ++------ .../actions/ModifySettingActionBottomSheet.kt | 116 ++++++++++++------ .../base/actions/PerformActionsUseCase.kt | 19 ++- base/src/main/res/values/strings.xml | 5 +- .../keymapper/data/entities/ActionEntity.kt | 1 + .../keymapper/system/settings/SettingType.kt | 10 ++ .../system/settings/SettingsAdapter.kt | 57 +++++++++ 12 files changed, 212 insertions(+), 184 deletions(-) create mode 100644 system/src/main/java/io/github/sds100/keymapper/system/settings/SettingType.kt create mode 100644 system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 4ab5bda852..52f8cbd9d9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -951,47 +951,22 @@ sealed class ActionData : Comparable { } @Serializable - sealed class ModifySetting : ActionData() { - abstract val settingKey: String - abstract val value: String - - @Serializable - data class System( - override val settingKey: String, - override val value: String, - ) : ModifySetting() { - override val id: ActionId = ActionId.MODIFY_SYSTEM_SETTING - - override fun compareTo(other: ActionData) = when (other) { - is System -> compareValuesBy(this, other, { it.settingKey }, { it.value }) - else -> super.compareTo(other) - } - } - - @Serializable - data class Secure( - override val settingKey: String, - override val value: String, - ) : ModifySetting() { - override val id: ActionId = ActionId.MODIFY_SECURE_SETTING - - override fun compareTo(other: ActionData) = when (other) { - is Secure -> compareValuesBy(this, other, { it.settingKey }, { it.value }) - else -> super.compareTo(other) - } - } - - @Serializable - data class Global( - override val settingKey: String, - override val value: String, - ) : ModifySetting() { - override val id: ActionId = ActionId.MODIFY_GLOBAL_SETTING + data class ModifySetting( + val settingType: io.github.sds100.keymapper.system.settings.SettingType, + val settingKey: String, + val value: String, + ) : ActionData() { + override val id: ActionId = ActionId.MODIFY_SETTING - override fun compareTo(other: ActionData) = when (other) { - is Global -> compareValuesBy(this, other, { it.settingKey }, { it.value }) - else -> super.compareTo(other) - } + override fun compareTo(other: ActionData) = when (other) { + is ModifySetting -> compareValuesBy( + this, + other, + { it.settingType }, + { it.settingKey }, + { it.value }, + ) + else -> super.compareTo(other) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index d4bb5ce0a6..7e8bb512b7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -724,31 +724,21 @@ object ActionDataEntityMapper { ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp - ActionId.MODIFY_SYSTEM_SETTING -> { + ActionId.MODIFY_SETTING -> { val value = entity.extras.getData(ActionEntity.EXTRA_SETTING_VALUE) .valueOrNull() ?: return null - ActionData.ModifySetting.System( - settingKey = entity.data, - value = value, - ) - } + val settingTypeString = entity.extras.getData(ActionEntity.EXTRA_SETTING_TYPE) + .valueOrNull() ?: "SYSTEM" // Default to SYSTEM for backward compatibility - ActionId.MODIFY_SECURE_SETTING -> { - val value = entity.extras.getData(ActionEntity.EXTRA_SETTING_VALUE) - .valueOrNull() ?: return null - - ActionData.ModifySetting.Secure( - settingKey = entity.data, - value = value, - ) - } - - ActionId.MODIFY_GLOBAL_SETTING -> { - val value = entity.extras.getData(ActionEntity.EXTRA_SETTING_VALUE) - .valueOrNull() ?: return null + val settingType = try { + io.github.sds100.keymapper.system.settings.SettingType.valueOf(settingTypeString) + } catch (e: IllegalArgumentException) { + io.github.sds100.keymapper.system.settings.SettingType.SYSTEM + } - ActionData.ModifySetting.Global( + ActionData.ModifySetting( + settingType = settingType, settingKey = entity.data, value = value, ) @@ -1138,6 +1128,7 @@ object ActionDataEntityMapper { is ActionData.ModifySetting -> listOf( EntityExtra(ActionEntity.EXTRA_SETTING_VALUE, data.value), + EntityExtra(ActionEntity.EXTRA_SETTING_TYPE, data.settingType.name), ) else -> emptyList() @@ -1315,8 +1306,6 @@ object ActionDataEntityMapper { ActionId.FORCE_STOP_APP to "force_stop_app", ActionId.CLEAR_RECENT_APP to "clear_recent_app", - ActionId.MODIFY_SYSTEM_SETTING to "modify_system_setting", - ActionId.MODIFY_SECURE_SETTING to "modify_secure_setting", - ActionId.MODIFY_GLOBAL_SETTING to "modify_global_setting", + ActionId.MODIFY_SETTING to "modify_setting", ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index fcd70dea63..aa3b463135 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -148,7 +148,5 @@ enum class ActionId { FORCE_STOP_APP, CLEAR_RECENT_APP, - MODIFY_SYSTEM_SETTING, - MODIFY_SECURE_SETTING, - MODIFY_GLOBAL_SETTING, + MODIFY_SETTING, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index be36a5c640..6dbae2f8b6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -652,20 +652,20 @@ class ActionUiHelper( ActionData.Microphone.Toggle -> getString(R.string.action_toggle_mute_microphone) ActionData.Microphone.Unmute -> getString(R.string.action_unmute_microphone) - is ActionData.ModifySetting.System -> getString( - R.string.modify_setting_description, - arrayOf(action.settingKey, action.value, getString(R.string.modify_setting_type_system)), - ) - - is ActionData.ModifySetting.Secure -> getString( - R.string.modify_setting_description, - arrayOf(action.settingKey, action.value, getString(R.string.modify_setting_type_secure)), - ) - - is ActionData.ModifySetting.Global -> getString( - R.string.modify_setting_description, - arrayOf(action.settingKey, action.value, getString(R.string.modify_setting_type_global)), - ) + is ActionData.ModifySetting -> { + val typeString = when (action.settingType) { + io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> + getString(R.string.modify_setting_type_system) + io.github.sds100.keymapper.system.settings.SettingType.SECURE -> + getString(R.string.modify_setting_type_secure) + io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> + getString(R.string.modify_setting_type_global) + } + getString( + R.string.modify_setting_description, + arrayOf(action.settingKey, action.value, typeString), + ) + } } fun getIcon(action: ActionData): ComposeIconInfo = when (action) { 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 14131285f3..5d7338e57f 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 @@ -254,9 +254,7 @@ object ActionUtils { ActionId.FORCE_STOP_APP -> ActionCategory.APPS ActionId.CLEAR_RECENT_APP -> ActionCategory.APPS - ActionId.MODIFY_SYSTEM_SETTING -> ActionCategory.DISPLAY - ActionId.MODIFY_SECURE_SETTING -> ActionCategory.DISPLAY - ActionId.MODIFY_GLOBAL_SETTING -> ActionCategory.DISPLAY + ActionId.MODIFY_SETTING -> ActionCategory.DISPLAY ActionId.CONSUME_KEY_EVENT -> ActionCategory.SPECIAL } @@ -388,9 +386,7 @@ object ActionUtils { ActionId.FORCE_STOP_APP -> R.string.action_force_stop_app ActionId.CLEAR_RECENT_APP -> R.string.action_clear_recent_app - ActionId.MODIFY_SYSTEM_SETTING -> R.string.action_modify_system_setting - ActionId.MODIFY_SECURE_SETTING -> R.string.action_modify_secure_setting - ActionId.MODIFY_GLOBAL_SETTING -> R.string.action_modify_global_setting + ActionId.MODIFY_SETTING -> R.string.action_modify_setting } @DrawableRes @@ -768,11 +764,7 @@ object ActionUtils { return listOf(Permission.FIND_NEARBY_DEVICES) } - ActionId.MODIFY_SYSTEM_SETTING -> return listOf(Permission.WRITE_SETTINGS) - - ActionId.MODIFY_SECURE_SETTING, - ActionId.MODIFY_GLOBAL_SETTING, - -> return emptyList() // System bridge is required for these + ActionId.MODIFY_SETTING -> return emptyList() // Permissions handled based on setting type at runtime else -> return emptyList() } @@ -905,9 +897,7 @@ object ActionUtils { ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit - ActionId.MODIFY_SYSTEM_SETTING -> Icons.Outlined.Settings - ActionId.MODIFY_SECURE_SETTING -> Icons.Outlined.Settings - ActionId.MODIFY_GLOBAL_SETTING -> Icons.Outlined.Settings + ActionId.MODIFY_SETTING -> Icons.Outlined.Settings } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index c7d562a326..f82644e708 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -934,56 +934,24 @@ class CreateActionDelegate( ActionId.FORCE_STOP_APP -> return ActionData.ForceStopApp ActionId.CLEAR_RECENT_APP -> return ActionData.ClearRecentApp - ActionId.MODIFY_SYSTEM_SETTING -> { - val settingKey = when (oldData) { - is ActionData.ModifySetting.System -> oldData.settingKey - else -> "" - } - - val value = when (oldData) { - is ActionData.ModifySetting.System -> oldData.value - else -> "" - } - - modifySettingActionBottomSheetState = ModifySettingActionBottomSheetState.System( - settingKey = settingKey, - value = value, - ) - - return null - } - - ActionId.MODIFY_SECURE_SETTING -> { - val settingKey = when (oldData) { - is ActionData.ModifySetting.Secure -> oldData.settingKey - else -> "" - } - - val value = when (oldData) { - is ActionData.ModifySetting.Secure -> oldData.value - else -> "" + ActionId.MODIFY_SETTING -> { + val settingType = when (oldData) { + is ActionData.ModifySetting -> oldData.settingType + else -> io.github.sds100.keymapper.system.settings.SettingType.SYSTEM // Default to SYSTEM } - modifySettingActionBottomSheetState = ModifySettingActionBottomSheetState.Secure( - settingKey = settingKey, - value = value, - ) - - return null - } - - ActionId.MODIFY_GLOBAL_SETTING -> { val settingKey = when (oldData) { - is ActionData.ModifySetting.Global -> oldData.settingKey + is ActionData.ModifySetting -> oldData.settingKey else -> "" } val value = when (oldData) { - is ActionData.ModifySetting.Global -> oldData.value + is ActionData.ModifySetting -> oldData.value else -> "" } - modifySettingActionBottomSheetState = ModifySettingActionBottomSheetState.Global( + modifySettingActionBottomSheetState = ModifySettingActionBottomSheetState( + settingType = settingType, settingKey = settingKey, value = value, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index c6811719e3..ca25f1be9e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -5,7 +5,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -24,25 +27,11 @@ import io.github.sds100.keymapper.base.utils.ui.compose.BottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.BottomSheetDefaults import kotlinx.coroutines.launch -sealed class ModifySettingActionBottomSheetState( - open val settingKey: String, - open val value: String, -) { - data class System( - override val settingKey: String, - override val value: String, - ) : ModifySettingActionBottomSheetState(settingKey, value) - - data class Secure( - override val settingKey: String, - override val value: String, - ) : ModifySettingActionBottomSheetState(settingKey, value) - - data class Global( - override val settingKey: String, - override val value: String, - ) : ModifySettingActionBottomSheetState(settingKey, value) -} +data class ModifySettingActionBottomSheetState( + val settingType: io.github.sds100.keymapper.system.settings.SettingType, + val settingKey: String, + val value: String, +) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -88,27 +77,28 @@ private fun ModifySettingActionBottomSheetContent( onDismiss: () -> Unit, onComplete: (ActionData.ModifySetting) -> Unit, ) { + var settingType by remember(state) { mutableStateOf(state.settingType) } var settingKey by remember(state) { mutableStateOf(state.settingKey) } var value by remember(state) { mutableStateOf(state.value) } - - val titleRes = when (state) { - is ModifySettingActionBottomSheetState.System -> R.string.action_modify_system_setting - is ModifySettingActionBottomSheetState.Secure -> R.string.action_modify_secure_setting - is ModifySettingActionBottomSheetState.Global -> R.string.action_modify_global_setting + + var settingTypeExpanded by remember { mutableStateOf(false) } + var settingKeyExpanded by remember { mutableStateOf(false) } + + // Available setting keys based on selected type - placeholder, would need SettingsAdapter + val availableKeys = remember(settingType) { + // For now, return empty list. This will be populated via dependency injection + emptyList() } BottomSheet( - title = stringResource(titleRes), + title = stringResource(R.string.action_modify_setting), onDismiss = onDismiss, positiveButton = BottomSheetDefaults.OkButton { - val action = when (state) { - is ModifySettingActionBottomSheetState.System -> - ActionData.ModifySetting.System(settingKey, value) - is ModifySettingActionBottomSheetState.Secure -> - ActionData.ModifySetting.Secure(settingKey, value) - is ModifySettingActionBottomSheetState.Global -> - ActionData.ModifySetting.Global(settingKey, value) - } + val action = ActionData.ModifySetting( + settingType = settingType, + settingKey = settingKey, + value = value, + ) onComplete(action) }, positiveButtonEnabled = settingKey.isNotBlank() && value.isNotBlank(), @@ -120,6 +110,58 @@ private fun ModifySettingActionBottomSheetContent( ) { Spacer(modifier = Modifier.height(16.dp)) + // Setting Type Dropdown + ExposedDropdownMenuBox( + expanded = settingTypeExpanded, + onExpandedChange = { settingTypeExpanded = it }, + ) { + OutlinedTextField( + value = when (settingType) { + io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> + stringResource(R.string.modify_setting_type_system) + io.github.sds100.keymapper.system.settings.SettingType.SECURE -> + stringResource(R.string.modify_setting_type_secure) + io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> + stringResource(R.string.modify_setting_type_global) + }, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.modify_setting_type_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = settingTypeExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + singleLine = true, + ) + ExposedDropdownMenu( + expanded = settingTypeExpanded, + onDismissRequest = { settingTypeExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.modify_setting_type_system)) }, + onClick = { + settingType = io.github.sds100.keymapper.system.settings.SettingType.SYSTEM + settingTypeExpanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.modify_setting_type_secure)) }, + onClick = { + settingType = io.github.sds100.keymapper.system.settings.SettingType.SECURE + settingTypeExpanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.modify_setting_type_global)) }, + onClick = { + settingType = io.github.sds100.keymapper.system.settings.SettingType.GLOBAL + settingTypeExpanded = false + }, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Setting Key - allow both dropdown selection and manual entry OutlinedTextField( value = settingKey, onValueChange = { settingKey = it }, @@ -140,12 +182,12 @@ private fun ModifySettingActionBottomSheetContent( Spacer(modifier = Modifier.height(16.dp)) - val exampleText = when (state) { - is ModifySettingActionBottomSheetState.System -> + val exampleText = when (settingType) { + io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> stringResource(R.string.modify_setting_example_system) - is ModifySettingActionBottomSheetState.Secure -> + io.github.sds100.keymapper.system.settings.SettingType.SECURE -> stringResource(R.string.modify_setting_example_secure) - is ModifySettingActionBottomSheetState.Global -> + io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> stringResource(R.string.modify_setting_example_global) } 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 be71b8df87..47a984007f 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 @@ -1017,16 +1017,15 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } } - is ActionData.ModifySetting.System -> { - result = displayAdapter.modifySystemSetting(action.settingKey, action.value) - } - - is ActionData.ModifySetting.Secure -> { - result = displayAdapter.modifySecureSetting(action.settingKey, action.value) - } - - is ActionData.ModifySetting.Global -> { - result = displayAdapter.modifyGlobalSetting(action.settingKey, action.value) + is ActionData.ModifySetting -> { + result = when (action.settingType) { + io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> + displayAdapter.modifySystemSetting(action.settingKey, action.value) + io.github.sds100.keymapper.system.settings.SettingType.SECURE -> + displayAdapter.modifySecureSetting(action.settingKey, action.value) + io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> + displayAdapter.modifyGlobalSetting(action.settingKey, action.value) + } } } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index fef9f998be..69a63fa14a 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1164,11 +1164,10 @@ Force stop app Close and clear app from recents - Modify system setting - Modify secure setting - Modify global setting + Modify setting Setting Key Value + Setting Type Example: screen_off_timeout = 2147483647 (System settings use WRITE_SETTINGS permission) Example: accessibility_enabled = 1 (Secure settings require system bridge) Example: airplane_mode_on = 1 (Global settings require system bridge) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 02a3ff8507..20d60ac50a 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -141,6 +141,7 @@ data class ActionEntity( const val EXTRA_HOLD_DOWN_DURATION = "extra_hold_down_duration" const val EXTRA_REPEAT_LIMIT = "extra_repeat_limit" const val EXTRA_SETTING_VALUE = "extra_setting_value" + const val EXTRA_SETTING_TYPE = "extra_setting_type" val DESERIALIZER = jsonDeserializer { val typeString by it.json.byString(NAME_ACTION_TYPE) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingType.kt b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingType.kt new file mode 100644 index 0000000000..8e0a5fd5e0 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingType.kt @@ -0,0 +1,10 @@ +package io.github.sds100.keymapper.system.settings + +import kotlinx.serialization.Serializable + +@Serializable +enum class SettingType { + SYSTEM, + SECURE, + GLOBAL, +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt new file mode 100644 index 0000000000..fb822f3c42 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt @@ -0,0 +1,57 @@ +package io.github.sds100.keymapper.system.settings + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.Settings +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SettingsAdapter @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val ctx = context.applicationContext + + fun getSystemSettingKeys(): List { + return getSettingKeys(Settings.System.CONTENT_URI) + } + + fun getSecureSettingKeys(): List { + return getSettingKeys(Settings.Secure.CONTENT_URI) + } + + fun getGlobalSettingKeys(): List { + return getSettingKeys(Settings.Global.CONTENT_URI) + } + + private fun getSettingKeys(uri: Uri): List { + val keys = mutableListOf() + var cursor: Cursor? = null + try { + cursor = ctx.contentResolver.query( + uri, + arrayOf("name"), + null, + null, + null, + ) + + cursor?.use { + val nameIndex = it.getColumnIndex("name") + if (nameIndex >= 0) { + while (it.moveToNext()) { + val name = it.getString(nameIndex) + if (!name.isNullOrBlank()) { + keys.add(name) + } + } + } + } + } catch (e: Exception) { + // Some devices may not allow querying all settings + } + return keys.sorted() + } +} From 51b18ebf1de28004673b8d1eff1a38591929c3cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:11:55 +0000 Subject: [PATCH 07/62] Refactor settings modification to use SettingsAdapter and permissions - Move MODIFY_SETTING to APPS category - Create AndroidSettingsAdapter interface implementing SettingsAdapter - Use SettingsUtils for all setting modifications (no SystemBridge needed) - Remove SystemBridge methods for settings (putSystemSetting, etc) - Add permission checks: WRITE_SETTINGS for System, WRITE_SECURE_SETTINGS for Secure/Global - Remove setting methods from DisplayAdapter Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../base/actions/ActionErrorSnapshot.kt | 20 +++++ .../keymapper/base/actions/ActionUtils.kt | 3 +- .../base/actions/PerformActionsUseCase.kt | 7 +- .../keymapper/sysbridge/ISystemBridge.aidl | 4 - .../sysbridge/service/SystemBridge.kt | 47 ---------- .../system/display/AndroidDisplayAdapter.kt | 52 ----------- .../system/display/DisplayAdapter.kt | 4 - .../system/settings/SettingsAdapter.kt | 86 +++++++++++++++++-- 8 files changed, 106 insertions(+), 117 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt index a20a449237..6d453d93b5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt @@ -231,6 +231,26 @@ class LazyActionErrorSnapshot( } } + is ActionData.ModifySetting -> { + return when (action.settingType) { + io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> { + if (!isPermissionGranted(Permission.WRITE_SETTINGS)) { + SystemError.PermissionDenied(Permission.WRITE_SETTINGS) + } else { + null + } + } + io.github.sds100.keymapper.system.settings.SettingType.SECURE, + io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> { + if (!isPermissionGranted(Permission.WRITE_SECURE_SETTINGS)) { + SystemError.PermissionDenied(Permission.WRITE_SECURE_SETTINGS) + } else { + null + } + } + } + } + else -> {} } 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 5d7338e57f..81b8464d8c 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 @@ -253,8 +253,7 @@ object ActionUtils { ActionId.INTERACT_UI_ELEMENT -> ActionCategory.APPS ActionId.FORCE_STOP_APP -> ActionCategory.APPS ActionId.CLEAR_RECENT_APP -> ActionCategory.APPS - - ActionId.MODIFY_SETTING -> ActionCategory.DISPLAY + ActionId.MODIFY_SETTING -> ActionCategory.APPS ActionId.CONSUME_KEY_EVENT -> ActionCategory.SPECIAL } 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 47a984007f..ec168ca2ca 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 @@ -120,6 +120,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val settingsRepository: PreferenceRepository, private val inputEventHub: InputEventHub, private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val settingsAdapter: io.github.sds100.keymapper.system.settings.SettingsAdapter, ) : PerformActionsUseCase { @AssistedFactory @@ -1020,11 +1021,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is ActionData.ModifySetting -> { result = when (action.settingType) { io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> - displayAdapter.modifySystemSetting(action.settingKey, action.value) + settingsAdapter.modifySystemSetting(action.settingKey, action.value) io.github.sds100.keymapper.system.settings.SettingType.SECURE -> - displayAdapter.modifySecureSetting(action.settingKey, action.value) + settingsAdapter.modifySecureSetting(action.settingKey, action.value) io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> - displayAdapter.modifyGlobalSetting(action.settingKey, action.value) + settingsAdapter.modifyGlobalSetting(action.settingKey, action.value) } } } diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl index d7fcc36c79..602310a084 100644 --- a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl @@ -42,8 +42,4 @@ interface ISystemBridge { void removeTasks(String packageName) = 17; void setRingerMode(int ringerMode) = 18; - - boolean putSystemSetting(String key, String value) = 19; - boolean putSecureSetting(String key, String value) = 20; - boolean putGlobalSetting(String key, String value) = 21; } \ No newline at end of file diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index 60baba88d2..e89edb6930 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -668,51 +668,4 @@ internal class SystemBridge : ISystemBridge.Stub() { audioService.setRingerModeInternal(ringerMode, processPackageName) } - - override fun putSystemSetting(key: String?, value: String?): Boolean { - if (key == null || value == null) { - return false - } - - return try { - // SystemBridge runs as shell/root user, so we can directly access Settings - // We need to use the system context's content resolver - val contentResolver = android.app.ActivityThread.systemMain() - .getSystemContext().contentResolver - Settings.System.putString(contentResolver, key, value) - } catch (e: Exception) { - Log.e(TAG, "Failed to put system setting: $key=$value", e) - false - } - } - - override fun putSecureSetting(key: String?, value: String?): Boolean { - if (key == null || value == null) { - return false - } - - return try { - val contentResolver = android.app.ActivityThread.systemMain() - .getSystemContext().contentResolver - Settings.Secure.putString(contentResolver, key, value) - } catch (e: Exception) { - Log.e(TAG, "Failed to put secure setting: $key=$value", e) - false - } - } - - override fun putGlobalSetting(key: String?, value: String?): Boolean { - if (key == null || value == null) { - return false - } - - return try { - val contentResolver = android.app.ActivityThread.systemMain() - .getSystemContext().contentResolver - Settings.Global.putString(contentResolver, key, value) - } catch (e: Exception) { - Log.e(TAG, "Failed to put global setting: $key=$value", e) - false - } - } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt index 770894e74d..9067b25632 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt @@ -18,7 +18,6 @@ import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.getRealDisplaySize -import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -33,7 +32,6 @@ import javax.inject.Singleton class AndroidDisplayAdapter @Inject constructor( @ApplicationContext private val context: Context, private val coroutineScope: CoroutineScope, - private val systemBridgeConnectionManager: SystemBridgeConnectionManager, ) : DisplayAdapter { companion object { @@ -255,54 +253,4 @@ class AndroidDisplayAdapter @Inject constructor( private fun isAodEnabled(): Boolean { return SettingsUtils.getSecureSetting(ctx, "doze_always_on") == 1 } - - override fun modifySystemSetting(key: String, value: String): KMResult<*> { - // Try to parse value as different types and use the appropriate method - val success = when { - value.toIntOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toInt()) - value.toLongOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toLong()) - value.toFloatOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toFloat()) - else -> SettingsUtils.putSystemSetting(ctx, key, value) - } - - return if (success) { - Success(Unit) - } else { - KMError.FailedToModifySystemSetting(key) - } - } - - override fun modifySecureSetting(key: String, value: String): KMResult<*> { - return systemBridgeConnectionManager.run { bridge -> - val success = when { - value.toIntOrNull() != null -> bridge.putSecureSetting(key, value) - value.toLongOrNull() != null -> bridge.putSecureSetting(key, value) - value.toFloatOrNull() != null -> bridge.putSecureSetting(key, value) - else -> bridge.putSecureSetting(key, value) - } - - if (success) { - Success(Unit) - } else { - KMError.FailedToModifySystemSetting(key) - } - } - } - - override fun modifyGlobalSetting(key: String, value: String): KMResult<*> { - return systemBridgeConnectionManager.run { bridge -> - val success = when { - value.toIntOrNull() != null -> bridge.putGlobalSetting(key, value) - value.toLongOrNull() != null -> bridge.putGlobalSetting(key, value) - value.toFloatOrNull() != null -> bridge.putGlobalSetting(key, value) - else -> bridge.putGlobalSetting(key, value) - } - - if (success) { - Success(Unit) - } else { - KMError.FailedToModifySystemSetting(key) - } - } - } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt index 2881c423fd..a38fe5a177 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt @@ -27,8 +27,4 @@ interface DisplayAdapter { fun decreaseBrightness(): KMResult<*> fun enableAutoBrightness(): KMResult<*> fun disableAutoBrightness(): KMResult<*> - - fun modifySystemSetting(key: String, value: String): KMResult<*> - fun modifySecureSetting(key: String, value: String): KMResult<*> - fun modifyGlobalSetting(key: String, value: String): KMResult<*> } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt index fb822f3c42..032483db65 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt @@ -5,27 +5,89 @@ import android.database.Cursor import android.net.Uri import android.provider.Settings import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.utils.KMError +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 javax.inject.Inject import javax.inject.Singleton @Singleton -class SettingsAdapter @Inject constructor( +class AndroidSettingsAdapter @Inject constructor( @ApplicationContext private val context: Context, -) { +) : SettingsAdapter { private val ctx = context.applicationContext - fun getSystemSettingKeys(): List { + override fun getSystemSettingKeys(): List { return getSettingKeys(Settings.System.CONTENT_URI) } - fun getSecureSettingKeys(): List { + override fun getSecureSettingKeys(): List { return getSettingKeys(Settings.Secure.CONTENT_URI) } - fun getGlobalSettingKeys(): List { + override fun getGlobalSettingKeys(): List { return getSettingKeys(Settings.Global.CONTENT_URI) } + override fun getSystemSettingValue(key: String): String? { + return SettingsUtils.getSystemSetting(ctx, key) + } + + override fun getSecureSettingValue(key: String): String? { + return SettingsUtils.getSecureSetting(ctx, key) + } + + override fun getGlobalSettingValue(key: String): String? { + return SettingsUtils.getGlobalSetting(ctx, key) + } + + override fun modifySystemSetting(key: String, value: String): KMResult<*> { + // Try to parse value as different types and use the appropriate method + val success = when { + value.toIntOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toInt()) + value.toLongOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toLong()) + value.toFloatOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toFloat()) + else -> SettingsUtils.putSystemSetting(ctx, key, value) + } + + return if (success) { + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(key) + } + } + + override fun modifySecureSetting(key: String, value: String): KMResult<*> { + val success = when { + value.toIntOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toInt()) + value.toLongOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toLong()) + value.toFloatOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toFloat()) + else -> SettingsUtils.putSecureSetting(ctx, key, value) + } + + return if (success) { + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(key) + } + } + + override fun modifyGlobalSetting(key: String, value: String): KMResult<*> { + val success = when { + value.toIntOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toInt()) + value.toLongOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toLong()) + value.toFloatOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toFloat()) + else -> SettingsUtils.putGlobalSetting(ctx, key, value) + } + + return if (success) { + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(key) + } + } + private fun getSettingKeys(uri: Uri): List { val keys = mutableListOf() var cursor: Cursor? = null @@ -55,3 +117,17 @@ class SettingsAdapter @Inject constructor( return keys.sorted() } } + +interface SettingsAdapter { + fun getSystemSettingKeys(): List + fun getSecureSettingKeys(): List + fun getGlobalSettingKeys(): List + + fun getSystemSettingValue(key: String): String? + fun getSecureSettingValue(key: String): String? + fun getGlobalSettingValue(key: String): String? + + fun modifySystemSetting(key: String, value: String): KMResult<*> + fun modifySecureSetting(key: String, value: String): KMResult<*> + fun modifyGlobalSetting(key: String, value: String): KMResult<*> +} From b57cdf842cd06e005545e35093a915434197c37d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:16:32 +0000 Subject: [PATCH 08/62] Add ChooseSettingScreen and ViewModel for setting selection - Created ChooseSettingViewModel with setting type selection and search - Created ChooseSettingScreen with dropdown for type and searchable list - Added ChooseSetting nav destination - Added string resources for choosesetting screen - Screen shows all available settings with current values - User can select from existing settings or enter custom key Next: Wire up navigation in CreateActionDelegate Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../base/actions/ChooseSettingScreen.kt | 198 ++++++++++++++++++ .../base/actions/ChooseSettingViewModel.kt | 96 +++++++++ .../base/utils/navigation/NavDestination.kt | 8 + base/src/main/res/values/strings.xml | 3 + 4 files changed, 305 insertions(+) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt new file mode 100644 index 0000000000..fd94fe562b --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt @@ -0,0 +1,198 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.utils.ui.compose.SearchAppBarActions +import io.github.sds100.keymapper.common.utils.State +import kotlinx.coroutines.flow.update + +@Composable +fun ChooseSettingScreen(modifier: Modifier = Modifier, viewModel: ChooseSettingViewModel) { + val state by viewModel.settings.collectAsStateWithLifecycle() + val query by viewModel.searchQuery.collectAsStateWithLifecycle() + val settingType by viewModel.selectedSettingType.collectAsStateWithLifecycle() + + ChooseSettingScreen( + modifier = modifier, + state = state, + query = query, + settingType = settingType, + onQueryChange = { newQuery -> viewModel.searchQuery.update { newQuery } }, + onCloseSearch = { viewModel.searchQuery.update { null } }, + onSettingTypeChange = { viewModel.selectedSettingType.value = it }, + onClickSetting = viewModel::onSettingClick, + onNavigateBack = viewModel::onNavigateBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChooseSettingScreen( + modifier: Modifier = Modifier, + state: State>, + query: String? = null, + settingType: io.github.sds100.keymapper.system.settings.SettingType, + onQueryChange: (String) -> Unit = {}, + onCloseSearch: () -> Unit = {}, + onSettingTypeChange: (io.github.sds100.keymapper.system.settings.SettingType) -> Unit = {}, + onClickSetting: (String, String?) -> Unit = { _, _ -> }, + onNavigateBack: () -> Unit = {}, +) { + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.choose_setting_title)) }, + ) + }, + bottomBar = { + BottomAppBar( + modifier = Modifier.imePadding(), + actions = { + SearchAppBarActions( + onCloseSearch = onCloseSearch, + onNavigateBack = onNavigateBack, + onQueryChange = onQueryChange, + enabled = state is State.Data, + query = query, + ) + }, + ) + }, + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Setting type dropdown + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + OutlinedTextField( + value = when (settingType) { + io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> + stringResource(R.string.modify_setting_type_system) + io.github.sds100.keymapper.system.settings.SettingType.SECURE -> + stringResource(R.string.modify_setting_type_secure) + io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> + stringResource(R.string.modify_setting_type_global) + }, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.modify_setting_type_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + singleLine = true, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.modify_setting_type_system)) }, + onClick = { + onSettingTypeChange(io.github.sds100.keymapper.system.settings.SettingType.SYSTEM) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.modify_setting_type_secure)) }, + onClick = { + onSettingTypeChange(io.github.sds100.keymapper.system.settings.SettingType.SECURE) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.modify_setting_type_global)) }, + onClick = { + onSettingTypeChange(io.github.sds100.keymapper.system.settings.SettingType.GLOBAL) + expanded = false + }, + ) + } + } + + HorizontalDivider() + + // Settings list + when (state) { + State.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is State.Data -> { + if (state.data.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.choose_setting_empty), + style = MaterialTheme.typography.bodyLarge, + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.data) { item -> + ListItem( + headlineContent = { Text(item.key) }, + supportingContent = item.value?.let { { Text(it) } }, + modifier = Modifier.clickable { + onClickSetting(item.key, item.value) + }, + ) + HorizontalDivider() + } + } + } + } + } + } + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt new file mode 100644 index 0000000000..06bf1e6a54 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt @@ -0,0 +1,96 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +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.system.settings.SettingsAdapter +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class SettingItem( + val key: String, + val value: String?, +) + +@HiltViewModel +class ChooseSettingViewModel @Inject constructor( + private val settingsAdapter: SettingsAdapter, + resourceProvider: ResourceProvider, + navigationProvider: NavigationProvider, + dialogProvider: DialogProvider, +) : ViewModel(), + ResourceProvider by resourceProvider, + DialogProvider by dialogProvider, + NavigationProvider by navigationProvider { + + val searchQuery = MutableStateFlow(null) + val selectedSettingType = MutableStateFlow(io.github.sds100.keymapper.system.settings.SettingType.SYSTEM) + + val settings: StateFlow>> = + combine(selectedSettingType, searchQuery) { type, query -> + val keys = when (type) { + io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> + settingsAdapter.getSystemSettingKeys() + io.github.sds100.keymapper.system.settings.SettingType.SECURE -> + settingsAdapter.getSecureSettingKeys() + io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> + settingsAdapter.getGlobalSettingKeys() + } + + val items = keys + .filter { query == null || it.contains(query, ignoreCase = true) } + .map { key -> + val value = when (type) { + io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> + settingsAdapter.getSystemSettingValue(key) + io.github.sds100.keymapper.system.settings.SettingType.SECURE -> + settingsAdapter.getSecureSettingValue(key) + io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> + settingsAdapter.getGlobalSettingValue(key) + } + SettingItem(key, value) + } + + State.Data(items) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) + + fun onNavigateBack() { + viewModelScope.launch { + popBackStack() + } + } + + fun onSettingClick(key: String, currentValue: String?) { + viewModelScope.launch { + popBackStackWithResult( + kotlinx.serialization.json.Json.encodeToString( + ChooseSettingResult.serializer(), + ChooseSettingResult( + settingType = selectedSettingType.value, + key = key, + currentValue = currentValue, + ), + ), + ) + } + } +} + +@kotlinx.serialization.Serializable +data class ChooseSettingResult( + val settingType: io.github.sds100.keymapper.system.settings.SettingType, + val key: String, + val currentValue: String?, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index fdbc390a85..3683bb9599 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -38,6 +38,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_CONFIG_KEY_MAP = "config_key_map" const val ID_INTERACT_UI_ELEMENT_ACTION = "interact_ui_element_action" const val ID_SHELL_COMMAND_ACTION = "shell_command_action" + const val ID_CHOOSE_SETTING = "choose_setting" const val ID_PRO_MODE = "pro_mode" const val ID_LOG = "log" const val ID_ADVANCED_TRIGGERS = "advanced_triggers" @@ -172,6 +173,13 @@ abstract class NavDestination(val isCompose: Boolean = false) { override val id: String = ID_SHELL_COMMAND_ACTION } + @Serializable + data class ChooseSetting( + val currentSettingType: io.github.sds100.keymapper.system.settings.SettingType?, + ) : NavDestination(isCompose = true) { + override val id: String = ID_CHOOSE_SETTING + } + @Serializable data object ProMode : NavDestination(isCompose = true) { override val id: String = ID_PRO_MODE diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 69a63fa14a..9bcff9020f 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1171,6 +1171,9 @@ Example: screen_off_timeout = 2147483647 (System settings use WRITE_SETTINGS permission) Example: accessibility_enabled = 1 (Secure settings require system bridge) Example: airplane_mode_on = 1 (Global settings require system bridge) + Choose Setting + No settings found + Choose Existing Setting From c1f999f3939a66e96da7a139f5c0ab6217ffadc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:24:06 +0000 Subject: [PATCH 09/62] Fix code review feedback: remove fully qualified names and use KeyMapperDropdownMenu - Import SettingType instead of using fully qualified names - Import Json and Serializable for cleaner code - Replace custom dropdown with KeyMapperDropdownMenu in ChooseSettingScreen - Remove unused imports - Update NavDestination to use imported SettingType Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../base/actions/ChooseSettingScreen.kt | 75 ++++--------------- .../base/actions/ChooseSettingViewModel.kt | 23 +++--- .../base/utils/navigation/NavDestination.kt | 7 +- 3 files changed, 33 insertions(+), 72 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt index fd94fe562b..2d5bb9dffb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt @@ -3,29 +3,19 @@ package io.github.sds100.keymapper.base.actions import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.BottomAppBar import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -41,8 +31,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperDropdownMenu import io.github.sds100.keymapper.base.utils.ui.compose.SearchAppBarActions import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.system.settings.SettingType import kotlinx.coroutines.flow.update @Composable @@ -70,10 +62,10 @@ private fun ChooseSettingScreen( modifier: Modifier = Modifier, state: State>, query: String? = null, - settingType: io.github.sds100.keymapper.system.settings.SettingType, + settingType: SettingType, onQueryChange: (String) -> Unit = {}, onCloseSearch: () -> Unit = {}, - onSettingTypeChange: (io.github.sds100.keymapper.system.settings.SettingType) -> Unit = {}, + onSettingTypeChange: (SettingType) -> Unit = {}, onClickSetting: (String, String?) -> Unit = { _, _ -> }, onNavigateBack: () -> Unit = {}, ) { @@ -108,56 +100,21 @@ private fun ChooseSettingScreen( // Setting type dropdown var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it }, + KeyMapperDropdownMenu( modifier = Modifier .fillMaxWidth() .padding(16.dp), - ) { - OutlinedTextField( - value = when (settingType) { - io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> - stringResource(R.string.modify_setting_type_system) - io.github.sds100.keymapper.system.settings.SettingType.SECURE -> - stringResource(R.string.modify_setting_type_secure) - io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> - stringResource(R.string.modify_setting_type_global) - }, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.modify_setting_type_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier.fillMaxWidth().menuAnchor(), - singleLine = true, - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.modify_setting_type_system)) }, - onClick = { - onSettingTypeChange(io.github.sds100.keymapper.system.settings.SettingType.SYSTEM) - expanded = false - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.modify_setting_type_secure)) }, - onClick = { - onSettingTypeChange(io.github.sds100.keymapper.system.settings.SettingType.SECURE) - expanded = false - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.modify_setting_type_global)) }, - onClick = { - onSettingTypeChange(io.github.sds100.keymapper.system.settings.SettingType.GLOBAL) - expanded = false - }, - ) - } - } + expanded = expanded, + onExpandedChange = { expanded = it }, + label = { Text(stringResource(R.string.modify_setting_type_label)) }, + selectedValue = settingType, + values = listOf( + SettingType.SYSTEM to stringResource(R.string.modify_setting_type_system), + SettingType.SECURE to stringResource(R.string.modify_setting_type_secure), + SettingType.GLOBAL to stringResource(R.string.modify_setting_type_global), + ), + onValueChanged = onSettingTypeChange, + ) HorizontalDivider() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt index 06bf1e6a54..6227002f4b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt @@ -8,6 +8,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.system.settings.SettingsAdapter +import io.github.sds100.keymapper.system.settings.SettingType import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -17,6 +18,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json data class SettingItem( val key: String, @@ -35,16 +38,16 @@ class ChooseSettingViewModel @Inject constructor( NavigationProvider by navigationProvider { val searchQuery = MutableStateFlow(null) - val selectedSettingType = MutableStateFlow(io.github.sds100.keymapper.system.settings.SettingType.SYSTEM) + val selectedSettingType = MutableStateFlow(SettingType.SYSTEM) val settings: StateFlow>> = combine(selectedSettingType, searchQuery) { type, query -> val keys = when (type) { - io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> + SettingType.SYSTEM -> settingsAdapter.getSystemSettingKeys() - io.github.sds100.keymapper.system.settings.SettingType.SECURE -> + SettingType.SECURE -> settingsAdapter.getSecureSettingKeys() - io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> + SettingType.GLOBAL -> settingsAdapter.getGlobalSettingKeys() } @@ -52,11 +55,11 @@ class ChooseSettingViewModel @Inject constructor( .filter { query == null || it.contains(query, ignoreCase = true) } .map { key -> val value = when (type) { - io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> + SettingType.SYSTEM -> settingsAdapter.getSystemSettingValue(key) - io.github.sds100.keymapper.system.settings.SettingType.SECURE -> + SettingType.SECURE -> settingsAdapter.getSecureSettingValue(key) - io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> + SettingType.GLOBAL -> settingsAdapter.getGlobalSettingValue(key) } SettingItem(key, value) @@ -75,7 +78,7 @@ class ChooseSettingViewModel @Inject constructor( fun onSettingClick(key: String, currentValue: String?) { viewModelScope.launch { popBackStackWithResult( - kotlinx.serialization.json.Json.encodeToString( + Json.encodeToString( ChooseSettingResult.serializer(), ChooseSettingResult( settingType = selectedSettingType.value, @@ -88,9 +91,9 @@ class ChooseSettingViewModel @Inject constructor( } } -@kotlinx.serialization.Serializable +@Serializable data class ChooseSettingResult( - val settingType: io.github.sds100.keymapper.system.settings.SettingType, + val settingType: SettingType, val key: String, val currentValue: String?, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index 3683bb9599..ef674f495d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -1,6 +1,6 @@ -package io.github.sds100.keymapper.base.utils.navigation - +import io.github.sds100.keymapper.base.utils.navigation import io.github.sds100.keymapper.base.actions.ActionData +import io.github.sds100.keymapper.base.actions.ChooseSettingResult import io.github.sds100.keymapper.base.actions.pinchscreen.PinchPickCoordinateResult import io.github.sds100.keymapper.base.actions.swipescreen.SwipePickCoordinateResult import io.github.sds100.keymapper.base.actions.tapscreen.PickCoordinateResult @@ -10,6 +10,7 @@ import io.github.sds100.keymapper.base.system.intents.ConfigIntentResult import io.github.sds100.keymapper.base.trigger.TriggerSetupShortcut import io.github.sds100.keymapper.system.apps.ActivityInfo import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo +import io.github.sds100.keymapper.system.settings.SettingType import kotlinx.serialization.Serializable @Serializable @@ -175,7 +176,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { @Serializable data class ChooseSetting( - val currentSettingType: io.github.sds100.keymapper.system.settings.SettingType?, + val currentSettingType: SettingType?, ) : NavDestination(isCompose = true) { override val id: String = ID_CHOOSE_SETTING } From c351c610d97ad4232a3caede9651d4f51da8c518 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:39:02 +0000 Subject: [PATCH 10/62] Refactor SettingsAdapter and ModifySettingActionBottomSheet per review - Consolidate modify methods into single modifySetting(settingType, key, value) method - Import SettingType instead of using fully qualified name - Replace custom dropdown with KeyMapperDropdownMenu - Add onChooseSetting callback and Button to navigate to ChooseSettingScreen - Update PerformActionsUseCase to use new unified method - Add onChooseSettingClick method to CreateActionDelegate for navigation Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../base/actions/CreateActionDelegate.kt | 9 ++ .../actions/ModifySettingActionBottomSheet.kt | 89 +++++++------------ .../base/actions/PerformActionsUseCase.kt | 13 ++- .../system/settings/SettingsAdapter.kt | 61 +++++-------- 4 files changed, 66 insertions(+), 106 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index f82644e708..9e9620dfbd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -202,6 +202,15 @@ class CreateActionDelegate( actionResult.update { action } } + fun onChooseSettingClick(settingType: io.github.sds100.keymapper.system.settings.SettingType) { + coroutineScope.launch { + navigate( + "choose_setting", + NavDestination.ChooseSetting(currentSettingType = settingType), + ) + } + } + suspend fun editAction(oldData: ActionData) { if (!oldData.isEditable()) { throw IllegalArgumentException("This action ${oldData.javaClass.name} can't be edited!") diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index ca25f1be9e..dfbcf47a0e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -5,10 +5,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -25,10 +23,12 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.ui.compose.BottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.BottomSheetDefaults +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperDropdownMenu +import io.github.sds100.keymapper.system.settings.SettingType import kotlinx.coroutines.launch data class ModifySettingActionBottomSheetState( - val settingType: io.github.sds100.keymapper.system.settings.SettingType, + val settingType: SettingType, val settingKey: String, val value: String, ) @@ -59,6 +59,9 @@ fun ModifySettingActionBottomSheet(delegate: CreateActionDelegate) { delegate.modifySettingActionBottomSheetState = null } }, + onChooseSetting = { settingType -> + delegate.onChooseSettingClick(settingType) + }, onComplete = { action -> scope.launch { sheetState.hide() @@ -75,6 +78,7 @@ fun ModifySettingActionBottomSheet(delegate: CreateActionDelegate) { private fun ModifySettingActionBottomSheetContent( state: ModifySettingActionBottomSheetState, onDismiss: () -> Unit, + onChooseSetting: (SettingType) -> Unit, onComplete: (ActionData.ModifySetting) -> Unit, ) { var settingType by remember(state) { mutableStateOf(state.settingType) } @@ -82,13 +86,6 @@ private fun ModifySettingActionBottomSheetContent( var value by remember(state) { mutableStateOf(state.value) } var settingTypeExpanded by remember { mutableStateOf(false) } - var settingKeyExpanded by remember { mutableStateOf(false) } - - // Available setting keys based on selected type - placeholder, would need SettingsAdapter - val availableKeys = remember(settingType) { - // For now, return empty list. This will be populated via dependency injection - emptyList() - } BottomSheet( title = stringResource(R.string.action_modify_setting), @@ -111,57 +108,33 @@ private fun ModifySettingActionBottomSheetContent( Spacer(modifier = Modifier.height(16.dp)) // Setting Type Dropdown - ExposedDropdownMenuBox( + KeyMapperDropdownMenu( + modifier = Modifier.fillMaxWidth(), expanded = settingTypeExpanded, onExpandedChange = { settingTypeExpanded = it }, + label = { Text(stringResource(R.string.modify_setting_type_label)) }, + selectedValue = settingType, + values = listOf( + SettingType.SYSTEM to stringResource(R.string.modify_setting_type_system), + SettingType.SECURE to stringResource(R.string.modify_setting_type_secure), + SettingType.GLOBAL to stringResource(R.string.modify_setting_type_global), + ), + onValueChanged = { settingType = it }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Button to choose an existing setting + Button( + onClick = { onChooseSetting(settingType) }, + modifier = Modifier.fillMaxWidth(), ) { - OutlinedTextField( - value = when (settingType) { - io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> - stringResource(R.string.modify_setting_type_system) - io.github.sds100.keymapper.system.settings.SettingType.SECURE -> - stringResource(R.string.modify_setting_type_secure) - io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> - stringResource(R.string.modify_setting_type_global) - }, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.modify_setting_type_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = settingTypeExpanded) }, - modifier = Modifier.fillMaxWidth().menuAnchor(), - singleLine = true, - ) - ExposedDropdownMenu( - expanded = settingTypeExpanded, - onDismissRequest = { settingTypeExpanded = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.modify_setting_type_system)) }, - onClick = { - settingType = io.github.sds100.keymapper.system.settings.SettingType.SYSTEM - settingTypeExpanded = false - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.modify_setting_type_secure)) }, - onClick = { - settingType = io.github.sds100.keymapper.system.settings.SettingType.SECURE - settingTypeExpanded = false - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.modify_setting_type_global)) }, - onClick = { - settingType = io.github.sds100.keymapper.system.settings.SettingType.GLOBAL - settingTypeExpanded = false - }, - ) - } + Text(stringResource(R.string.choose_existing_setting)) } Spacer(modifier = Modifier.height(16.dp)) - // Setting Key - allow both dropdown selection and manual entry + // Setting Key - manual entry OutlinedTextField( value = settingKey, onValueChange = { settingKey = it }, @@ -183,11 +156,11 @@ private fun ModifySettingActionBottomSheetContent( Spacer(modifier = Modifier.height(16.dp)) val exampleText = when (settingType) { - io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> + SettingType.SYSTEM -> stringResource(R.string.modify_setting_example_system) - io.github.sds100.keymapper.system.settings.SettingType.SECURE -> + SettingType.SECURE -> stringResource(R.string.modify_setting_example_secure) - io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> + SettingType.GLOBAL -> stringResource(R.string.modify_setting_example_global) } 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 ec168ca2ca..413d2272b6 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 @@ -1019,14 +1019,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.ModifySetting -> { - result = when (action.settingType) { - io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> - settingsAdapter.modifySystemSetting(action.settingKey, action.value) - io.github.sds100.keymapper.system.settings.SettingType.SECURE -> - settingsAdapter.modifySecureSetting(action.settingKey, action.value) - io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> - settingsAdapter.modifyGlobalSetting(action.settingKey, action.value) - } + result = settingsAdapter.modifySetting( + action.settingType, + action.settingKey, + action.value, + ) } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt index 032483db65..047ad7e77e 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt @@ -42,43 +42,26 @@ class AndroidSettingsAdapter @Inject constructor( return SettingsUtils.getGlobalSetting(ctx, key) } - override fun modifySystemSetting(key: String, value: String): KMResult<*> { - // Try to parse value as different types and use the appropriate method - val success = when { - value.toIntOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toInt()) - value.toLongOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toLong()) - value.toFloatOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toFloat()) - else -> SettingsUtils.putSystemSetting(ctx, key, value) - } - - return if (success) { - Success(Unit) - } else { - KMError.FailedToModifySystemSetting(key) - } - } - - override fun modifySecureSetting(key: String, value: String): KMResult<*> { - val success = when { - value.toIntOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toInt()) - value.toLongOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toLong()) - value.toFloatOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toFloat()) - else -> SettingsUtils.putSecureSetting(ctx, key, value) - } - - return if (success) { - Success(Unit) - } else { - KMError.FailedToModifySystemSetting(key) - } - } - - override fun modifyGlobalSetting(key: String, value: String): KMResult<*> { - val success = when { - value.toIntOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toInt()) - value.toLongOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toLong()) - value.toFloatOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toFloat()) - else -> SettingsUtils.putGlobalSetting(ctx, key, value) + override fun modifySetting(settingType: SettingType, key: String, value: String): KMResult<*> { + val success = when (settingType) { + SettingType.SYSTEM -> when { + value.toIntOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toInt()) + value.toLongOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toLong()) + value.toFloatOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toFloat()) + else -> SettingsUtils.putSystemSetting(ctx, key, value) + } + SettingType.SECURE -> when { + value.toIntOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toInt()) + value.toLongOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toLong()) + value.toFloatOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toFloat()) + else -> SettingsUtils.putSecureSetting(ctx, key, value) + } + SettingType.GLOBAL -> when { + value.toIntOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toInt()) + value.toLongOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toLong()) + value.toFloatOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toFloat()) + else -> SettingsUtils.putGlobalSetting(ctx, key, value) + } } return if (success) { @@ -127,7 +110,5 @@ interface SettingsAdapter { fun getSecureSettingValue(key: String): String? fun getGlobalSettingValue(key: String): String? - fun modifySystemSetting(key: String, value: String): KMResult<*> - fun modifySecureSetting(key: String, value: String): KMResult<*> - fun modifyGlobalSetting(key: String, value: String): KMResult<*> + fun modifySetting(settingType: SettingType, key: String, value: String): KMResult<*> } From b149abf01203be0fdc3632d5e76d24126919a83e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 00:23:25 +0000 Subject: [PATCH 11/62] Consolidate SettingsAdapter methods and import SettingType in ActionErrorSnapshot - Refactored getKeys methods into single getAll(settingType) returning Map - Consolidated getValue methods into single getValue(settingType, key) method - Updated ChooseSettingViewModel to use new unified methods - Imported SettingType in ActionErrorSnapshot instead of using fully qualified name - Simplified interface with only 3 methods: getAll, getValue, modifySetting Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../base/actions/ActionErrorSnapshot.kt | 7 +- .../base/actions/ChooseSettingViewModel.kt | 27 +---- .../system/settings/SettingsAdapter.kt | 98 ++++++++----------- 3 files changed, 51 insertions(+), 81 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt index 6d453d93b5..4554ed268a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt @@ -27,6 +27,7 @@ import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter +import io.github.sds100.keymapper.system.settings.SettingType class LazyActionErrorSnapshot( private val packageManager: PackageManagerAdapter, @@ -233,15 +234,15 @@ class LazyActionErrorSnapshot( is ActionData.ModifySetting -> { return when (action.settingType) { - io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> { + SettingType.SYSTEM -> { if (!isPermissionGranted(Permission.WRITE_SETTINGS)) { SystemError.PermissionDenied(Permission.WRITE_SETTINGS) } else { null } } - io.github.sds100.keymapper.system.settings.SettingType.SECURE, - io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> { + SettingType.SECURE, + SettingType.GLOBAL -> { if (!isPermissionGranted(Permission.WRITE_SECURE_SETTINGS)) { SystemError.PermissionDenied(Permission.WRITE_SECURE_SETTINGS) } else { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt index 6227002f4b..a512b455be 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt @@ -42,28 +42,11 @@ class ChooseSettingViewModel @Inject constructor( val settings: StateFlow>> = combine(selectedSettingType, searchQuery) { type, query -> - val keys = when (type) { - SettingType.SYSTEM -> - settingsAdapter.getSystemSettingKeys() - SettingType.SECURE -> - settingsAdapter.getSecureSettingKeys() - SettingType.GLOBAL -> - settingsAdapter.getGlobalSettingKeys() - } - - val items = keys - .filter { query == null || it.contains(query, ignoreCase = true) } - .map { key -> - val value = when (type) { - SettingType.SYSTEM -> - settingsAdapter.getSystemSettingValue(key) - SettingType.SECURE -> - settingsAdapter.getSecureSettingValue(key) - SettingType.GLOBAL -> - settingsAdapter.getGlobalSettingValue(key) - } - SettingItem(key, value) - } + val allSettings = settingsAdapter.getAll(type) + + val items = allSettings + .filter { (key, _) -> query == null || key.contains(query, ignoreCase = true) } + .map { (key, value) -> SettingItem(key, value) } State.Data(items) }.flowOn(Dispatchers.Default) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt index 047ad7e77e..58d53f8fec 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt @@ -18,28 +18,49 @@ class AndroidSettingsAdapter @Inject constructor( ) : SettingsAdapter { private val ctx = context.applicationContext - override fun getSystemSettingKeys(): List { - return getSettingKeys(Settings.System.CONTENT_URI) - } - - override fun getSecureSettingKeys(): List { - return getSettingKeys(Settings.Secure.CONTENT_URI) - } - - override fun getGlobalSettingKeys(): List { - return getSettingKeys(Settings.Global.CONTENT_URI) - } - - override fun getSystemSettingValue(key: String): String? { - return SettingsUtils.getSystemSetting(ctx, key) - } + override fun getAll(settingType: SettingType): Map { + val uri = when (settingType) { + SettingType.SYSTEM -> Settings.System.CONTENT_URI + SettingType.SECURE -> Settings.Secure.CONTENT_URI + SettingType.GLOBAL -> Settings.Global.CONTENT_URI + } + + val settings = mutableMapOf() + var cursor: Cursor? = null + try { + cursor = ctx.contentResolver.query( + uri, + arrayOf("name", "value"), + null, + null, + null, + ) - override fun getSecureSettingValue(key: String): String? { - return SettingsUtils.getSecureSetting(ctx, key) + cursor?.use { + val nameIndex = it.getColumnIndex("name") + val valueIndex = it.getColumnIndex("value") + if (nameIndex >= 0) { + while (it.moveToNext()) { + val name = it.getString(nameIndex) + if (!name.isNullOrBlank()) { + val value = if (valueIndex >= 0) it.getString(valueIndex) else null + settings[name] = value + } + } + } + } + } catch (e: Exception) { + // Some devices may not allow querying all settings + } + return settings.toSortedMap() } - override fun getGlobalSettingValue(key: String): String? { - return SettingsUtils.getGlobalSetting(ctx, key) + override fun getValue(settingType: SettingType, key: String): String? { + return when (settingType) { + SettingType.SYSTEM -> SettingsUtils.getSystemSetting(ctx, key) + SettingType.SECURE -> SettingsUtils.getSecureSetting(ctx, key) + SettingType.GLOBAL -> SettingsUtils.getGlobalSetting(ctx, key) + } } override fun modifySetting(settingType: SettingType, key: String, value: String): KMResult<*> { @@ -70,45 +91,10 @@ class AndroidSettingsAdapter @Inject constructor( KMError.FailedToModifySystemSetting(key) } } - - private fun getSettingKeys(uri: Uri): List { - val keys = mutableListOf() - var cursor: Cursor? = null - try { - cursor = ctx.contentResolver.query( - uri, - arrayOf("name"), - null, - null, - null, - ) - - cursor?.use { - val nameIndex = it.getColumnIndex("name") - if (nameIndex >= 0) { - while (it.moveToNext()) { - val name = it.getString(nameIndex) - if (!name.isNullOrBlank()) { - keys.add(name) - } - } - } - } - } catch (e: Exception) { - // Some devices may not allow querying all settings - } - return keys.sorted() - } } interface SettingsAdapter { - fun getSystemSettingKeys(): List - fun getSecureSettingKeys(): List - fun getGlobalSettingKeys(): List - - fun getSystemSettingValue(key: String): String? - fun getSecureSettingValue(key: String): String? - fun getGlobalSettingValue(key: String): String? - + fun getAll(settingType: SettingType): Map + fun getValue(settingType: SettingType, key: String): String? fun modifySetting(settingType: SettingType, key: String, value: String): KMResult<*> } From 0ac7e7268a43fe95b52909b217272f5db114b2cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:38:21 +0000 Subject: [PATCH 12/62] Initial plan From ecf4b6c3eb52bd5717222d13734f9ec04d834e9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:50:42 +0000 Subject: [PATCH 13/62] Add CREATE_NOTIFICATION action with basic implementation - Added CREATE_NOTIFICATION to ActionId enum - Added CreateNotification data class to ActionData with title, text, and optional timeout - Added CHANNEL_CUSTOM_NOTIFICATIONS channel in NotificationController - Added POST_NOTIFICATIONS permission requirement in ActionUtils - Implemented notification creation in PerformActionsUseCase - Added entity mapping in ActionDataEntityMapper - Added string resources for the action - Added notification extras constants in ActionEntity Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../keymapper/base/actions/ActionData.kt | 14 +++++++++++ .../base/actions/ActionDataEntityMapper.kt | 25 +++++++++++++++++++ .../sds100/keymapper/base/actions/ActionId.kt | 1 + .../keymapper/base/actions/ActionUtils.kt | 6 +++++ .../base/actions/CreateActionDelegate.kt | 12 +++++++++ .../base/actions/PerformActionsUseCase.kt | 23 +++++++++++++++++ .../notifications/NotificationController.kt | 9 +++++++ base/src/main/res/values/strings.xml | 2 ++ .../keymapper/data/entities/ActionEntity.kt | 3 +++ 9 files changed, 95 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 35feedbca9..7fa8136182 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -871,6 +871,20 @@ sealed class ActionData : Comparable { override val id: ActionId = ActionId.DISMISS_ALL_NOTIFICATIONS } + @Serializable + data class CreateNotification( + val title: String, + val text: String, + val timeoutMs: Long?, + ) : ActionData() { + override val id: ActionId = ActionId.CREATE_NOTIFICATION + + override fun compareTo(other: ActionData) = when (other) { + is CreateNotification -> title.compareTo(other.title) + else -> super.compareTo(other) + } + } + @Serializable data object AnswerCall : ActionData() { override val id: ActionId = ActionId.ANSWER_PHONE_CALL diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index b13b0757e3..bc768a2668 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -50,6 +50,7 @@ object ActionDataEntityMapper { ActionEntity.Type.INTERACT_UI_ELEMENT -> ActionId.INTERACT_UI_ELEMENT ActionEntity.Type.SHELL_COMMAND -> ActionId.SHELL_COMMAND + ActionEntity.Type.CREATE_NOTIFICATION -> ActionId.CREATE_NOTIFICATION } return when (actionId) { @@ -556,6 +557,21 @@ object ActionDataEntityMapper { ActionId.SHOW_POWER_MENU -> ActionData.ShowPowerMenu ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionData.DismissLastNotification ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionData.DismissAllNotifications + ActionId.CREATE_NOTIFICATION -> { + val title = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TITLE).valueOrNull() + ?: return null + + val text = entity.data + + val timeoutMs = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TIMEOUT).valueOrNull() + ?.toLongOrNull() + + ActionData.CreateNotification( + title = title, + text = text, + timeoutMs = timeoutMs, + ) + } ActionId.ANSWER_PHONE_CALL -> ActionData.AnswerCall ActionId.END_PHONE_CALL -> ActionData.EndCall ActionId.DEVICE_CONTROLS -> ActionData.DeviceControls @@ -749,6 +765,7 @@ object ActionDataEntityMapper { is ActionData.Sound -> ActionEntity.Type.SOUND is ActionData.InteractUiElement -> ActionEntity.Type.INTERACT_UI_ELEMENT is ActionData.ShellCommand -> ActionEntity.Type.SHELL_COMMAND + is ActionData.CreateNotification -> ActionEntity.Type.CREATE_NOTIFICATION else -> ActionEntity.Type.SYSTEM_ACTION } @@ -819,6 +836,7 @@ object ActionDataEntityMapper { data.command.toByteArray(), Base64.DEFAULT, ).trim() // Trim to remove trailing newline added by Base64.DEFAULT + is ActionData.CreateNotification -> data.text is ActionData.HttpRequest -> SYSTEM_ACTION_ID_MAP[data.id]!! is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! is ActionData.ControlMediaForApp.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!! @@ -1105,6 +1123,13 @@ object ActionDataEntityMapper { EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMillis.toString()), ) + is ActionData.CreateNotification -> buildList { + add(EntityExtra(ActionEntity.EXTRA_NOTIFICATION_TITLE, data.title)) + data.timeoutMs?.let { + add(EntityExtra(ActionEntity.EXTRA_NOTIFICATION_TIMEOUT, it.toString())) + } + } + else -> emptyList() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index 481dc59c5b..3cf37a8b94 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -138,6 +138,7 @@ enum class ActionId { DISMISS_MOST_RECENT_NOTIFICATION, DISMISS_ALL_NOTIFICATIONS, + CREATE_NOTIFICATION, ANSWER_PHONE_CALL, END_PHONE_CALL, 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 0902ef74db..db1318872a 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 @@ -248,6 +248,7 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionCategory.NOTIFICATIONS ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionCategory.NOTIFICATIONS + ActionId.CREATE_NOTIFICATION -> ActionCategory.NOTIFICATIONS ActionId.DEVICE_CONTROLS -> ActionCategory.APPS ActionId.INTERACT_UI_ELEMENT -> ActionCategory.APPS @@ -373,6 +374,7 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> R.string.action_dismiss_most_recent_notification ActionId.DISMISS_ALL_NOTIFICATIONS -> R.string.action_dismiss_all_notifications + ActionId.CREATE_NOTIFICATION -> R.string.action_create_notification ActionId.ANSWER_PHONE_CALL -> R.string.action_answer_call ActionId.END_PHONE_CALL -> R.string.action_end_call ActionId.SEND_SMS -> R.string.action_send_sms @@ -500,6 +502,7 @@ object ActionUtils { ActionId.SOUND -> R.drawable.ic_outline_volume_up_24 ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> R.drawable.ic_baseline_clear_all_24 ActionId.DISMISS_ALL_NOTIFICATIONS -> R.drawable.ic_baseline_clear_all_24 + ActionId.CREATE_NOTIFICATION -> R.drawable.ic_notification_play ActionId.ANSWER_PHONE_CALL -> R.drawable.ic_outline_call_24 ActionId.END_PHONE_CALL -> R.drawable.ic_outline_call_end_24 ActionId.SEND_SMS -> R.drawable.ic_outline_message_24 @@ -744,6 +747,8 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION, -> return listOf(Permission.NOTIFICATION_LISTENER) + ActionId.CREATE_NOTIFICATION -> return listOf(Permission.POST_NOTIFICATIONS) + ActionId.ANSWER_PHONE_CALL, ActionId.END_PHONE_CALL, -> return listOf(Permission.ANSWER_PHONE_CALL) @@ -882,6 +887,7 @@ object ActionUtils { ActionId.SOUND -> Icons.AutoMirrored.Outlined.VolumeUp ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> Icons.Outlined.ClearAll ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll + ActionId.CREATE_NOTIFICATION -> Icons.AutoMirrored.Outlined.Message ActionId.ANSWER_PHONE_CALL -> Icons.Outlined.Call ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index ad6ed0c4f3..9088eab927 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -52,6 +52,7 @@ class CreateActionDelegate( ) var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) + var createNotificationBottomSheetState: ActionData.CreateNotification? by mutableStateOf(null) var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) @@ -884,6 +885,17 @@ class CreateActionDelegate( ActionId.DISABLE_DND_MODE -> return ActionData.DoNotDisturb.Disable ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> return ActionData.DismissLastNotification ActionId.DISMISS_ALL_NOTIFICATIONS -> return ActionData.DismissAllNotifications + ActionId.CREATE_NOTIFICATION -> { + // This will be handled by a configuration screen later + // For now, we'll navigate to the screen + createNotificationBottomSheetState = oldData as? ActionData.CreateNotification + ?: ActionData.CreateNotification( + title = "", + text = "", + timeoutMs = null, + ) + return null + } ActionId.ANSWER_PHONE_CALL -> return ActionData.AnswerCall ActionId.END_PHONE_CALL -> return ActionData.EndCall ActionId.DEVICE_CONTROLS -> return ActionData.DeviceControls 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 ab1fe35d3b..a4cdd6ac1a 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 @@ -18,6 +18,7 @@ import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityServic import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.base.system.navigation.OpenMenuHelper +import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCase import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.Constants @@ -60,6 +61,7 @@ import io.github.sds100.keymapper.system.lock.LockScreenAdapter import io.github.sds100.keymapper.system.media.MediaAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.nfc.NfcAdapter +import io.github.sds100.keymapper.system.notifications.NotificationModel import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapter import io.github.sds100.keymapper.system.notifications.NotificationServiceEvent import io.github.sds100.keymapper.system.phone.PhoneAdapter @@ -116,6 +118,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val resourceProvider: ResourceProvider, private val soundsManager: SoundsManager, private val notificationReceiverAdapter: NotificationReceiverAdapter, + private val manageNotifications: ManageNotificationsUseCase, private val ringtoneAdapter: RingtoneAdapter, private val settingsRepository: PreferenceRepository, private val inputEventHub: InputEventHub, @@ -928,6 +931,26 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ) } + is ActionData.CreateNotification -> { + // Generate a unique notification ID based on title and text hash + val notificationId = (action.title + action.text).hashCode() + + val notification = NotificationModel( + id = notificationId, + channel = io.github.sds100.keymapper.base.system.notifications.NotificationController.CHANNEL_CUSTOM_NOTIFICATIONS, + title = action.title, + text = action.text, + icon = R.drawable.ic_notification_play, + showOnLockscreen = false, + onGoing = false, + autoCancel = true, + timeout = action.timeoutMs, + ) + + manageNotifications.show(notification) + result = success() + } + ActionData.AnswerCall -> { phoneAdapter.answerCall() result = success() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt index 2c9fcc417b..1b03c9977a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt @@ -70,6 +70,7 @@ class NotificationController @Inject constructor( const val CHANNEL_TOGGLE_KEYBOARD = "channel_toggle_keymapper_keyboard" const val CHANNEL_NEW_FEATURES = "channel_new_features" const val CHANNEL_SETUP_ASSISTANT = "channel_setup_assistant" + const val CHANNEL_CUSTOM_NOTIFICATIONS = "channel_custom_notifications" @Deprecated("Removed in 2.0. This channel shouldn't exist") private const val CHANNEL_ID_WARNINGS = "channel_warnings" @@ -100,6 +101,14 @@ class NotificationController @Inject constructor( ), ) + manageNotifications.createChannel( + NotificationChannelModel( + id = CHANNEL_CUSTOM_NOTIFICATIONS, + name = getString(R.string.notification_channel_custom_notifications), + NotificationManagerCompat.IMPORTANCE_DEFAULT, + ), + ) + combine( controlAccessibilityService.serviceState, pauseMappings.isPaused, diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 188a2d3f59..c58b6bf573 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -512,6 +512,7 @@ Keyboard is hidden warning Toggle Key Mapper Input Method New features + Custom notifications Running Tap to open Key Mapper. @@ -1082,6 +1083,7 @@ Play sound Dismiss most recent notification Dismiss all notifications + Create notification Device controls screen HTTP request HTTP Method diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 5d33801ee2..3d1509ec69 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -89,6 +89,8 @@ data class ActionEntity( const val EXTRA_SHELL_COMMAND_USE_ROOT = "extra_shell_command_use_root" const val EXTRA_SHELL_COMMAND_DESCRIPTION = "extra_shell_command_description" const val EXTRA_SHELL_COMMAND_TIMEOUT = "extra_shell_command_timeout" + const val EXTRA_NOTIFICATION_TITLE = "extra_notification_title" + const val EXTRA_NOTIFICATION_TIMEOUT = "extra_notification_timeout" // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" @@ -183,6 +185,7 @@ data class ActionEntity( SOUND, INTERACT_UI_ELEMENT, SHELL_COMMAND, + CREATE_NOTIFICATION, } constructor( From 4c3d41fc95af25d782f7aa6bd560b8428c24bf52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:55:09 +0000 Subject: [PATCH 14/62] Add UI configuration screen for CREATE_NOTIFICATION action - Created ConfigCreateNotificationViewModel for managing notification state - Created CreateNotificationActionScreen with title, text, and timeout inputs - Added navigation support via NavDestination.ConfigCreateNotification - Integrated configuration screen into BaseMainNavHost - Added string resources for configuration UI labels - Marked CreateNotification action as editable - Configured timeout slider with 5-300 second range Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../sds100/keymapper/base/BaseMainNavHost.kt | 15 ++ .../keymapper/base/actions/ActionUtils.kt | 1 + .../ConfigCreateNotificationViewModel.kt | 80 ++++++++ .../base/actions/CreateActionDelegate.kt | 20 +- .../actions/CreateNotificationActionScreen.kt | 181 ++++++++++++++++++ .../base/utils/navigation/NavDestination.kt | 7 + base/src/main/res/values/strings.xml | 7 + 7 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index f57a042dbc..74eb1799f9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -18,7 +18,9 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import io.github.sds100.keymapper.base.actions.ChooseActionScreen import io.github.sds100.keymapper.base.actions.ChooseActionViewModel +import io.github.sds100.keymapper.base.actions.ConfigCreateNotificationViewModel import io.github.sds100.keymapper.base.actions.ConfigShellCommandViewModel +import io.github.sds100.keymapper.base.actions.CreateNotificationActionScreen import io.github.sds100.keymapper.base.actions.ShellCommandActionScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementViewModel @@ -89,6 +91,19 @@ fun BaseMainNavHost( ) } + composable { backStackEntry -> + val viewModel: ConfigCreateNotificationViewModel = hiltViewModel() + + backStackEntry.handleRouteArgs { destination -> + destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) } + } + + CreateNotificationActionScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + composable { val viewModel: ChooseConstraintViewModel = hiltViewModel() 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 db1318872a..7bad8091be 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 @@ -940,6 +940,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.ComposeSms, is ActionData.HttpRequest, is ActionData.ShellCommand, + is ActionData.CreateNotification, is ActionData.InteractUiElement, is ActionData.MoveCursor, -> true diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt new file mode 100644 index 0000000000..43917d2fa7 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt @@ -0,0 +1,80 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import javax.inject.Inject + +data class CreateNotificationActionState( + val title: String = "", + val text: String = "", + val timeoutEnabled: Boolean = false, + /** + * UI works with seconds for user-friendliness + */ + val timeoutSeconds: Int = 30, +) + +@HiltViewModel +class ConfigCreateNotificationViewModel @Inject constructor( + private val navigationProvider: NavigationProvider, + private val createActionDelegate: CreateActionDelegate, +) : ViewModel() { + + var state: CreateNotificationActionState by mutableStateOf(CreateNotificationActionState()) + private set + + fun loadAction(action: ActionData.CreateNotification) { + state = state.copy( + title = action.title, + text = action.text, + timeoutEnabled = action.timeoutMs != null, + timeoutSeconds = (action.timeoutMs ?: 30000) / 1000, + ) + } + + fun onTitleChanged(newTitle: String) { + state = state.copy(title = newTitle) + } + + fun onTextChanged(newText: String) { + state = state.copy(text = newText) + } + + fun onTimeoutEnabledChanged(enabled: Boolean) { + state = state.copy(timeoutEnabled = enabled) + } + + fun onTimeoutChanged(newTimeoutSeconds: Int) { + state = state.copy(timeoutSeconds = newTimeoutSeconds) + } + + fun onDoneClick() { + if (state.title.isBlank() || state.text.isBlank()) { + return + } + + val timeoutMs = if (state.timeoutEnabled) { + state.timeoutSeconds * 1000L + } else { + null + } + + val action = ActionData.CreateNotification( + title = state.title, + text = state.text, + timeoutMs = timeoutMs, + ) + + createActionDelegate.actionResult.value = action + navigationProvider.navigate(io.github.sds100.keymapper.base.utils.navigation.NavDestination.Pop) + } + + fun onCancelClick() { + navigationProvider.navigate(io.github.sds100.keymapper.base.utils.navigation.NavDestination.Pop) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 9088eab927..6223e8c850 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -52,7 +52,6 @@ class CreateActionDelegate( ) var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) - var createNotificationBottomSheetState: ActionData.CreateNotification? by mutableStateOf(null) var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) @@ -886,15 +885,16 @@ class CreateActionDelegate( ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> return ActionData.DismissLastNotification ActionId.DISMISS_ALL_NOTIFICATIONS -> return ActionData.DismissAllNotifications ActionId.CREATE_NOTIFICATION -> { - // This will be handled by a configuration screen later - // For now, we'll navigate to the screen - createNotificationBottomSheetState = oldData as? ActionData.CreateNotification - ?: ActionData.CreateNotification( - title = "", - text = "", - timeoutMs = null, - ) - return null + val oldAction = oldData as? ActionData.CreateNotification + + return navigate( + "config_create_notification_action", + NavDestination.ConfigCreateNotification( + oldAction?.let { + Json.encodeToString(oldAction) + }, + ), + ) } ActionId.ANSWER_PHONE_CALL -> return ActionData.AnswerCall ActionId.END_PHONE_CALL -> return ActionData.EndCall diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt new file mode 100644 index 0000000000..1b5045a234 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt @@ -0,0 +1,181 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText + +@Composable +fun CreateNotificationActionScreen( + modifier: Modifier = Modifier, + viewModel: ConfigCreateNotificationViewModel, +) { + CreateNotificationActionScreen( + modifier = modifier, + state = viewModel.state, + onTitleChanged = viewModel::onTitleChanged, + onTextChanged = viewModel::onTextChanged, + onTimeoutEnabledChanged = viewModel::onTimeoutEnabledChanged, + onTimeoutChanged = viewModel::onTimeoutChanged, + onDoneClick = viewModel::onDoneClick, + onCancelClick = viewModel::onCancelClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateNotificationActionScreen( + state: CreateNotificationActionState, + onTitleChanged: (String) -> Unit, + onTextChanged: (String) -> Unit, + onTimeoutEnabledChanged: (Boolean) -> Unit, + onTimeoutChanged: (Int) -> Unit, + onDoneClick: () -> Unit, + onCancelClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.action_create_notification)) }, + navigationIcon = { + IconButton(onClick = onCancelClick) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.pos_cancel), + ) + } + }, + ) + }, + bottomBar = { + BottomAppBar { + Spacer(modifier = Modifier.weight(1f)) + ExtendedFloatingActionButton( + onClick = { + keyboardController?.hide() + onDoneClick() + }, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + text = { Text(stringResource(R.string.pos_done)) }, + icon = { + Icon( + Icons.Rounded.Check, + contentDescription = stringResource(R.string.pos_done), + ) + }, + ) + } + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = state.title, + onValueChange = onTitleChanged, + label = { Text(stringResource(R.string.action_create_notification_title_label)) }, + placeholder = { Text(stringResource(R.string.action_create_notification_title_hint)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = state.text, + onValueChange = onTextChanged, + label = { Text(stringResource(R.string.action_create_notification_text_label)) }, + placeholder = { Text(stringResource(R.string.action_create_notification_text_hint)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 10, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + CheckBoxText( + label = stringResource(R.string.action_create_notification_timeout_checkbox), + checked = state.timeoutEnabled, + onCheckedChange = onTimeoutEnabledChanged, + ) + + if (state.timeoutEnabled) { + Spacer(modifier = Modifier.height(8.dp)) + + SliderOptionText( + label = stringResource(R.string.action_create_notification_timeout_label), + value = state.timeoutSeconds, + onValueChange = { onTimeoutChanged(it.toInt()) }, + sliderValue = state.timeoutSeconds.toFloat(), + valueRange = 5f..300f, + steps = 58, // (300 - 5) / 5 - 1 = 58 steps for increments of 5 seconds + valueLabel = stringResource( + R.string.action_create_notification_timeout_value, + state.timeoutSeconds, + ), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Preview +@Composable +private fun CreateNotificationActionScreenPreview() { + KeyMapperTheme { + CreateNotificationActionScreen( + state = CreateNotificationActionState( + title = "Test Notification", + text = "This is a test notification message", + timeoutEnabled = true, + timeoutSeconds = 30, + ), + onTitleChanged = {}, + onTextChanged = {}, + onTimeoutEnabledChanged = {}, + onTimeoutChanged = {}, + onDoneClick = {}, + onCancelClick = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index fdbc390a85..e2d70489ff 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -38,6 +38,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_CONFIG_KEY_MAP = "config_key_map" const val ID_INTERACT_UI_ELEMENT_ACTION = "interact_ui_element_action" const val ID_SHELL_COMMAND_ACTION = "shell_command_action" + const val ID_CREATE_NOTIFICATION_ACTION = "create_notification_action" const val ID_PRO_MODE = "pro_mode" const val ID_LOG = "log" const val ID_ADVANCED_TRIGGERS = "advanced_triggers" @@ -172,6 +173,12 @@ abstract class NavDestination(val isCompose: Boolean = false) { override val id: String = ID_SHELL_COMMAND_ACTION } + @Serializable + data class ConfigCreateNotification(val actionJson: String?) : + NavDestination(isCompose = true) { + override val id: String = ID_CREATE_NOTIFICATION_ACTION + } + @Serializable data object ProMode : NavDestination(isCompose = true) { override val id: String = ID_PRO_MODE diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index c58b6bf573..ae66de7dad 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1084,6 +1084,13 @@ Dismiss most recent notification Dismiss all notifications Create notification + Notification title + Enter notification title + Notification content + Enter notification content + Auto-dismiss notification + Auto-dismiss after + %d seconds Device controls screen HTTP request HTTP Method From 1af1451595bafa96dc1e766aa5ccdb7590ae5647 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:58:34 +0000 Subject: [PATCH 15/62] Address code review feedback - Improved notification ID generation to avoid collisions using timestamp + hash - Added validation feedback with error messages and disabled Done button - Extracted magic numbers to constants (MIN/MAX/STEP timeout values) - Added supportingText for empty field errors in UI Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../actions/CreateNotificationActionScreen.kt | 17 +++++++++++++++-- .../base/actions/PerformActionsUseCase.kt | 4 ++-- base/src/main/res/values/strings.xml | 2 ++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt index 1b5045a234..856f1220cd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt @@ -33,6 +33,10 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +private const val MIN_TIMEOUT_SECONDS = 5 +private const val MAX_TIMEOUT_SECONDS = 300 +private const val TIMEOUT_STEP_SECONDS = 5 + @Composable fun CreateNotificationActionScreen( modifier: Modifier = Modifier, @@ -87,6 +91,7 @@ private fun CreateNotificationActionScreen( keyboardController?.hide() onDoneClick() }, + enabled = state.title.isNotBlank() && state.text.isNotBlank(), elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), text = { Text(stringResource(R.string.pos_done)) }, icon = { @@ -115,6 +120,10 @@ private fun CreateNotificationActionScreen( placeholder = { Text(stringResource(R.string.action_create_notification_title_hint)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, + isError = state.title.isBlank(), + supportingText = if (state.title.isBlank()) { + { Text(stringResource(R.string.action_create_notification_title_error)) } + } else null, ) Spacer(modifier = Modifier.height(16.dp)) @@ -127,6 +136,10 @@ private fun CreateNotificationActionScreen( modifier = Modifier.fillMaxWidth(), minLines = 3, maxLines = 10, + isError = state.text.isBlank(), + supportingText = if (state.text.isBlank()) { + { Text(stringResource(R.string.action_create_notification_text_error)) } + } else null, ) Spacer(modifier = Modifier.height(16.dp)) @@ -145,8 +158,8 @@ private fun CreateNotificationActionScreen( value = state.timeoutSeconds, onValueChange = { onTimeoutChanged(it.toInt()) }, sliderValue = state.timeoutSeconds.toFloat(), - valueRange = 5f..300f, - steps = 58, // (300 - 5) / 5 - 1 = 58 steps for increments of 5 seconds + valueRange = MIN_TIMEOUT_SECONDS.toFloat()..MAX_TIMEOUT_SECONDS.toFloat(), + steps = ((MAX_TIMEOUT_SECONDS - MIN_TIMEOUT_SECONDS) / TIMEOUT_STEP_SECONDS) - 1, valueLabel = stringResource( R.string.action_create_notification_timeout_value, state.timeoutSeconds, 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 a4cdd6ac1a..21eaca195e 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 @@ -932,8 +932,8 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.CreateNotification -> { - // Generate a unique notification ID based on title and text hash - val notificationId = (action.title + action.text).hashCode() + // Generate a unique notification ID using timestamp and hash to avoid collisions + val notificationId = (System.currentTimeMillis() / 1000).toInt() + (action.title + action.text).hashCode() val notification = NotificationModel( id = notificationId, diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index ae66de7dad..56240c43a7 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1086,8 +1086,10 @@ Create notification Notification title Enter notification title + Title cannot be empty Notification content Enter notification content + Content cannot be empty Auto-dismiss notification Auto-dismiss after %d seconds From e1828155f5235d0c5ed9b29cadb83ddc6e3d7da9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:00:27 +0000 Subject: [PATCH 16/62] Fix validation and ID generation issues - Added validation for entity.data to ensure text is not blank - Fixed notification ID generation to avoid integer overflow - Use Long arithmetic and convert to absolute value to ensure positive IDs Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../keymapper/base/actions/ActionDataEntityMapper.kt | 3 ++- .../keymapper/base/actions/PerformActionsUseCase.kt | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index bc768a2668..54e95dc517 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -561,7 +561,8 @@ object ActionDataEntityMapper { val title = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TITLE).valueOrNull() ?: return null - val text = entity.data + val text = entity.data.takeIf { it.isNotBlank() } + ?: return null val timeoutMs = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TIMEOUT).valueOrNull() ?.toLongOrNull() 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 21eaca195e..3eb17f3bf9 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 @@ -932,8 +932,13 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.CreateNotification -> { - // Generate a unique notification ID using timestamp and hash to avoid collisions - val notificationId = (System.currentTimeMillis() / 1000).toInt() + (action.title + action.text).hashCode() + // Generate a unique notification ID using absolute value to avoid negative IDs + // Combine timestamp and hash to ensure uniqueness + val timestamp = (System.currentTimeMillis() / 1000).toInt() + val contentHash = (action.title + action.text).hashCode() + val notificationId = (timestamp.toLong() + contentHash).toInt().let { + if (it < 0) -it else it + } val notification = NotificationModel( id = notificationId, From 3f26997b56572746a115f870666a5e98ca3a18aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:31:06 +0000 Subject: [PATCH 17/62] Initial plan From 7c1742585256324f25c11cdab74d6195bdb2d43d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:40:01 +0000 Subject: [PATCH 18/62] Initial plan From 10497b7fa827cd809a6bf573cc1710396cd621ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:41:03 +0000 Subject: [PATCH 19/62] Add keyboard showing constraints UI components Co-authored-by: jambl3r <54366245+jambl3r@users.noreply.github.com> --- .../constraints/ChooseConstraintViewModel.kt | 9 ++++++++ .../keymapper/base/constraints/Constraint.kt | 23 +++++++++++++++++++ .../base/constraints/ConstraintDependency.kt | 1 + .../base/constraints/ConstraintId.kt | 3 +++ .../base/constraints/ConstraintSnapshot.kt | 4 ++++ .../base/constraints/ConstraintUtils.kt | 6 +++++ .../constraints/DetectConstraintsUseCase.kt | 4 ++++ base/src/main/res/values/strings.xml | 5 ++++ .../data/entities/ConstraintEntity.kt | 3 +++ 9 files changed, 58 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt index 685535a791..c39dacae37 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt @@ -77,6 +77,9 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintId.IME_CHOSEN, ConstraintId.IME_NOT_CHOSEN, + ConstraintId.KEYBOARD_SHOWING, + ConstraintId.KEYBOARD_NOT_SHOWING, + ConstraintId.DEVICE_IS_LOCKED, ConstraintId.DEVICE_IS_UNLOCKED, ConstraintId.LOCK_SCREEN_SHOWING, @@ -204,6 +207,12 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintId.IME_NOT_CHOSEN, -> onSelectImeChosenConstraint(constraintType) + ConstraintId.KEYBOARD_SHOWING -> + returnResult.emit(ConstraintData.KeyboardShowing) + + ConstraintId.KEYBOARD_NOT_SHOWING -> + returnResult.emit(ConstraintData.KeyboardNotShowing) + ConstraintId.DEVICE_IS_LOCKED -> returnResult.emit(ConstraintData.DeviceIsLocked) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt index ca20bad507..547802579f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt @@ -127,6 +127,16 @@ sealed class ConstraintData { override val id: ConstraintId = ConstraintId.IME_NOT_CHOSEN } + @Serializable + data object KeyboardShowing : ConstraintData() { + override val id: ConstraintId = ConstraintId.KEYBOARD_SHOWING + } + + @Serializable + data object KeyboardNotShowing : ConstraintData() { + override val id: ConstraintId = ConstraintId.KEYBOARD_NOT_SHOWING + } + @Serializable data object DeviceIsLocked : ConstraintData() { override val id: ConstraintId = ConstraintId.DEVICE_IS_LOCKED @@ -334,6 +344,9 @@ object ConstraintEntityMapper { getImeLabel(), ) + ConstraintEntity.KEYBOARD_SHOWING -> ConstraintData.KeyboardShowing + ConstraintEntity.KEYBOARD_NOT_SHOWING -> ConstraintData.KeyboardNotShowing + ConstraintEntity.DEVICE_IS_UNLOCKED -> ConstraintData.DeviceIsUnlocked ConstraintEntity.DEVICE_IS_LOCKED -> ConstraintData.DeviceIsLocked ConstraintEntity.LOCK_SCREEN_SHOWING -> ConstraintData.LockScreenShowing @@ -570,6 +583,16 @@ object ConstraintEntityMapper { ) } + is ConstraintData.KeyboardShowing -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.KEYBOARD_SHOWING, + ) + + is ConstraintData.KeyboardNotShowing -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.KEYBOARD_NOT_SHOWING, + ) + is ConstraintData.DeviceIsLocked -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.DEVICE_IS_LOCKED, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt index 2036fe2d97..d48c3dcbb2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt @@ -11,6 +11,7 @@ enum class ConstraintDependency { WIFI_SSID, WIFI_STATE, CHOSEN_IME, + KEYBOARD_STATE, DEVICE_LOCKED_STATE, LOCK_SCREEN_SHOWING, PHONE_STATE, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt index 57cf603867..b48b61b8c2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt @@ -37,6 +37,9 @@ enum class ConstraintId { IME_CHOSEN, IME_NOT_CHOSEN, + KEYBOARD_SHOWING, + KEYBOARD_NOT_SHOWING, + DEVICE_IS_LOCKED, DEVICE_IS_UNLOCKED, LOCK_SCREEN_SHOWING, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt index 6c33b1a456..f5ae895d0d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt @@ -57,6 +57,8 @@ class LazyConstraintSnapshot( networkAdapter.connectedWifiSSIDFlow.firstBlocking() } private val chosenImeId: String? by lazy { inputMethodAdapter.chosenIme.value?.id } + // TODO: Implement keyboard state detection + private val isKeyboardShowing: Boolean by lazy { false } private val callState: CallState by lazy { phoneAdapter.getCallState() } private val isCharging: Boolean by lazy { powerAdapter.isCharging.value } @@ -139,6 +141,8 @@ class LazyConstraintSnapshot( is ConstraintData.WifiOn -> isWifiEnabled is ConstraintData.ImeChosen -> chosenImeId == constraint.data.imeId is ConstraintData.ImeNotChosen -> chosenImeId != constraint.data.imeId + is ConstraintData.KeyboardShowing -> isKeyboardShowing + is ConstraintData.KeyboardNotShowing -> !isKeyboardShowing is ConstraintData.DeviceIsLocked -> isLocked is ConstraintData.DeviceIsUnlocked -> !isLocked is ConstraintData.InPhoneCall -> diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt index ffad00f16c..2d7b1912aa 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.outlined.CallEnd import androidx.compose.material.icons.outlined.FlashlightOff import androidx.compose.material.icons.outlined.FlashlightOn import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.KeyboardHide import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material.icons.outlined.MobileOff @@ -78,6 +79,9 @@ object ConstraintUtils { ConstraintId.IME_NOT_CHOSEN, -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard) + ConstraintId.KEYBOARD_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard) + ConstraintId.KEYBOARD_NOT_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.KeyboardHide) + ConstraintId.DEVICE_IS_LOCKED -> ComposeIconInfo.Vector(Icons.Outlined.Lock) ConstraintId.DEVICE_IS_UNLOCKED -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen) @@ -124,6 +128,8 @@ object ConstraintUtils { ConstraintId.WIFI_DISCONNECTED -> R.string.constraint_wifi_disconnected ConstraintId.IME_CHOSEN -> R.string.constraint_ime_chosen ConstraintId.IME_NOT_CHOSEN -> R.string.constraint_ime_not_chosen + ConstraintId.KEYBOARD_SHOWING -> R.string.constraint_keyboard_showing + ConstraintId.KEYBOARD_NOT_SHOWING -> R.string.constraint_keyboard_not_showing ConstraintId.DEVICE_IS_LOCKED -> R.string.constraint_device_is_locked ConstraintId.DEVICE_IS_UNLOCKED -> R.string.constraint_device_is_unlocked ConstraintId.IN_PHONE_CALL -> R.string.constraint_in_phone_call diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt index 86d2c63ca7..7955abacf6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt @@ -16,6 +16,7 @@ import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.power.PowerAdapter import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -78,6 +79,9 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( networkAdapter.connectedWifiSSIDFlow.map { dependency } ConstraintDependency.WIFI_STATE -> networkAdapter.isWifiEnabledFlow().map { dependency } ConstraintDependency.CHOSEN_IME -> inputMethodAdapter.chosenIme.map { dependency } + ConstraintDependency.KEYBOARD_STATE -> + // TODO: Implement keyboard state detection + flowOf(dependency) ConstraintDependency.DEVICE_LOCKED_STATE -> lockScreenAdapter.isLockedFlow().map { dependency } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 188a2d3f59..d8acae0f82 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -277,6 +277,11 @@ Input method is not chosen %s is not chosen + Keyboard is showing + Keyboard is showing + Keyboard is not showing + Keyboard is not showing + Device is locked Device is unlocked Lockscreen is showing diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt index 5af0dd3fa3..24d5954c60 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt @@ -69,6 +69,9 @@ data class ConstraintEntity( const val IME_CHOSEN = "ime_chosen" const val IME_NOT_CHOSEN = "ime_not_chosen" + const val KEYBOARD_SHOWING = "keyboard_showing" + const val KEYBOARD_NOT_SHOWING = "keyboard_not_showing" + const val DEVICE_IS_LOCKED = "is_locked" const val DEVICE_IS_UNLOCKED = "is_unlocked" const val LOCK_SCREEN_SHOWING = "lock_screen_showing" From 5d8d3e825bb45e3e5c3b3384326ce8f839c2df26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:42:42 +0000 Subject: [PATCH 20/62] Add keyboard constraint tests and UI helper Co-authored-by: jambl3r <54366245+jambl3r@users.noreply.github.com> --- .../base/constraints/ConstraintUiHelper.kt | 3 ++ .../constraints/ConstraintSnapshotTest.kt | 32 +++++++++++++++++++ .../base/utils/TestConstraintSnapshot.kt | 3 ++ 3 files changed, 38 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt index 28f1a97154..2b465a8860 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt @@ -135,6 +135,9 @@ class ConstraintUiHelper( getString(R.string.constraint_ime_not_chosen_description, label) } + is ConstraintData.KeyboardShowing -> getString(R.string.constraint_keyboard_showing_description) + is ConstraintData.KeyboardNotShowing -> getString(R.string.constraint_keyboard_not_showing_description) + is ConstraintData.DeviceIsLocked -> getString(R.string.constraint_device_is_locked) is ConstraintData.DeviceIsUnlocked -> getString(R.string.constraint_device_is_unlocked) is ConstraintData.InPhoneCall -> getString(R.string.constraint_in_phone_call) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt index d0030f4883..116070ee6f 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt @@ -206,4 +206,36 @@ class ConstraintSnapshotTest { val state = ConstraintState(constraints = emptySet()) assertThat(snapshot.isSatisfied(state), `is`(true)) } + + @Test + fun `When keyboard is showing and constraint is KeyboardShowing return true`() { + val snapshot = TestConstraintSnapshot(isKeyboardShowing = true) + val constraint = Constraint(data = ConstraintData.KeyboardShowing) + val state = ConstraintState(constraints = setOf(constraint)) + assertThat(snapshot.isSatisfied(state), `is`(true)) + } + + @Test + fun `When keyboard is not showing and constraint is KeyboardShowing return false`() { + val snapshot = TestConstraintSnapshot(isKeyboardShowing = false) + val constraint = Constraint(data = ConstraintData.KeyboardShowing) + val state = ConstraintState(constraints = setOf(constraint)) + assertThat(snapshot.isSatisfied(state), `is`(false)) + } + + @Test + fun `When keyboard is not showing and constraint is KeyboardNotShowing return true`() { + val snapshot = TestConstraintSnapshot(isKeyboardShowing = false) + val constraint = Constraint(data = ConstraintData.KeyboardNotShowing) + val state = ConstraintState(constraints = setOf(constraint)) + assertThat(snapshot.isSatisfied(state), `is`(true)) + } + + @Test + fun `When keyboard is showing and constraint is KeyboardNotShowing return false`() { + val snapshot = TestConstraintSnapshot(isKeyboardShowing = true) + val constraint = Constraint(data = ConstraintData.KeyboardNotShowing) + val state = ConstraintState(constraints = setOf(constraint)) + assertThat(snapshot.isSatisfied(state), `is`(false)) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt index 69708cf609..063557172f 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt @@ -22,6 +22,7 @@ class TestConstraintSnapshot( val isWifiEnabled: Boolean = false, val connectedWifiSSID: String? = null, val chosenImeId: String? = null, + val isKeyboardShowing: Boolean = false, val callState: CallState = CallState.NONE, val isCharging: Boolean = false, val isLocked: Boolean = false, @@ -94,6 +95,8 @@ class TestConstraintSnapshot( is ConstraintData.WifiOn -> isWifiEnabled is ConstraintData.ImeChosen -> chosenImeId == data.imeId is ConstraintData.ImeNotChosen -> chosenImeId != data.imeId + is ConstraintData.KeyboardShowing -> isKeyboardShowing + is ConstraintData.KeyboardNotShowing -> !isKeyboardShowing is ConstraintData.DeviceIsLocked -> isLocked is ConstraintData.DeviceIsUnlocked -> !isLocked is ConstraintData.InPhoneCall -> callState == CallState.IN_PHONE_CALL From ea6d500c6a3e10315dd1c15c39a9b142f2f31530 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:47:53 +0000 Subject: [PATCH 21/62] Improve keyboard constraint descriptions Co-authored-by: jambl3r <54366245+jambl3r@users.noreply.github.com> --- 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 d8acae0f82..0c4c169220 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -278,9 +278,9 @@ %s is not chosen Keyboard is showing - Keyboard is showing + On-screen keyboard is visible Keyboard is not showing - Keyboard is not showing + On-screen keyboard is hidden Device is locked Device is unlocked From 0c9b39452b56e4a8ab767bbb3d65c76edd40a8f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:50:00 +0000 Subject: [PATCH 22/62] Add toggle hotspot action boilerplate Co-authored-by: jambl3r <54366245+jambl3r@users.noreply.github.com> --- .../keymapper/base/actions/ActionData.kt | 18 ++++++++++++++++ .../base/actions/ActionDataEntityMapper.kt | 8 +++++++ .../sds100/keymapper/base/actions/ActionId.kt | 4 ++++ .../base/actions/CreateActionDelegate.kt | 4 ++++ .../base/actions/PerformActionsUseCase.kt | 16 ++++++++++++++ base/src/main/res/values/strings.xml | 4 ++++ .../keymapper/sysbridge/ISystemBridge.aidl | 2 ++ .../sysbridge/service/SystemBridge.kt | 16 ++++++++++++++ .../system/network/AndroidNetworkAdapter.kt | 21 +++++++++++++++++++ .../system/network/NetworkAdapter.kt | 5 +++++ 10 files changed, 98 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 35feedbca9..8d24570412 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -667,6 +667,24 @@ sealed class ActionData : Comparable { } } + @Serializable + sealed class Hotspot : ActionData() { + @Serializable + data object Enable : Hotspot() { + override val id = ActionId.ENABLE_HOTSPOT + } + + @Serializable + data object Disable : Hotspot() { + override val id = ActionId.DISABLE_HOTSPOT + } + + @Serializable + data object Toggle : Hotspot() { + override val id = ActionId.TOGGLE_HOTSPOT + } + } + @Serializable sealed class Brightness : ActionData() { @Serializable diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index b13b0757e3..54f1d7f008 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -485,6 +485,10 @@ object ActionDataEntityMapper { ActionId.ENABLE_MOBILE_DATA -> ActionData.MobileData.Enable ActionId.DISABLE_MOBILE_DATA -> ActionData.MobileData.Disable + ActionId.TOGGLE_HOTSPOT -> ActionData.Hotspot.Toggle + ActionId.ENABLE_HOTSPOT -> ActionData.Hotspot.Enable + ActionId.DISABLE_HOTSPOT -> ActionData.Hotspot.Disable + ActionId.TOGGLE_AUTO_BRIGHTNESS -> ActionData.Brightness.ToggleAuto ActionId.DISABLE_AUTO_BRIGHTNESS -> ActionData.Brightness.DisableAuto ActionId.ENABLE_AUTO_BRIGHTNESS -> ActionData.Brightness.EnableAuto @@ -1166,6 +1170,10 @@ object ActionDataEntityMapper { ActionId.ENABLE_MOBILE_DATA to "enable_mobile_data", ActionId.DISABLE_MOBILE_DATA to "disable_mobile_data", + ActionId.TOGGLE_HOTSPOT to "toggle_hotspot", + ActionId.ENABLE_HOTSPOT to "enable_hotspot", + ActionId.DISABLE_HOTSPOT to "disable_hotspot", + ActionId.TOGGLE_AUTO_BRIGHTNESS to "toggle_auto_brightness", ActionId.DISABLE_AUTO_BRIGHTNESS to "disable_auto_brightness", ActionId.ENABLE_AUTO_BRIGHTNESS to "enable_auto_brightness", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index 481dc59c5b..eb8155d0b2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -28,6 +28,10 @@ enum class ActionId { ENABLE_MOBILE_DATA, DISABLE_MOBILE_DATA, + TOGGLE_HOTSPOT, + ENABLE_HOTSPOT, + DISABLE_HOTSPOT, + TOGGLE_AUTO_BRIGHTNESS, DISABLE_AUTO_BRIGHTNESS, ENABLE_AUTO_BRIGHTNESS, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index ad6ed0c4f3..748c3b4313 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -812,6 +812,10 @@ class CreateActionDelegate( ActionId.ENABLE_MOBILE_DATA -> return ActionData.MobileData.Enable ActionId.DISABLE_MOBILE_DATA -> return ActionData.MobileData.Disable + ActionId.TOGGLE_HOTSPOT -> return ActionData.Hotspot.Toggle + ActionId.ENABLE_HOTSPOT -> return ActionData.Hotspot.Enable + ActionId.DISABLE_HOTSPOT -> return ActionData.Hotspot.Disable + ActionId.TOGGLE_AUTO_BRIGHTNESS -> return ActionData.Brightness.ToggleAuto ActionId.DISABLE_AUTO_BRIGHTNESS -> return ActionData.Brightness.DisableAuto ActionId.ENABLE_AUTO_BRIGHTNESS -> return ActionData.Brightness.EnableAuto 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 ab1fe35d3b..44a46786e0 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 @@ -477,6 +477,22 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = networkAdapter.disableMobileData() } + is ActionData.Hotspot.Toggle -> { + result = if (networkAdapter.isHotspotEnabled()) { + networkAdapter.disableHotspot() + } else { + networkAdapter.enableHotspot() + } + } + + is ActionData.Hotspot.Enable -> { + result = networkAdapter.enableHotspot() + } + + is ActionData.Hotspot.Disable -> { + result = networkAdapter.disableHotspot() + } + is ActionData.Brightness.ToggleAuto -> { result = if (displayAdapter.isAutoBrightnessEnabled()) { displayAdapter.disableAutoBrightness() diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 188a2d3f59..5f72e27276 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -920,6 +920,10 @@ Enable mobile data Disable mobile data + Toggle hotspot + Enable hotspot + Disable hotspot + Toggle auto brightness Disable auto brightness Enable auto brightness diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl index 602310a084..6b7f4ace97 100644 --- a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl @@ -42,4 +42,6 @@ interface ISystemBridge { void removeTasks(String packageName) = 17; void setRingerMode(int ringerMode) = 18; + + void setTetheringEnabled(boolean enable) = 19; } \ No newline at end of file diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index fdb9d4cc66..8ddda1c5f2 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -667,4 +667,20 @@ internal class SystemBridge : ISystemBridge.Stub() { audioService.setRingerModeInternal(ringerMode, processPackageName) } + + override fun setTetheringEnabled(enable: Boolean) { + if (connectivityManager == null) { + throw UnsupportedOperationException("ConnectivityManager not supported") + } + + connectivityManager.startTethering( + /* type = */ 0, // TETHERING_WIFI + /* showProvisioningUi = */ false, + /* receiver = */ null, + ) + + if (!enable) { + connectivityManager.stopTethering(0) // TETHERING_WIFI + } + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt index 1ecd1b5116..011951fdf0 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt @@ -199,6 +199,27 @@ class AndroidNetworkAdapter @Inject constructor( } } + override fun isHotspotEnabled(): Boolean { + // This requires reflection or system bridge + return false + } + + override fun enableHotspot(): KMResult<*> { + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(true) } + } else { + return KMError.FeatureUnavailable + } + } + + override fun disableHotspot(): KMResult<*> { + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(false) } + } else { + return KMError.FeatureUnavailable + } + } + /** * @return Null on Android 10+ because there is no API to do this anymore. */ diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt index 1440caa651..cb111c8126 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt @@ -19,6 +19,11 @@ interface NetworkAdapter { fun enableMobileData(): KMResult<*> fun disableMobileData(): KMResult<*> + fun isHotspotEnabled(): Boolean + + fun enableHotspot(): KMResult<*> + fun disableHotspot(): KMResult<*> + fun getKnownWifiSSIDs(): List suspend fun sendHttpRequest( From 2dd5f5f13159af0719eccb86d426831912cd6a7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:52:14 +0000 Subject: [PATCH 23/62] Fix hotspot tethering implementation logic and method signature Co-authored-by: jambl3r <54366245+jambl3r@users.noreply.github.com> --- .../keymapper/sysbridge/service/SystemBridge.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index 8ddda1c5f2..d89daa446a 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -673,13 +673,15 @@ internal class SystemBridge : ISystemBridge.Stub() { throw UnsupportedOperationException("ConnectivityManager not supported") } - connectivityManager.startTethering( - /* type = */ 0, // TETHERING_WIFI - /* showProvisioningUi = */ false, - /* receiver = */ null, - ) - - if (!enable) { + if (enable) { + // Type 0 = TETHERING_WIFI + connectivityManager.startTethering( + 0, // type + null, // ResultReceiver + false, // showProvisioningUi + processPackageName, // callerPkg + ) + } else { connectivityManager.stopTethering(0) // TETHERING_WIFI } } From e29329ceba6a805dc1b43384fd56d61e1dfd4ed1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:57:35 +0000 Subject: [PATCH 24/62] Address code review feedback - add TETHERING_WIFI constant and improve comments Co-authored-by: jambl3r <54366245+jambl3r@users.noreply.github.com> --- .../sds100/keymapper/sysbridge/service/SystemBridge.kt | 7 ++++--- .../keymapper/system/network/AndroidNetworkAdapter.kt | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index d89daa446a..521eafd34e 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -82,6 +82,7 @@ internal class SystemBridge : ISystemBridge.Stub() { private const val KEYMAPPER_CHECK_INTERVAL_MS = 60 * 1000L // 1 minute private const val DATA_ENABLED_REASON_USER: Int = 0 + private const val TETHERING_WIFI: Int = 0 @JvmStatic fun main(args: Array) { @@ -674,15 +675,15 @@ internal class SystemBridge : ISystemBridge.Stub() { } if (enable) { - // Type 0 = TETHERING_WIFI connectivityManager.startTethering( - 0, // type + TETHERING_WIFI, null, // ResultReceiver false, // showProvisioningUi processPackageName, // callerPkg ) } else { - connectivityManager.stopTethering(0) // TETHERING_WIFI + connectivityManager.stopTethering(TETHERING_WIFI) } } + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt index 011951fdf0..d107b6f651 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt @@ -200,7 +200,9 @@ class AndroidNetworkAdapter @Inject constructor( } override fun isHotspotEnabled(): Boolean { - // This requires reflection or system bridge + // TODO: Implement hotspot state detection using reflection or system bridge. + // For now, returning false means toggle action will always attempt to enable. + // This is acceptable for the initial implementation. return false } From c8dcc53e8737890f1409ad9fb5837a8aa7814558 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:36:30 +0000 Subject: [PATCH 25/62] Address PR review feedback - Use action.hashCode() for notification ID as requested - Rename ConfigCreateNotification to ConfigNotificationAction Co-authored-by: sds100 <16245954+sds100@users.noreply.github.com> --- .../io/github/sds100/keymapper/base/BaseMainNavHost.kt | 4 ++-- .../sds100/keymapper/base/actions/CreateActionDelegate.kt | 2 +- .../sds100/keymapper/base/actions/PerformActionsUseCase.kt | 7 ++----- .../keymapper/base/utils/navigation/NavDestination.kt | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index 74eb1799f9..44d4693722 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -91,10 +91,10 @@ fun BaseMainNavHost( ) } - composable { backStackEntry -> + composable { backStackEntry -> val viewModel: ConfigCreateNotificationViewModel = hiltViewModel() - backStackEntry.handleRouteArgs { destination -> + backStackEntry.handleRouteArgs { destination -> destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 6223e8c850..b9c8432cb9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -889,7 +889,7 @@ class CreateActionDelegate( return navigate( "config_create_notification_action", - NavDestination.ConfigCreateNotification( + NavDestination.ConfigNotificationAction( oldAction?.let { Json.encodeToString(oldAction) }, 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 3eb17f3bf9..3c945f9f0e 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 @@ -932,11 +932,8 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.CreateNotification -> { - // Generate a unique notification ID using absolute value to avoid negative IDs - // Combine timestamp and hash to ensure uniqueness - val timestamp = (System.currentTimeMillis() / 1000).toInt() - val contentHash = (action.title + action.text).hashCode() - val notificationId = (timestamp.toLong() + contentHash).toInt().let { + // Use the hashcode of the action instance as the unique notification ID + val notificationId = action.hashCode().let { if (it < 0) -it else it } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index e2d70489ff..26dcf46a3b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -174,7 +174,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data class ConfigCreateNotification(val actionJson: String?) : + data class ConfigNotificationAction(val actionJson: String?) : NavDestination(isCompose = true) { override val id: String = ID_CREATE_NOTIFICATION_ACTION } From 162a959a4285f2f96fc72748360777d61e2a4fab Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 00:43:10 +0100 Subject: [PATCH 26/62] #1871 clean up modify settings bottom sheet --- .../base/actions/ActionDataEntityMapper.kt | 4 +- .../base/actions/ActionErrorSnapshot.kt | 3 +- .../keymapper/base/actions/ActionUtils.kt | 3 +- .../base/actions/ChooseSettingScreen.kt | 12 +- .../base/actions/ChooseSettingViewModel.kt | 9 +- .../base/actions/CreateActionDelegate.kt | 57 +++--- .../actions/ModifySettingActionBottomSheet.kt | 183 +++++++++++------- .../base/utils/navigation/NavDestination.kt | 8 +- base/src/main/res/values/strings.xml | 11 +- .../sysbridge/service/SystemBridge.kt | 1 - .../keymapper/system/SystemHiltModule.kt | 6 + .../system/settings/SettingsAdapter.kt | 32 +-- 12 files changed, 187 insertions(+), 142 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index 7e8bb512b7..7b0fd6626e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -732,7 +732,9 @@ object ActionDataEntityMapper { .valueOrNull() ?: "SYSTEM" // Default to SYSTEM for backward compatibility val settingType = try { - io.github.sds100.keymapper.system.settings.SettingType.valueOf(settingTypeString) + io.github.sds100.keymapper.system.settings.SettingType.valueOf( + settingTypeString, + ) } catch (e: IllegalArgumentException) { io.github.sds100.keymapper.system.settings.SettingType.SYSTEM } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt index 4554ed268a..45d67f71cf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt @@ -242,7 +242,8 @@ class LazyActionErrorSnapshot( } } SettingType.SECURE, - SettingType.GLOBAL -> { + SettingType.GLOBAL, + -> { if (!isPermissionGranted(Permission.WRITE_SECURE_SETTINGS)) { SystemError.PermissionDenied(Permission.WRITE_SECURE_SETTINGS) } else { 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 81b8464d8c..224c0a7108 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 @@ -763,7 +763,8 @@ object ActionUtils { return listOf(Permission.FIND_NEARBY_DEVICES) } - ActionId.MODIFY_SETTING -> return emptyList() // Permissions handled based on setting type at runtime + // Permissions handled based on setting type at runtime + ActionId.MODIFY_SETTING -> return emptyList() else -> return emptyList() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt index 2d5bb9dffb..c9a9a78e9a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt @@ -99,7 +99,7 @@ private fun ChooseSettingScreen( Column(modifier = Modifier.fillMaxSize()) { // Setting type dropdown var expanded by remember { mutableStateOf(false) } - + KeyMapperDropdownMenu( modifier = Modifier .fillMaxWidth() @@ -121,13 +121,19 @@ private fun ChooseSettingScreen( // Settings list when (state) { State.Loading -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } } is State.Data -> { if (state.data.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { Text( text = stringResource(R.string.choose_setting_empty), style = MaterialTheme.typography.bodyLarge, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt index a512b455be..1c834dbf85 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt @@ -7,8 +7,8 @@ import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider 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.system.settings.SettingsAdapter import io.github.sds100.keymapper.system.settings.SettingType +import io.github.sds100.keymapper.system.settings.SettingsAdapter import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -21,10 +21,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -data class SettingItem( - val key: String, - val value: String?, -) +data class SettingItem(val key: String, val value: String?) @HiltViewModel class ChooseSettingViewModel @Inject constructor( @@ -43,7 +40,7 @@ class ChooseSettingViewModel @Inject constructor( val settings: StateFlow>> = combine(selectedSettingType, searchQuery) { type, query -> val allSettings = settingsAdapter.getAll(type) - + val items = allSettings .filter { (key, _) -> query == null || key.contains(query, ignoreCase = true) } .map { (key, value) -> SettingItem(key, value) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 9e9620dfbd..234d313ed7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -24,6 +24,7 @@ import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.network.HttpMethod +import io.github.sds100.keymapper.system.settings.SettingType import io.github.sds100.keymapper.system.volume.DndMode import io.github.sds100.keymapper.system.volume.RingerMode import io.github.sds100.keymapper.system.volume.VolumeStream @@ -54,7 +55,9 @@ class CreateActionDelegate( var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) - var modifySettingActionBottomSheetState: ModifySettingActionBottomSheetState? by mutableStateOf(null) + var modifySettingActionBottomSheetState: ModifySettingActionBottomSheetState? by mutableStateOf( + null, + ) init { coroutineScope.launch { @@ -197,18 +200,31 @@ class CreateActionDelegate( } } - fun onDoneModifySettingClick(action: ActionData.ModifySetting) { + fun onDoneModifySettingClick() { + val state = modifySettingActionBottomSheetState ?: return + val result = ActionData.ModifySetting( + settingType = state.settingType, + settingKey = state.settingKey, + value = state.value, + ) + modifySettingActionBottomSheetState = null - actionResult.update { action } + actionResult.update { result } } - fun onChooseSettingClick(settingType: io.github.sds100.keymapper.system.settings.SettingType) { - coroutineScope.launch { - navigate( - "choose_setting", - NavDestination.ChooseSetting(currentSettingType = settingType), - ) - } + fun onSelectSettingType(settingType: SettingType) { + modifySettingActionBottomSheetState = + modifySettingActionBottomSheetState?.copy(settingType = settingType) + } + + fun onSettingKeyChange(key: String) { + modifySettingActionBottomSheetState = + modifySettingActionBottomSheetState?.copy(settingKey = key) + } + + fun onSettingValueChange(value: String) { + modifySettingActionBottomSheetState = + modifySettingActionBottomSheetState?.copy(value = value) } suspend fun editAction(oldData: ActionData) { @@ -944,25 +960,12 @@ class CreateActionDelegate( ActionId.CLEAR_RECENT_APP -> return ActionData.ClearRecentApp ActionId.MODIFY_SETTING -> { - val settingType = when (oldData) { - is ActionData.ModifySetting -> oldData.settingType - else -> io.github.sds100.keymapper.system.settings.SettingType.SYSTEM // Default to SYSTEM - } - - val settingKey = when (oldData) { - is ActionData.ModifySetting -> oldData.settingKey - else -> "" - } - - val value = when (oldData) { - is ActionData.ModifySetting -> oldData.value - else -> "" - } + val oldAction = oldData as? ActionData.ModifySetting modifySettingActionBottomSheetState = ModifySettingActionBottomSheetState( - settingType = settingType, - settingKey = settingKey, - value = value, + settingType = oldAction?.settingType ?: SettingType.SYSTEM, + settingKey = oldAction?.settingKey ?: "", + value = oldAction?.value ?: "", ) return null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index dfbcf47a0e..66236afc38 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -1,14 +1,22 @@ package io.github.sds100.keymapper.base.actions +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -17,12 +25,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.compose.BottomSheet -import io.github.sds100.keymapper.base.utils.ui.compose.BottomSheetDefaults +import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperDropdownMenu import io.github.sds100.keymapper.system.settings.SettingType import kotlinx.coroutines.launch @@ -40,122 +51,102 @@ fun ModifySettingActionBottomSheet(delegate: CreateActionDelegate) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) if (delegate.modifySettingActionBottomSheetState != null) { - ModalBottomSheet( + ModifySettingActionBottomSheet( + sheetState = sheetState, + state = delegate.modifySettingActionBottomSheetState!!, onDismissRequest = { + delegate.modifySettingActionBottomSheetState = null + }, + onSelectSettingType = delegate::onSelectSettingType, + onSettingKeyChange = delegate::onSettingKeyChange, + onSettingValueChange = delegate::onSettingValueChange, + onDoneClick = { scope.launch { sheetState.hide() - }.invokeOnCompletion { - delegate.modifySettingActionBottomSheetState = null + delegate.onDoneModifySettingClick() } }, - sheetState = sheetState, - ) { - ModifySettingActionBottomSheetContent( - state = delegate.modifySettingActionBottomSheetState!!, - onDismiss = { - scope.launch { - sheetState.hide() - }.invokeOnCompletion { - delegate.modifySettingActionBottomSheetState = null - } - }, - onChooseSetting = { settingType -> - delegate.onChooseSettingClick(settingType) - }, - onComplete = { action -> - scope.launch { - sheetState.hide() - }.invokeOnCompletion { - delegate.onDoneModifySettingClick(action) - } - }, - ) - } + ) } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ModifySettingActionBottomSheetContent( +private fun ModifySettingActionBottomSheet( + sheetState: SheetState, state: ModifySettingActionBottomSheetState, - onDismiss: () -> Unit, - onChooseSetting: (SettingType) -> Unit, - onComplete: (ActionData.ModifySetting) -> Unit, + onDismissRequest: () -> Unit = {}, + onSelectSettingType: (SettingType) -> Unit = {}, + onSettingKeyChange: (String) -> Unit = {}, + onSettingValueChange: (String) -> Unit = {}, + onDoneClick: () -> Unit = {}, ) { - var settingType by remember(state) { mutableStateOf(state.settingType) } - var settingKey by remember(state) { mutableStateOf(state.settingKey) } - var value by remember(state) { mutableStateOf(state.value) } - + val scope = rememberCoroutineScope() var settingTypeExpanded by remember { mutableStateOf(false) } - BottomSheet( - title = stringResource(R.string.action_modify_setting), - onDismiss = onDismiss, - positiveButton = BottomSheetDefaults.OkButton { - val action = ActionData.ModifySetting( - settingType = settingType, - settingKey = settingKey, - value = value, - ) - onComplete(action) + ModalBottomSheet( + onDismissRequest = { + scope.launch { + sheetState.hide() + } }, - positiveButtonEnabled = settingKey.isNotBlank() && value.isNotBlank(), + sheetState = sheetState, + dragHandle = null, ) { Column( modifier = Modifier + .verticalScroll(rememberScrollState()) .fillMaxWidth() .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.modify_setting_bottom_sheet_title), + style = MaterialTheme.typography.headlineMedium, + ) - // Setting Type Dropdown KeyMapperDropdownMenu( modifier = Modifier.fillMaxWidth(), expanded = settingTypeExpanded, onExpandedChange = { settingTypeExpanded = it }, label = { Text(stringResource(R.string.modify_setting_type_label)) }, - selectedValue = settingType, + selectedValue = state.settingType, values = listOf( SettingType.SYSTEM to stringResource(R.string.modify_setting_type_system), SettingType.SECURE to stringResource(R.string.modify_setting_type_secure), SettingType.GLOBAL to stringResource(R.string.modify_setting_type_global), ), - onValueChanged = { settingType = it }, + onValueChanged = onSelectSettingType, ) - Spacer(modifier = Modifier.height(16.dp)) - - // Button to choose an existing setting Button( - onClick = { onChooseSetting(settingType) }, + onClick = { onSelectSettingType(state.settingType) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.choose_existing_setting)) } - Spacer(modifier = Modifier.height(16.dp)) - - // Setting Key - manual entry OutlinedTextField( - value = settingKey, - onValueChange = { settingKey = it }, + value = state.settingKey, + onValueChange = onSettingKeyChange, label = { Text(stringResource(R.string.modify_setting_key_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) - Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( - value = value, - onValueChange = { value = it }, + value = state.value, + onValueChange = onSettingValueChange, label = { Text(stringResource(R.string.modify_setting_value_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) - Spacer(modifier = Modifier.height(16.dp)) - - val exampleText = when (settingType) { + val exampleText = when (state.settingType) { SettingType.SYSTEM -> stringResource(R.string.modify_setting_example_system) SettingType.SECURE -> @@ -166,11 +157,61 @@ private fun ModifySettingActionBottomSheetContent( Text( text = exampleText, - style = androidx.compose.material3.MaterialTheme.typography.bodySmall, - color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(modifier = Modifier.height(16.dp)) + // TODO do not allow empty text fields + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = onDoneClick, + ) { + Text(stringResource(R.string.pos_done)) + } + } } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + ModifySettingActionBottomSheet( + sheetState = sheetState, + state = ModifySettingActionBottomSheetState( + settingType = SettingType.GLOBAL, + settingKey = "adb_enabled", + value = "1", + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index ef674f495d..dfef10b955 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -1,4 +1,5 @@ -import io.github.sds100.keymapper.base.utils.navigation +package io.github.sds100.keymapper.base.utils.navigation + import io.github.sds100.keymapper.base.actions.ActionData import io.github.sds100.keymapper.base.actions.ChooseSettingResult import io.github.sds100.keymapper.base.actions.pinchscreen.PinchPickCoordinateResult @@ -175,9 +176,8 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data class ChooseSetting( - val currentSettingType: SettingType?, - ) : NavDestination(isCompose = true) { + data class ChooseSetting(val currentSettingType: SettingType?) : + NavDestination(isCompose = true) { override val id: String = ID_CHOOSE_SETTING } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 6b40728739..8cea7d35f4 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1093,9 +1093,10 @@ Compose SMS Compose SMS: "%s" to %s Set %3$s: %1$s = %2$s - System Setting - Secure Setting - Global Setting + System + Secure + Global + Modify setting Play sound Dismiss most recent notification Dismiss all notifications @@ -1178,7 +1179,7 @@ Force stop app Close and clear app from recents Modify setting - Setting Key + Key Value Setting Type Example: screen_off_timeout = 2147483647 (System settings use WRITE_SETTINGS permission) @@ -1186,7 +1187,7 @@ Example: airplane_mode_on = 1 (Global settings require system bridge) Choose Setting No settings found - Choose Existing Setting + Choose existing setting diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index e89edb6930..fdb9d4cc66 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -26,7 +26,6 @@ import android.os.Process import android.os.ServiceManager import android.permission.IPermissionManager import android.permission.PermissionManagerApis -import android.provider.Settings import android.util.Log import android.view.InputEvent import com.android.internal.telephony.ITelephony diff --git a/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt b/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt index a151730aa5..8909d67dcc 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt @@ -53,6 +53,8 @@ import io.github.sds100.keymapper.system.ringtones.AndroidRingtoneAdapter import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.root.SuAdapterImpl +import io.github.sds100.keymapper.system.settings.AndroidSettingsAdapter +import io.github.sds100.keymapper.system.settings.SettingsAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter import io.github.sds100.keymapper.system.shell.StandardShellAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter @@ -193,4 +195,8 @@ abstract class SystemHiltModule { abstract fun provideSystemFeatureAdapter( impl: AndroidSystemFeatureAdapter, ): SystemFeatureAdapter + + @Singleton + @Binds + abstract fun provideSettingsAdapter(impl: AndroidSettingsAdapter): SettingsAdapter } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt index 58d53f8fec..493ba0f327 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.system.settings import android.content.Context import android.database.Cursor -import android.net.Uri import android.provider.Settings import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.utils.KMError @@ -24,9 +23,9 @@ class AndroidSettingsAdapter @Inject constructor( SettingType.SECURE -> Settings.Secure.CONTENT_URI SettingType.GLOBAL -> Settings.Global.CONTENT_URI } - + val settings = mutableMapOf() - var cursor: Cursor? = null + var cursor: Cursor? try { cursor = ctx.contentResolver.query( uri, @@ -43,7 +42,11 @@ class AndroidSettingsAdapter @Inject constructor( while (it.moveToNext()) { val name = it.getString(nameIndex) if (!name.isNullOrBlank()) { - val value = if (valueIndex >= 0) it.getString(valueIndex) else null + val value = if (valueIndex >= 0) { + it.getString(valueIndex) + } else { + null + } settings[name] = value } } @@ -65,24 +68,9 @@ class AndroidSettingsAdapter @Inject constructor( override fun modifySetting(settingType: SettingType, key: String, value: String): KMResult<*> { val success = when (settingType) { - SettingType.SYSTEM -> when { - value.toIntOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toInt()) - value.toLongOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toLong()) - value.toFloatOrNull() != null -> SettingsUtils.putSystemSetting(ctx, key, value.toFloat()) - else -> SettingsUtils.putSystemSetting(ctx, key, value) - } - SettingType.SECURE -> when { - value.toIntOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toInt()) - value.toLongOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toLong()) - value.toFloatOrNull() != null -> SettingsUtils.putSecureSetting(ctx, key, value.toFloat()) - else -> SettingsUtils.putSecureSetting(ctx, key, value) - } - SettingType.GLOBAL -> when { - value.toIntOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toInt()) - value.toLongOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toLong()) - value.toFloatOrNull() != null -> SettingsUtils.putGlobalSetting(ctx, key, value.toFloat()) - else -> SettingsUtils.putGlobalSetting(ctx, key, value) - } + SettingType.SYSTEM -> SettingsUtils.putSystemSetting(ctx, key, value) + SettingType.SECURE -> SettingsUtils.putSecureSetting(ctx, key, value) + SettingType.GLOBAL -> SettingsUtils.putGlobalSetting(ctx, key, value) } return if (success) { From 16ef439777e5bf676f383e15f3aaf6de45429eb9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 00:49:37 +0100 Subject: [PATCH 27/62] #1871 use segmented buttons to switch setting type --- .../actions/ModifySettingActionBottomSheet.kt | 18 +++++------------- .../utils/ui/compose/KeyMapperDropdownMenu.kt | 3 ++- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index 66236afc38..6338e99e5f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -20,11 +20,7 @@ import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity @@ -34,7 +30,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperDropdownMenu +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import io.github.sds100.keymapper.system.settings.SettingType import kotlinx.coroutines.launch @@ -82,7 +78,6 @@ private fun ModifySettingActionBottomSheet( onDoneClick: () -> Unit = {}, ) { val scope = rememberCoroutineScope() - var settingTypeExpanded by remember { mutableStateOf(false) } ModalBottomSheet( onDismissRequest = { @@ -109,18 +104,15 @@ private fun ModifySettingActionBottomSheet( style = MaterialTheme.typography.headlineMedium, ) - KeyMapperDropdownMenu( + KeyMapperSegmentedButtonRow( modifier = Modifier.fillMaxWidth(), - expanded = settingTypeExpanded, - onExpandedChange = { settingTypeExpanded = it }, - label = { Text(stringResource(R.string.modify_setting_type_label)) }, - selectedValue = state.settingType, - values = listOf( + buttonStates = listOf( SettingType.SYSTEM to stringResource(R.string.modify_setting_type_system), SettingType.SECURE to stringResource(R.string.modify_setting_type_secure), SettingType.GLOBAL to stringResource(R.string.modify_setting_type_global), ), - onValueChanged = onSelectSettingType, + selectedState = state.settingType, + onStateSelected = onSelectSettingType, ) Button( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperDropdownMenu.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperDropdownMenu.kt index cd4629e72d..b18412f4c2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperDropdownMenu.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperDropdownMenu.kt @@ -20,6 +20,7 @@ fun KeyMapperDropdownMenu( label: (@Composable () -> Unit)? = null, selectedValue: T, values: List>, + readOnly: Boolean = true, onValueChanged: (T) -> Unit = {}, ) { ExposedDropdownMenuBox( @@ -33,7 +34,7 @@ fun KeyMapperDropdownMenu( onValueChange = { newValue -> onValueChanged(values.single { it.second == newValue }.first) }, - readOnly = true, + readOnly = readOnly, label = label, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.textFieldColors(), From 1862d3b5f07cfe67dc0cc8542439b7dedf9ca2c0 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 01:00:06 +0100 Subject: [PATCH 28/62] #1871 clean up ChooseSettingScreen.kt and add previews --- .../sds100/keymapper/base/BaseMainNavHost.kt | 8 ++ .../base/actions/ChooseSettingScreen.kt | 83 +++++++++++++++---- .../base/actions/ChooseSettingViewModel.kt | 7 +- .../base/actions/CreateActionDelegate.kt | 15 ++++ .../actions/ModifySettingActionBottomSheet.kt | 26 +++--- .../base/utils/navigation/NavDestination.kt | 2 +- base/src/main/res/values/strings.xml | 6 +- 7 files changed, 106 insertions(+), 41 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index f57a042dbc..7dbe5c9a35 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -18,6 +18,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import io.github.sds100.keymapper.base.actions.ChooseActionScreen import io.github.sds100.keymapper.base.actions.ChooseActionViewModel +import io.github.sds100.keymapper.base.actions.ChooseSettingScreen import io.github.sds100.keymapper.base.actions.ConfigShellCommandViewModel import io.github.sds100.keymapper.base.actions.ShellCommandActionScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementScreen @@ -164,6 +165,13 @@ fun BaseMainNavHost( ) } + composable { + ChooseSettingScreen( + modifier = Modifier.fillMaxSize(), + viewModel = hiltViewModel(), + ) + } + composableDestinations() } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt index c9a9a78e9a..d06d7ee21b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt @@ -22,16 +22,15 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperDropdownMenu +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import io.github.sds100.keymapper.base.utils.ui.compose.SearchAppBarActions import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.settings.SettingType @@ -97,28 +96,21 @@ private fun ChooseSettingScreen( .padding(innerPadding), ) { Column(modifier = Modifier.fillMaxSize()) { - // Setting type dropdown - var expanded by remember { mutableStateOf(false) } - - KeyMapperDropdownMenu( + KeyMapperSegmentedButtonRow( modifier = Modifier .fillMaxWidth() .padding(16.dp), - expanded = expanded, - onExpandedChange = { expanded = it }, - label = { Text(stringResource(R.string.modify_setting_type_label)) }, - selectedValue = settingType, - values = listOf( + buttonStates = listOf( SettingType.SYSTEM to stringResource(R.string.modify_setting_type_system), SettingType.SECURE to stringResource(R.string.modify_setting_type_secure), SettingType.GLOBAL to stringResource(R.string.modify_setting_type_global), ), - onValueChanged = onSettingTypeChange, + selectedState = settingType, + onStateSelected = onSettingTypeChange, ) HorizontalDivider() - // Settings list when (state) { State.Loading -> { Box( @@ -159,3 +151,64 @@ private fun ChooseSettingScreen( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewWithData() { + KeyMapperTheme { + ChooseSettingScreen( + state = State.Data( + listOf( + SettingItem("adb_enabled", "0"), + SettingItem("airplane_mode_on", "0"), + SettingItem("bluetooth_on", "1"), + SettingItem("screen_brightness", "128"), + SettingItem("wifi_on", "1"), + ), + ), + settingType = SettingType.GLOBAL, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewLoading() { + KeyMapperTheme { + ChooseSettingScreen( + state = State.Loading, + settingType = SettingType.SECURE, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewEmpty() { + KeyMapperTheme { + ChooseSettingScreen( + state = State.Data(emptyList()), + settingType = SettingType.SYSTEM, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewWithSearch() { + KeyMapperTheme { + ChooseSettingScreen( + state = State.Data( + listOf( + SettingItem("bluetooth_on", "1"), + ), + ), + query = "bluetooth", + settingType = SettingType.SECURE, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt index 1c834dbf85..2e3c343042 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt @@ -21,8 +21,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -data class SettingItem(val key: String, val value: String?) - @HiltViewModel class ChooseSettingViewModel @Inject constructor( private val settingsAdapter: SettingsAdapter, @@ -33,10 +31,9 @@ class ChooseSettingViewModel @Inject constructor( ResourceProvider by resourceProvider, DialogProvider by dialogProvider, NavigationProvider by navigationProvider { - val searchQuery = MutableStateFlow(null) - val selectedSettingType = MutableStateFlow(SettingType.SYSTEM) + val selectedSettingType = MutableStateFlow(SettingType.SYSTEM) val settings: StateFlow>> = combine(selectedSettingType, searchQuery) { type, query -> val allSettings = settingsAdapter.getAll(type) @@ -71,6 +68,8 @@ class ChooseSettingViewModel @Inject constructor( } } +data class SettingItem(val key: String, val value: String?) + @Serializable data class ChooseSettingResult( val settingType: SettingType, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 234d313ed7..359546f2d0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -222,6 +222,21 @@ class CreateActionDelegate( modifySettingActionBottomSheetState?.copy(settingKey = key) } + fun onChooseExistingSettingClick() { + val type = modifySettingActionBottomSheetState?.settingType ?: return + val destination = NavDestination.ChooseSetting(settingType = type) + + coroutineScope.launch { + val setting = navigate("choose_setting", destination) ?: return@launch + + modifySettingActionBottomSheetState = modifySettingActionBottomSheetState?.copy( + settingType = setting.settingType, + settingKey = setting.key, + value = setting.currentValue ?: "", + ) + } + } + fun onSettingValueChange(value: String) { modifySettingActionBottomSheetState = modifySettingActionBottomSheetState?.copy(value = value) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index 6338e99e5f..c2a89aa7c5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity 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 @@ -56,6 +57,7 @@ fun ModifySettingActionBottomSheet(delegate: CreateActionDelegate) { onSelectSettingType = delegate::onSelectSettingType, onSettingKeyChange = delegate::onSettingKeyChange, onSettingValueChange = delegate::onSettingValueChange, + onChooseExistingClick = delegate::onChooseExistingSettingClick, onDoneClick = { scope.launch { sheetState.hide() @@ -75,6 +77,7 @@ private fun ModifySettingActionBottomSheet( onSelectSettingType: (SettingType) -> Unit = {}, onSettingKeyChange: (String) -> Unit = {}, onSettingValueChange: (String) -> Unit = {}, + onChooseExistingClick: () -> Unit = {}, onDoneClick: () -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -116,7 +119,7 @@ private fun ModifySettingActionBottomSheet( ) Button( - onClick = { onSelectSettingType(state.settingType) }, + onClick = onChooseExistingClick, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.choose_existing_setting)) @@ -128,6 +131,9 @@ private fun ModifySettingActionBottomSheet( label = { Text(stringResource(R.string.modify_setting_key_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), ) OutlinedTextField( @@ -136,21 +142,9 @@ private fun ModifySettingActionBottomSheet( label = { Text(stringResource(R.string.modify_setting_value_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, - ) - - val exampleText = when (state.settingType) { - SettingType.SYSTEM -> - stringResource(R.string.modify_setting_example_system) - SettingType.SECURE -> - stringResource(R.string.modify_setting_example_secure) - SettingType.GLOBAL -> - stringResource(R.string.modify_setting_example_global) - } - - Text( - text = exampleText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), ) // TODO do not allow empty text fields diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index dfef10b955..3d0bf322eb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -176,7 +176,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data class ChooseSetting(val currentSettingType: SettingType?) : + data class ChooseSetting(val settingType: SettingType?) : NavDestination(isCompose = true) { override val id: String = ID_CHOOSE_SETTING } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 8cea7d35f4..6c72a161fa 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1181,11 +1181,7 @@ Modify setting Key Value - Setting Type - Example: screen_off_timeout = 2147483647 (System settings use WRITE_SETTINGS permission) - Example: accessibility_enabled = 1 (Secure settings require system bridge) - Example: airplane_mode_on = 1 (Global settings require system bridge) - Choose Setting + Choose setting No settings found Choose existing setting From a75ea9f51a58c0d7a30b9e2187332e36af79cb6c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 01:19:59 +0100 Subject: [PATCH 29/62] #1871 ModifySetting action is now editable --- .../java/io/github/sds100/keymapper/base/actions/ActionUtils.kt | 1 + 1 file changed, 1 insertion(+) 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 224c0a7108..1e2ff04491 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 @@ -944,6 +944,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.ShellCommand, is ActionData.InteractUiElement, is ActionData.MoveCursor, + is ActionData.ModifySetting, -> true else -> false From 8149230c4a60912accf468021d9b3f33fed09edd Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 01:35:35 +0100 Subject: [PATCH 30/62] #1871 add backhandler to ChooseSettingScreen --- .../sds100/keymapper/base/actions/ChooseSettingScreen.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt index d06d7ee21b..3e80ef9c00 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -68,6 +69,8 @@ private fun ChooseSettingScreen( onClickSetting: (String, String?) -> Unit = { _, _ -> }, onNavigateBack: () -> Unit = {}, ) { + BackHandler(onBack = onNavigateBack) + Scaffold( modifier = modifier.displayCutoutPadding(), topBar = { From 95d22d0f9eb36ad3c681cc1328e247bacb15c75c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 01:36:06 +0100 Subject: [PATCH 31/62] #1871 fix dismissing ModifySettingActionBottomSheet --- .../base/actions/ModifySettingActionBottomSheet.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index c2a89aa7c5..daf53384ad 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -83,11 +83,7 @@ private fun ModifySettingActionBottomSheet( val scope = rememberCoroutineScope() ModalBottomSheet( - onDismissRequest = { - scope.launch { - sheetState.hide() - } - }, + onDismissRequest = onDismissRequest, sheetState = sheetState, dragHandle = null, ) { From 2b0efdce862b46ad7b77bc11c011ed9aa3cf62b6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 01:36:50 +0100 Subject: [PATCH 32/62] chore: upgrade compose BOM and navigation libraries --- .../base/actions/ActionOptionsBottomSheet.kt | 10 ++- .../actions/FlashlightActionBottomSheet.kt | 18 +++-- .../base/actions/HttpRequestBottomSheet.kt | 10 ++- .../actions/ModifySettingActionBottomSheet.kt | 6 +- .../base/actions/SmsActionBottomSheet.kt | 18 +++-- .../base/actions/VolumeActionBottomSheet.kt | 10 ++- .../keyevent/FixKeyEventActionBottomSheet.kt | 14 ++-- .../constraints/TimeConstraintBottomSheet.kt | 6 +- .../trigger/TriggerDiscoverBottomSheet.kt | 6 +- .../trigger/TriggerKeyOptionsBottomSheet.kt | 22 +++---- .../base/trigger/TriggerSetupBottomSheet.kt | 66 +++++++++---------- gradle/libs.versions.toml | 6 +- 12 files changed, 85 insertions(+), 107 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt index c9b11661c5..90cf7cba0c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt @@ -24,14 +24,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue.Expanded import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -413,8 +411,8 @@ private fun Preview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) ActionOptionsBottomSheet( @@ -472,8 +470,8 @@ private fun PreviewNoEditButton() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) ActionOptionsBottomSheet( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt index 60f4fa07c8..d7d058aa61 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -41,7 +40,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -461,8 +459,8 @@ private fun PreviewBothLenses() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) EnableFlashlightActionBottomSheet( @@ -497,8 +495,8 @@ private fun PreviewOnlyBackLens() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) EnableFlashlightActionBottomSheet( @@ -528,8 +526,8 @@ private fun PreviewOnlyBackLensChangeStrength() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) ChangeFlashlightStrengthActionBottomSheet( @@ -557,8 +555,8 @@ private fun PreviewUnsupportedAndroidVersion() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) EnableFlashlightActionBottomSheet( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/HttpRequestBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/HttpRequestBottomSheet.kt index 696b6f2f9f..2ebe6e1d51 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/HttpRequestBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/HttpRequestBottomSheet.kt @@ -19,7 +19,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -30,7 +29,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -299,8 +297,8 @@ private fun PreviewEmpty() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) HttpRequestBottomSheet( sheetState = sheetState, @@ -323,8 +321,8 @@ private fun PreviewFilled() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) HttpRequestBottomSheet( sheetState = sheetState, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index daf53384ad..5b52e4385b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -16,14 +16,12 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign @@ -183,8 +181,8 @@ private fun Preview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) ModifySettingActionBottomSheet( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt index a4ea1ee42f..b6ad399bb4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt @@ -20,7 +20,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -32,7 +31,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -334,8 +332,8 @@ private fun Preview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) SmsActionBottomSheet( @@ -357,8 +355,8 @@ private fun PreviewTestError() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) SmsActionBottomSheet( @@ -380,8 +378,8 @@ private fun PreviewTestSuccess() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) SmsActionBottomSheet( @@ -403,8 +401,8 @@ private fun PreviewEmpty() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) SmsActionBottomSheet( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt index c55738d03a..10b8b0ead8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt @@ -19,7 +19,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -30,7 +29,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -208,8 +206,8 @@ private fun PreviewVolumeActionBottomSheet() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) var state by remember { @@ -240,8 +238,8 @@ private fun PreviewVolumeActionBottomSheetDefaultStream() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) var state by remember { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt index f3dc4e3b01..b890e9ac19 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt @@ -29,13 +29,11 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedCard import androidx.compose.material3.RadioButton import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign @@ -307,8 +305,8 @@ private fun InputMethodPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) FixKeyEventActionBottomSheet( @@ -332,8 +330,8 @@ private fun ProModePreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) FixKeyEventActionBottomSheet( @@ -353,8 +351,8 @@ private fun ProModeUnsupportedPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) FixKeyEventActionBottomSheet( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt index b8b1ac7370..e0a1415247 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker @@ -36,7 +35,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -275,8 +273,8 @@ private fun Preview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) TimeConstraintBottomSheet( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt index a358f590ad..cf17a0def7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt @@ -9,12 +9,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -68,8 +66,8 @@ private fun PreviewNoKeyRecordedComplete() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) TriggerDiscoverBottomSheet( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt index b56911fc7b..5a90be8d5c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue.Expanded import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable @@ -29,7 +28,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -411,8 +409,8 @@ private fun PreviewKeyEvent() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) TriggerKeyOptionsBottomSheet( @@ -449,8 +447,8 @@ private fun PreviewKeyEventTiny() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) TriggerKeyOptionsBottomSheet( @@ -487,8 +485,8 @@ private fun PreviewEvdev() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) TriggerKeyOptionsBottomSheet( @@ -513,8 +511,8 @@ private fun AssistantPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) TriggerKeyOptionsBottomSheet( @@ -534,8 +532,8 @@ private fun FloatingButtonPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) TriggerKeyOptionsBottomSheet( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt index 18a9d505ce..5aaf8bdefa 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt @@ -32,7 +32,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -43,7 +42,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -904,8 +902,8 @@ private fun PowerButtonPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) PowerTriggerSetupBottomSheet( @@ -928,8 +926,8 @@ private fun PowerButtonDisabledPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) PowerTriggerSetupBottomSheet( @@ -952,8 +950,8 @@ private fun VolumeButtonPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) VolumeTriggerSetupBottomSheet( @@ -977,8 +975,8 @@ private fun VolumeButtonDisabledPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) VolumeTriggerSetupBottomSheet( @@ -1002,8 +1000,8 @@ private fun FingerprintGestureRequirementsMetPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) FingerprintGestureSetupBottomSheet( @@ -1024,8 +1022,8 @@ private fun FingerprintGestureRequirementsNotMetPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) FingerprintGestureSetupBottomSheet( @@ -1046,8 +1044,8 @@ private fun KeyboardButtonEnabledPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) KeyboardTriggerSetupBottomSheet( @@ -1071,8 +1069,8 @@ private fun KeyboardButtonDisabledPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) KeyboardTriggerSetupBottomSheet( @@ -1096,8 +1094,8 @@ private fun MouseButtonPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) MouseTriggerSetupBottomSheet( @@ -1120,8 +1118,8 @@ private fun MouseButtonDisabledPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) MouseTriggerSetupBottomSheet( @@ -1144,8 +1142,8 @@ private fun OtherButtonPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) OtherTriggerSetupBottomSheet( @@ -1169,8 +1167,8 @@ private fun OtherButtonDisabledPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) OtherTriggerSetupBottomSheet( @@ -1194,8 +1192,8 @@ private fun GamepadDpadPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) GamepadTriggerSetupBottomSheet( @@ -1219,8 +1217,8 @@ private fun GamepadDpadDisabledPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) GamepadTriggerSetupBottomSheet( @@ -1244,8 +1242,8 @@ private fun GamepadSimpleButtonsPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) GamepadTriggerSetupBottomSheet( @@ -1269,8 +1267,8 @@ private fun GamepadSimpleButtonsDisabledPreview() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, ) GamepadTriggerSetupBottomSheet( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 67a55b1535..9b4fd25842 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ androidx-legacy-support-core-ui = "1.0.0" androidx-lifecycle = "2.9.0" androidx-lifecycle-extensions = "2.2.0" # Note: lifecycle-extensions is deprecated androidx-multidex = "2.0.1" -androidx-navigation = "2.9.0" # App level nav_version +androidx-navigation = "2.9.6" # App level nav_version androidx-navigation-safeargs-gradle-plugin = "2.6.0" # Project level nav_version androidx-preference-ktx = "1.2.1" androidx-recyclerview = "1.4.0" @@ -29,9 +29,9 @@ androidx-test-core = "1.6.1" androidx-viewpager2 = "1.1.0" dagger-hilt-android = "2.56.2" -hilt-navigation-compose = "1.2.0" +hilt-navigation-compose = "1.3.0" -compose-bom = "2025.05.01" +compose-bom = "2025.11.00" compose-compiler = "1.5.10" # kotlinCompilerExtensionVersion desugar-jdk-libs = "2.1.5" From 0f168ad6736a3e1c46f244a99c5811a9bd78b5d7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 01:40:58 +0100 Subject: [PATCH 33/62] #1871 use monospace font in ChooseSettingScreen.kt --- .../base/actions/ChooseSettingScreen.kt | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt index 3e80ef9c00..bb8c2514ad 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -26,6 +27,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -135,18 +137,11 @@ private fun ChooseSettingScreen( ) } } else { - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(state.data) { item -> - ListItem( - headlineContent = { Text(item.key) }, - supportingContent = item.value?.let { { Text(it) } }, - modifier = Modifier.clickable { - onClickSetting(item.key, item.value) - }, - ) - HorizontalDivider() - } - } + LoadedList( + modifier = Modifier.fillMaxSize(), + listItems = state.data, + onClick = onClickSetting, + ) } } } @@ -155,6 +150,42 @@ private fun ChooseSettingScreen( } } +@Composable +private fun LoadedList( + modifier: Modifier = Modifier, + listItems: List, + onClick: (String, String?) -> Unit, +) { + LazyColumn(modifier = modifier) { + items(listItems) { item -> + ListItem( + headlineContent = { + Text( + item.key, + style = LocalTextStyle.current.copy( + fontFamily = FontFamily.Monospace, + ), + ) + }, + supportingContent = item.value?.let { + { + Text( + it, + style = LocalTextStyle.current.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + }, + modifier = Modifier.clickable { + onClick(item.key, item.value) + }, + ) + HorizontalDivider() + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable From 94b77f2a3192d2fce6bc4142742b3cbd79703357 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 01:48:35 +0100 Subject: [PATCH 34/62] #1871 fix saving ModifySettings action --- .../keymapper/base/actions/ActionDataEntityMapper.kt | 11 ++++++----- .../sds100/keymapper/data/entities/ActionEntity.kt | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index 7b0fd6626e..f60f3eab11 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -22,6 +22,7 @@ import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.intents.IntentExtraModel import io.github.sds100.keymapper.system.intents.IntentTarget import io.github.sds100.keymapper.system.network.HttpMethod +import io.github.sds100.keymapper.system.settings.SettingType import io.github.sds100.keymapper.system.volume.DndMode import io.github.sds100.keymapper.system.volume.RingerMode import io.github.sds100.keymapper.system.volume.VolumeStream @@ -50,6 +51,7 @@ object ActionDataEntityMapper { ActionEntity.Type.INTERACT_UI_ELEMENT -> ActionId.INTERACT_UI_ELEMENT ActionEntity.Type.SHELL_COMMAND -> ActionId.SHELL_COMMAND + ActionEntity.Type.MODIFY_SETTING -> ActionId.MODIFY_SETTING } return when (actionId) { @@ -732,11 +734,9 @@ object ActionDataEntityMapper { .valueOrNull() ?: "SYSTEM" // Default to SYSTEM for backward compatibility val settingType = try { - io.github.sds100.keymapper.system.settings.SettingType.valueOf( - settingTypeString, - ) - } catch (e: IllegalArgumentException) { - io.github.sds100.keymapper.system.settings.SettingType.SYSTEM + SettingType.valueOf(settingTypeString) + } catch (_: IllegalArgumentException) { + SettingType.SYSTEM } ActionData.ModifySetting( @@ -771,6 +771,7 @@ object ActionDataEntityMapper { is ActionData.Sound -> ActionEntity.Type.SOUND is ActionData.InteractUiElement -> ActionEntity.Type.INTERACT_UI_ELEMENT is ActionData.ShellCommand -> ActionEntity.Type.SHELL_COMMAND + is ActionData.ModifySetting -> ActionEntity.Type.MODIFY_SETTING else -> ActionEntity.Type.SYSTEM_ACTION } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 20d60ac50a..7961aa8b83 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -185,6 +185,7 @@ data class ActionEntity( SOUND, INTERACT_UI_ELEMENT, SHELL_COMMAND, + MODIFY_SETTING, } constructor( From f300bf8b8977fa54344aaacc1bd0b8f6b7e79534 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 14:09:47 +0100 Subject: [PATCH 35/62] #1871 add TODO --- .../keymapper/base/actions/ModifySettingActionBottomSheet.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index 5b52e4385b..e02e4fdbbb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -142,6 +142,8 @@ private fun ModifySettingActionBottomSheet( ) // TODO do not allow empty text fields + // TODO add test button + // TODO add disclaimer that simply modifying setting values is not sufficient for the system to actually process the change Row( modifier = Modifier .fillMaxWidth() From 192868c8464c5c9589d6f59e9b3511b4bb9b03cd Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 8 Nov 2025 15:27:35 +0100 Subject: [PATCH 36/62] chore: bump version to 4.0.0-beta.3 --- app/version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/version.properties b/app/version.properties index 886f7bcec6..a73bc7508e 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ -VERSION_NAME=4.0.0-beta.2 -VERSION_CODE=187 +VERSION_NAME=4.0.0-beta.3 +VERSION_CODE=189 VERSION_NUM=01 \ No newline at end of file From fefc970680567ce52a8c3ca5dcad2bd9629f25a7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 11:59:11 +0100 Subject: [PATCH 37/62] fix: do not crash when deserializing an unknown action type --- .../sds100/keymapper/base/backup/BackupManager.kt | 1 + .../github/sds100/keymapper/base/keymaps/KeyMap.kt | 4 +++- .../db/typeconverter/ActionListTypeConverter.kt | 9 ++++----- .../sds100/keymapper/data/entities/ActionEntity.kt | 14 ++++++++++++-- .../data/entities/FingerprintMapEntity.kt | 7 +++++-- .../sds100/keymapper/data/entities/KeyMapEntity.kt | 7 +++++-- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt index ce438072ed..eebd08fb2d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt @@ -780,6 +780,7 @@ class BackupManagerImpl @Inject constructor( private fun getSoundUidsToBackup(keyMapList: List): Set { val soundActions = keyMapList .flatMap { it.actionList } + .filterNotNull() .filter { it.type == ActionEntity.Type.SOUND } return soundActions diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt index dbfee5352f..4a211445ff 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt @@ -112,7 +112,9 @@ object KeyMapEntityMapper { entity: KeyMapEntity, floatingButtons: List, ): KeyMap { - val actionList = entity.actionList.mapNotNull { ActionEntityMapper.fromEntity(it) } + val actionList = entity.actionList + .filterNotNull() + .mapNotNull { ActionEntityMapper.fromEntity(it) } val constraintList = entity.constraintList.map { ConstraintEntityMapper.fromEntity(it) }.toSet() diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ActionListTypeConverter.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ActionListTypeConverter.kt index 993a25a63a..d484377a13 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ActionListTypeConverter.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ActionListTypeConverter.kt @@ -5,16 +5,15 @@ import com.github.salomonbrys.kotson.fromJson import com.github.salomonbrys.kotson.registerTypeAdapter import com.google.gson.GsonBuilder import io.github.sds100.keymapper.data.entities.ActionEntity -import io.github.sds100.keymapper.data.entities.ConstraintEntity class ActionListTypeConverter { - private val gson = GsonBuilder().registerTypeAdapter(ConstraintEntity.DESERIALIZER).create() + private val gson = GsonBuilder().registerTypeAdapter(ActionEntity.DESERIALIZER).create() @TypeConverter - fun toActionList(json: String): List { - return gson.fromJson>(json) + fun toActionList(json: String): List { + return gson.fromJson>(json) } @TypeConverter - fun toJsonString(actionList: List): String = gson.toJson(actionList)!! + fun toJsonString(actionList: List): String = gson.toJson(actionList)!! } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 5d33801ee2..4418a7a8a1 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -142,8 +142,18 @@ data class ActionEntity( const val EXTRA_REPEAT_LIMIT = "extra_repeat_limit" val DESERIALIZER = jsonDeserializer { - val typeString by it.json.byString(NAME_ACTION_TYPE) - val type = Type.valueOf(typeString) + val typeString by it.json.byNullableString(NAME_ACTION_TYPE) + // If it is an unknown type then do not deserialize + if (typeString == null) { + return@jsonDeserializer null + } + + val type: Type = try { + Type.valueOf(typeString!!) + } catch (e: IllegalArgumentException) { + // If it is an unknown type then do not deserialize + return@jsonDeserializer null + } val data by it.json.byString(NAME_DATA) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/FingerprintMapEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/FingerprintMapEntity.kt index b5c824f97b..9440dd1819 100755 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/FingerprintMapEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/FingerprintMapEntity.kt @@ -17,9 +17,12 @@ data class FingerprintMapEntity( @PrimaryKey val id: Int = ID_UNKNOWN, + /** + * The action can be null if it wasn't deserialized successfully. + */ @SerializedName(NAME_ACTION_LIST) @ColumnInfo(name = FingerprintMapDao.KEY_ACTION_LIST) - val actionList: List = listOf(), + val actionList: List = listOf(), @SerializedName(NAME_CONSTRAINTS) @ColumnInfo(name = FingerprintMapDao.KEY_CONSTRAINT_LIST) @@ -63,7 +66,7 @@ data class FingerprintMapEntity( val id by it.json.byNullableInt(NAME_ID) val actionListJson by it.json.byArray(NAME_ACTION_LIST) - val actionList = it.context.deserialize>(actionListJson) + val actionList = it.context.deserialize>(actionListJson) val extrasJson by it.json.byArray(NAME_EXTRAS) val extras = it.context.deserialize>(extrasJson) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt index d94ae2d09f..a91eac14f2 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt @@ -41,9 +41,12 @@ data class KeyMapEntity( @ColumnInfo(name = KeyMapDao.KEY_TRIGGER) val trigger: TriggerEntity = TriggerEntity(), + /** + * The action can be null if it wasn't deserialized successfully. + */ @SerializedName(NAME_ACTION_LIST) @ColumnInfo(name = KeyMapDao.KEY_ACTION_LIST) - val actionList: List = listOf(), + val actionList: List = listOf(), @SerializedName(NAME_CONSTRAINT_LIST) @ColumnInfo(name = KeyMapDao.KEY_CONSTRAINT_LIST) @@ -87,7 +90,7 @@ data class KeyMapEntity( val DESERIALIZER = jsonDeserializer { val actionListJsonArray by it.json.byArray(NAME_ACTION_LIST) - val actionList = it.context.deserialize>(actionListJsonArray) + val actionList = it.context.deserialize>(actionListJsonArray) val triggerJsonObject by it.json.byObject(NAME_TRIGGER) val trigger = it.context.deserialize(triggerJsonObject) From 04328e3e5d98a7b0f06f0be93341b2b86d442b91 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 12:56:18 +0100 Subject: [PATCH 38/62] change logging message --- .../java/io/github/sds100/keymapper/base/input/InputEventHub.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt index c557db49e1..e8d73beb13 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt @@ -343,7 +343,7 @@ class InputEventHubImpl @Inject constructor( val androidKeyEvent = event.toAndroidKeyEvent(flags = KeyEvent.FLAG_FROM_SYSTEM) if (logInputEventsEnabled.value) { - Timber.d("Injecting key event $androidKeyEvent with system bridge") + Timber.d("Injecting key event with system bridge $androidKeyEvent") } return withContext(Dispatchers.IO) { From 302095dbc5d00ceb74d2e9c0a0a33b6192372a08 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 19:21:18 +0100 Subject: [PATCH 39/62] feat: WRITE_SECURE_SETTINGS permission dialog now directs the user to PRO mode --- .../sds100/keymapper/base/BaseMainActivity.kt | 14 +++--- .../shortcuts/CreateKeyMapShortcutActivity.kt | 16 ++++--- .../permissions/RequestPermissionDelegate.kt | 48 +++++++++++-------- base/src/main/res/values/strings.xml | 4 +- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt index 7b8f927f18..23e6cf7e58 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt @@ -21,7 +21,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.withStateAtLeast -import androidx.navigation.findNavController import com.anggrayudi.storage.extension.openInputStream import com.anggrayudi.storage.extension.openOutputStream import com.anggrayudi.storage.extension.toDocumentFile @@ -32,6 +31,7 @@ import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase import io.github.sds100.keymapper.base.system.accessibility.AccessibilityServiceAdapterImpl import io.github.sds100.keymapper.base.system.permissions.RequestPermissionDelegate import io.github.sds100.keymapper.base.trigger.RecordTriggerControllerImpl +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupControllerImpl @@ -43,12 +43,12 @@ import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapt import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter import io.github.sds100.keymapper.system.root.SuAdapterImpl import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject abstract class BaseMainActivity : AppCompatActivity() { @@ -105,6 +105,9 @@ abstract class BaseMainActivity : AppCompatActivity() { @Inject lateinit var inputEventHub: InputEventHubImpl + @Inject + lateinit var navigationProvider: NavigationProvider + private lateinit var requestPermissionDelegate: RequestPermissionDelegate private val currentNightMode: Int @@ -162,15 +165,14 @@ abstract class BaseMainActivity : AppCompatActivity() { notificationReceiverAdapter = notificationReceiverAdapter, buildConfigProvider = buildConfigProvider, shizukuAdapter = shizukuAdapter, + navigationProvider = navigationProvider, + coroutineScope = lifecycleScope, ) permissionAdapter.request .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) .onEach { permission -> - requestPermissionDelegate.requestPermission( - permission, - findNavController(R.id.container), - ) + requestPermissionDelegate.requestPermission(permission) } .launchIn(lifecycleScope) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt index 741ed4f789..d13b869230 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt @@ -8,23 +8,23 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.ui.graphics.toArgb import androidx.lifecycle.Lifecycle -import androidx.navigation.findNavController +import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.ComposeColors import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase import io.github.sds100.keymapper.base.system.accessibility.AccessibilityServiceAdapterImpl import io.github.sds100.keymapper.base.system.permissions.RequestPermissionDelegate import io.github.sds100.keymapper.base.trigger.RecordTriggerControllerImpl +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl import io.github.sds100.keymapper.base.utils.ui.launchRepeatOnLifecycle import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapterImpl import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter -import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest +import javax.inject.Inject @AndroidEntryPoint class CreateKeyMapShortcutActivity : AppCompatActivity() { @@ -53,6 +53,9 @@ class CreateKeyMapShortcutActivity : AppCompatActivity() { @Inject lateinit var buildConfigProvider: BuildConfigProvider + @Inject + lateinit var navigationProvider: NavigationProvider + private lateinit var requestPermissionDelegate: RequestPermissionDelegate private val viewModel by viewModels() @@ -86,15 +89,14 @@ class CreateKeyMapShortcutActivity : AppCompatActivity() { notificationReceiverAdapter = notificationReceiverAdapter, buildConfigProvider = buildConfigProvider, shizukuAdapter = shizukuAdapter, + navigationProvider = navigationProvider, + coroutineScope = lifecycleScope, ) launchRepeatOnLifecycle(Lifecycle.State.STARTED) { permissionAdapter.request .collectLatest { permission -> - requestPermissionDelegate.requestPermission( - permission, - findNavController(R.id.container), - ) + requestPermissionDelegate.requestPermission(permission) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt index 22067867e6..89f521832f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt @@ -13,16 +13,21 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat -import androidx.navigation.NavController import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.utils.navigation.NavDestination +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.str import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.system.DeviceAdmin import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapterImpl import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter import io.github.sds100.keymapper.system.url.UrlUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import splitties.alertdialog.appcompat.messageResource import splitties.alertdialog.appcompat.negativeButton import splitties.alertdialog.appcompat.neutralButton @@ -38,6 +43,8 @@ class RequestPermissionDelegate( private val notificationReceiverAdapter: NotificationReceiverAdapterImpl, private val buildConfigProvider: BuildConfigProvider, private val shizukuAdapter: ShizukuAdapter, + private val navigationProvider: NavigationProvider, + private val coroutineScope: CoroutineScope, ) { private val startActivityForResultLauncher = @@ -58,7 +65,7 @@ class RequestPermissionDelegate( permissionAdapter.onPermissionsChanged() } - fun requestPermission(permission: Permission, navController: NavController?) { + fun requestPermission(permission: Permission) { when (permission) { Permission.WRITE_SETTINGS -> requestWriteSettings() Permission.CAMERA -> requestPermissionLauncher.launch(Manifest.permission.CAMERA) @@ -162,30 +169,29 @@ class RequestPermissionDelegate( } private fun requestWriteSecureSettings() { - if (permissionAdapter.isGranted(Permission.SHIZUKU) || - permissionAdapter.isGranted(Permission.ROOT) - ) { - permissionAdapter.grant(Manifest.permission.WRITE_SECURE_SETTINGS) + // Try granting with Shizuku, Root, or System Bridge + permissionAdapter.grant(Manifest.permission.WRITE_SECURE_SETTINGS).onFailure { error -> + activity.materialAlertDialog { + titleResource = R.string.dialog_title_write_secure_settings + messageResource = R.string.dialog_message_write_secure_settings - return - } + positiveButton(R.string.pos_proceed) { + val destination = NavDestination.ProMode - activity.materialAlertDialog { - titleResource = R.string.dialog_title_write_secure_settings - messageResource = R.string.dialog_message_write_secure_settings + coroutineScope.launch { + navigationProvider.navigate( + "grant_write_secure_settings_pro_mode", + destination, + ) + } + } - positiveButton(R.string.pos_grant_write_secure_settings_guide) { - UrlUtils.openUrl( - activity, - activity.str(R.string.url_grant_write_secure_settings_guide), - ) - } + negativeButton(R.string.neg_cancel) { + it.cancel() + } - negativeButton(R.string.neg_cancel) { - it.cancel() + show() } - - show() } } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 6c72a161fa..04d65e7fa7 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -328,7 +328,6 @@ https://docs.keymapper.club/redirects/fingerprint-map-options https://docs.keymapper.club/redirects/quick-start https://docs.keymapper.club/redirects/faq - https://docs.keymapper.club/redirects/grant-write-secure-settings https://dontkillmyapp.com https://docs.keymapper.club/redirects/keymap-action-options https://docs.keymapper.club/redirects/trigger-key-options @@ -427,7 +426,7 @@ Please grant Key Mapper root permission in your root management app, such as Magisk. Grant WRITE_SECURE_SETTINGS permission - A PC/Mac is required to grant this permission. Read the online guide. + You will need to use PRO mode to grant this permission. Your device doesn\'t seem to have an accessibility services settings page. Tap \"guide\" to read the online guide that explains how to fix this. You must hold down the keys in the order that they are listed. @@ -487,7 +486,6 @@ Done Kill - Guide Guide Change Fix partially From ca8951d6c3632fc3e8748fda7326287307db013f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 19:43:21 +0100 Subject: [PATCH 40/62] #1871 feat: complete modify system setting action --- CHANGELOG.md | 7 + base/src/main/assets/whats-new.txt | 1 + .../keymapper/base/actions/ActionUiHelper.kt | 10 +- .../base/actions/CreateActionDelegate.kt | 48 +++- .../base/actions/CreateActionUseCase.kt | 27 +- .../actions/ModifySettingActionBottomSheet.kt | 257 +++++++++++++++++- .../base/actions/PerformActionsUseCase.kt | 2 +- base/src/main/res/values/strings.xml | 8 +- .../system/settings/SettingsAdapter.kt | 35 ++- 9 files changed, 364 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c136de1c..be03102384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [4.0.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.03) + +#### TO BE RELEASED + +## Added +- #1871 action to modify any system settings + ## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02) #### 08 November 2025 diff --git a/base/src/main/assets/whats-new.txt b/base/src/main/assets/whats-new.txt index d170dd02d8..a9bdb53303 100644 --- a/base/src/main/assets/whats-new.txt +++ b/base/src/main/assets/whats-new.txt @@ -6,6 +6,7 @@ You can now remap ALL buttons when the screen is off (including the power button • Send SMS messages • Force stop current app or clear from recents • Mute/unmute microphone +• Modify any system setting 🆕 New Features • Redesigned Settings screen diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 6dbae2f8b6..579ed07356 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -653,17 +653,9 @@ class ActionUiHelper( ActionData.Microphone.Unmute -> getString(R.string.action_unmute_microphone) is ActionData.ModifySetting -> { - val typeString = when (action.settingType) { - io.github.sds100.keymapper.system.settings.SettingType.SYSTEM -> - getString(R.string.modify_setting_type_system) - io.github.sds100.keymapper.system.settings.SettingType.SECURE -> - getString(R.string.modify_setting_type_secure) - io.github.sds100.keymapper.system.settings.SettingType.GLOBAL -> - getString(R.string.modify_setting_type_global) - } getString( R.string.modify_setting_description, - arrayOf(action.settingKey, action.value, typeString), + arrayOf(action.settingKey, action.value), ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 359546f2d0..85d31ee0e5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -4,6 +4,7 @@ import android.text.InputType import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.pinchscreen.PinchPickCoordinateResult import io.github.sds100.keymapper.base.actions.swipescreen.SwipePickCoordinateResult @@ -29,13 +30,17 @@ import io.github.sds100.keymapper.system.volume.DndMode import io.github.sds100.keymapper.system.volume.RingerMode import io.github.sds100.keymapper.system.volume.VolumeStream import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +@OptIn(ExperimentalCoroutinesApi::class) class CreateActionDelegate( private val coroutineScope: CoroutineScope, private val useCase: CreateActionUseCase, @@ -67,6 +72,22 @@ class CreateActionDelegate( } } } + + coroutineScope.launch { + snapshotFlow { modifySettingActionBottomSheetState?.settingType } + .filterNotNull() + .flatMapLatest { settingType -> + val permission = useCase.getRequiredPermissionForSettingType(settingType) + useCase.isPermissionGrantedFlow(permission) + } + .collectLatest { isGranted -> + modifySettingActionBottomSheetState = + modifySettingActionBottomSheetState?.copy( + isPermissionGranted = isGranted, + testResult = null, + ) + } + } } fun onDoneConfigEnableFlashlightClick() { @@ -214,12 +235,18 @@ class CreateActionDelegate( fun onSelectSettingType(settingType: SettingType) { modifySettingActionBottomSheetState = - modifySettingActionBottomSheetState?.copy(settingType = settingType) + modifySettingActionBottomSheetState?.copy( + settingType = settingType, + testResult = null, + ) } fun onSettingKeyChange(key: String) { modifySettingActionBottomSheetState = - modifySettingActionBottomSheetState?.copy(settingKey = key) + modifySettingActionBottomSheetState?.copy( + settingKey = key, + testResult = null, + ) } fun onChooseExistingSettingClick() { @@ -233,6 +260,7 @@ class CreateActionDelegate( settingType = setting.settingType, settingKey = setting.key, value = setting.currentValue ?: "", + testResult = null, ) } } @@ -242,6 +270,22 @@ class CreateActionDelegate( modifySettingActionBottomSheetState?.copy(value = value) } + fun onTestModifySettingClick() { + val state = modifySettingActionBottomSheetState ?: return + + coroutineScope.launch { + val result = useCase.setSettingValue(state.settingType, state.settingKey, state.value) + modifySettingActionBottomSheetState = + modifySettingActionBottomSheetState?.copy(testResult = result) + } + } + + fun onRequestModifySettingPermission() { + val state = modifySettingActionBottomSheetState ?: return + val permission = useCase.getRequiredPermissionForSettingType(state.settingType) + useCase.requestPermission(permission) + } + suspend fun editAction(oldData: ActionData) { if (!oldData.isEditable()) { throw IllegalArgumentException("This action ${oldData.javaClass.name} can't be edited!") diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt index 2e7071def9..8538a859b4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt @@ -11,10 +11,12 @@ import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter import io.github.sds100.keymapper.system.phone.PhoneAdapter -import javax.inject.Inject +import io.github.sds100.keymapper.system.settings.SettingType +import io.github.sds100.keymapper.system.settings.SettingsAdapter import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge +import javax.inject.Inject class CreateActionUseCaseImpl @Inject constructor( private val inputMethodAdapter: InputMethodAdapter, @@ -22,6 +24,7 @@ class CreateActionUseCaseImpl @Inject constructor( private val cameraAdapter: CameraAdapter, private val permissionAdapter: PermissionAdapter, private val phoneAdapter: PhoneAdapter, + private val settingsAdapter: SettingsAdapter, ) : CreateActionUseCase, IsActionSupportedUseCase by IsActionSupportedUseCaseImpl( systemFeatureAdapter, @@ -69,6 +72,25 @@ class CreateActionUseCaseImpl @Inject constructor( return phoneAdapter.sendSms(number, message) } + + override fun setSettingValue( + settingType: SettingType, + key: String, + value: String, + ): KMResult { + return settingsAdapter.setValue(settingType, key, value) + } + + override fun getRequiredPermissionForSettingType(settingType: SettingType): Permission { + return when (settingType) { + SettingType.SYSTEM -> Permission.WRITE_SETTINGS + SettingType.SECURE, SettingType.GLOBAL -> Permission.WRITE_SECURE_SETTINGS + } + } + + override fun isPermissionGrantedFlow(permission: Permission): Flow { + return permissionAdapter.isGrantedFlow(permission) + } } interface CreateActionUseCase : IsActionSupportedUseCase { @@ -83,4 +105,7 @@ interface CreateActionUseCase : IsActionSupportedUseCase { fun requestPermission(permission: Permission) suspend fun testSms(number: String, message: String): KMResult + fun setSettingValue(settingType: SettingType, key: String, value: String): KMResult + fun getRequiredPermissionForSettingType(settingType: SettingType): Permission + fun isPermissionGrantedFlow(permission: Permission): Flow } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index e02e4fdbbb..8865110d47 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton @@ -19,9 +21,15 @@ import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign @@ -29,7 +37,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow +import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.SystemError +import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.settings.SettingType import kotlinx.coroutines.launch @@ -37,6 +53,8 @@ data class ModifySettingActionBottomSheetState( val settingType: SettingType, val settingKey: String, val value: String, + val testResult: KMResult? = null, + val isPermissionGranted: Boolean = false, ) @OptIn(ExperimentalMaterial3Api::class) @@ -56,6 +74,8 @@ fun ModifySettingActionBottomSheet(delegate: CreateActionDelegate) { onSettingKeyChange = delegate::onSettingKeyChange, onSettingValueChange = delegate::onSettingValueChange, onChooseExistingClick = delegate::onChooseExistingSettingClick, + onTestClick = delegate::onTestModifySettingClick, + onRequestPermissionClick = delegate::onRequestModifySettingPermission, onDoneClick = { scope.launch { sheetState.hide() @@ -76,10 +96,28 @@ private fun ModifySettingActionBottomSheet( onSettingKeyChange: (String) -> Unit = {}, onSettingValueChange: (String) -> Unit = {}, onChooseExistingClick: () -> Unit = {}, + onTestClick: () -> Unit = {}, + onRequestPermissionClick: () -> Unit = {}, onDoneClick: () -> Unit = {}, ) { val scope = rememberCoroutineScope() + val settingKeyEmptyErrorString = stringResource(R.string.modify_setting_key_empty_error) + val settingValueEmptyErrorString = stringResource(R.string.modify_setting_value_empty_error) + + var settingKeyError: String? by rememberSaveable { mutableStateOf(null) } + var settingValueError: String? by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(state) { + if (!state.settingKey.isBlank()){ + settingKeyError = null + } + + if (!state.value.isBlank()){ + settingValueError = null + } + } + ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, @@ -112,6 +150,16 @@ private fun ModifySettingActionBottomSheet( onStateSelected = onSelectSettingType, ) + if (!state.isPermissionGranted) { + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = onRequestPermissionClick, + colors = ButtonDefaults.filledTonalButtonColorsError(), + ) { + Text(stringResource(R.string.modify_setting_grant_permission_button)) + } + } + Button( onClick = onChooseExistingClick, modifier = Modifier.fillMaxWidth(), @@ -125,9 +173,18 @@ private fun ModifySettingActionBottomSheet( label = { Text(stringResource(R.string.modify_setting_key_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, - textStyle = MaterialTheme.typography.bodySmall.copy( + textStyle = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace, ), + isError = settingKeyError != null, + supportingText = { + if (settingKeyError != null) { + Text( + text = settingKeyError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, ) OutlinedTextField( @@ -136,14 +193,76 @@ private fun ModifySettingActionBottomSheet( label = { Text(stringResource(R.string.modify_setting_value_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, - textStyle = MaterialTheme.typography.bodySmall.copy( + textStyle = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace, ), + isError = settingValueError != null, + supportingText = { + if (settingValueError != null) { + Text( + text = settingValueError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + if (state.testResult != null) { + val resultText: String = when (state.testResult) { + is Success -> stringResource(R.string.test_modify_setting_result_ok) + is KMError -> state.testResult.getFullMessage(LocalContext.current) + } + + val textColor = when (state.testResult) { + is Success -> LocalCustomColorsPalette.current.green + is KMError -> MaterialTheme.colorScheme.error + } + + Text( + modifier = Modifier.weight(1f), + text = resultText, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + OutlinedButton( + onClick = { + var hasError = false + + if (state.settingKey.isBlank()) { + settingKeyError = settingKeyEmptyErrorString + hasError = true + } + + if (state.value.isBlank()) { + settingValueError = settingValueEmptyErrorString + hasError = true + } + + if (!hasError) { + onTestClick() + } + }, + ) { + Text(stringResource(R.string.button_test_modify_setting)) + } + } + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.modify_setting_disclaimer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // TODO do not allow empty text fields - // TODO add test button - // TODO add disclaimer that simply modifying setting values is not sufficient for the system to actually process the change Row( modifier = Modifier .fillMaxWidth() @@ -167,7 +286,19 @@ private fun ModifySettingActionBottomSheet( Button( modifier = Modifier.weight(1f), - onClick = onDoneClick, + onClick = { + if (state.settingKey.isBlank()) { + settingKeyError = settingKeyEmptyErrorString + } + + if (state.value.isBlank()) { + settingValueError = settingValueEmptyErrorString + } + + if (settingKeyError == null && settingValueError == null) { + onDoneClick() + } + }, ) { Text(stringResource(R.string.pos_done)) } @@ -197,3 +328,117 @@ private fun Preview() { ) } } + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewEmpty() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + ModifySettingActionBottomSheet( + sheetState = sheetState, + state = ModifySettingActionBottomSheetState( + settingType = SettingType.SYSTEM, + settingKey = "", + value = "", + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewPermissionNotGranted() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + ModifySettingActionBottomSheet( + sheetState = sheetState, + state = ModifySettingActionBottomSheetState( + settingType = SettingType.SECURE, + settingKey = "airplane_mode_on", + value = "1", + isPermissionGranted = false, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewTestLoading() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + ModifySettingActionBottomSheet( + sheetState = sheetState, + state = ModifySettingActionBottomSheetState( + settingType = SettingType.GLOBAL, + settingKey = "adb_enabled", + value = "1", + testResult = null, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewTestSuccess() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + ModifySettingActionBottomSheet( + sheetState = sheetState, + state = ModifySettingActionBottomSheetState( + settingType = SettingType.GLOBAL, + settingKey = "adb_enabled", + value = "1", + testResult = Success(Unit), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewTestError() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + ModifySettingActionBottomSheet( + sheetState = sheetState, + state = ModifySettingActionBottomSheetState( + settingType = SettingType.SECURE, + settingKey = "airplane_mode_on", + value = "1", + testResult = SystemError.PermissionDenied(Permission.WRITE_SECURE_SETTINGS), + ), + ) + } +} 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 413d2272b6..9d05d0f6bc 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 @@ -1019,7 +1019,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.ModifySetting -> { - result = settingsAdapter.modifySetting( + result = settingsAdapter.setValue( action.settingType, action.settingKey, action.value, diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 04d65e7fa7..ebbdcbc3b1 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1090,7 +1090,7 @@ Send SMS: "%s"" to %s Compose SMS Compose SMS: "%s" to %s - Set %3$s: %1$s = %2$s + Set setting: %1$s = %2$s System Secure Global @@ -1179,6 +1179,12 @@ Modify setting Key Value + Setting key cannot be empty + Setting value cannot be empty + Note: Simply modifying setting values may not be sufficient for the system to process the change. Some settings require additional actions or broadcasts to take effect. + Test + Setting modified successfully + Grant permission Choose setting No settings found Choose existing setting diff --git a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt index 493ba0f327..3551501e68 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt @@ -8,6 +8,8 @@ import io.github.sds100.keymapper.common.utils.KMError 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.system.SystemError +import io.github.sds100.keymapper.system.permissions.Permission import javax.inject.Inject import javax.inject.Singleton @@ -66,17 +68,28 @@ class AndroidSettingsAdapter @Inject constructor( } } - override fun modifySetting(settingType: SettingType, key: String, value: String): KMResult<*> { - val success = when (settingType) { - SettingType.SYSTEM -> SettingsUtils.putSystemSetting(ctx, key, value) - SettingType.SECURE -> SettingsUtils.putSecureSetting(ctx, key, value) - SettingType.GLOBAL -> SettingsUtils.putGlobalSetting(ctx, key, value) - } + override fun setValue(settingType: SettingType, key: String, value: String): KMResult { + try { + val success = when (settingType) { + SettingType.SYSTEM -> SettingsUtils.putSystemSetting(ctx, key, value) + SettingType.SECURE -> SettingsUtils.putSecureSetting(ctx, key, value) + SettingType.GLOBAL -> SettingsUtils.putGlobalSetting(ctx, key, value) + } - return if (success) { - Success(Unit) - } else { - KMError.FailedToModifySystemSetting(key) + return if (success) { + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(key) + } + } catch (_: IllegalArgumentException) { + return KMError.FailedToModifySystemSetting(key) + } catch (_: SecurityException) { + return when (settingType) { + SettingType.SYSTEM -> + SystemError.PermissionDenied(Permission.WRITE_SETTINGS) + SettingType.SECURE, SettingType.GLOBAL -> + SystemError.PermissionDenied(Permission.WRITE_SECURE_SETTINGS) + } } } } @@ -84,5 +97,5 @@ class AndroidSettingsAdapter @Inject constructor( interface SettingsAdapter { fun getAll(settingType: SettingType): Map fun getValue(settingType: SettingType, key: String): String? - fun modifySetting(settingType: SettingType, key: String, value: String): KMResult<*> + fun setValue(settingType: SettingType, key: String, value: String): KMResult } From ec764ddc93b103dcd36548cef5897475a01875b5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 19:45:39 +0100 Subject: [PATCH 41/62] style: reformat --- .../java/io/github/sds100/keymapper/base/BaseMainActivity.kt | 2 +- .../sds100/keymapper/base/actions/CreateActionUseCase.kt | 2 +- .../keymapper/base/actions/ModifySettingActionBottomSheet.kt | 4 ++-- .../keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt index 23e6cf7e58..72e88b01bf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt @@ -43,12 +43,12 @@ import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapt import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter import io.github.sds100.keymapper.system.root.SuAdapterImpl import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber -import javax.inject.Inject abstract class BaseMainActivity : AppCompatActivity() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt index 8538a859b4..2d0bee70cd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt @@ -13,10 +13,10 @@ import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.settings.SettingType import io.github.sds100.keymapper.system.settings.SettingsAdapter +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge -import javax.inject.Inject class CreateActionUseCaseImpl @Inject constructor( private val inputMethodAdapter: InputMethodAdapter, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index 8865110d47..2eb5906188 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -109,11 +109,11 @@ private fun ModifySettingActionBottomSheet( var settingValueError: String? by rememberSaveable { mutableStateOf(null) } LaunchedEffect(state) { - if (!state.settingKey.isBlank()){ + if (!state.settingKey.isBlank()) { settingKeyError = null } - if (!state.value.isBlank()){ + if (!state.value.isBlank()) { settingValueError = null } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt index d13b869230..34ffa141ec 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt @@ -23,8 +23,8 @@ import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapterImpl import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter -import kotlinx.coroutines.flow.collectLatest import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest @AndroidEntryPoint class CreateKeyMapShortcutActivity : AppCompatActivity() { From 3f92503fb81717443977022dbe6c6a04a4876d67 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 20:23:51 +0100 Subject: [PATCH 42/62] #1221 feat: create action to show a notification --- .../sds100/keymapper/base/BaseMainNavHost.kt | 15 - .../keymapper/base/actions/ActionUiHelper.kt | 7 + .../base/actions/ChooseActionScreen.kt | 1 + .../ConfigCreateNotificationViewModel.kt | 80 ----- .../base/actions/CreateActionDelegate.kt | 95 ++++- .../base/actions/CreateActionUseCase.kt | 26 +- .../CreateNotificationActionBottomSheet.kt | 329 ++++++++++++++++++ .../actions/CreateNotificationActionScreen.kt | 194 ----------- .../actions/ModifySettingActionBottomSheet.kt | 2 +- .../base/actions/PerformActionsUseCase.kt | 19 +- base/src/main/res/values/strings.xml | 4 + 11 files changed, 461 insertions(+), 311 deletions(-) delete mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionBottomSheet.kt delete mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index 510b2c199e..7dbe5c9a35 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -19,9 +19,7 @@ import androidx.navigation.compose.composable import io.github.sds100.keymapper.base.actions.ChooseActionScreen import io.github.sds100.keymapper.base.actions.ChooseActionViewModel import io.github.sds100.keymapper.base.actions.ChooseSettingScreen -import io.github.sds100.keymapper.base.actions.ConfigCreateNotificationViewModel import io.github.sds100.keymapper.base.actions.ConfigShellCommandViewModel -import io.github.sds100.keymapper.base.actions.CreateNotificationActionScreen import io.github.sds100.keymapper.base.actions.ShellCommandActionScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementViewModel @@ -92,19 +90,6 @@ fun BaseMainNavHost( ) } - composable { backStackEntry -> - val viewModel: ConfigCreateNotificationViewModel = hiltViewModel() - - backStackEntry.handleRouteArgs { destination -> - destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) } - } - - CreateNotificationActionScreen( - modifier = Modifier.fillMaxSize(), - viewModel = viewModel, - ) - } - composable { val viewModel: ChooseConstraintViewModel = hiltViewModel() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 579ed07356..834be3b614 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -658,6 +658,13 @@ class ActionUiHelper( arrayOf(action.settingKey, action.value), ) } + + is ActionData.CreateNotification -> { + getString( + R.string.action_create_notification_description, + action.title, + ) + } } fun getIcon(action: ActionData): ComposeIconInfo = when (action) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt index a7c1a1868d..7d895e7fcf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt @@ -57,6 +57,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { SmsActionBottomSheet(delegate) VolumeActionBottomSheet(delegate) ModifySettingActionBottomSheet(delegate) + CreateNotificationActionBottomSheet(delegate) } @Composable diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt deleted file mode 100644 index 43917d2fa7..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigCreateNotificationViewModel.kt +++ /dev/null @@ -1,80 +0,0 @@ -package io.github.sds100.keymapper.base.actions - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider -import io.github.sds100.keymapper.base.utils.navigation.navigate -import javax.inject.Inject - -data class CreateNotificationActionState( - val title: String = "", - val text: String = "", - val timeoutEnabled: Boolean = false, - /** - * UI works with seconds for user-friendliness - */ - val timeoutSeconds: Int = 30, -) - -@HiltViewModel -class ConfigCreateNotificationViewModel @Inject constructor( - private val navigationProvider: NavigationProvider, - private val createActionDelegate: CreateActionDelegate, -) : ViewModel() { - - var state: CreateNotificationActionState by mutableStateOf(CreateNotificationActionState()) - private set - - fun loadAction(action: ActionData.CreateNotification) { - state = state.copy( - title = action.title, - text = action.text, - timeoutEnabled = action.timeoutMs != null, - timeoutSeconds = (action.timeoutMs ?: 30000) / 1000, - ) - } - - fun onTitleChanged(newTitle: String) { - state = state.copy(title = newTitle) - } - - fun onTextChanged(newText: String) { - state = state.copy(text = newText) - } - - fun onTimeoutEnabledChanged(enabled: Boolean) { - state = state.copy(timeoutEnabled = enabled) - } - - fun onTimeoutChanged(newTimeoutSeconds: Int) { - state = state.copy(timeoutSeconds = newTimeoutSeconds) - } - - fun onDoneClick() { - if (state.title.isBlank() || state.text.isBlank()) { - return - } - - val timeoutMs = if (state.timeoutEnabled) { - state.timeoutSeconds * 1000L - } else { - null - } - - val action = ActionData.CreateNotification( - title = state.title, - text = state.text, - timeoutMs = timeoutMs, - ) - - createActionDelegate.actionResult.value = action - navigationProvider.navigate(io.github.sds100.keymapper.base.utils.navigation.NavDestination.Pop) - } - - fun onCancelClick() { - navigationProvider.navigate(io.github.sds100.keymapper.base.utils.navigation.NavDestination.Pop) - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index b53faf0069..0c5c956ab5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -25,6 +25,7 @@ import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.network.HttpMethod +import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.settings.SettingType import io.github.sds100.keymapper.system.volume.DndMode import io.github.sds100.keymapper.system.volume.RingerMode @@ -60,9 +61,10 @@ class CreateActionDelegate( var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) - var modifySettingActionBottomSheetState: ModifySettingActionBottomSheetState? by mutableStateOf( - null, - ) + var modifySettingActionBottomSheetState: ModifySettingActionBottomSheetState? + by mutableStateOf(null) + var createNotificationActionBottomSheetState: CreateNotificationActionBottomSheetState? + by mutableStateOf(null) init { coroutineScope.launch { @@ -88,6 +90,20 @@ class CreateActionDelegate( ) } } + + coroutineScope.launch { + snapshotFlow { createNotificationActionBottomSheetState } + .filterNotNull() + .flatMapLatest { + useCase.isPermissionGrantedFlow(Permission.POST_NOTIFICATIONS) + } + .collectLatest { isGranted -> + createNotificationActionBottomSheetState = + createNotificationActionBottomSheetState?.copy( + isPermissionGranted = isGranted, + ) + } + } } fun onDoneConfigEnableFlashlightClick() { @@ -280,12 +296,69 @@ class CreateActionDelegate( } } - fun onRequestModifySettingPermission() { + fun onRequestModifySettingPermissionClick() { val state = modifySettingActionBottomSheetState ?: return val permission = useCase.getRequiredPermissionForSettingType(state.settingType) useCase.requestPermission(permission) } + fun onCreateNotificationTitleChange(title: String) { + createNotificationActionBottomSheetState = + createNotificationActionBottomSheetState?.copy(title = title) + } + + fun onCreateNotificationTextChange(text: String) { + createNotificationActionBottomSheetState = + createNotificationActionBottomSheetState?.copy(text = text) + } + + fun onCreateNotificationTimeoutEnabledChange(enabled: Boolean) { + createNotificationActionBottomSheetState = + createNotificationActionBottomSheetState?.copy(timeoutEnabled = enabled) + } + + fun onCreateNotificationTimeoutChange(timeoutSeconds: Int) { + createNotificationActionBottomSheetState = + createNotificationActionBottomSheetState?.copy(timeoutSeconds = timeoutSeconds) + } + + fun onTestCreateNotificationClick() { + val state = createNotificationActionBottomSheetState ?: return + + coroutineScope.launch { + val timeoutMs = if (state.timeoutEnabled) { + state.timeoutSeconds * 1000L + } else { + null + } + + useCase.testCreateNotification(state.title, state.text, timeoutMs) + } + } + + fun onDoneCreateNotificationClick() { + val state = createNotificationActionBottomSheetState ?: return + + val timeoutMs = if (state.timeoutEnabled) { + state.timeoutSeconds * 1000L + } else { + null + } + + val action = ActionData.CreateNotification( + title = state.title, + text = state.text, + timeoutMs = timeoutMs, + ) + + createNotificationActionBottomSheetState = null + actionResult.update { action } + } + + fun onRequestNotificationPermissionClick() { + useCase.requestPermission(Permission.POST_NOTIFICATIONS) + } + suspend fun editAction(oldData: ActionData) { if (!oldData.isEditable()) { throw IllegalArgumentException("This action ${oldData.javaClass.name} can't be edited!") @@ -977,14 +1050,14 @@ class CreateActionDelegate( ActionId.CREATE_NOTIFICATION -> { val oldAction = oldData as? ActionData.CreateNotification - return navigate( - "config_create_notification_action", - NavDestination.ConfigNotificationAction( - oldAction?.let { - Json.encodeToString(oldAction) - }, - ), + createNotificationActionBottomSheetState = CreateNotificationActionBottomSheetState( + title = oldAction?.title ?: "", + text = oldAction?.text ?: "", + timeoutEnabled = oldAction?.timeoutMs != null, + timeoutSeconds = ((oldAction?.timeoutMs ?: 30000) / 1000).toInt(), ) + + return null } ActionId.ANSWER_PHONE_CALL -> return ActionData.AnswerCall ActionId.END_PHONE_CALL -> return ActionData.EndCall diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt index 2d0bee70cd..8352525042 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt @@ -1,5 +1,7 @@ package io.github.sds100.keymapper.base.actions +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.system.notifications.NotificationController import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.camera.CameraAdapter @@ -7,16 +9,18 @@ import io.github.sds100.keymapper.system.camera.CameraFlashInfo import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.notifications.NotificationAdapter +import io.github.sds100.keymapper.system.notifications.NotificationModel import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.settings.SettingType import io.github.sds100.keymapper.system.settings.SettingsAdapter -import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge +import javax.inject.Inject class CreateActionUseCaseImpl @Inject constructor( private val inputMethodAdapter: InputMethodAdapter, @@ -25,6 +29,7 @@ class CreateActionUseCaseImpl @Inject constructor( private val permissionAdapter: PermissionAdapter, private val phoneAdapter: PhoneAdapter, private val settingsAdapter: SettingsAdapter, + private val notificationAdapter: NotificationAdapter, ) : CreateActionUseCase, IsActionSupportedUseCase by IsActionSupportedUseCaseImpl( systemFeatureAdapter, @@ -91,6 +96,24 @@ class CreateActionUseCaseImpl @Inject constructor( override fun isPermissionGrantedFlow(permission: Permission): Flow { return permissionAdapter.isGrantedFlow(permission) } + + override fun testCreateNotification(title: String, text: String, timeoutMs: Long?) { + val notification = NotificationModel( + // Use the same id for notifications created when testing so they overwrite each other + id = 0, + channel = NotificationController.CHANNEL_CUSTOM_NOTIFICATIONS, + title = title, + text = text, + icon = R.drawable.ic_launcher_foreground, + showOnLockscreen = true, + onGoing = false, + autoCancel = true, + timeout = timeoutMs, + bigTextStyle = true, + ) + + notificationAdapter.showNotification(notification) + } } interface CreateActionUseCase : IsActionSupportedUseCase { @@ -108,4 +131,5 @@ interface CreateActionUseCase : IsActionSupportedUseCase { fun setSettingValue(settingType: SettingType, key: String, value: String): KMResult fun getRequiredPermissionForSettingType(settingType: SettingType): Permission fun isPermissionGrantedFlow(permission: Permission): Flow + fun testCreateNotification(title: String, text: String, timeoutMs: Long?) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionBottomSheet.kt new file mode 100644 index 0000000000..68f74a43ad --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionBottomSheet.kt @@ -0,0 +1,329 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError +import kotlinx.coroutines.launch + +private const val MIN_TIMEOUT_SECONDS = 5 +private const val MAX_TIMEOUT_SECONDS = 60 +private const val TIMEOUT_STEP_SECONDS = 5 + +data class CreateNotificationActionBottomSheetState( + val title: String = "", + val text: String = "", + val timeoutEnabled: Boolean = true, + /** + * UI works with seconds for user-friendliness + */ + val timeoutSeconds: Int = 30, + val isPermissionGranted: Boolean = false, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateNotificationActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.createNotificationActionBottomSheetState != null) { + CreateNotificationActionBottomSheet( + sheetState = sheetState, + state = delegate.createNotificationActionBottomSheetState!!, + onRequestPermissionClick = delegate::onRequestNotificationPermissionClick, + onDismissRequest = { + delegate.createNotificationActionBottomSheetState = null + }, + onTitleChange = delegate::onCreateNotificationTitleChange, + onTextChange = delegate::onCreateNotificationTextChange, + onTimeoutEnabledChange = delegate::onCreateNotificationTimeoutEnabledChange, + onTimeoutChange = delegate::onCreateNotificationTimeoutChange, + onTestClick = delegate::onTestCreateNotificationClick, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneCreateNotificationClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateNotificationActionBottomSheet( + sheetState: SheetState, + state: CreateNotificationActionBottomSheetState, + onDismissRequest: () -> Unit = {}, + onRequestPermissionClick: () -> Unit = {}, + onTitleChange: (String) -> Unit = {}, + onTextChange: (String) -> Unit = {}, + onTimeoutEnabledChange: (Boolean) -> Unit = {}, + onTimeoutChange: (Int) -> Unit = {}, + onTestClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + + val titleEmptyErrorString = stringResource(R.string.action_create_notification_title_error) + val textEmptyErrorString = stringResource(R.string.action_create_notification_text_error) + + var titleError: String? by rememberSaveable { mutableStateOf(null) } + var textError: String? by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(state) { + if (state.title.isNotBlank()) { + titleError = null + } + + if (state.text.isNotBlank()) { + textError = null + } + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.action_create_notification), + style = MaterialTheme.typography.headlineMedium, + ) + + if (!state.isPermissionGranted) { + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = onRequestPermissionClick, + colors = ButtonDefaults.filledTonalButtonColorsError(), + ) { + Text(stringResource(R.string.modify_setting_grant_permission_button)) + } + } + + OutlinedTextField( + value = state.title, + onValueChange = onTitleChange, + label = { Text(stringResource(R.string.action_create_notification_title_label)) }, + placeholder = { + Text(stringResource(R.string.action_create_notification_title_hint)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = titleError != null, + supportingText = { + if (titleError != null) { + Text( + text = titleError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + OutlinedTextField( + value = state.text, + onValueChange = onTextChange, + label = { Text(stringResource(R.string.action_create_notification_text_label)) }, + placeholder = { + Text(stringResource(R.string.action_create_notification_text_hint)) + }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 10, + isError = textError != null, + supportingText = { + if (textError != null) { + Text( + text = textError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + CheckBoxText( + text = stringResource(R.string.action_create_notification_timeout_checkbox), + isChecked = state.timeoutEnabled, + onCheckedChange = onTimeoutEnabledChange, + ) + + if (state.timeoutEnabled) { + val timeoutValueFormat = + stringResource(R.string.action_create_notification_timeout_value) + + SliderOptionText( + title = stringResource(R.string.action_create_notification_timeout_label), + value = state.timeoutSeconds.toFloat(), + defaultValue = 30f, + valueText = { value -> + timeoutValueFormat.format(value.toInt()) + }, + onValueChange = { onTimeoutChange(it.toInt()) }, + valueRange = MIN_TIMEOUT_SECONDS.toFloat()..MAX_TIMEOUT_SECONDS.toFloat(), + stepSize = TIMEOUT_STEP_SECONDS, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { + var hasError = false + + if (state.title.isBlank()) { + titleError = titleEmptyErrorString + hasError = true + } + + if (state.text.isBlank()) { + textError = textEmptyErrorString + hasError = true + } + + if (!hasError) { + onTestClick() + } + }, + enabled = state.isPermissionGranted, + ) { + Text(stringResource(R.string.button_test_create_notification)) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = { + if (state.title.isBlank()) { + titleError = titleEmptyErrorString + } + + if (state.text.isBlank()) { + textError = textEmptyErrorString + } + + if (titleError == null && textError == null) { + onDoneClick() + } + }, + ) { + Text(stringResource(R.string.pos_done)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun CreateNotificationActionBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + CreateNotificationActionBottomSheet( + sheetState = sheetState, + state = CreateNotificationActionBottomSheetState( + title = "Test Notification", + text = "This is a test notification message", + timeoutEnabled = true, + timeoutSeconds = 30, + isPermissionGranted = true, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun CreateNotificationActionBottomSheetEmptyPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + CreateNotificationActionBottomSheet( + sheetState = sheetState, + state = CreateNotificationActionBottomSheetState( + isPermissionGranted = false, + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt deleted file mode 100644 index 856f1220cd..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionScreen.kt +++ /dev/null @@ -1,194 +0,0 @@ -package io.github.sds100.keymapper.base.actions - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.compose.KeyMapperTheme -import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText -import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText - -private const val MIN_TIMEOUT_SECONDS = 5 -private const val MAX_TIMEOUT_SECONDS = 300 -private const val TIMEOUT_STEP_SECONDS = 5 - -@Composable -fun CreateNotificationActionScreen( - modifier: Modifier = Modifier, - viewModel: ConfigCreateNotificationViewModel, -) { - CreateNotificationActionScreen( - modifier = modifier, - state = viewModel.state, - onTitleChanged = viewModel::onTitleChanged, - onTextChanged = viewModel::onTextChanged, - onTimeoutEnabledChanged = viewModel::onTimeoutEnabledChanged, - onTimeoutChanged = viewModel::onTimeoutChanged, - onDoneClick = viewModel::onDoneClick, - onCancelClick = viewModel::onCancelClick, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CreateNotificationActionScreen( - state: CreateNotificationActionState, - onTitleChanged: (String) -> Unit, - onTextChanged: (String) -> Unit, - onTimeoutEnabledChanged: (Boolean) -> Unit, - onTimeoutChanged: (Int) -> Unit, - onDoneClick: () -> Unit, - onCancelClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val keyboardController = LocalSoftwareKeyboardController.current - - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.action_create_notification)) }, - navigationIcon = { - IconButton(onClick = onCancelClick) { - Icon( - Icons.Rounded.Close, - contentDescription = stringResource(R.string.pos_cancel), - ) - } - }, - ) - }, - bottomBar = { - BottomAppBar { - Spacer(modifier = Modifier.weight(1f)) - ExtendedFloatingActionButton( - onClick = { - keyboardController?.hide() - onDoneClick() - }, - enabled = state.title.isNotBlank() && state.text.isNotBlank(), - elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), - text = { Text(stringResource(R.string.pos_done)) }, - icon = { - Icon( - Icons.Rounded.Check, - contentDescription = stringResource(R.string.pos_done), - ) - }, - ) - } - }, - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()), - ) { - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = state.title, - onValueChange = onTitleChanged, - label = { Text(stringResource(R.string.action_create_notification_title_label)) }, - placeholder = { Text(stringResource(R.string.action_create_notification_title_hint)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - isError = state.title.isBlank(), - supportingText = if (state.title.isBlank()) { - { Text(stringResource(R.string.action_create_notification_title_error)) } - } else null, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = state.text, - onValueChange = onTextChanged, - label = { Text(stringResource(R.string.action_create_notification_text_label)) }, - placeholder = { Text(stringResource(R.string.action_create_notification_text_hint)) }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - maxLines = 10, - isError = state.text.isBlank(), - supportingText = if (state.text.isBlank()) { - { Text(stringResource(R.string.action_create_notification_text_error)) } - } else null, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - CheckBoxText( - label = stringResource(R.string.action_create_notification_timeout_checkbox), - checked = state.timeoutEnabled, - onCheckedChange = onTimeoutEnabledChanged, - ) - - if (state.timeoutEnabled) { - Spacer(modifier = Modifier.height(8.dp)) - - SliderOptionText( - label = stringResource(R.string.action_create_notification_timeout_label), - value = state.timeoutSeconds, - onValueChange = { onTimeoutChanged(it.toInt()) }, - sliderValue = state.timeoutSeconds.toFloat(), - valueRange = MIN_TIMEOUT_SECONDS.toFloat()..MAX_TIMEOUT_SECONDS.toFloat(), - steps = ((MAX_TIMEOUT_SECONDS - MIN_TIMEOUT_SECONDS) / TIMEOUT_STEP_SECONDS) - 1, - valueLabel = stringResource( - R.string.action_create_notification_timeout_value, - state.timeoutSeconds, - ), - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - } - } -} - -@Preview -@Composable -private fun CreateNotificationActionScreenPreview() { - KeyMapperTheme { - CreateNotificationActionScreen( - state = CreateNotificationActionState( - title = "Test Notification", - text = "This is a test notification message", - timeoutEnabled = true, - timeoutSeconds = 30, - ), - onTitleChanged = {}, - onTextChanged = {}, - onTimeoutEnabledChanged = {}, - onTimeoutChanged = {}, - onDoneClick = {}, - onCancelClick = {}, - ) - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index 2eb5906188..20fe980ed8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -75,7 +75,7 @@ fun ModifySettingActionBottomSheet(delegate: CreateActionDelegate) { onSettingValueChange = delegate::onSettingValueChange, onChooseExistingClick = delegate::onChooseExistingSettingClick, onTestClick = delegate::onTestModifySettingClick, - onRequestPermissionClick = delegate::onRequestModifySettingPermission, + onRequestPermissionClick = delegate::onRequestModifySettingPermissionClick, onDoneClick = { scope.launch { sheetState.hide() 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 fd530d0533..11fcb1645e 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 @@ -18,7 +18,7 @@ import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityServic import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.base.system.navigation.OpenMenuHelper -import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCase +import io.github.sds100.keymapper.base.system.notifications.NotificationController import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.Constants @@ -61,6 +61,7 @@ import io.github.sds100.keymapper.system.lock.LockScreenAdapter import io.github.sds100.keymapper.system.media.MediaAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.nfc.NfcAdapter +import io.github.sds100.keymapper.system.notifications.NotificationAdapter import io.github.sds100.keymapper.system.notifications.NotificationModel import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapter import io.github.sds100.keymapper.system.notifications.NotificationServiceEvent @@ -85,6 +86,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber +import kotlin.math.absoluteValue class PerformActionsUseCaseImpl @AssistedInject constructor( @Assisted @@ -118,7 +120,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val resourceProvider: ResourceProvider, private val soundsManager: SoundsManager, private val notificationReceiverAdapter: NotificationReceiverAdapter, - private val manageNotifications: ManageNotificationsUseCase, + private val notificationAdapter: NotificationAdapter, private val ringtoneAdapter: RingtoneAdapter, private val settingsRepository: PreferenceRepository, private val inputEventHub: InputEventHub, @@ -934,13 +936,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is ActionData.CreateNotification -> { // Use the hashcode of the action instance as the unique notification ID - val notificationId = action.hashCode().let { - if (it < 0) -it else it - } - + val notificationId = action.hashCode().absoluteValue + val notification = NotificationModel( id = notificationId, - channel = io.github.sds100.keymapper.base.system.notifications.NotificationController.CHANNEL_CUSTOM_NOTIFICATIONS, + channel = NotificationController.CHANNEL_CUSTOM_NOTIFICATIONS, title = action.title, text = action.text, icon = R.drawable.ic_notification_play, @@ -948,9 +948,10 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( onGoing = false, autoCancel = true, timeout = action.timeoutMs, + bigTextStyle = true, ) - - manageNotifications.show(notification) + + notificationAdapter.showNotification(notification) result = success() } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index ce90459519..65fce8d692 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1100,6 +1100,7 @@ Dismiss most recent notification Dismiss all notifications Create notification + Show notification: %1$s Notification title Enter notification title Title cannot be empty @@ -1109,6 +1110,9 @@ Auto-dismiss notification Auto-dismiss after %d seconds + Test + Testing… + Notification shown successfully Device controls screen HTTP request HTTP Method From c0ac7a4f7cae51e6f82b4260088a31633a99bfa2 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 20:41:28 +0100 Subject: [PATCH 43/62] fix: save and restore state when configuring key maps --- .../sds100/keymapper/base/BaseMainActivity.kt | 19 ++++++++++++------- .../keymapper/base/actions/ActionData.kt | 7 ++----- .../base/actions/ActionDataEntityMapper.kt | 17 ++++++++++------- .../base/actions/CreateActionDelegate.kt | 2 +- .../base/actions/CreateActionUseCase.kt | 2 +- .../base/actions/PerformActionsUseCase.kt | 2 +- .../base/keymaps/ConfigKeyMapState.kt | 18 +++++++++++++++--- 7 files changed, 42 insertions(+), 25 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt index 72e88b01bf..d316fbc58a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt @@ -27,10 +27,10 @@ import com.anggrayudi.storage.extension.toDocumentFile import io.github.sds100.keymapper.base.compose.ComposeColors import io.github.sds100.keymapper.base.input.InputEventDetectionSource import io.github.sds100.keymapper.base.input.InputEventHubImpl +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapStateImpl import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase import io.github.sds100.keymapper.base.system.accessibility.AccessibilityServiceAdapterImpl import io.github.sds100.keymapper.base.system.permissions.RequestPermissionDelegate -import io.github.sds100.keymapper.base.trigger.RecordTriggerControllerImpl import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl import io.github.sds100.keymapper.common.BuildConfigProvider @@ -56,9 +56,6 @@ abstract class BaseMainActivity : AppCompatActivity() { const val ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG = "${BuildConfig.LIBRARY_PACKAGE_NAME}.ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG" - const val ACTION_USE_FLOATING_BUTTONS = - "${BuildConfig.LIBRARY_PACKAGE_NAME}.ACTION_USE_FLOATING_BUTTONS" - const val ACTION_SAVE_FILE = "${BuildConfig.LIBRARY_PACKAGE_NAME}.ACTION_SAVE_FILE" const val EXTRA_FILE_URI = "${BuildConfig.LIBRARY_PACKAGE_NAME}.EXTRA_FILE_URI" @@ -78,9 +75,6 @@ abstract class BaseMainActivity : AppCompatActivity() { @Inject lateinit var onboardingUseCase: OnboardingUseCase - @Inject - lateinit var recordTriggerController: RecordTriggerControllerImpl - @Inject lateinit var notificationReceiverAdapter: NotificationReceiverAdapterImpl @@ -108,6 +102,9 @@ abstract class BaseMainActivity : AppCompatActivity() { @Inject lateinit var navigationProvider: NavigationProvider + @Inject + lateinit var configKeyMapState: ConfigKeyMapStateImpl + private lateinit var requestPermissionDelegate: RequestPermissionDelegate private val currentNightMode: Int @@ -158,6 +155,8 @@ abstract class BaseMainActivity : AppCompatActivity() { ) super.onCreate(savedInstanceState) + savedInstanceState?.let { configKeyMapState.restoreState(it) } + requestPermissionDelegate = RequestPermissionDelegate( this, showDialogs = true, @@ -209,6 +208,12 @@ abstract class BaseMainActivity : AppCompatActivity() { onboardingUseCase.handledMigrateScreenOffKeyMapsNotification() } + override fun onSaveInstanceState(outState: Bundle) { + configKeyMapState.saveState(outState) + + super.onSaveInstanceState(outState) + } + override fun onDestroy() { onboardingUseCase.shownAppIntro = true diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 733e47f96b..0353b1effd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -872,11 +872,8 @@ sealed class ActionData : Comparable { } @Serializable - data class CreateNotification( - val title: String, - val text: String, - val timeoutMs: Long?, - ) : ActionData() { + data class CreateNotification(val title: String, val text: String, val timeoutMs: Long?) : + ActionData() { override val id: ActionId = ActionId.CREATE_NOTIFICATION override fun compareTo(other: ActionData) = when (other) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index 3796cf0d1f..c1bf2c2de2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -560,15 +560,18 @@ object ActionDataEntityMapper { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionData.DismissLastNotification ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionData.DismissAllNotifications ActionId.CREATE_NOTIFICATION -> { - val title = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TITLE).valueOrNull() - ?: return null - + val title = + entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TITLE).valueOrNull() + ?: return null + val text = entity.data.takeIf { it.isNotBlank() } ?: return null - - val timeoutMs = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TIMEOUT).valueOrNull() + + val timeoutMs = entity.extras.getData( + ActionEntity.EXTRA_NOTIFICATION_TIMEOUT, + ).valueOrNull() ?.toLongOrNull() - + ActionData.CreateNotification( title = title, text = text, @@ -1155,7 +1158,7 @@ object ActionDataEntityMapper { is ActionData.CreateNotification -> buildList { add(EntityExtra(ActionEntity.EXTRA_NOTIFICATION_TITLE, data.title)) - data.timeoutMs?.let { + data.timeoutMs?.let { add(EntityExtra(ActionEntity.EXTRA_NOTIFICATION_TIMEOUT, it.toString())) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 0c5c956ab5..2dc2ccb85b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -62,7 +62,7 @@ class CreateActionDelegate( var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) var modifySettingActionBottomSheetState: ModifySettingActionBottomSheetState? - by mutableStateOf(null) + by mutableStateOf(null) var createNotificationActionBottomSheetState: CreateNotificationActionBottomSheetState? by mutableStateOf(null) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt index 8352525042..cb9a9dce1b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt @@ -17,10 +17,10 @@ import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.settings.SettingType import io.github.sds100.keymapper.system.settings.SettingsAdapter +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge -import javax.inject.Inject class CreateActionUseCaseImpl @Inject constructor( private val inputMethodAdapter: InputMethodAdapter, 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 11fcb1645e..e7e4ffd3ce 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 @@ -74,6 +74,7 @@ import io.github.sds100.keymapper.system.url.OpenUrlAdapter import io.github.sds100.keymapper.system.volume.RingerMode import io.github.sds100.keymapper.system.volume.VolumeAdapter import io.github.sds100.keymapper.system.volume.VolumeStream +import kotlin.math.absoluteValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -86,7 +87,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber -import kotlin.math.absoluteValue class PerformActionsUseCaseImpl @AssistedInject constructor( @Assisted diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt index c4cc8c2222..d9f3ddf70c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt @@ -1,8 +1,10 @@ package io.github.sds100.keymapper.base.keymaps import android.database.sqlite.SQLiteConstraintException +import android.os.Bundle import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull +import io.github.sds100.keymapper.common.utils.ifIsData import io.github.sds100.keymapper.common.utils.mapData import io.github.sds100.keymapper.data.entities.FloatingButtonEntityWithLayout import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository @@ -19,6 +21,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json @Singleton class ConfigKeyMapStateImpl @Inject constructor( @@ -98,8 +101,18 @@ class ConfigKeyMapStateImpl @Inject constructor( } } - override fun restoreState(keyMap: KeyMap) { - _keyMap.update { State.Data(keyMap) } + fun saveState(bundle: Bundle) { + _keyMap.value.ifIsData { keyMap -> + bundle.putString("ConfigKeyMapState.key_map", Json.encodeToString(keyMap)) + } + } + + fun restoreState(bundle: Bundle) { + if (bundle.containsKey("ConfigKeyMapState.key_map")) { + val json = bundle.getString("ConfigKeyMapState.key_map") ?: return + + _keyMap.update { State.Data(Json.decodeFromString(json)) } + } } override fun update(block: (keyMap: KeyMap) -> KeyMap) { @@ -114,7 +127,6 @@ interface ConfigKeyMapState { fun update(block: (keyMap: KeyMap) -> KeyMap) fun save() - fun restoreState(keyMap: KeyMap) suspend fun loadKeyMap(uid: String) fun loadNewKeyMap(groupUid: String?) From 9df056595fedc94483ce97e8bd40cede8fc528f6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 20:43:32 +0100 Subject: [PATCH 44/62] #1221 update changelog and whats-new --- CHANGELOG.md | 1 + base/src/main/assets/whats-new.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be03102384..d30d3dacdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Added - #1871 action to modify any system settings +- #1221 action to show a custom notification ## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02) diff --git a/base/src/main/assets/whats-new.txt b/base/src/main/assets/whats-new.txt index a9bdb53303..0a0d51fd24 100644 --- a/base/src/main/assets/whats-new.txt +++ b/base/src/main/assets/whats-new.txt @@ -7,6 +7,7 @@ You can now remap ALL buttons when the screen is off (including the power button • Force stop current app or clear from recents • Mute/unmute microphone • Modify any system setting +• Show a custom notification 🆕 New Features • Redesigned Settings screen From 82316a86e39a402f5473f6e4a16596859c22bf3f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 21:27:46 +0100 Subject: [PATCH 45/62] fix: Do not automatically go back to Key Mapper after enabling wireless debugging 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 --- .../sysbridge/service/SystemBridgeSetupController.kt | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) 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 beedaf047c..9fb3b3dbbd 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 @@ -83,14 +83,9 @@ class SystemBridgeSetupControllerImpl @Inject constructor( SettingsUtils.settingsCallbackFlow(ctx, uri).collect { isWirelessDebuggingEnabled.update { getWirelessDebuggingEnabled() } - // Only go back if the user is currently setting up the wireless debugging step. - // This stops Key Mapper going back if they are turning on wireless debugging - // for another reason. - if (isWirelessDebuggingEnabled.value && - setupAssistantStepState.value == SystemBridgeSetupStep.WIRELESS_DEBUGGING - ) { - getKeyMapperAppTask()?.moveToFront() - } + // 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 } } From 051ececd8d02a9d3190a520f7ad70a4e05b7ec3f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 21:27:56 +0100 Subject: [PATCH 46/62] feat: auto restart system bridge on debug builds --- .../base/promode/SystemBridgeAutoStarter.kt | 9 ++++++--- .../manager/SystemBridgeConnectionManager.kt | 7 +++++++ .../sysbridge/starter/SystemBridgeStarter.kt | 15 +++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeAutoStarter.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeAutoStarter.kt index 4c9e5fe176..ca660a171c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeAutoStarter.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeAutoStarter.kt @@ -154,9 +154,12 @@ class SystemBridgeAutoStarter @Inject constructor( if (isBoot) { handleAutoStartOnBoot() - } else if (BuildConfig.DEBUG) { - Timber.i("Auto starting system bridge because debug build") - autoStartTypeFlow.first()?.let { autoStart(it) } + } else if (BuildConfig.DEBUG && connectionManager.isConnected()) { + // This is useful when developing and need to restart the system bridge + // after making changes to it. + Timber.w("Restarting system bridge on debug build.") + + connectionManager.restartSystemBridge() } else { handleAutoStartFromPreVersion4() } 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 58db6df87f..56629c0d9a 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 @@ -136,6 +136,12 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( } } + override fun restartSystemBridge() { + coroutineScope.launch { + systemBridgeFlow.value?.let { restartSystemBridge(it) } + } + } + @SuppressLint("LogNotTimber") private suspend fun restartSystemBridge(systemBridge: ISystemBridge) { starter.startSystemBridge(executeCommand = { command -> @@ -256,6 +262,7 @@ interface SystemBridgeConnectionManager { fun run(block: (ISystemBridge) -> T): KMResult fun stopSystemBridge() + fun restartSystemBridge() fun startWithRoot() fun startWithShizuku() 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 09d32c36a0..360f0e1c6a 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 @@ -53,10 +53,17 @@ class SystemBridgeStarter @Inject constructor( ) { private val userManager by lazy { ctx.getSystemService(UserManager::class.java)!! } - private val baseApkPath = ctx.applicationInfo.sourceDir - private val splitApkPaths: Array = ctx.applicationInfo.splitSourceDirs ?: emptyArray() - private val libPath = ctx.applicationInfo.nativeLibraryDir - private val packageName = ctx.applicationInfo.packageName + // Important! Use getters because the values can change at runtime just after the app process + // starts + private val baseApkPath: String + get() = ctx.applicationInfo.sourceDir + private val splitApkPaths: Array + get() = ctx.applicationInfo.splitSourceDirs ?: emptyArray() + private val libPath: String? + get() = ctx.applicationInfo.nativeLibraryDir + private val packageName: String + get() = ctx.applicationInfo.packageName + private val startMutex: Mutex = Mutex() private val shizukuStarterConnection: ServiceConnection = object : ServiceConnection { From 9c4910a6d80363e1d10b80fcbce61270be0a3007 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 22:31:21 +0100 Subject: [PATCH 47/62] #1491 feat: action to toggle/enable/disable hotspot --- CHANGELOG.md | 1 + app/proguard-rules.pro | 3 + base/src/main/assets/whats-new.txt | 1 + .../keymapper/base/actions/ActionUiHelper.kt | 4 + .../keymapper/base/actions/ActionUtils.kt | 24 +++++ .../base/actions/PerformActionsUseCase.kt | 12 ++- .../keymapper/sysbridge/ISystemBridge.aidl | 4 +- .../sysbridge/service/SystemBridge.kt | 101 ++++++++++++++++-- .../system/network/AndroidNetworkAdapter.kt | 25 ++--- .../system/network/NetworkAdapter.kt | 8 +- .../aidl/android/net/IIntResultListener.aidl | 5 + .../aidl/android/net/ITetheringConnector.aidl | 17 +++ .../android/net/ITetheringEventCallback.aidl | 24 +++++ .../aidl/android/net/TetherStatesParcel.aidl | 6 ++ .../main/aidl/android/net/TetheredClient.aidl | 3 + .../net/TetheringCallbackStartedParcel.aidl | 7 ++ .../net/TetheringConfigurationParcel.aidl | 3 + .../aidl/android/net/TetheringInterface.aidl | 3 + .../android/net/TetheringRequestParcel.aidl | 12 +++ 19 files changed, 233 insertions(+), 30 deletions(-) create mode 100644 systemstubs/src/main/aidl/android/net/IIntResultListener.aidl create mode 100644 systemstubs/src/main/aidl/android/net/ITetheringConnector.aidl create mode 100644 systemstubs/src/main/aidl/android/net/ITetheringEventCallback.aidl create mode 100644 systemstubs/src/main/aidl/android/net/TetherStatesParcel.aidl create mode 100644 systemstubs/src/main/aidl/android/net/TetheredClient.aidl create mode 100644 systemstubs/src/main/aidl/android/net/TetheringCallbackStartedParcel.aidl create mode 100644 systemstubs/src/main/aidl/android/net/TetheringConfigurationParcel.aidl create mode 100644 systemstubs/src/main/aidl/android/net/TetheringInterface.aidl create mode 100644 systemstubs/src/main/aidl/android/net/TetheringRequestParcel.aidl diff --git a/CHANGELOG.md b/CHANGELOG.md index d30d3dacdd..17cc0bc709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## Added - #1871 action to modify any system settings - #1221 action to show a custom notification +- #1491 action to toggle/enable/disable hotspot ## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8127b3ee91..a81ca3f5d6 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -77,6 +77,9 @@ -keep class io.github.sds100.keymapper.api.IKeyEventRelayServiceCallback$Stub { *; } -keep class com.android.internal.telephony.ITelephony { *; } -keep class com.android.internal.telephony.ITelephony$Stub { *; } +-keep class android.net.ITetheringConnector { *; } +-keep class android.net.ITetheringConnector$Stub { *; } +-keep class android.net.* { *; } -keepattributes *Annotation*, InnerClasses -dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations diff --git a/base/src/main/assets/whats-new.txt b/base/src/main/assets/whats-new.txt index 0a0d51fd24..f22f122931 100644 --- a/base/src/main/assets/whats-new.txt +++ b/base/src/main/assets/whats-new.txt @@ -8,6 +8,7 @@ You can now remap ALL buttons when the screen is off (including the power button • Mute/unmute microphone • Modify any system setting • Show a custom notification +• Toggle hotspot 🆕 New Features • Redesigned Settings screen diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 834be3b614..57b09e086f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -507,6 +507,10 @@ class ActionUiHelper( ActionData.MobileData.Enable -> getString(R.string.action_enable_mobile_data) ActionData.MobileData.Toggle -> getString(R.string.action_toggle_mobile_data) + ActionData.Hotspot.Disable -> getString(R.string.action_disable_hotspot) + ActionData.Hotspot.Enable -> getString(R.string.action_enable_hotspot) + ActionData.Hotspot.Toggle -> getString(R.string.action_toggle_hotspot) + is ActionData.MoveCursor -> { when (action.direction) { ActionData.MoveCursor.Direction.START -> { 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 4bc2eb8a62..b051aa498d 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 @@ -67,6 +67,8 @@ import androidx.compose.material.icons.outlined.Swipe import androidx.compose.material.icons.outlined.TouchApp import androidx.compose.material.icons.outlined.VerticalSplit import androidx.compose.material.icons.outlined.ViewArray +import androidx.compose.material.icons.outlined.WifiTethering +import androidx.compose.material.icons.outlined.WifiTetheringOff import androidx.compose.material.icons.rounded.Abc import androidx.compose.material.icons.rounded.Android import androidx.compose.material.icons.rounded.Bluetooth @@ -143,6 +145,10 @@ object ActionUtils { ActionId.ENABLE_MOBILE_DATA -> ActionCategory.CONNECTIVITY ActionId.DISABLE_MOBILE_DATA -> ActionCategory.CONNECTIVITY + ActionId.TOGGLE_HOTSPOT -> ActionCategory.CONNECTIVITY + ActionId.ENABLE_HOTSPOT -> ActionCategory.CONNECTIVITY + ActionId.DISABLE_HOTSPOT -> ActionCategory.CONNECTIVITY + ActionId.TOGGLE_AUTO_BRIGHTNESS -> ActionCategory.DISPLAY ActionId.DISABLE_AUTO_BRIGHTNESS -> ActionCategory.DISPLAY ActionId.ENABLE_AUTO_BRIGHTNESS -> ActionCategory.DISPLAY @@ -388,6 +394,9 @@ object ActionUtils { ActionId.CLEAR_RECENT_APP -> R.string.action_clear_recent_app ActionId.MODIFY_SETTING -> R.string.action_modify_setting + ActionId.TOGGLE_HOTSPOT -> R.string.action_toggle_hotspot + ActionId.ENABLE_HOTSPOT -> R.string.action_enable_hotspot + ActionId.DISABLE_HOTSPOT -> R.string.action_disable_hotspot } @DrawableRes @@ -555,6 +564,13 @@ object ActionUtils { ActionId.SHOW_POWER_MENU -> Build.VERSION_CODES.LOLLIPOP ActionId.DEVICE_CONTROLS -> Build.VERSION_CODES.S + // It could be supported on older versions but system bridge min API is Q and its extra + // maintenance effort to support the older tethering system API. + ActionId.TOGGLE_HOTSPOT, + ActionId.ENABLE_HOTSPOT, + ActionId.DISABLE_HOTSPOT, + -> Build.VERSION_CODES.R + else -> Constants.MIN_API } @@ -620,6 +636,11 @@ object ActionUtils { ActionId.DISABLE_MOBILE_DATA, -> true + ActionId.TOGGLE_HOTSPOT, + ActionId.ENABLE_HOTSPOT, + ActionId.DISABLE_HOTSPOT, + -> true + ActionId.ENABLE_NFC, ActionId.DISABLE_NFC, ActionId.TOGGLE_NFC, @@ -904,6 +925,9 @@ object ActionUtils { ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit ActionId.MODIFY_SETTING -> Icons.Outlined.Settings + ActionId.TOGGLE_HOTSPOT -> Icons.Outlined.WifiTethering + ActionId.ENABLE_HOTSPOT -> Icons.Outlined.WifiTethering + ActionId.DISABLE_HOTSPOT -> Icons.Outlined.WifiTetheringOff } } 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 ab66af211f..25e9764bf9 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 @@ -484,10 +484,16 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.Hotspot.Toggle -> { - result = if (networkAdapter.isHotspotEnabled()) { - networkAdapter.disableHotspot() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + result = networkAdapter.isHotspotEnabled().then { isEnabled -> + if (isEnabled) { + networkAdapter.disableHotspot() + } else { + networkAdapter.enableHotspot() + } + } } else { - networkAdapter.enableHotspot() + result = SdkVersionTooLow(minSdk = Build.VERSION_CODES.R) } } diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl index 6b7f4ace97..6ddec51060 100644 --- a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl @@ -43,5 +43,7 @@ interface ISystemBridge { void setRingerMode(int ringerMode) = 18; - void setTetheringEnabled(boolean enable) = 19; + boolean isTetheringEnabled() = 19; + + void setTetheringEnabled(boolean enable) = 20; } \ No newline at end of file diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index 521eafd34e..28389c77cb 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -14,6 +14,14 @@ import android.content.pm.PackageManager import android.hardware.input.IInputManager import android.media.IAudioService import android.net.IConnectivityManager +import android.net.ITetheringConnector +import android.net.ITetheringEventCallback +import android.net.Network +import android.net.TetherStatesParcel +import android.net.TetheredClient +import android.net.TetheringCallbackStartedParcel +import android.net.TetheringConfigurationParcel +import android.net.TetheringRequestParcel import android.net.wifi.IWifiManager import android.nfc.INfcAdapter import android.nfc.NfcAdapterApis @@ -28,6 +36,7 @@ import android.permission.IPermissionManager import android.permission.PermissionManagerApis import android.util.Log import android.view.InputEvent +import androidx.annotation.RequiresApi import com.android.internal.telephony.ITelephony import io.github.sds100.keymapper.common.models.EvdevDeviceHandle import io.github.sds100.keymapper.common.models.ShellResult @@ -175,6 +184,7 @@ internal class SystemBridge : ISystemBridge.Stub() { private val bluetoothManager: IBluetoothManager? private val nfcAdapter: INfcAdapter? private val connectivityManager: IConnectivityManager? + private val tetheringConnector: ITetheringConnector? private val activityManager: IActivityManager private val activityTaskManager: IActivityTaskManager private val audioService: IAudioService? @@ -263,6 +273,14 @@ internal class SystemBridge : ISystemBridge.Stub() { audioService = IAudioService.Stub.asInterface(ServiceManager.getService(Context.AUDIO_SERVICE)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + waitSystemService("tethering") + tetheringConnector = + ITetheringConnector.Stub.asInterface(ServiceManager.getService("tethering")) + } else { + tetheringConnector = null + } + val applicationInfo = getKeyMapperPackageInfo() if (applicationInfo == null) { @@ -669,21 +687,84 @@ internal class SystemBridge : ISystemBridge.Stub() { audioService.setRingerModeInternal(ringerMode, processPackageName) } + @RequiresApi(Build.VERSION_CODES.R) + override fun isTetheringEnabled(): Boolean { + if (tetheringConnector == null) { + throw UnsupportedOperationException("TetheringConnector not supported") + } + + val lock = Object() + var result = false + val timeoutMillis = 5000L + + val callback = object : ITetheringEventCallback.Stub() { + override fun onCallbackStarted(parcel: TetheringCallbackStartedParcel?) { + if (parcel?.states?.tetheredList != null) { + // Check if any tethering interface is active + result = parcel.states.tetheredList.isNotEmpty() + } + + synchronized(lock) { + lock.notify() + } + } + + override fun onCallbackStopped(errorCode: Int) {} + override fun onUpstreamChanged(network: Network?) {} + override fun onConfigurationChanged(config: TetheringConfigurationParcel?) {} + override fun onTetherStatesChanged(states: TetherStatesParcel?) {} + override fun onTetherClientsChanged(clients: List?) {} + override fun onOffloadStatusChanged(status: Int) {} + override fun onSupportedTetheringTypes(supportedBitmap: Long) {} + } + + try { + // We register and immediately unregister the callback after getting the state + // instead of keeping it registered for the lifetime of SystemBridge. This is + // a safety measure in case there's a bug in the callback that could crash + // the entire SystemBridge process. + tetheringConnector.registerTetheringEventCallback(callback, processPackageName) + + // Wait for callback with timeout using Handler + mainHandler.postDelayed({ + synchronized(lock) { + lock.notify() + } + }, timeoutMillis) + + synchronized(lock) { + lock.wait(timeoutMillis) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + tetheringConnector.unregisterTetheringEventCallback(callback, processPackageName) + } + + return result + } + + @RequiresApi(Build.VERSION_CODES.R) override fun setTetheringEnabled(enable: Boolean) { - if (connectivityManager == null) { - throw UnsupportedOperationException("ConnectivityManager not supported") + if (tetheringConnector == null) { + throw UnsupportedOperationException("TetheringConnector not supported") } if (enable) { - connectivityManager.startTethering( - TETHERING_WIFI, - null, // ResultReceiver - false, // showProvisioningUi - processPackageName, // callerPkg - ) + val request = TetheringRequestParcel().apply { + // TetheringManager.TETHERING_WIFI + tetheringType = TETHERING_WIFI + localIPv4Address = null + staticClientAddress = null + exemptFromEntitlementCheck = false + showProvisioningUi = true + // TetheringManager.CONNECTIVITY_SCOPE_GLOBAL + connectivityScope = 1 + } + + tetheringConnector.startTethering(request, processPackageName, null, null) } else { - connectivityManager.stopTethering(TETHERING_WIFI) + tetheringConnector.stopTethering(TETHERING_WIFI, processPackageName, null, null) } } - } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt index d107b6f651..e7c9085d1e 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt @@ -199,27 +199,22 @@ class AndroidNetworkAdapter @Inject constructor( } } - override fun isHotspotEnabled(): Boolean { - // TODO: Implement hotspot state detection using reflection or system bridge. - // For now, returning false means toggle action will always attempt to enable. - // This is acceptable for the initial implementation. - return false + @RequiresApi(Build.VERSION_CODES.R) + override suspend fun isHotspotEnabled(): KMResult { + // isTetheringEnabled is a blocking call that registers a callback + return withContext(Dispatchers.IO) { + systemBridgeConnManager.run { systemBridge -> systemBridge.isTetheringEnabled } + } } + @RequiresApi(Build.VERSION_CODES.R) override fun enableHotspot(): KMResult<*> { - if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { - return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(true) } - } else { - return KMError.FeatureUnavailable - } + return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(true) } } + @RequiresApi(Build.VERSION_CODES.R) override fun disableHotspot(): KMResult<*> { - if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { - return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(false) } - } else { - return KMError.FeatureUnavailable - } + return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(false) } } /** diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt index cb111c8126..a68d3807a2 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt @@ -1,5 +1,7 @@ package io.github.sds100.keymapper.system.network +import android.os.Build +import androidx.annotation.RequiresApi import io.github.sds100.keymapper.common.utils.KMResult import kotlinx.coroutines.flow.Flow @@ -19,9 +21,13 @@ interface NetworkAdapter { fun enableMobileData(): KMResult<*> fun disableMobileData(): KMResult<*> - fun isHotspotEnabled(): Boolean + @RequiresApi(Build.VERSION_CODES.R) + suspend fun isHotspotEnabled(): KMResult + @RequiresApi(Build.VERSION_CODES.R) fun enableHotspot(): KMResult<*> + + @RequiresApi(Build.VERSION_CODES.R) fun disableHotspot(): KMResult<*> fun getKnownWifiSSIDs(): List diff --git a/systemstubs/src/main/aidl/android/net/IIntResultListener.aidl b/systemstubs/src/main/aidl/android/net/IIntResultListener.aidl new file mode 100644 index 0000000000..8c3971f3a4 --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/IIntResultListener.aidl @@ -0,0 +1,5 @@ +package android.net; + +oneway interface IIntResultListener { + void onResult(int resultCode); +} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/ITetheringConnector.aidl b/systemstubs/src/main/aidl/android/net/ITetheringConnector.aidl new file mode 100644 index 0000000000..689600122c --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/ITetheringConnector.aidl @@ -0,0 +1,17 @@ +package android.net; + +import android.net.IIntResultListener; +import android.net.TetheringRequestParcel; +import android.net.ITetheringEventCallback; + +oneway interface ITetheringConnector { + void startTethering(in TetheringRequestParcel request, String callerPkg, + String callingAttributionTag, IIntResultListener receiver); + + void stopTethering(int type, String callerPkg, String callingAttributionTag, + IIntResultListener receiver); + + void registerTetheringEventCallback(ITetheringEventCallback callback, String callerPkg); + + void unregisterTetheringEventCallback(ITetheringEventCallback callback, String callerPkg); +} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/ITetheringEventCallback.aidl b/systemstubs/src/main/aidl/android/net/ITetheringEventCallback.aidl new file mode 100644 index 0000000000..00257e2771 --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/ITetheringEventCallback.aidl @@ -0,0 +1,24 @@ +package android.net; + +import android.net.Network; +import android.net.TetheredClient; +import android.net.TetheringConfigurationParcel; +import android.net.TetheringCallbackStartedParcel; +import android.net.TetherStatesParcel; + +/** + * Callback class for receiving tethering changed events. + * @hide + */ +oneway interface ITetheringEventCallback +{ + /** Called immediately after the callbacks are registered */ + void onCallbackStarted(in TetheringCallbackStartedParcel parcel); + void onCallbackStopped(int errorCode); + void onUpstreamChanged(in Network network); + void onConfigurationChanged(in TetheringConfigurationParcel config); + void onTetherStatesChanged(in TetherStatesParcel states); + void onTetherClientsChanged(in List clients); + void onOffloadStatusChanged(int status); + void onSupportedTetheringTypes(long supportedBitmap); +} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/TetherStatesParcel.aidl b/systemstubs/src/main/aidl/android/net/TetherStatesParcel.aidl new file mode 100644 index 0000000000..674ec6ddac --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/TetherStatesParcel.aidl @@ -0,0 +1,6 @@ +package android.net; + +parcelable TetherStatesParcel { + TetheringInterface[] availableList; + TetheringInterface[] tetheredList; +} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/TetheredClient.aidl b/systemstubs/src/main/aidl/android/net/TetheredClient.aidl new file mode 100644 index 0000000000..4ff9878902 --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/TetheredClient.aidl @@ -0,0 +1,3 @@ +package android.net; + +parcelable TetheredClient {} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/TetheringCallbackStartedParcel.aidl b/systemstubs/src/main/aidl/android/net/TetheringCallbackStartedParcel.aidl new file mode 100644 index 0000000000..1d667055b0 --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/TetheringCallbackStartedParcel.aidl @@ -0,0 +1,7 @@ +package android.net; + +import android.net.TetherStatesParcel; + +parcelable TetheringCallbackStartedParcel { + TetherStatesParcel states; +} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/TetheringConfigurationParcel.aidl b/systemstubs/src/main/aidl/android/net/TetheringConfigurationParcel.aidl new file mode 100644 index 0000000000..d4ad6567a4 --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/TetheringConfigurationParcel.aidl @@ -0,0 +1,3 @@ +package android.net; + +parcelable TetheringConfigurationParcel {} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/TetheringInterface.aidl b/systemstubs/src/main/aidl/android/net/TetheringInterface.aidl new file mode 100644 index 0000000000..04daa57b18 --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/TetheringInterface.aidl @@ -0,0 +1,3 @@ +package android.net; + +parcelable TetheringInterface {} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/TetheringRequestParcel.aidl b/systemstubs/src/main/aidl/android/net/TetheringRequestParcel.aidl new file mode 100644 index 0000000000..82dd8279b5 --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/TetheringRequestParcel.aidl @@ -0,0 +1,12 @@ +package android.net; + +import android.net.LinkAddress; + +parcelable TetheringRequestParcel { + int tetheringType; + LinkAddress localIPv4Address; + LinkAddress staticClientAddress; + boolean exemptFromEntitlementCheck; + boolean showProvisioningUi; + int connectivityScope; +} \ No newline at end of file From c13d1474cae334de84c96e91dc69e956deaa2c05 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 9 Nov 2025 22:34:17 +0100 Subject: [PATCH 48/62] fix tests --- .../sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt index e8caecddaf..7f2151377e 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt @@ -86,6 +86,8 @@ class PerformActionsUseCaseTest { systemBridgeConnectionManager = mock(), executeShellCommandUseCase = mock(), coroutineScope = testCoroutineScope, + notificationAdapter = mock(), + settingsAdapter = mock(), ) } From bb4028dd1a800346ee4b38f03e03a867833ac3a2 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 11 Nov 2025 16:58:08 +0100 Subject: [PATCH 49/62] fix: do not obfuscate SettingType enum --- app/version.properties | 2 +- .../io/github/sds100/keymapper/system/settings/SettingType.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index a73bc7508e..0aad39bb77 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=4.0.0-beta.3 -VERSION_CODE=189 +VERSION_CODE=191 VERSION_NUM=01 \ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingType.kt b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingType.kt index 8e0a5fd5e0..b675738360 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingType.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingType.kt @@ -1,8 +1,10 @@ package io.github.sds100.keymapper.system.settings +import androidx.annotation.Keep import kotlinx.serialization.Serializable @Serializable +@Keep enum class SettingType { SYSTEM, SECURE, From 2474a5a82b99b3cfd3ef6da3f8b954864e1a7198 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 15 Nov 2025 13:11:05 +0100 Subject: [PATCH 50/62] #1414 feat: constraint for when the keyboard is showing --- CHANGELOG.md | 1 + .../base/constraints/ConstraintDependency.kt | 2 +- .../base/constraints/ConstraintSnapshot.kt | 5 +++-- .../base/constraints/ConstraintUiHelper.kt | 8 ++++++-- .../base/constraints/DetectConstraintsUseCase.kt | 15 ++++++++++----- .../comparators/KeyMapConstraintsComparator.kt | 2 ++ .../accessibility/BaseAccessibilityService.kt | 16 ++++++++++++++++ .../accessibility/IAccessibilityService.kt | 9 ++++++++- .../inputmethod/AutoSwitchImeController.kt | 10 +--------- 9 files changed, 48 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17cc0bc709..20a2d4e666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - #1871 action to modify any system settings - #1221 action to show a custom notification - #1491 action to toggle/enable/disable hotspot +- #1414 constraint for when the keyboard is showing ## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt index d48c3dcbb2..e8b3f784e9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt @@ -11,7 +11,7 @@ enum class ConstraintDependency { WIFI_SSID, WIFI_STATE, CHOSEN_IME, - KEYBOARD_STATE, + KEYBOARD_VISIBLE, DEVICE_LOCKED_STATE, LOCK_SCREEN_SHOWING, PHONE_STATE, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt index f5ae895d0d..629a610c3a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt @@ -57,8 +57,9 @@ class LazyConstraintSnapshot( networkAdapter.connectedWifiSSIDFlow.firstBlocking() } private val chosenImeId: String? by lazy { inputMethodAdapter.chosenIme.value?.id } - // TODO: Implement keyboard state detection - private val isKeyboardShowing: Boolean by lazy { false } + private val isKeyboardShowing: Boolean by lazy { + accessibilityService.isInputMethodVisible.firstBlocking() + } private val callState: CallState by lazy { phoneAdapter.getCallState() } private val isCharging: Boolean by lazy { powerAdapter.isCharging.value } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt index 2b465a8860..62246d6817 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt @@ -135,8 +135,12 @@ class ConstraintUiHelper( getString(R.string.constraint_ime_not_chosen_description, label) } - is ConstraintData.KeyboardShowing -> getString(R.string.constraint_keyboard_showing_description) - is ConstraintData.KeyboardNotShowing -> getString(R.string.constraint_keyboard_not_showing_description) + is ConstraintData.KeyboardShowing -> getString( + R.string.constraint_keyboard_showing_description, + ) + is ConstraintData.KeyboardNotShowing -> getString( + R.string.constraint_keyboard_not_showing_description, + ) is ConstraintData.DeviceIsLocked -> getString(R.string.constraint_device_is_locked) is ConstraintData.DeviceIsUnlocked -> getString(R.string.constraint_device_is_unlocked) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt index 7955abacf6..87e38e6af3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.constraints +import android.os.Build import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -16,7 +17,7 @@ import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.power.PowerAdapter import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -79,9 +80,6 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( networkAdapter.connectedWifiSSIDFlow.map { dependency } ConstraintDependency.WIFI_STATE -> networkAdapter.isWifiEnabledFlow().map { dependency } ConstraintDependency.CHOSEN_IME -> inputMethodAdapter.chosenIme.map { dependency } - ConstraintDependency.KEYBOARD_STATE -> - // TODO: Implement keyboard state detection - flowOf(dependency) ConstraintDependency.DEVICE_LOCKED_STATE -> lockScreenAdapter.isLockedFlow().map { dependency } @@ -93,7 +91,14 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( ConstraintDependency.PHONE_STATE -> phoneAdapter.callStateFlow.map { dependency } ConstraintDependency.CHARGING_STATE -> powerAdapter.isCharging.map { dependency } - ConstraintDependency.HINGE_STATE -> foldableAdapter.hingeState.map { dependency } + ConstraintDependency.HINGE_STATE -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + foldableAdapter.hingeState.map { dependency } + } else { + emptyFlow() + } + ConstraintDependency.KEYBOARD_VISIBLE -> + accessibilityService.isInputMethodVisible.map { dependency } } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt index 65df1611cc..bb53bd5f66 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt @@ -138,6 +138,8 @@ class KeyMapConstraintsComparator( ConstraintData.HingeClosed -> Success("") ConstraintData.HingeOpen -> Success("") + ConstraintData.KeyboardNotShowing -> Success("") + ConstraintData.KeyboardShowing -> Success("") } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt index 24b064b4ad..0e286e3296 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt @@ -16,6 +16,7 @@ import android.os.HandlerThread import android.view.KeyEvent import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo import android.view.inputmethod.EditorInfo import androidx.annotation.RequiresApi import androidx.core.content.getSystemService @@ -113,6 +114,13 @@ abstract class BaseAccessibilityService : override val isKeyboardHidden: Flow get() = _isKeyboardHidden + private val _isInputMethodVisible by lazy { + MutableStateFlow(isImeWindowVisible()) + } + + override val isInputMethodVisible: Flow + get() = _isInputMethodVisible + override var serviceFlags: Int? get() = serviceInfo?.flags set(value) { @@ -273,6 +281,7 @@ abstract class BaseAccessibilityService : } _activeWindowPackage.update { rootNode?.packageName?.toString() } + _isInputMethodVisible.update { isImeWindowVisible() } } getController()?.onAccessibilityEvent(event) @@ -540,4 +549,11 @@ abstract class BaseAccessibilityService : return Success(Unit) } + + fun isImeWindowVisible(): Boolean { + val imeWindow: AccessibilityWindowInfo? = + windows.find { it.type == AccessibilityWindowInfo.TYPE_INPUT_METHOD } + + return imeWindow != null && imeWindow.root?.isVisibleToUser == true + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt index 470900c561..9894cbee3f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt @@ -49,9 +49,16 @@ interface IAccessibilityService : SwitchImeInterface { fun hideKeyboard() fun showKeyboard() + + /** + * Whether the keyboard is force hidden by the accessibility service SoftKeyboardController + */ val isKeyboardHidden: Flow - fun disableSelf() + /** + * Whether the input method is visible on the screen. + */ + val isInputMethodVisible: Flow fun findFocussedNode(focus: Int): AccessibilityNodeModel? } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/AutoSwitchImeController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/AutoSwitchImeController.kt index bce7414cbd..a967ce1091 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/AutoSwitchImeController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/AutoSwitchImeController.kt @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.base.system.inputmethod import android.os.Build import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityWindowInfo import android.view.inputmethod.EditorInfo import androidx.annotation.RequiresApi import dagger.assisted.Assisted @@ -168,7 +167,7 @@ class AutoSwitchImeController @AssistedInject constructor( return } - val isInputStarted = isImeWindowVisible() + val isInputStarted = service.isImeWindowVisible() if (isInputStarted) { if (chooseIncompatibleIme()) { @@ -182,13 +181,6 @@ class AutoSwitchImeController @AssistedInject constructor( } } - private fun isImeWindowVisible(): Boolean { - val imeWindow: AccessibilityWindowInfo? = - service.windows.find { it.type == AccessibilityWindowInfo.TYPE_INPUT_METHOD } - - return imeWindow != null && imeWindow.root?.isVisibleToUser == true - } - fun onStartInput(attribute: EditorInfo, restarting: Boolean) { if (!changeImeOnStartInput.value) { return From b7392fe7c9c88b6101a66537bd4060e90c85339e Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 18 Nov 2025 21:52:32 +0100 Subject: [PATCH 51/62] style: import rather than fully qualified name --- .../java/io/github/sds100/keymapper/base/actions/ActionData.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index ffd63da9a9..bb92e9bec0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -8,6 +8,7 @@ import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.intents.IntentExtraModel import io.github.sds100.keymapper.system.intents.IntentTarget import io.github.sds100.keymapper.system.network.HttpMethod +import io.github.sds100.keymapper.system.settings.SettingType import io.github.sds100.keymapper.system.volume.DndMode import io.github.sds100.keymapper.system.volume.RingerMode import io.github.sds100.keymapper.system.volume.VolumeStream @@ -981,7 +982,7 @@ sealed class ActionData : Comparable { @Serializable data class ModifySetting( - val settingType: io.github.sds100.keymapper.system.settings.SettingType, + val settingType: SettingType, val settingKey: String, val value: String, ) : ActionData() { From f8b1ba8c78469025d4c237509d1540f1ba1fb502 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 18 Nov 2025 21:52:41 +0100 Subject: [PATCH 52/62] fix: add serializable to evdev trigger key --- .../io/github/sds100/keymapper/base/trigger/EvdevTriggerKey.kt | 2 ++ .../io/github/sds100/keymapper/common/models/EvdevDeviceInfo.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/EvdevTriggerKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/EvdevTriggerKey.kt index 1eafadd975..99836c3937 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/EvdevTriggerKey.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/EvdevTriggerKey.kt @@ -7,12 +7,14 @@ import io.github.sds100.keymapper.common.utils.withFlag import io.github.sds100.keymapper.data.entities.EvdevTriggerKeyEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import java.util.UUID +import kotlinx.serialization.Serializable /** * This must be a different class to KeyEventTriggerKey because trigger keys from evdev events * must come from one device, even if it is internal, and can not come from any device. The input * devices must be grabbed so that Key Mapper can remap them. */ +@Serializable data class EvdevTriggerKey( override val uid: String = UUID.randomUUID().toString(), override val keyCode: Int, diff --git a/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceInfo.kt b/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceInfo.kt index b37d5ea6dd..a6bdf19106 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceInfo.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceInfo.kt @@ -2,7 +2,9 @@ package io.github.sds100.keymapper.common.models import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Serializable @Parcelize data class EvdevDeviceInfo(val name: String, val bus: Int, val vendor: Int, val product: Int) : Parcelable From 4cae358040adb2dc91ec8f641795309a244f68bd Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 18 Nov 2025 22:58:17 +0100 Subject: [PATCH 53/62] #1900 log to logcat if extra logging is enabled. --- CHANGELOG.md | 9 +++++---- .../keymapper/base/logging/KeyMapperLoggingTree.kt | 7 +++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a2d4e666..1d6ad19535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ #### TO BE RELEASED ## Added -- #1871 action to modify any system settings -- #1221 action to show a custom notification -- #1491 action to toggle/enable/disable hotspot -- #1414 constraint for when the keyboard is showing +- #1871 action to modify any system settings. +- #1221 action to show a custom notification. +- #1491 action to toggle/enable/disable hotspot. +- #1414 constraint for when the keyboard is showing. +- #1900 log error, warn, and info to logcat if extra logging is enabled. ## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/KeyMapperLoggingTree.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/KeyMapperLoggingTree.kt index 2e8efcaec9..0e9a664d30 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/KeyMapperLoggingTree.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/KeyMapperLoggingTree.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.logging import android.util.Log +import io.github.sds100.keymapper.base.BuildConfig import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.entities.LogEntryEntity import io.github.sds100.keymapper.data.repositories.LogRepository @@ -53,6 +54,12 @@ class KeyMapperLoggingTree @Inject constructor( return } + // Log to logcat if extra logging is enabled. If it is a debug build then a Timber + // DebugTree is planted in BaseKeyMapperApp so do not duplicate the log. + if (logEverything.value && !BuildConfig.DEBUG) { + Log.println(priority, tag, message) + } + val severity = when (priority) { Log.ERROR -> LogEntryEntity.SEVERITY_ERROR Log.DEBUG -> LogEntryEntity.SEVERITY_DEBUG From 7e134716b2334059afd225e339afe4be10343f8d Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 18 Nov 2025 23:09:40 +0100 Subject: [PATCH 54/62] #1900 log to logcat if extra logging is enabled. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6ad19535..a7ad4de024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - #1221 action to show a custom notification. - #1491 action to toggle/enable/disable hotspot. - #1414 constraint for when the keyboard is showing. -- #1900 log error, warn, and info to logcat if extra logging is enabled. +- #1900 log to logcat if extra logging is enabled. ## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02) From 511e61ae1a46975cd4c2f676ce61f71f72a20cad Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 20 Nov 2025 15:40:03 +0100 Subject: [PATCH 55/62] 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 0aad39bb77..811046f1d5 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=4.0.0-beta.3 -VERSION_CODE=191 +VERSION_CODE=193 VERSION_NUM=01 \ No newline at end of file From e9587d9c59896f7bb663c72a1401820303ecf436 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 20 Nov 2025 16:24:30 +0100 Subject: [PATCH 56/62] store: update feature graphic --- .../android/en-US/images/featureGraphic.png | Bin 36189 -> 23832 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png index 6fc2b28c8da2583add438b45ccbe4ecbbdf524cb..b604d9f7e7ff0ded53c35524c7c18b9f1b3abd2f 100644 GIT binary patch literal 23832 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�H?buJz5LY^B{KZjbQoa5}t+C-Ha@~qD{sI$$RRk8Xh^7HeN>KyJKv9k5~ z^JO5#OxnVkph_!2=P2f=hJF6Kr%DL5z7X%83#EUWGQm4RQ4#SA@2E^Z&|~O%2L=WL z$bUf<<587l;S7}_Fn`yE7vNTk zbTM+OR~=1t0KIaw{dyu3!w%e95viOgkkI`=ADfv!q^D08{6KL_LBr?RsW_rVw-*-E z51=2Q(0Sq}K7vcn#Y^~-OtAE(1ATpcJD3OQNgX2%PJeq*4gynorKko<;%Iwn77cW= z`^gNYotXuY`(k2Z1*-Q;brBxO)O@rzAtzAatgK@~y@XW%Bg1jwj+V>j$AK4hP*X56 zQ*xMo9ncA@vVZYRzciBDIu#QWGrJnt5EJXC;6WVm>@16nyn{^&Kg~O1xQ~FSHg&sp zOP8uPG2ORNJ#X(B(Utf>n zvefnr7_G+_FfH74f&H#1jR*=#@FiwZWeQO%-2nSqfuOE9l%-YUmWwq91bMrDRW-P! z_ERYc*(w4o>Sf)_D96Ez?khui@r`=mM@YUbaI+8JG&7alRtydNy*61hsUO+hEt8h} zcX{-kCfDdC5q>TRS_kt>Qe-|-GiV~V3C@MZzG9QJJWn~kZjqVM1#)F*TAlmfkSVWB zyI+(SOx-gzzkUyYKUyowrAmEQ{rEvC9_HeL^)~fj$AGYONp|*5QH+*)mi*3x0ZV*b zT-mS(3X7>KKgKMZ@2I|Sf6WJU&0j4*iD@K-^J>0Jjzw3JObL2nvURXl6FBEvWWpL~ zoBXmkFi(VvW{^vr<+3oV^4ebLS}-IhE>;uin=CeoX!IOd9?G+@%*HaoqNU)rq7h;7 zdOxAU;=i{#oCH}!jY&?P4{KYE*Z{3*R_=1hN_Pi1n0FMyVRIrAyBkhk19N6%N(!S* z;g^`u$DO#y?^_W=SN{w)JPVI6+}*Po_@{X#qPz^j5a91J^74dqZ*jJNQU@9?c7Hp9 zvSF(-w-}?A74HrYn{;0>&|Y=RR^oiO2J^Hm*fFTa-Mn1D$vF%peia%>=TpS5@}ZKW zy9@wZ0Drw(QUHg4bUbrDBKU@!nW zfUQ8X@a)#2_x%27buZn#Qo-D5Q-ulaS1A3SUjbh86WvoRgPG~+#N&+`EiLy`_B#5v zKtb(7RC%I>gK{S2g~PBibY<#bsd-a}DH+E*ysYs{?NUo2yI4868Nys;H!lqCSG=vi z7|UbZs(p{Aw0tN>@)+PJd(~zW8TVON4<8C4`S@T1&;VSRCEM# z(*T}aKq*6>SeMvzny!vJZ&cA%KmHmX9=>7ScYAr-bm$?_CW7AGDdNQCpZikJR-nXmCWN65!Wa?jj94R4X z%{QCI+-djkF$TB^E^;OIYCYp-#OMSw@QJIfl0B1{)ZhPj0#klsi}p|fkX8<<%4d(<{#Ne7jXCVvz;;hs=U^Gci9 zctm0d1HxNKQ;9R?>6fT2*Zk#3s9nL7fCxK)@dgh<~-eiV>XUfP%AO*fxPE7x|N(v zlNK2)Q!xJI9yN7+#>#Wqe`zltJ1@T5dCqnGF)g3mh!H zu5sRyy*D=~2I3h44)5(K=)MnlLAMn9*ne(avE-?$qmvU-Ptc%zUPH_EXn(_f)buJ? zD8iW7Lg_U+_VWDfwAgZ{NxkCJ_*U$va&X3ljn*%L3hO_ z&;uD{=kBiJ9cq@MpD^vPxU9Ac>PgQd&s=BGou`rN^LcpE($YY+eZcqDW^>wEB;PJK z;B-52&?vWRoabQ;n^u<)GIkj4XLe2;b!Y12DN!Tkpjx=LG&{QMIcE7N{| z{nkV73Lah2};PT>#xaBY?4XW(z>=^nlvIEc_|x9XeP`Qb!(MMM!~xxf1?n=@_oJ`OYq{3-^9T z7Hg6MpHb{YcZRf#tM`n=Ap~YK!!FdAjnoAv0R4*qmcf^ngN-VAN=VBiO#u`a_o5d# z^QO;_wKUW!mHosFGzz~zSSDXxD`Qy*PPNU+t;LPQR_Z+eETOUw1-D9-Nz6EaC!{dS z(gVb)ucVU#tkYp;UVOY(_2BWJdi=Ghv+qZSFql8=cj&LDgOjmUo3ut6SC3Hdg$_qgM$%eT+afkysz(FBlm z*}#&Gy4>R9L@@OJxV?D$CRF%JaWmGC?w+swezP)cQy43{3>pzH2@yj{C~}LSzZ*S= zbRD2at;`e>7nchh;8RzrlG1f&TqtKwYgZ{HuGhFJ{ zOO&?uyQ%NIk6o^c4U}dgYNjv6Xqi$6h9DW5t}oork0;H-f!T3p2LS=-7bdhL%|=Ty zpy{Jn3NiB`u&SN<kvBJK$R95Sz=%F`0gmgLprTgkZ;|>@OuISBE zfZ4XIzaFRM`4(#Cp>Qt+qEHtI2?=LIchQ@p7J7**=L0q&iJh7(wi|7_tR`LFSt-+1 zTq`8Ee;PG>zQJQT{f~wop!-~?|5Vk_fGvJ__io+19k4q|kPp?Wo|2G|q}xxrDhZq& z=mb0s!n%018%nRO9g2mnc#EO*h0=mSl=aJa*S%r0LVo2Cz?_gAh*Vv3H7fLNA)!=2 zkKf4B@ztx(2xAQ29T`)4mNttAARgcd)BSoxo?=mb_!6ltI?RY{W=|3`|Knx&P#&1j zeSX*Kq(^fM=<%I?bomF)S{y=w{DQv5SREh676{n*nEbG?);=FLNAms{dgcCGp%+?E`o1t1^mxGYg5Iq=}nWcI=7MebM_@DrtZS{pfdrQk6 zIcdHWK)@?uN}BmAr@}8^E-&^Z8BdIzWxTveKmeMNF#>x=`_*6H5Pnd>4x+P)CCk+` z>G+bv(gMk74uod_?3M?AWdI=-bP#2{uH8XeQx)ffXs)UJQXtU`dlH37In86h4iNd0 zql3d@z|(aZs43Q*a{2iAHJ|9W)8DYQ99$uSPGK#>+@4qG?XPX!weHbfW8D?;!pn!v z&*$WDgHZrQrn->FMtBBVM{QUW+9Oe`hX&*I#LWZ^B> zob=tGo%&HVVx#(43SR5G3iCGkS2iQreD{emXwSfH?P6=nhrhr5pslSfCh^zXrW`;s zLKsxoiIN$t+@2ICJ?wNlbvXzo2#J8s&RniTl|p= z%?Q(|fkzD7G++_cJ0zkvBjJgpfjhFrYe0!vhlKp}N#A!2c-hij}>qRSv+W-y_ z@(b1oc%N{X1Xoj2(~g!5w-1h+4YXEiT=-kE#0Hg`7+e&HaTN?G+!8kETOvE!tI}aD zBb6WR(pbMh{;`IhHaU_oP^$ZxvPW@r1P`Q%H>@B@NfBw$R zH3u{R}w0!(9XTL=h70L_ji(DWDUq67=XuZ)Qqa=8J zV<=tAnJ0^kLLdu&gQ$`L<>Lj4WJxqP(lRQwhSbjGpVipjZ?gsItLX`|+mgVV+4KbXC??w5x zrPn%*Ahv^GNCqNjPPjFACHUZOBe52gOhV=1+%}6rK{KO0$oy1I@YCY zhF}>-C)-`yrxW_^U%%npyq_+cBBt2Joq1PD9qiI(sushSj}5BrB&dN2Ra}9K&jMk^ zA}~-&pvqX)>;j<{MB2ZBfZz4#mjnv3U)5pm3}8%K0ET!Jyj;>VIOGCu>MT_|!9vY) z^totgg5lQYLnTbVC_MW^FSKQ9Ix&HBQohk{ZI+0fF)xAYShKF~!DMB*amUxY#GKPA z%biy~cv6K5f>-t?3j3~-$`k=58O3Q4*I@;wfd=tU4xHOE>~!(5$_dCGmY{*r{rzoy zf2E_f7u)>+a&B!~M^jVNjISl_s?_g*a}w5!hnJ!U>L2JM1TjO;BAbH>@X0rDh;+zO zfQY}&cg5!&IIKKLMylxxQPyYB3myb8u>;z^hmB$zY#@dMUeE;CE935jXWy=-D#zGb zrmhfqVAVv3f&hu?oWo)wyXJJwLP{xb5ud+%x6#+vCZ0kvDt-MD<@6g}Jh zjAS2bcL;PUOi!Tx%~3CBUD=0F2SOe}sGPR;_al6L#RHBImXWranfABSg(>n zWp$FDtmbs-1%JzQYM^Ht6Cj03d`2ZwBn^sL=|+r`sA9_4TPHkF~WM$ z?o-&*`_gd|8a|tFFXf%$3%EZG*$A*}ah=gYSi(z@266d3d~?Hv2G(Pa7yimZL<^2i z?xAvTa1lde88Uc}aU>LnH|YNgLrTRhyLW6M@F)BM(-vbl?*CpkX2@o>PYLqc#}`pD zf`EmvF}~m9LBun+dy*eyZ=tNE6 zKPdXIi2Mk<)(1+V_oy@j*2p4GdRAi9|Xz@Cr0%CT;@H?Zc7;a$8PbI=#ltSdv{ zvOAy@#qY}F`FAaEhN0^e8Wkl*McyzO%Bb((6&=OTytQdFL5But(DdxR+$gjl0sDbq z4^hvoFl$vy5F1>|jJv^NQTRo0_F2*!$}pA%*z^jUC4x&hQcED$L>g}tP^P~BcQGYj zzmgE3B3W5=ITp^(s#IjdlDE+;yASeds|AA}VsP zQ`6EOLh2O>An!p$X(eWAe8;PW*jG?8`j6lbK}$B-VJeJSh{7L6bPW!DF zLXY|s`Si<@DTR(+v0j%aI~v$m$oixZQ{o2V%&Vc^+K&MycEmo75Tp`^$eeM-gB2nD zKLn$K+BwY^SGT(!{{H!eH!qstX3`UuN~*rOwoLoOnxp1;FT=CZ>&qLd#POHI(kiyy zLeo-G5RC#kBdCOcj##R_(|P{5v3X_n9~?(9%;biK#dcqo2d53*(UcPUArwbCVNF^i zESyh-Fy~Vp6Vk%{jM917Ph-$=3=9m=v*)}7YQI{XKS*&004BUeH`rGkr!yn6qgSh| zWu&7~*s9`Gr@>}3u@m$}Q~MJkTJ@hw$j(_R6Lw~rt=+gHWq1E9ou_ve)0<@vDxNQB z2wx;tDksI=uR|5yq>Ljh>@Q2K_*9aWSRHGe6qNNBjXi5-dLG9zw`x|I$ z;H+smFs4+KK4CWdhwR7NPnhgf6ZJT7h`l4ASv=nnVxg=Y2NmX+ z&`QS2Kb}qrE*B)9I4|wkF1?IWxlfj3W38IWoI9S`@9P-tK9Tc2(D>@|ePWgc6J?N) zJosKv`rJ4)#GN~kz}md$DQL)$UTbEyR*fcvkW86x&%LuM>Y7FBENaa1znJ?j)SRU( z4r$7@x}Q5c6HqZr_2@Kb4mxj)mz;)LFWAhdL@;umZB(ljPdbTfvYUtnpU@7p3RTH2 zAJZEjyo=m=bRpKc{!^y&WQY}$Qb5SaGtfaxf|w8$gAL-=YgX%Hh@15M@Hga@cxDZ* z$G*crRN^37X^0p$3>o%38m_LiLUnPO_IdK6#hUKpR)3d1)3nsOw}vjU&+Du_u^RMn zFEv|PuTXIAjKO3-ds1#mSEEczN4Em~^Lxk=k<*z25U6)_>MWm|J<^w|B891rPTtmO zT3r3}TdZr4j7nriV7t>@<~p4`!#mJcG&VI6T!tS-Y_q_ALlfw{@FkKmrEG_7OONA? zt}2hZH~FQW$K&={dt>F-%D3?s-p#k)2D!tH|MFv20GBjCo7mo5S0zh35XUp1E$yK` zgoCHBrc!8l62aQc=(mZQZOm3dggu3tphVK+xCJDRisfe?pXpipXU!|15W+@+Spu6k zd<)-2|HD~c9BLhpY=m)zlssG2qDHdxT=qUYy8@6|cdtt=!z;HMf1TZZ><{TvH^+pH z6z9H&%L+8T0gZ-aYPA6pD=C z=1uDpsl0oLEhd63#=Y*Mr4VQUVh z8}q3D`4a=1`H#e*mRrf=Dd;7^t(F^|HhO_-vhPdzgDyymjY#4b#)%tzaI^H1QLJ!*3#O&AiXMhb9 zpzQ}E6o}4|h$sc{IsKzY-QR4#@;VkEPXz={4|FG-2HI{dDu9A76M%3_S65f=8jMhA zBAsJ^T%ljT(gT=;PC5(^7??d|v5n`lOa#ea27oXI(7VHw0wiD`Z|@%Hdi2-0pYzrX z{qR|Xg3wB$ivuu6)*K=nO6vKla0ZyFRM`#`ZbtwxtsCwZb|U>Dp3%o2+5zQ76|*=~ zRDqDeN%0U&Pnpl+NhxSbctM1~0KH~d=+Rt+7VccsZ73GutqS%K(gvhfecu-B-`&DREn4EfwIxsCyKjf@W|}y(L{y6&X^j`RnRXhuTEuYc zoaI<(`Hr5i^@wz|Jbql76XCQJ%)Pag#*}Nn`rE5Rlumm7_>}JStm4qn>zZ97)wZq6 zl%VxX?Zn%M#6(1m27)*ary#~rX=dc$;E-O)RVlo0qLI|&FbXZi&)IXAu8}{g0X^tE zkn^g17E(ZtUBkocuyrrg?`VC^K||A^J&uS>_+i?oW1EeKXJo9#-92l=8fxPC7f<=| zfwJ#_vY`;21Dz(=zjIS-H6tUVc_3dJrtJ!vIUYR7tj#apTOKY<B)oV5q14_wV1OVM~KK(T5;fC=sX$32qk;XUs>}Al;0t6DZWos^g)?Hwe>$a%SKc zg@D`G2~>3xig`&Qf%6QXhD#lxj8Jd$1I3QWI4Jr%pt7(zI9ywtSxUE74dw=&T-FJ6 zs}KC<8?%D6%x48q&1wa@-axtDMmji$Pdto_csRik{Ky$Lcx-8ulq!bUH7ioZfqSff z9^txQv7rb7ZC0Uh-mHxm6I0&#$R;+DRXC%3OyP0d)3hgDd}$A6np&#Fh>j8d8Au0+x(vj9Fbyd5#*7bPq)7MFWx1aL92w*V ztpK={!c5~A0I2Y|pH%`445|}h5M3w)&w>cy2K7s%tXl?->`QzpG7|@FO=fpZ@=-ol zL`=o!Y&Ur4xS`>NWDM79=+9XFdv-sqqaK6SMi`>xn|6$+clFu?0fC_i=CRS|JF6d)i9VbnTS{r={e;taA$Fjtdt zU*c;5h#az6VdMJ2TIXQi2GA(t#`4IzO%0&rbvoH>E+jn%R=gK(8kIEa0Cd_dX7zOQ zakOYKbZJsy(uQfMX&I8D6kQeJD>kb8LK);B zNLFXF0t?i4g{3mcmfU%FIRzQPf;w3COI#^9Ud!2&(Sv7ne#k@vmExb z!eEs9F7}_wjq2xl#JEGCR4cY!QN;8l-`kkXL)y-U31YoPMtxQ%@u1;}bC~vaSE?oTaB%I8#ccDmtia5@1yHA=dM)I!MIBm=JP*|$F zMA_amm2V?bEaY`fML+~nvF9a2d^ z)QHHz55*;tH<_1kyl`R8Q6ta;H4w)kX@00o+_(YGSvvj^8yS;D7;kk7VEGyv8nP#! zRA??BLU9G~yX?uerH{d`so{|0=arGkC}iZoxHsAR;#HD2+m&6Rj?FuYk^YaD=C=>1 zU;2G7tAZ(w*0wfrU?92I3vMZy(k?|?RJR`n%+9+_+A~ViJg1Yjg^tu3MJ`g@*M?bWQ?R zwa)Mk9sWTEMXjtM&Adbm4YFfSb%DJ|7yld0x5~xFVGw(1#>vObVH6CRN7e6&GuynD zUD%))oGKk3msB?ddVBIVsG1p|$t4QYsKG#~Me7_+p6zj#mz`{^q!uq>6t+#I@D|ps zN-UVUxquRiyR!;>0-5#`c5)-$ak$mQ-9hCc*rR=2FyXrJix-BI)vm_S{T+6wXJ)?O zc5yPuOXczuIan36p~>iRpOx;AwoV-6gABr}Q4b;#LU>WUztOr^+A*TUqd+Wn{?>Yv>)4rp{7Iw+ok%8^oS3mJqnO8j=1>rxE z?XvD7liW!9hDm2i*PSoOIAI4g9)V~XLi+9Yd_&L^K@=r0G%!SFQTYw&j{rR>GLr!W zAQGA3ZRh0|+M?J^tWNH<#hm>aHi!=c`A%}Q4uM28$6AT4oUCktTcE5H1xnUfLBVjU*SFM2?!4~C;V61=r~011 z?QN4QgC$9_VnqGMzAIeJH$06mtSol()C$VHR#mOLAe-NlNS#G=d-@aU1zZB}+J6*& zMEx^+TyJ$>#18KrLGDW4>}|s@x)pAB!6GX+3RMq|EfrlW1m&@$xr>LEq zoqeINuiu+$4mAFrrdBC*AR30CRr47^(FteH#rA#{^cXV$=VmImJADBp&|Iv7BlFJJ zX{N|OJCz*Wez&s(ZAv=KE72|QA+W;G#QhhdqTbKDr{D}EZ!CX<@Jn0W+1~E=5QNPM zTF$KuX*AWEHEyS`-C3Y)@y_e(?Y&lJeeO9sJ4>^>0V5SVCR$mbQh2Q0@R{VX_KTCv zK=-i~>(CUGcet;w%EiL|nX`s;MC*a89*!N1yFdQ> zbUEMmSk^h`7~FjSz;VrlBlD7TGbHo-J*kkJq=hGPf>jJWXxr3`3x`gpvrV;@mu8EsdHa#ijAT$zxx3%^ zvobQgLEFxF9f>eO;8=vRG;?*x)pU*I2K=e4N2*h=}$!!mD|WHh}`sPKYtc< zM>{X^hD`$w_W@b&UC{0CB2wk)O(->!-{`v2m0cxyH0ALjANnEOMmK+rnpc?BbO~%W z+}!7>l_VAgZ3y(z9%>8UT{w7(` z8imUV=-VcAeC!|*PqVlmino7#Obxg_lN{l3(8Y1L2&(HR2 zHuh~x$E?JAO}{_`tr;fuE1RA6ki3qIi#wehBIWa$Ie_p-ZjXKfxQHfDuHXD72boX@a-~;}`;f3RMF2m{7_6qJr+-Ga zS(0;nw*EFYs{5TEDf_aAoY>s~1tq0sfa`llBHX*p;KJJQv)&mkKqpwX&_*MK5PId@%)%OMlE|yF+vckvZ#t4c*wB%iXOxXk^ibanO9g_4F((fyj~e z0Ml#;rRcO{1(rvZq(u$Dbb^(HzP`R0I)Ovc_SmPKAbh(Gi8cVN&;eSI(*RneV}5>j zmMfN*)6|S(JMjIi49W^P4e~$5rIT*X+7FyT?KlnnMt{x;2WrJ^;#K zcWD=`r0Ga|w7Wt~2_I`Qoew2_14CL20bmnkeDprWBs4@MzCQZxL3Z&BY+YKlH8?Jg z0=PtfDD`Lt3js+#)O6WwOt#(0n1|+)6q>uc)MB!1cTf#q{5;opFT0IDHavX(0}0sb zcchETf3!&b@z*6X+SHQF%*>xKufj9oePjkNWC?nWX3(ksH6Cc?{eqbRc_k$gJv}|s zU*GQ{g%z@IV3eslchHERK#%P%8A3m!d`1=K*_!O88TMmN?r}ckh-;` zy4|qeab+iTI}_c{&o*I#v>5!5=|Xr47!)d%OdbOHrJNQ zen3chDJ3O!6}&ERZx}Wb@gwN%leix34D0ZmPY}Y9?1N(LE0hL|Y9)*a^jj=N?hA>b z89tVT6hg40SUhx36mKCAPut%G=1#O>rrMeD%fF#iMak7^YqsSv6o=_xDgd-bm2U-j z6n*|P#6kWRvYZAmR`Wq!ll+A7{$#|_`}w{bzk_>*3dQ~hXRRpUyls}t_3H82#5gcN{$0BonIG-)q@V4 z24CZv^u*N;4JqSHEjoY_G&9=YOa#7(W}N-zD!P`)NN9hyE!Uw z%s4o=AR?JVUG|Cz8LtXqoFWO2h#)qAt6sR2d&X1=7ST~bgQXXO`HY48vD%nb8E)(ddm=}jay zBcJjiIC3-HYZU_}@>)ejb_!BEqdko?;8!k$Qm)AIhR;U_o3N;ZW8Wa*Rxr?zEs@Fd|!Ka zd0^8^;N;oeDH3_G6M#bg%gf7vPFSV|&JU$`HYNp{D1YD7?~W53x6HaCTMKG}=@{!y z@0gevo(g^6Tn!zwnWh^G3b-yos&Uo<_xfeQx^V zKS+2T-kFY-<*#FUbjBtvl`lM4uX7{O*ArejKNk3yo&vNN@-a3aQ7A(yQsIWvR+I?R zP@#VK@F5giuj$e-li7_4C8hb;s~wh7E{m+JX-P^Jk@2vxR@lnBjp1_ z-iX|z9pyn73?~i9C8uzuxVq6k~H)8c}F4$7vYm6y$mQ;=6tW*x=i zkQL3;GQ=5|A&enG$}3pWF=VJPrcgw2s~TWXfwfE?qC}$|5(oR8FIi%QxcJxBgr+6E z_kQ=@bI<*q-+8!K_vOdjDr{a+3l~B~pU%VXWKz@)x5a7MJ3Ba4iAmW!7kr3a~@35H5dP zY_J)E|4|ueQ&H>b&Kl3s${osqunQT@voAe<9HT1}@tUsRzFMP4u-{R*`UJ7DCE*MM z)HFX9L6lmggs=RKV{VKF_R?7kNz&(c4x5pP0{Z0}hjQVBtEYeSDP=gt5;zRb!Z_>0+2JA2+lOCJHwJ>#4%Brz0u2^pg;1YEMp&)vqx0?%xNRQH zG!@bx&{+g{Vqr)~A=JcVCU4aj3^;u)BCi{-Uk@@j#sb1{VHs83&5qMOokrt55du3B zccFy>i$M{H{pE*nZJk&e`Jqm3IMVXWgmEz3bk6s)%}YW4ZzyC$keFB*{#7|J16aw0 z`pS;@KzQ7p!)D}-qH5KaX9J`YrJO0#B^hq!=c@9oJWRl?5VM$0t8>1-c*l`wAO}WL%4A*OR&2TgX9vNSP)6} zMY($=IlG${9m{>d`E^7B702D((?}B|uRS|4=Cjuw;8m*!Ru*iJjurr%TB{t*;rl)5)6PF=W@|YoEJ29)^B|v1VtD_NN4VTEsmB6x-5tj@ zVKRg&mFgkF;;sOl=R@HDyZuq1;8YN3TeWYmnFIeFP)BMAQ1Z?whS36KRbd4UO|ECs z0ViGjXBL;1rV%bcBA@=p-rPV)5oG+12f|7skbx@&eb+v(+oeRU&OoR20F)Le7AD-X zMeV|#@5_eF+Gmj@U0OKP_%HxDJ=2R}>)`@m!KNI6{XnllLfT(O&|n=Zwm??IrwK!y zp1?JQPKTu3V}Ql=P#yCCp3>Oe<*!5rnmr_6e*Jo6L3$uR*&If729R32ZZ@f(jkUU6muHbN(1$`IqDdcP^)fa?Mc~FpAsoGu zXv};DX79R#p~eocDp85$~#-Duu-QA*AmV zlm|^0blDNO1{{xdMM5>5#U_xnb(lRU&@^Z}4H%gS)VTtkRXT~4Mg}T#Lp8?dlVy|Cp*1`z-(AREM)i=WXv8-Mab$ RCxD*i1K)u6)vLGe{~JAb!ruS@ literal 36189 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 Date: Thu, 20 Nov 2025 17:40:31 +0100 Subject: [PATCH 57/62] #1901 prompt user to set default USB configuration to 'No data transfer' after starting pro mode --- CHANGELOG.md | 4 ++ app/proguard-rules.pro | 2 + .../keymapper/base/promode/ProModeScreen.kt | 58 ++++++++++++++++++- .../base/promode/ProModeViewModel.kt | 12 +++- .../base/promode/SystemBridgeSetupUseCase.kt | 33 ++++++++++- base/src/main/res/values/strings.xml | 3 + .../keymapper/sysbridge/ISystemBridge.aidl | 2 + .../sysbridge/service/SystemBridge.kt | 15 +++++ .../android/hardware/usb/IUsbManager.aidl | 5 ++ 9 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 systemstubs/src/main/aidl/android/hardware/usb/IUsbManager.aidl diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ad4de024..c0aeb91428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ - #1414 constraint for when the keyboard is showing. - #1900 log to logcat if extra logging is enabled. +## Bug fixes + +- #1901 prompt user to set default USB configuration to 'No data transfer' after starting pro mode. + ## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02) #### 08 November 2025 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a81ca3f5d6..3ba9020698 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -79,6 +79,8 @@ -keep class com.android.internal.telephony.ITelephony$Stub { *; } -keep class android.net.ITetheringConnector { *; } -keep class android.net.ITetheringConnector$Stub { *; } +-keep class android.hardware.usb.IUsbManager { *; } +-keep class android.hardware.usb.IUsbManager$Stub { *; } -keep class android.net.* { *; } -keepattributes *Annotation*, InnerClasses diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeScreen.kt index f35cea093f..719c2cfabb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeScreen.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.promode import android.os.Build +import android.provider.Settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -33,6 +34,7 @@ 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 import androidx.compose.material3.ButtonDefaults @@ -54,6 +56,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -68,6 +71,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.SwitchPreferenceCompose import io.github.sds100.keymapper.base.utils.ui.compose.icons.FakeShizuku 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 @Composable @@ -295,7 +299,17 @@ private fun LoadedContent( } when (state) { - ProModeState.Started -> { + is ProModeState.Started -> { + if (!state.isDefaultUsbModeCompatible) { + IncompatibleUsbModeCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + + Spacer(Modifier.height(8.dp)) + } + ProModeStartedCard( modifier = Modifier .fillMaxWidth() @@ -429,6 +443,44 @@ private fun LoadedContent( } } +@Composable +private fun IncompatibleUsbModeCard(modifier: Modifier = Modifier) { + val ctx = LocalContext.current + SetupCard( + modifier = modifier, + color = MaterialTheme.colorScheme.errorContainer, + icon = { + Icon( + imageVector = Icons.Rounded.Usb, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + title = stringResource( + R.string.pro_mode_setup_wizard_change_default_usb_configuration_title, + ), + content = { + Text( + text = stringResource( + R.string.pro_mode_setup_wizard_change_default_usb_configuration_description, + ), + style = MaterialTheme.typography.bodyMedium, + ) + }, + buttonText = stringResource( + R.string.button_fix, + ), + onButtonClick = { + // Go to developer options and highlight the "Default USB configuration" option + SettingsUtils.launchSettingsScreen( + ctx, + Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS, + "default_usb_configuration", + ) + }, + ) +} + @Composable private fun WarningCard( modifier: Modifier = Modifier, @@ -692,7 +744,7 @@ private fun PreviewDark() { ProModeScreen { Content( warningState = ProModeWarningState.Understood, - setupState = State.Data(ProModeState.Started), + setupState = State.Data(ProModeState.Started(isDefaultUsbModeCompatible = true)), showInfoCard = false, onInfoCardDismiss = {}, autoStartAtBoot = true, @@ -728,7 +780,7 @@ private fun PreviewStarted() { ProModeScreen { Content( warningState = ProModeWarningState.Understood, - setupState = State.Data(ProModeState.Started), + setupState = State.Data(ProModeState.Started(isDefaultUsbModeCompatible = false)), showInfoCard = false, onInfoCardDismiss = {}, autoStartAtBoot = false, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt index 8a20e3c561..ef43cd2051 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt @@ -12,7 +12,7 @@ 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 javax.inject.Inject +import io.github.sds100.keymapper.common.utils.valueOrNull import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -25,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 ProModeViewModel @Inject constructor( @@ -156,7 +157,12 @@ class ProModeViewModel @Inject constructor( isNotificationPermissionGranted: Boolean, ): State { if (isSystemBridgeConnected) { - return State.Data(ProModeState.Started) + return State.Data( + ProModeState.Started( + isDefaultUsbModeCompatible = + useCase.isCompatibleUsbModeSelected().valueOrNull() ?: false, + ), + ) } else { return State.Data( ProModeState.Stopped( @@ -182,5 +188,5 @@ sealed class ProModeState { val isNotificationPermissionGranted: Boolean, ) : ProModeState() - data object Started : ProModeState() + data class Started(val isDefaultUsbModeCompatible: Boolean) : ProModeState() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt index d75044ae03..dfac84f80e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt @@ -1,9 +1,11 @@ package io.github.sds100.keymapper.base.promode import android.os.Build +import android.os.Process import androidx.annotation.RequiresApi import dagger.hilt.android.scopes.ViewModelScoped import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.PreferenceDefaults @@ -19,7 +21,6 @@ import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import javax.inject.Inject @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) @ViewModelScoped @@ -42,6 +44,15 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( private val accessibilityServiceAdapter: AccessibilityServiceAdapter, private val networkAdapter: NetworkAdapter, ) : SystemBridgeSetupUseCase { + + companion object { + /** + * This is specified in android/hardware/usb/UsbManager.java and called + * FUNCTION_NONE in that file. + */ + private const val USB_FUNCTION_NONE = 0 + } + override val isWarningUnderstood: Flow = preferences.get(Keys.isProModeWarningUnderstood).map { it ?: false } @@ -198,6 +209,24 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( } } + /** + * This applies to older Android versions (tested on Android 11 and 13). + * Having the "Default USB Configuration" in developer options set to something other + * than "No data transfer" causes the system bridge (ADB) process to be killed when the device + * is locked. Not 100% sure what the mechanic is but it can be reproduced by pressing the + * power button. On Android 15 this is not the case because it seems like turning on + * wireless debugging or ADB resets the setting to "No data transfer". + */ + override fun isCompatibleUsbModeSelected(): KMResult { + return systemBridgeConnectionManager + .run { systemBridge -> + // The USB setting does not matter if the system bridge is running as root + // because it doesn't rely on the ADB process. + systemBridge.processUid == Process.SHELL_UID && + systemBridge.usbScreenUnlockedFunctions.toInt() == 0 + } + } + @RequiresApi(Build.VERSION_CODES.R) private fun getNextStep( accessibilityServiceState: AccessibilityServiceState, @@ -253,4 +282,6 @@ interface SystemBridgeSetupUseCase { fun startSystemBridgeWithRoot() fun startSystemBridgeWithShizuku() suspend fun startSystemBridgeWithAdb() + + fun isCompatibleUsbModeSelected(): KMResult } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 79b6ecb85d..4286af0b29 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1697,6 +1697,9 @@ Key Mapper needs permission to notify you if there are any issues with the set up process. Give permission + Incompatible USB configuration + You must select \'No data transfer\' as your default USB configuration so that PRO Mode is not killed every time you lock your device. + Setup assistant PRO mode is running diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl index 6ddec51060..64f7a0afe3 100644 --- a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl @@ -46,4 +46,6 @@ interface ISystemBridge { boolean isTetheringEnabled() = 19; void setTetheringEnabled(boolean enable) = 20; + + long getUsbScreenUnlockedFunctions() = 21; } \ No newline at end of file diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index 28389c77cb..ba92a552d4 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -12,6 +12,7 @@ import android.content.pm.ApplicationInfo import android.content.pm.IPackageManager import android.content.pm.PackageManager import android.hardware.input.IInputManager +import android.hardware.usb.IUsbManager import android.media.IAudioService import android.net.IConnectivityManager import android.net.ITetheringConnector @@ -31,6 +32,7 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.Process +import android.os.RemoteException import android.os.ServiceManager import android.permission.IPermissionManager import android.permission.PermissionManagerApis @@ -188,6 +190,7 @@ internal class SystemBridge : ISystemBridge.Stub() { private val activityManager: IActivityManager private val activityTaskManager: IActivityTaskManager private val audioService: IAudioService? + private val usbManager: IUsbManager? private val processPackageName: String = when (Process.myUid()) { Process.ROOT_UID -> "root" @@ -281,6 +284,10 @@ internal class SystemBridge : ISystemBridge.Stub() { tetheringConnector = null } + waitSystemService(Context.USB_SERVICE) + usbManager = + IUsbManager.Stub.asInterface(ServiceManager.getService(Context.USB_SERVICE)) + val applicationInfo = getKeyMapperPackageInfo() if (applicationInfo == null) { @@ -767,4 +774,12 @@ internal class SystemBridge : ISystemBridge.Stub() { tetheringConnector.stopTethering(TETHERING_WIFI, processPackageName, null, null) } } + + override fun getUsbScreenUnlockedFunctions(): Long { + return try { + usbManager?.screenUnlockedFunctions ?: 0 + } catch (_: RemoteException) { + -1 + } + } } diff --git a/systemstubs/src/main/aidl/android/hardware/usb/IUsbManager.aidl b/systemstubs/src/main/aidl/android/hardware/usb/IUsbManager.aidl new file mode 100644 index 0000000000..d40de6df6b --- /dev/null +++ b/systemstubs/src/main/aidl/android/hardware/usb/IUsbManager.aidl @@ -0,0 +1,5 @@ +package android.hardware.usb; + +interface IUsbManager { + long getScreenUnlockedFunctions(); +} \ No newline at end of file From 57b9edb7ab2e20ce5b921b0f0757da210919d828 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 20 Nov 2025 17:53:35 +0100 Subject: [PATCH 58/62] #1898 do not launch directly into the Wireless Debugging activity on Xiaomi devices due to a bug they introduced. --- CHANGELOG.md | 1 + .../base/promode/SystemBridgeSetupUseCase.kt | 2 +- .../service/SystemBridgeSetupController.kt | 21 ++++++++++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0aeb91428..95cdca1f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ## Bug fixes - #1901 prompt user to set default USB configuration to 'No data transfer' after starting pro mode. +- #1898 do not launch directly into the Wireless Debugging activity on Xiaomi devices due to a bug they introduced. ## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt index dfac84f80e..030e5cdbdb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt @@ -21,6 +21,7 @@ import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -30,7 +31,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import javax.inject.Inject @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) @ViewModelScoped 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 9fb3b3dbbd..7a96446c2d 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 @@ -255,6 +255,13 @@ class SystemBridgeSetupControllerImpl @Inject constructor( * false then developer options was launched. */ private fun launchWirelessDebuggingActivity(): Boolean { + // See issue #1898. Xiaomi like to do dodgy stuff, which causes a crash + // when long pressing the quick settings tile for wireless debugging. + if (Build.BRAND in setOf("xiaomi", "redmi", "poco")) { + highlightDeveloperOptionsWirelessDebuggingOption() + return false + } + val quickSettingsIntent = Intent(TileService.ACTION_QS_TILE_PREFERENCES).apply { // Set the package name because this action can also resolve to a "Permission Controller" activity. val packageName = "com.android.settings" @@ -280,16 +287,20 @@ class SystemBridgeSetupControllerImpl @Inject constructor( ctx.startActivity(quickSettingsIntent) return true } catch (_: ActivityNotFoundException) { - SettingsUtils.launchSettingsScreen( - ctx, - Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS, - "toggle_adb_wireless", - ) + highlightDeveloperOptionsWirelessDebuggingOption() return false } } + private fun highlightDeveloperOptionsWirelessDebuggingOption() { + SettingsUtils.launchSettingsScreen( + ctx, + Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS, + "toggle_adb_wireless", + ) + } + fun invalidateSettings() { isDeveloperOptionsEnabled.update { getDeveloperOptionsEnabled() } isWirelessDebuggingEnabled.update { getWirelessDebuggingEnabled() } From 8b4271a6452b157d9db97840fdd4c1e24025836d Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 20 Nov 2025 18:40:58 +0100 Subject: [PATCH 59/62] #1902 feat: add toggle next to record trigger button to use PRO mode --- CHANGELOG.md | 1 + .../trigger/ConfigTriggerViewModel.kt | 3 + .../base/promode/ProModeViewModel.kt | 2 +- .../trigger/BaseConfigTriggerViewModel.kt | 48 ++++- .../base/trigger/BaseTriggerScreen.kt | 73 ++++++-- .../base/trigger/ProModeRecordSwitchState.kt | 7 + .../base/trigger/RecordTriggerButtonRow.kt | 121 ++++++++++++ .../base/trigger/RecordTriggerController.kt | 14 +- .../base/trigger/TriggerSetupDelegate.kt | 6 +- .../trigger/ConfigTriggerViewModelTest.kt | 173 ++++++++++++++++++ 10 files changed, 424 insertions(+), 24 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/trigger/ProModeRecordSwitchState.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerViewModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cdca1f7a..e56c2ab942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - #1491 action to toggle/enable/disable hotspot. - #1414 constraint for when the keyboard is showing. - #1900 log to logcat if extra logging is enabled. +- #1902 add toggle next to record trigger button to use PRO mode. ## Bug fixes diff --git a/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt index 898a1d21b4..603a15602b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt @@ -16,6 +16,7 @@ import io.github.sds100.keymapper.base.trigger.TriggerSetupShortcut import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import javax.inject.Inject import kotlinx.coroutines.launch @@ -27,6 +28,7 @@ class ConfigTriggerViewModel @Inject constructor( private val createKeyMapShortcut: CreateKeyMapShortcutUseCase, private val displayKeyMap: DisplayKeyMapUseCase, private val fingerprintGesturesSupported: FingerprintGesturesSupportedUseCase, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, onboardingTipDelegate: OnboardingTipDelegate, triggerSetupDelegate: TriggerSetupDelegate, @@ -41,6 +43,7 @@ class ConfigTriggerViewModel @Inject constructor( displayKeyMap = displayKeyMap, fingerprintGesturesSupported = fingerprintGesturesSupported, setupAccessibilityServiceDelegate = setupAccessibilityServiceDelegate, + systemBridgeConnectionManager = systemBridgeConnectionManager, onboardingTipDelegate = onboardingTipDelegate, triggerSetupDelegate = triggerSetupDelegate, resourceProvider = resourceProvider, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt index ef43cd2051..415b7163f0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.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 ProModeViewModel @Inject constructor( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt index 9ea19d39e7..b94322819c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.trigger +import android.os.Build import android.view.KeyEvent import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -36,6 +37,8 @@ import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.mapData +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -59,6 +62,7 @@ abstract class BaseConfigTriggerViewModel( private val displayKeyMap: DisplayKeyMapUseCase, private val fingerprintGesturesSupported: FingerprintGesturesSupportedUseCase, private val setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, onboardingTipDelegate: OnboardingTipDelegate, triggerSetupDelegate: TriggerSetupDelegate, resourceProvider: ResourceProvider, @@ -75,6 +79,18 @@ abstract class BaseConfigTriggerViewModel( companion object { private const val DEVICE_ID_ANY = "any" private const val DEVICE_ID_INTERNAL = "internal" + + fun buildProModeSwitchState( + recordTriggerState: RecordTriggerState, + isProModeRecordingEnabled: Boolean, + systemBridgeState: SystemBridgeConnectionState, + ): ProModeRecordSwitchState { + return ProModeRecordSwitchState( + isVisible = systemBridgeState is SystemBridgeConnectionState.Connected, + isChecked = isProModeRecordingEnabled, + isEnabled = recordTriggerState !is RecordTriggerState.CountingDown, + ) + } } val optionsViewModel = ConfigKeyMapOptionsViewModel( @@ -96,6 +112,29 @@ abstract class BaseConfigTriggerViewModel( RecordTriggerState.Idle, ) + val proModeSwitchState: StateFlow = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + combine( + recordTrigger.state, + recordTrigger.isEvdevRecordingEnabled, + systemBridgeConnectionManager.connectionState, + Companion::buildProModeSwitchState, + ) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + ProModeRecordSwitchState( + isVisible = false, + isChecked = false, + isEnabled = false, + ), + ) + } else { + MutableStateFlow( + ProModeRecordSwitchState(isVisible = false, isChecked = false, isEnabled = false), + ) + } + val showFingerprintGesturesShortcut: StateFlow = fingerprintGesturesSupported.isSupported.map { it ?: false } .stateIn(viewModelScope, SharingStarted.Lazily, false) @@ -116,6 +155,10 @@ abstract class BaseConfigTriggerViewModel( private val midDot = getString(R.string.middot) init { + // Always disable when launching the trigger screen because recording with PRO mode should + // only be used when necessary. + recordTrigger.setEvdevRecordingEnabled(false) + // IMPORTANT! Do not flow on another thread because this causes the drag and drop // animations to be more janky. combine( @@ -486,7 +529,7 @@ abstract class BaseConfigTriggerViewModel( is RecordTriggerState.Completed, RecordTriggerState.Idle, - -> recordTrigger.startRecording(enableEvdevRecording = false) + -> recordTrigger.startRecording() } // Show dialog if the accessibility service is disabled or crashed @@ -494,6 +537,9 @@ abstract class BaseConfigTriggerViewModel( } } + fun onProModeSwitchChange(isChecked: Boolean) = + recordTrigger.setEvdevRecordingEnabled(isChecked) + fun handleServiceEventResult(result: KMResult<*>) { if (result is AccessibilityServiceError) { showFixAccessibilityServiceDialog(result) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt index f6f80c4fdd..fa33295b6c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt @@ -59,6 +59,7 @@ fun BaseTriggerScreen( val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val recordTriggerState by viewModel.recordTriggerState.collectAsStateWithLifecycle() + val proModeSwitchState by viewModel.proModeSwitchState.collectAsStateWithLifecycle() val showFingerprintGestures: Boolean by viewModel.showFingerprintGesturesShortcut.collectAsStateWithLifecycle() @@ -133,9 +134,11 @@ fun BaseTriggerScreen( modifier = modifier, configState = state.data, recordTriggerState = recordTriggerState, + proModeSwitchState = proModeSwitchState, onRemoveClick = viewModel::onRemoveKeyClick, onEditClick = viewModel::onTriggerKeyOptionsClick, onRecordTriggerClick = viewModel::onRecordTriggerButtonClick, + onProModeSwitchChange = viewModel::onProModeSwitchChange, onAdvancedTriggersClick = viewModel::onAdvancedTriggersClick, onSelectClickType = viewModel::onClickTypeRadioButtonChecked, onSelectParallelMode = viewModel::onParallelRadioButtonChecked, @@ -153,9 +156,11 @@ fun BaseTriggerScreen( modifier = modifier, configState = state.data, recordTriggerState = recordTriggerState, + proModeSwitchState = proModeSwitchState, onRemoveClick = viewModel::onRemoveKeyClick, onEditClick = viewModel::onTriggerKeyOptionsClick, onRecordTriggerClick = viewModel::onRecordTriggerButtonClick, + onProModeSwitchChange = viewModel::onProModeSwitchChange, onAdvancedTriggersClick = viewModel::onAdvancedTriggersClick, onSelectClickType = viewModel::onClickTypeRadioButtonChecked, onSelectParallelMode = viewModel::onParallelRadioButtonChecked, @@ -200,12 +205,14 @@ private fun TriggerScreenVertical( modifier: Modifier = Modifier, configState: ConfigTriggerState, recordTriggerState: RecordTriggerState, + proModeSwitchState: ProModeRecordSwitchState, onRemoveClick: (String) -> Unit = {}, onEditClick: (String) -> Unit = {}, onSelectClickType: (ClickType) -> Unit = {}, onSelectParallelMode: () -> Unit = {}, onSelectSequenceMode: () -> Unit = {}, onRecordTriggerClick: () -> Unit = {}, + onProModeSwitchChange: (Boolean) -> Unit = {}, onAdvancedTriggersClick: () -> Unit = {}, onMoveTriggerKey: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, onFixErrorClick: (TriggerError) -> Unit = {}, @@ -232,6 +239,8 @@ private fun TriggerScreenVertical( modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp), onRecordTriggerClick = onRecordTriggerClick, recordTriggerState = recordTriggerState, + proModeRecordSwitchState = proModeSwitchState, + onProModeSwitchChange = onProModeSwitchChange, onAdvancedTriggersClick = onAdvancedTriggersClick, ) } @@ -281,21 +290,23 @@ private fun TriggerScreenVertical( isCompact = isCompact, ) } - } - } - if (!isCompact) { - Spacer(Modifier.height(8.dp)) - } + if (!isCompact) { + Spacer(Modifier.height(8.dp)) + } - RecordTriggerButtonRow( - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), - onRecordTriggerClick = onRecordTriggerClick, - recordTriggerState = recordTriggerState, - onAdvancedTriggersClick = onAdvancedTriggersClick, - ) + RecordTriggerButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), + onRecordTriggerClick = onRecordTriggerClick, + recordTriggerState = recordTriggerState, + proModeRecordSwitchState = proModeSwitchState, + onProModeSwitchChange = onProModeSwitchChange, + onAdvancedTriggersClick = onAdvancedTriggersClick, + ) + } + } } } } @@ -305,12 +316,14 @@ private fun TriggerScreenHorizontal( modifier: Modifier = Modifier, configState: ConfigTriggerState, recordTriggerState: RecordTriggerState, + proModeSwitchState: ProModeRecordSwitchState, onRemoveClick: (String) -> Unit = {}, onEditClick: (String) -> Unit = {}, onSelectClickType: (ClickType) -> Unit = {}, onSelectParallelMode: () -> Unit = {}, onSelectSequenceMode: () -> Unit = {}, onRecordTriggerClick: () -> Unit = {}, + onProModeSwitchChange: (Boolean) -> Unit = {}, onAdvancedTriggersClick: () -> Unit = {}, onMoveTriggerKey: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, onFixErrorClick: (TriggerError) -> Unit = {}, @@ -336,6 +349,8 @@ private fun TriggerScreenHorizontal( .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), onRecordTriggerClick = onRecordTriggerClick, recordTriggerState = recordTriggerState, + proModeRecordSwitchState = proModeSwitchState, + onProModeSwitchChange = onProModeSwitchChange, onAdvancedTriggersClick = onAdvancedTriggersClick, ) } @@ -403,6 +418,8 @@ private fun TriggerScreenHorizontal( modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp), onRecordTriggerClick = onRecordTriggerClick, recordTriggerState = recordTriggerState, + proModeRecordSwitchState = proModeSwitchState, + onProModeSwitchChange = onProModeSwitchChange, onAdvancedTriggersClick = onAdvancedTriggersClick, ) } @@ -608,6 +625,11 @@ private fun VerticalPreview() { TriggerScreenVertical( configState = previewState, recordTriggerState = RecordTriggerState.Idle, + proModeSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = false, + isEnabled = true, + ), discoverScreenContent = { TriggerDiscoverScreen() }, @@ -622,6 +644,11 @@ private fun VerticalPreviewTiny() { TriggerScreenVertical( configState = previewState, recordTriggerState = RecordTriggerState.Idle, + proModeSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = true, + isEnabled = true, + ), discoverScreenContent = { TriggerDiscoverScreen() }, @@ -636,6 +663,11 @@ private fun VerticalEmptyPreview() { TriggerScreenVertical( configState = ConfigTriggerState.Empty, recordTriggerState = RecordTriggerState.Idle, + proModeSwitchState = ProModeRecordSwitchState( + isVisible = false, + isChecked = false, + isEnabled = true, + ), discoverScreenContent = { TriggerDiscoverScreen() }, @@ -650,6 +682,11 @@ private fun VerticalEmptyDarkPreview() { TriggerScreenVertical( configState = ConfigTriggerState.Empty, recordTriggerState = RecordTriggerState.Idle, + proModeSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = true, + isEnabled = false, + ), discoverScreenContent = { TriggerDiscoverScreen() }, @@ -664,6 +701,11 @@ private fun HorizontalPreview() { TriggerScreenHorizontal( configState = previewState, recordTriggerState = RecordTriggerState.Idle, + proModeSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = false, + isEnabled = true, + ), discoverScreenContent = { TriggerDiscoverScreen() }, @@ -693,6 +735,11 @@ private fun HorizontalEmptyPreview() { TriggerScreenHorizontal( configState = ConfigTriggerState.Empty, recordTriggerState = RecordTriggerState.Idle, + proModeSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = false, + isEnabled = true, + ), discoverScreenContent = { TriggerDiscoverScreen() }, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ProModeRecordSwitchState.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ProModeRecordSwitchState.kt new file mode 100644 index 0000000000..37d2add993 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ProModeRecordSwitchState.kt @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.base.trigger + +data class ProModeRecordSwitchState( + val isVisible: Boolean, + val isChecked: Boolean, + val isEnabled: Boolean, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerButtonRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerButtonRow.kt index fa34019f0e..64bff3dd91 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerButtonRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerButtonRow.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape @@ -26,6 +27,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -40,16 +42,30 @@ import androidx.compose.ui.unit.sp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.ProModeIcon +import io.github.sds100.keymapper.base.utils.ui.compose.icons.ProModeIconDisabled @Composable fun RecordTriggerButtonRow( modifier: Modifier = Modifier, onRecordTriggerClick: () -> Unit = {}, recordTriggerState: RecordTriggerState, + proModeRecordSwitchState: ProModeRecordSwitchState, + onProModeSwitchChange: (Boolean) -> Unit = {}, onAdvancedTriggersClick: () -> Unit = {}, ) { Column(modifier = modifier) { Row(verticalAlignment = Alignment.CenterVertically) { + if (proModeRecordSwitchState.isVisible) { + ProModeSwitch( + state = proModeRecordSwitchState, + onCheckedChange = onProModeSwitchChange, + ) + + Spacer(modifier = Modifier.width(8.dp)) + } + RecordTriggerButton( modifier = Modifier.weight(1f), recordTriggerState, @@ -63,6 +79,37 @@ fun RecordTriggerButtonRow( } } +@Composable +private fun ProModeSwitch( + modifier: Modifier = Modifier, + state: ProModeRecordSwitchState, + onCheckedChange: (Boolean) -> Unit, +) { + Switch( + modifier = modifier, + checked = state.isChecked, + enabled = state.isEnabled, +// colors = SwitchDefaults.colors( +// checkedTrackColor = MaterialTheme.colorScheme.surface, +// checkedThumbColor = LocalCustomColorsPalette.current.greenContainer, +// checkedIconColor = LocalCustomColorsPalette.current.onGreenContainer, +// checkedBorderColor = LocalCustomColorsPalette.current.green, +// ), + onCheckedChange = onCheckedChange, + thumbContent = { + Icon( + modifier = Modifier.padding(2.dp), + imageVector = if (state.isChecked) { + KeyMapperIcons.ProModeIcon + } else { + KeyMapperIcons.ProModeIconDisabled + }, + contentDescription = null, + ) + }, + ) +} + @Composable fun RecordTriggerButton(modifier: Modifier, state: RecordTriggerState, onClick: () -> Unit) { val colors = ButtonDefaults.filledTonalButtonColors().copy( @@ -149,6 +196,11 @@ private fun PreviewCountingDown() { RecordTriggerButtonRow( modifier = Modifier.fillMaxWidth(), recordTriggerState = RecordTriggerState.CountingDown(3), + proModeRecordSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = true, + isEnabled = true, + ), ) } } @@ -162,6 +214,11 @@ private fun PreviewStopped() { RecordTriggerButtonRow( modifier = Modifier.fillMaxWidth(), recordTriggerState = RecordTriggerState.Idle, + proModeRecordSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = false, + isEnabled = true, + ), ) } } @@ -175,6 +232,11 @@ private fun PreviewStoppedDark() { RecordTriggerButtonRow( modifier = Modifier.fillMaxWidth(), recordTriggerState = RecordTriggerState.Idle, + proModeRecordSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = true, + isEnabled = true, + ), ) } } @@ -188,6 +250,65 @@ private fun PreviewStoppedCompact() { RecordTriggerButtonRow( modifier = Modifier.fillMaxWidth(), recordTriggerState = RecordTriggerState.Idle, + proModeRecordSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = false, + isEnabled = true, + ), + ) + } + } +} + +@Preview(widthDp = 400) +@Composable +private fun PreviewProModeSwitchHidden() { + KeyMapperTheme { + Surface { + RecordTriggerButtonRow( + modifier = Modifier.fillMaxWidth(), + recordTriggerState = RecordTriggerState.Idle, + proModeRecordSwitchState = ProModeRecordSwitchState( + isVisible = false, + isChecked = false, + isEnabled = true, + ), + ) + } + } +} + +@Preview(widthDp = 400) +@Composable +private fun PreviewProModeSwitchDisabled() { + KeyMapperTheme { + Surface { + RecordTriggerButtonRow( + modifier = Modifier.fillMaxWidth(), + recordTriggerState = RecordTriggerState.Idle, + proModeRecordSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = true, + isEnabled = false, + ), + ) + } + } +} + +@Preview(widthDp = 400) +@Composable +private fun PreviewCompleted() { + KeyMapperTheme { + Surface { + RecordTriggerButtonRow( + modifier = Modifier.fillMaxWidth(), + recordTriggerState = RecordTriggerState.Completed(emptyList()), + proModeRecordSwitchState = ProModeRecordSwitchState( + isVisible = true, + isChecked = false, + isEnabled = true, + ), ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt index 5a1cceaeae..725ae003f7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt @@ -68,14 +68,14 @@ class RecordTriggerControllerImpl @Inject constructor( private val downEvdevEvents: MutableSet = mutableSetOf() private val dpadMotionEventTracker: DpadMotionEventTracker = DpadMotionEventTracker() - private var isEvdevRecordingEnabled: Boolean = false + override val isEvdevRecordingEnabled: MutableStateFlow = MutableStateFlow(false) override fun setEvdevRecordingEnabled(enabled: Boolean) { if (state.value is RecordTriggerState.CountingDown) { return } - isEvdevRecordingEnabled = enabled + isEvdevRecordingEnabled.value = enabled } override fun onInputEvent( @@ -88,7 +88,7 @@ class RecordTriggerControllerImpl @Inject constructor( when (event) { is KMEvdevEvent -> { - if (!isEvdevRecordingEnabled) { + if (!isEvdevRecordingEnabled.value) { return false } @@ -172,7 +172,7 @@ class RecordTriggerControllerImpl @Inject constructor( } } - override suspend fun startRecording(enableEvdevRecording: Boolean): KMResult<*> { + override suspend fun startRecording(): KMResult<*> { val serviceResult = accessibilityServiceAdapter.send(AccessibilityServiceEvent.Ping("record_trigger")) @@ -184,7 +184,6 @@ class RecordTriggerControllerImpl @Inject constructor( return Success(Unit) } - this.isEvdevRecordingEnabled = enableEvdevRecording recordingTriggerJob = recordTriggerJob() return Success(Unit) @@ -231,6 +230,8 @@ class RecordTriggerControllerImpl @Inject constructor( // Run on a different thread in case the main thread is locked up while recording and // the evdev devices aren't ungrabbed. private fun recordTriggerJob(): Job = coroutineScope.launch(Dispatchers.Default) { + val isEvdevRecordingEnabled = isEvdevRecordingEnabled.value + Timber.i("Starting trigger recording. Evdev recording: $isEvdevRecordingEnabled") recordedKeys.clear() @@ -273,11 +274,12 @@ interface RecordTriggerController { val state: StateFlow val onRecordKey: Flow + val isEvdevRecordingEnabled: StateFlow fun setEvdevRecordingEnabled(enabled: Boolean) /** * @return Success if started and an Error if failed to start. */ - suspend fun startRecording(enableEvdevRecording: Boolean): KMResult<*> + suspend fun startRecording(): KMResult<*> fun stopRecording(): KMResult<*> } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt index f18b6d4fcf..c5af7ca363 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt @@ -386,6 +386,8 @@ class TriggerSetupDelegateImpl @Inject constructor( is TriggerSetupState.NotDetected -> true } + recordTriggerController.setEvdevRecordingEnabled(enableEvdevRecording) + viewModelScope.launch { val recordTriggerState = recordTriggerController.state.firstOrNull() ?: return@launch @@ -396,9 +398,7 @@ class TriggerSetupDelegateImpl @Inject constructor( is RecordTriggerState.Completed, RecordTriggerState.Idle, - -> recordTriggerController.startRecording( - enableEvdevRecording, - ) + -> recordTriggerController.startRecording() } result.onSuccess { diff --git a/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerViewModelTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerViewModelTest.kt new file mode 100644 index 0000000000..cf6696a678 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerViewModelTest.kt @@ -0,0 +1,173 @@ +package io.github.sds100.keymapper.base.trigger + +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.Test + +class ConfigTriggerViewModelTest { + + @Test + fun `switch is visible when system bridge is connected`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.Idle, + isProModeRecordingEnabled = false, + systemBridgeState = SystemBridgeConnectionState.Connected(time = 0L), + ) + + assertThat(result.isVisible, `is`(true)) + } + + @Test + fun `switch is not visible when system bridge is disconnected`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.Idle, + isProModeRecordingEnabled = false, + systemBridgeState = SystemBridgeConnectionState.Disconnected( + time = 0L, + isExpected = true, + ), + ) + + assertThat(result.isVisible, `is`(false)) + } + + @Test + fun `switch is not visible when system bridge is disconnected unexpectedly`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.Idle, + isProModeRecordingEnabled = false, + systemBridgeState = SystemBridgeConnectionState.Disconnected( + time = 0L, + isExpected = false, + ), + ) + + assertThat(result.isVisible, `is`(false)) + } + + @Test + fun `switch is checked when pro mode recording is enabled`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.Idle, + isProModeRecordingEnabled = true, + systemBridgeState = SystemBridgeConnectionState.Connected(time = 0L), + ) + + assertThat(result.isChecked, `is`(true)) + } + + @Test + fun `switch is not checked when pro mode recording is disabled`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.Idle, + isProModeRecordingEnabled = false, + systemBridgeState = SystemBridgeConnectionState.Connected(time = 0L), + ) + + assertThat(result.isChecked, `is`(false)) + } + + @Test + fun `switch is enabled when record trigger state is idle`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.Idle, + isProModeRecordingEnabled = false, + systemBridgeState = SystemBridgeConnectionState.Connected(time = 0L), + ) + + assertThat(result.isEnabled, `is`(true)) + } + + @Test + fun `switch is enabled when record trigger state is completed`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.Completed(emptyList()), + isProModeRecordingEnabled = false, + systemBridgeState = SystemBridgeConnectionState.Connected(time = 0L), + ) + + assertThat(result.isEnabled, `is`(true)) + } + + @Test + fun `switch is disabled when record trigger state is counting down`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.CountingDown(timeLeft = 3), + isProModeRecordingEnabled = false, + systemBridgeState = SystemBridgeConnectionState.Connected(time = 0L), + ) + + assertThat(result.isEnabled, `is`(false)) + } + + @Test + fun `switch is disabled when counting down even if pro mode recording is enabled`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.CountingDown(timeLeft = 5), + isProModeRecordingEnabled = true, + systemBridgeState = SystemBridgeConnectionState.Connected(time = 0L), + ) + + assertThat(result.isEnabled, `is`(false)) + assertThat(result.isChecked, `is`(true)) + } + + @Test + fun `switch is visible and checked when connected and enabled`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.Idle, + isProModeRecordingEnabled = true, + systemBridgeState = SystemBridgeConnectionState.Connected(time = 0L), + ) + + assertThat(result.isVisible, `is`(true)) + assertThat(result.isChecked, `is`(true)) + assertThat(result.isEnabled, `is`(true)) + } + + @Test + fun `switch is not visible when disconnected even if recording is enabled`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.Idle, + isProModeRecordingEnabled = true, + systemBridgeState = SystemBridgeConnectionState.Disconnected( + time = 0L, + isExpected = true, + ), + ) + + assertThat(result.isVisible, `is`(false)) + assertThat(result.isChecked, `is`(true)) + assertThat(result.isEnabled, `is`(true)) + } + + @Test + fun `switch is visible but disabled when counting down and connected`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.CountingDown(timeLeft = 1), + isProModeRecordingEnabled = true, + systemBridgeState = SystemBridgeConnectionState.Connected(time = 0L), + ) + + assertThat(result.isVisible, `is`(true)) + assertThat(result.isChecked, `is`(true)) + assertThat(result.isEnabled, `is`(false)) + } + + @Test + fun `switch is not visible and disabled when counting down and disconnected`() { + val result = BaseConfigTriggerViewModel.buildProModeSwitchState( + recordTriggerState = RecordTriggerState.CountingDown(timeLeft = 2), + isProModeRecordingEnabled = false, + systemBridgeState = SystemBridgeConnectionState.Disconnected( + time = 0L, + isExpected = false, + ), + ) + + assertThat(result.isVisible, `is`(false)) + assertThat(result.isChecked, `is`(false)) + assertThat(result.isEnabled, `is`(false)) + } +} From bd70a5590907433d50fd79fe9e77fe80ab606712 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 20 Nov 2025 21:16:16 +0100 Subject: [PATCH 60/62] 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 811046f1d5..003ec7e3c3 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=4.0.0-beta.3 -VERSION_CODE=193 +VERSION_CODE=194 VERSION_NUM=01 \ No newline at end of file From 76943a7128d15a98a7519c74c6aab782409e028b Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 20 Nov 2025 21:59:00 +0100 Subject: [PATCH 61/62] fix: do not show usb mode warning on rooted system bridge --- .../sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt index 030e5cdbdb..570da2409a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt @@ -222,7 +222,7 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( .run { systemBridge -> // The USB setting does not matter if the system bridge is running as root // because it doesn't rely on the ADB process. - systemBridge.processUid == Process.SHELL_UID && + systemBridge.processUid == Process.ROOT_UID || systemBridge.usbScreenUnlockedFunctions.toInt() == 0 } } From 4e1aea5dcd7af81e735f32e5fb4dd8b0da090fa1 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 25 Nov 2025 15:39:08 +0100 Subject: [PATCH 62/62] chore: update changelog and whats new for 4.0 beta 3 --- CHANGELOG.md | 2 +- base/src/main/assets/whats-new.txt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e56c2ab942..3e66269720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [4.0.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.03) -#### TO BE RELEASED +#### 25 November 2025 ## Added - #1871 action to modify any system settings. diff --git a/base/src/main/assets/whats-new.txt b/base/src/main/assets/whats-new.txt index f22f122931..4a199f4438 100644 --- a/base/src/main/assets/whats-new.txt +++ b/base/src/main/assets/whats-new.txt @@ -15,6 +15,8 @@ You can now remap ALL buttons when the screen is off (including the power button • Constraints for foldable hinge open/closed • Shortcuts on the trigger screen to guide setup • Select notification and alarm sounds for Sound action +• Constraints for keyboard is showing +• Switch next to record trigger button to use PRO mode ⚙️ Enhanced Controls • Enable or disable all key maps in a group at once