diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c136de1c..3e66269720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## [4.0.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.03) + +#### 25 November 2025 + +## 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. +- #1900 log to logcat if extra logging is enabled. +- #1902 add toggle next to record trigger button to use PRO mode. + +## 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) #### 08 November 2025 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8127b3ee91..3ba9020698 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -77,6 +77,11 @@ -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.hardware.usb.IUsbManager { *; } +-keep class android.hardware.usb.IUsbManager$Stub { *; } +-keep class android.net.* { *; } -keepattributes *Annotation*, InnerClasses -dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations 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/app/version.properties b/app/version.properties index 886f7bcec6..003ec7e3c3 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=194 VERSION_NUM=01 \ No newline at end of file diff --git a/base/src/main/assets/whats-new.txt b/base/src/main/assets/whats-new.txt index d170dd02d8..4a199f4438 100644 --- a/base/src/main/assets/whats-new.txt +++ b/base/src/main/assets/whats-new.txt @@ -6,12 +6,17 @@ 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 +• Show a custom notification +• Toggle hotspot 🆕 New Features • Redesigned Settings screen • 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 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..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 @@ -21,17 +21,17 @@ 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 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 import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupControllerImpl @@ -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 @@ -105,6 +99,12 @@ abstract class BaseMainActivity : AppCompatActivity() { @Inject lateinit var inputEventHub: InputEventHubImpl + @Inject + lateinit var navigationProvider: NavigationProvider + + @Inject + lateinit var configKeyMapState: ConfigKeyMapStateImpl + private lateinit var requestPermissionDelegate: RequestPermissionDelegate private val currentNightMode: Int @@ -155,6 +155,8 @@ abstract class BaseMainActivity : AppCompatActivity() { ) super.onCreate(savedInstanceState) + savedInstanceState?.let { configKeyMapState.restoreState(it) } + requestPermissionDelegate = RequestPermissionDelegate( this, showDialogs = true, @@ -162,15 +164,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) @@ -207,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/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..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 @@ -667,6 +668,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 @@ -871,6 +890,17 @@ 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 @@ -949,4 +979,24 @@ sealed class ActionData : Comparable { data object ClearRecentApp : ActionData() { override val id: ActionId = ActionId.CLEAR_RECENT_APP } + + @Serializable + data class ModifySetting( + val settingType: 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..8cc75755ef 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,8 @@ 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 + ActionEntity.Type.CREATE_NOTIFICATION -> ActionId.CREATE_NOTIFICATION } return when (actionId) { @@ -485,6 +488,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 @@ -556,6 +563,25 @@ 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.takeIf { it.isNotBlank() } + ?: return null + + 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 @@ -723,6 +749,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 +795,8 @@ 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 + is ActionData.CreateNotification -> ActionEntity.Type.CREATE_NOTIFICATION else -> ActionEntity.Type.SYSTEM_ACTION } @@ -819,12 +867,14 @@ 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]!! 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 +1155,18 @@ 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), + ) + + 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() } @@ -1166,6 +1228,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", @@ -1279,5 +1345,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..ebcc6fa9d9 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, @@ -138,6 +142,7 @@ enum class ActionId { DISMISS_MOST_RECENT_NOTIFICATION, DISMISS_ALL_NOTIFICATIONS, + CREATE_NOTIFICATION, ANSWER_PHONE_CALL, END_PHONE_CALL, @@ -147,4 +152,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..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 -> { @@ -651,6 +655,20 @@ 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), + ) + } + + 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/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index 0902ef74db..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 @@ -248,11 +254,13 @@ 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 ActionId.FORCE_STOP_APP -> ActionCategory.APPS ActionId.CLEAR_RECENT_APP -> ActionCategory.APPS + ActionId.MODIFY_SETTING -> ActionCategory.APPS ActionId.CONSUME_KEY_EVENT -> ActionCategory.SPECIAL } @@ -373,6 +381,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 @@ -383,6 +392,11 @@ 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 + 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 @@ -500,6 +514,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 @@ -549,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 } @@ -614,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, @@ -744,6 +771,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) @@ -760,6 +789,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() } @@ -882,6 +914,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 @@ -890,6 +923,11 @@ 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 + ActionId.TOGGLE_HOTSPOT -> Icons.Outlined.WifiTethering + ActionId.ENABLE_HOTSPOT -> Icons.Outlined.WifiTethering + ActionId.DISABLE_HOTSPOT -> Icons.Outlined.WifiTetheringOff } } @@ -934,8 +972,10 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.ComposeSms, is ActionData.HttpRequest, is ActionData.ShellCommand, + is ActionData.CreateNotification, 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..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 @@ -56,6 +56,8 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { HttpRequestBottomSheet(delegate) SmsActionBottomSheet(delegate) VolumeActionBottomSheet(delegate) + ModifySettingActionBottomSheet(delegate) + CreateNotificationActionBottomSheet(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..a451bdc146 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,23 @@ 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 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 +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 createNotificationActionBottomSheetState: CreateNotificationActionBottomSheetState? + by mutableStateOf(null) init { coroutineScope.launch { @@ -63,6 +74,36 @@ 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, + ) + } + } + + coroutineScope.launch { + snapshotFlow { createNotificationActionBottomSheetState } + .filterNotNull() + .flatMapLatest { + useCase.isPermissionGrantedFlow(Permission.POST_NOTIFICATIONS) + } + .collectLatest { isGranted -> + createNotificationActionBottomSheetState = + createNotificationActionBottomSheetState?.copy( + isPermissionGranted = isGranted, + ) + } + } } fun onDoneConfigEnableFlashlightClick() { @@ -196,6 +237,128 @@ 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 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!") @@ -812,6 +975,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 @@ -884,6 +1051,18 @@ 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 -> { + val oldAction = oldData as? ActionData.CreateNotification + + 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 ActionId.DEVICE_CONTROLS -> return ActionData.DeviceControls @@ -927,6 +1106,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..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 @@ -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,10 +9,14 @@ 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 @@ -22,6 +28,8 @@ class CreateActionUseCaseImpl @Inject constructor( private val cameraAdapter: CameraAdapter, private val permissionAdapter: PermissionAdapter, private val phoneAdapter: PhoneAdapter, + private val settingsAdapter: SettingsAdapter, + private val notificationAdapter: NotificationAdapter, ) : CreateActionUseCase, IsActionSupportedUseCase by IsActionSupportedUseCaseImpl( systemFeatureAdapter, @@ -69,6 +77,43 @@ 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) + } + + 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 { @@ -83,4 +128,8 @@ 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 + 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/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..20fe980ed8 --- /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::onRequestModifySettingPermissionClick, + 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..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 @@ -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.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 @@ -60,6 +61,8 @@ 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 import io.github.sds100.keymapper.system.phone.PhoneAdapter @@ -71,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 @@ -116,10 +120,12 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val resourceProvider: ResourceProvider, private val soundsManager: SoundsManager, private val notificationReceiverAdapter: NotificationReceiverAdapter, + private val notificationAdapter: NotificationAdapter, private val ringtoneAdapter: RingtoneAdapter, private val settingsRepository: PreferenceRepository, private val inputEventHub: InputEventHub, private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val settingsAdapter: io.github.sds100.keymapper.system.settings.SettingsAdapter, ) : PerformActionsUseCase { @AssistedFactory @@ -477,6 +483,28 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = networkAdapter.disableMobileData() } + is ActionData.Hotspot.Toggle -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + result = networkAdapter.isHotspotEnabled().then { isEnabled -> + if (isEnabled) { + networkAdapter.disableHotspot() + } else { + networkAdapter.enableHotspot() + } + } + } else { + result = SdkVersionTooLow(minSdk = Build.VERSION_CODES.R) + } + } + + is ActionData.Hotspot.Enable -> { + result = networkAdapter.enableHotspot() + } + + is ActionData.Hotspot.Disable -> { + result = networkAdapter.disableHotspot() + } + is ActionData.Brightness.ToggleAuto -> { result = if (displayAdapter.isAutoBrightnessEnabled()) { displayAdapter.disableAutoBrightness() @@ -928,6 +956,27 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ) } + is ActionData.CreateNotification -> { + // Use the hashcode of the action instance as the unique notification ID + val notificationId = action.hashCode().absoluteValue + + val notification = NotificationModel( + id = notificationId, + channel = 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, + bigTextStyle = true, + ) + + notificationAdapter.showNotification(notification) + result = success() + } + ActionData.AnswerCall -> { phoneAdapter.answerCall() result = success() @@ -1016,6 +1065,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/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/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..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,6 +11,7 @@ enum class ConstraintDependency { WIFI_SSID, WIFI_STATE, CHOSEN_IME, + KEYBOARD_VISIBLE, 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..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,6 +57,9 @@ class LazyConstraintSnapshot( networkAdapter.connectedWifiSSIDFlow.firstBlocking() } private val chosenImeId: String? by lazy { inputMethodAdapter.chosenIme.value?.id } + 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 } @@ -139,6 +142,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/ConstraintUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt index 28f1a97154..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,6 +135,13 @@ 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/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..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,6 +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.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -89,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/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/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) { 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?) 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/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 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..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 @@ -12,6 +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 io.github.sds100.keymapper.common.utils.valueOrNull import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -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/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/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..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 @@ -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 @@ -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.ROOT_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/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt index 741ed4f789..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 @@ -8,15 +8,15 @@ 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 @@ -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/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 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 7bdb118f7d..3fa824beee 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 @@ -72,6 +72,7 @@ class NotificationController @Inject constructor( const val CHANNEL_NEW_FEATURES = "channel_new_features" const val CHANNEL_SETUP_ASSISTANT = "channel_setup_assistant" const val CHANNEL_VERSION_MIGRATION = "channel_version_migration" + 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" @@ -110,6 +111,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/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/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/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/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/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/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/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..0c7f95c588 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,8 @@ 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_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 +176,18 @@ 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 class ConfigNotificationAction(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/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..4286af0b29 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -278,6 +278,11 @@ Input method is not chosen %s is not chosen + Keyboard is showing + On-screen keyboard is visible + Keyboard is not showing + On-screen keyboard is hidden + Device is locked Device is unlocked Lockscreen is showing @@ -328,7 +333,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 +431,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 +491,6 @@ Done Kill - Guide Guide Change Fix partially @@ -522,6 +525,7 @@ Keyboard is hidden warning Toggle Key Mapper Input Method New features + Custom notifications Running Tap to open Key Mapper. @@ -933,6 +937,10 @@ Enable mobile data Disable mobile data + Toggle hotspot + Enable hotspot + Disable hotspot + Toggle auto brightness Disable auto brightness Enable auto brightness @@ -1092,9 +1100,28 @@ 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 + Create notification + Show notification: %1$s + 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 + Test + Testing… + Notification shown successfully Device controls screen HTTP request HTTP Method @@ -1173,6 +1200,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 @@ -1658,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/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(), ) } 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/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)) + } +} 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 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 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..b233308d29 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" @@ -140,10 +142,22 @@ 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.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) @@ -183,6 +197,8 @@ data class ActionEntity( SOUND, INTERACT_UI_ELEMENT, SHELL_COMMAND, + MODIFY_SETTING, + CREATE_NOTIFICATION, } constructor( 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" 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) diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png index 6fc2b28c8d..b604d9f7e7 100644 Binary files a/fastlane/metadata/android/en-US/images/featureGraphic.png and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ 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/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..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 @@ -42,4 +42,10 @@ interface ISystemBridge { void removeTasks(String packageName) = 17; void setRingerMode(int ringerMode) = 18; + + 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/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/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index fdb9d4cc66..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,8 +12,17 @@ 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 +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 @@ -23,11 +32,13 @@ 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 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 @@ -82,6 +93,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) { @@ -174,9 +186,11 @@ 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? + private val usbManager: IUsbManager? private val processPackageName: String = when (Process.myUid()) { Process.ROOT_UID -> "root" @@ -262,6 +276,18 @@ 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 + } + + waitSystemService(Context.USB_SERVICE) + usbManager = + IUsbManager.Stub.asInterface(ServiceManager.getService(Context.USB_SERVICE)) + val applicationInfo = getKeyMapperPackageInfo() if (applicationInfo == null) { @@ -667,4 +693,93 @@ 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 (tetheringConnector == null) { + throw UnsupportedOperationException("TetheringConnector not supported") + } + + if (enable) { + 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 { + tetheringConnector.stopTethering(TETHERING_WIFI, processPackageName, null, null) + } + } + + override fun getUsbScreenUnlockedFunctions(): Long { + return try { + usbManager?.screenUnlockedFunctions ?: 0 + } catch (_: RemoteException) { + -1 + } + } } 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..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 @@ -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 } } @@ -260,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" @@ -285,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() } 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 { 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/network/AndroidNetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt index 1ecd1b5116..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,6 +199,24 @@ class AndroidNetworkAdapter @Inject constructor( } } + @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<*> { + return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(true) } + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun disableHotspot(): KMResult<*> { + return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(false) } + } + /** * @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..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,6 +21,15 @@ interface NetworkAdapter { fun enableMobileData(): KMResult<*> fun disableMobileData(): KMResult<*> + @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 suspend fun sendHttpRequest( 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..b675738360 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/settings/SettingType.kt @@ -0,0 +1,12 @@ +package io.github.sds100.keymapper.system.settings + +import androidx.annotation.Keep +import kotlinx.serialization.Serializable + +@Serializable +@Keep +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 +} 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 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