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/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/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/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 35feedbca9..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 @@ -949,4 +949,24 @@ sealed class ActionData : Comparable { data object ClearRecentApp : ActionData() { override val id: ActionId = ActionId.CLEAR_RECENT_APP } + + @Serializable + 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 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 b13b0757e3..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) { @@ -723,6 +725,26 @@ object ActionDataEntityMapper { ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp + + ActionId.MODIFY_SETTING -> { + val value = entity.extras.getData(ActionEntity.EXTRA_SETTING_VALUE) + .valueOrNull() ?: return null + + val settingTypeString = entity.extras.getData(ActionEntity.EXTRA_SETTING_TYPE) + .valueOrNull() ?: "SYSTEM" // Default to SYSTEM for backward compatibility + + val settingType = try { + SettingType.valueOf(settingTypeString) + } catch (_: IllegalArgumentException) { + SettingType.SYSTEM + } + + ActionData.ModifySetting( + settingType = settingType, + settingKey = entity.data, + value = value, + ) + } } } @@ -749,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 } @@ -825,6 +848,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 +1129,11 @@ object ActionDataEntityMapper { EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMillis.toString()), ) + is ActionData.ModifySetting -> listOf( + EntityExtra(ActionEntity.EXTRA_SETTING_VALUE, data.value), + EntityExtra(ActionEntity.EXTRA_SETTING_TYPE, data.settingType.name), + ) + else -> emptyList() } @@ -1279,5 +1308,7 @@ 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_SETTING to "modify_setting", ) } 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..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 @@ -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, @@ -231,6 +232,27 @@ class LazyActionErrorSnapshot( } } + is ActionData.ModifySetting -> { + return when (action.settingType) { + SettingType.SYSTEM -> { + if (!isPermissionGranted(Permission.WRITE_SETTINGS)) { + SystemError.PermissionDenied(Permission.WRITE_SETTINGS) + } else { + null + } + } + SettingType.SECURE, + 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/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index 481dc59c5b..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 @@ -147,4 +147,6 @@ enum class ActionId { FORCE_STOP_APP, CLEAR_RECENT_APP, + + MODIFY_SETTING, } 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/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 7659de15ef..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 @@ -651,6 +651,13 @@ 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 -> { + getString( + R.string.modify_setting_description, + arrayOf(action.settingKey, action.value), + ) + } } 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 0902ef74db..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 @@ -253,6 +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.APPS ActionId.CONSUME_KEY_EVENT -> ActionCategory.SPECIAL } @@ -383,6 +384,8 @@ 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_SETTING -> R.string.action_modify_setting } @DrawableRes @@ -760,6 +763,9 @@ object ActionUtils { return listOf(Permission.FIND_NEARBY_DEVICES) } + // Permissions handled based on setting type at runtime + ActionId.MODIFY_SETTING -> return emptyList() + else -> return emptyList() } @@ -890,6 +896,8 @@ object ActionUtils { ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit + + ActionId.MODIFY_SETTING -> Icons.Outlined.Settings } } @@ -936,6 +944,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.ShellCommand, is ActionData.InteractUiElement, is ActionData.MoveCursor, + is ActionData.ModifySetting, -> true else -> false 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/ChooseSettingScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt new file mode 100644 index 0000000000..bb8c2514ad --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingScreen.kt @@ -0,0 +1,248 @@ +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 +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.BottomAppBar +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 +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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 +import io.github.sds100.keymapper.base.R +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 +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: SettingType, + onQueryChange: (String) -> Unit = {}, + onCloseSearch: () -> Unit = {}, + onSettingTypeChange: (SettingType) -> Unit = {}, + onClickSetting: (String, String?) -> Unit = { _, _ -> }, + onNavigateBack: () -> Unit = {}, +) { + BackHandler(onBack = onNavigateBack) + + 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()) { + KeyMapperSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + 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), + ), + selectedState = settingType, + onStateSelected = onSettingTypeChange, + ) + + HorizontalDivider() + + 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 { + LoadedList( + modifier = Modifier.fillMaxSize(), + listItems = state.data, + onClick = onClickSetting, + ) + } + } + } + } + } + } +} + +@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 +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 new file mode 100644 index 0000000000..2e3c343042 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseSettingViewModel.kt @@ -0,0 +1,78 @@ +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.SettingType +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 +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@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(SettingType.SYSTEM) + 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) } + + 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( + Json.encodeToString( + ChooseSettingResult.serializer(), + ChooseSettingResult( + settingType = selectedSettingType.value, + key = key, + currentValue = currentValue, + ), + ), + ) + } + } +} + +data class SettingItem(val key: String, val value: String?) + +@Serializable +data class ChooseSettingResult( + val settingType: SettingType, + val key: String, + val currentValue: String?, +) 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..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 @@ -24,17 +25,22 @@ 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 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, @@ -54,6 +60,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, + ) init { coroutineScope.launch { @@ -63,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() { @@ -196,6 +221,71 @@ class CreateActionDelegate( } } + fun onDoneModifySettingClick() { + val state = modifySettingActionBottomSheetState ?: return + val result = ActionData.ModifySetting( + settingType = state.settingType, + settingKey = state.settingKey, + value = state.value, + ) + + modifySettingActionBottomSheetState = null + actionResult.update { result } + } + + fun onSelectSettingType(settingType: SettingType) { + modifySettingActionBottomSheetState = + modifySettingActionBottomSheetState?.copy( + settingType = settingType, + testResult = null, + ) + } + + fun onSettingKeyChange(key: String) { + modifySettingActionBottomSheetState = + modifySettingActionBottomSheetState?.copy( + settingKey = key, + testResult = null, + ) + } + + 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 ?: "", + testResult = null, + ) + } + } + + fun onSettingValueChange(value: String) { + modifySettingActionBottomSheetState = + 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!") @@ -927,6 +1017,18 @@ class CreateActionDelegate( ActionId.MOVE_CURSOR -> return createMoverCursorAction() ActionId.FORCE_STOP_APP -> return ActionData.ForceStopApp ActionId.CLEAR_RECENT_APP -> return ActionData.ClearRecentApp + + ActionId.MODIFY_SETTING -> { + val oldAction = oldData as? ActionData.ModifySetting + + modifySettingActionBottomSheetState = ModifySettingActionBottomSheetState( + 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/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/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 new file mode 100644 index 0000000000..8865110d47 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -0,0 +1,444 @@ +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.platform.LocalContext +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 +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 + +data class ModifySettingActionBottomSheetState( + val settingType: SettingType, + val settingKey: String, + val value: String, + val testResult: KMResult? = null, + val isPermissionGranted: Boolean = false, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModifySettingActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.modifySettingActionBottomSheetState != null) { + ModifySettingActionBottomSheet( + sheetState = sheetState, + state = delegate.modifySettingActionBottomSheetState!!, + onDismissRequest = { + delegate.modifySettingActionBottomSheetState = null + }, + onSelectSettingType = delegate::onSelectSettingType, + onSettingKeyChange = delegate::onSettingKeyChange, + onSettingValueChange = delegate::onSettingValueChange, + onChooseExistingClick = delegate::onChooseExistingSettingClick, + onTestClick = delegate::onTestModifySettingClick, + onRequestPermissionClick = delegate::onRequestModifySettingPermission, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneModifySettingClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModifySettingActionBottomSheet( + sheetState: SheetState, + state: ModifySettingActionBottomSheetState, + onDismissRequest: () -> Unit = {}, + onSelectSettingType: (SettingType) -> Unit = {}, + 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, + 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.modify_setting_bottom_sheet_title), + style = MaterialTheme.typography.headlineMedium, + ) + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + 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), + ), + selectedState = state.settingType, + 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(), + ) { + Text(stringResource(R.string.choose_existing_setting)) + } + + OutlinedTextField( + value = state.settingKey, + onValueChange = onSettingKeyChange, + label = { Text(stringResource(R.string.modify_setting_key_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + isError = settingKeyError != null, + supportingText = { + if (settingKeyError != null) { + Text( + text = settingKeyError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + OutlinedTextField( + value = state.value, + onValueChange = onSettingValueChange, + label = { Text(stringResource(R.string.modify_setting_value_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + 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, + ) + + 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.settingKey.isBlank()) { + settingKeyError = settingKeyEmptyErrorString + } + + if (state.value.isBlank()) { + settingValueError = settingValueEmptyErrorString + } + + if (settingKeyError == null && settingValueError == null) { + onDoneClick() + } + }, + ) { + Text(stringResource(R.string.pos_done)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + ModifySettingActionBottomSheet( + sheetState = sheetState, + state = ModifySettingActionBottomSheetState( + settingType = SettingType.GLOBAL, + settingKey = "adb_enabled", + value = "1", + ), + ) + } +} + +@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 ab1fe35d3b..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 @@ -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 @@ -1016,6 +1017,14 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = SdkVersionTooLow(minSdk = Constants.SYSTEM_BRIDGE_MIN_API) } } + + is ActionData.ModifySetting -> { + result = settingsAdapter.setValue( + action.settingType, + action.settingKey, + action.value, + ) + } } when (result) { 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/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/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/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..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 @@ -1,6 +1,7 @@ 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 import io.github.sds100.keymapper.base.actions.swipescreen.SwipePickCoordinateResult import io.github.sds100.keymapper.base.actions.tapscreen.PickCoordinateResult @@ -10,6 +11,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 @@ -38,6 +40,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 +175,12 @@ abstract class NavDestination(val isCompose: Boolean = false) { override val id: String = ID_SHELL_COMMAND_ACTION } + @Serializable + data class ChooseSetting(val settingType: 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/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(), diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 2a2fadb1d3..ebbdcbc3b1 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 @@ -1092,6 +1090,11 @@ Send SMS: "%s"" to %s Compose SMS Compose SMS: "%s" to %s + Set setting: %1$s = %2$s + System + Secure + Global + Modify setting Play sound Dismiss most recent notification Dismiss all notifications @@ -1173,6 +1176,18 @@ Force stop app Close and clear app from recents + 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/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 4418a7a8a1..9d1afb1d40 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,8 @@ 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" + const val EXTRA_SETTING_TYPE = "extra_setting_type" val DESERIALIZER = jsonDeserializer { val typeString by it.json.byNullableString(NAME_ACTION_TYPE) @@ -193,6 +195,7 @@ data class ActionEntity( SOUND, INTERACT_UI_ELEMENT, SHELL_COMMAND, + MODIFY_SETTING, } constructor( 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" 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/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..3551501e68 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingsAdapter.kt @@ -0,0 +1,101 @@ +package io.github.sds100.keymapper.system.settings + +import android.content.Context +import android.database.Cursor +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 io.github.sds100.keymapper.system.SystemError +import io.github.sds100.keymapper.system.permissions.Permission +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AndroidSettingsAdapter @Inject constructor( + @ApplicationContext private val context: Context, +) : SettingsAdapter { + private val ctx = context.applicationContext + + 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? + try { + cursor = ctx.contentResolver.query( + uri, + arrayOf("name", "value"), + null, + null, + null, + ) + + 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 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 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) + } + } 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) + } + } + } +} + +interface SettingsAdapter { + fun getAll(settingType: SettingType): Map + fun getValue(settingType: SettingType, key: String): String? + fun setValue(settingType: SettingType, key: String, value: String): KMResult +}