From 5aeb4427e1ccc7116577379d54ba44e1056bcb15 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 26 Mar 2025 01:07:55 -0600 Subject: [PATCH 1/5] #1560 feat: configure flashlight brightness on Android 13+ and if the device supports it --- .../io/github/sds100/keymapper/UseCases.kt | 1 + .../sds100/keymapper/actions/ActionData.kt | 20 +- .../actions/ActionDataEntityMapper.kt | 60 ++- .../keymapper/actions/ActionErrorSnapshot.kt | 6 +- .../keymapper/actions/ActionUiHelper.kt | 42 +- .../sds100/keymapper/actions/ActionUtils.kt | 1 - .../sds100/keymapper/actions/ActionsScreen.kt | 12 +- .../keymapper/actions/ChooseActionScreen.kt | 2 + .../actions/ChooseActionViewModel.kt | 11 +- .../actions/ConfigActionsViewModel.kt | 15 +- .../ConfigFlashlightActionBottomSheet.kt | 380 ++++++++++++++++++ .../keymapper/actions/CreateActionUseCase.kt | 16 +- .../actions/CreateActionViewModel.kt | 119 +++++- .../actions/IsActionSupportedUseCase.kt | 12 + .../actions/PerformActionsUseCase.kt | 4 +- .../constraints/ConstraintErrorSnapshot.kt | 4 +- .../keymapper/data/entities/ActionEntity.kt | 2 + .../trigger/SetupGuiKeyboardBottomSheet.kt | 26 +- .../system/camera/AndroidCameraAdapter.kt | 82 +++- .../keymapper/system/camera/CameraAdapter.kt | 19 +- .../system/camera/CameraFlashInfo.kt | 7 + .../util/ui/compose/KeyMapperSliderThumb.kt | 16 + .../util/ui/compose/OptionsHeaderRow.kt | 32 ++ .../util/ui/compose/SliderOptionText.kt | 7 +- app/src/main/res/values/strings.xml | 12 +- 25 files changed, 823 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/system/camera/CameraFlashInfo.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/util/ui/compose/KeyMapperSliderThumb.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/util/ui/compose/OptionsHeaderRow.kt diff --git a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt index 6e2a831705..064ddd07de 100644 --- a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt +++ b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt @@ -194,6 +194,7 @@ object UseCases { fun createAction(ctx: Context) = CreateActionUseCaseImpl( ServiceLocator.inputMethodAdapter(ctx), ServiceLocator.systemFeatureAdapter(ctx), + ServiceLocator.cameraAdapter(ctx), ) private fun keyMapperImeMessenger( diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt index 177e2296f8..5c0df55cc3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt @@ -180,12 +180,28 @@ sealed class ActionData : Comparable { } @Serializable - data class Toggle(override val lens: CameraLens) : Flashlight() { + data class Toggle( + override val lens: CameraLens, + /** + * Strength is null if the default strength should be used. This is a percentage + * of the flash strength so key maps can be exported to other devices with potentially + * different strength levels. + */ + val strength: Float?, + ) : Flashlight() { override val id = ActionId.TOGGLE_FLASHLIGHT } @Serializable - data class Enable(override val lens: CameraLens) : Flashlight() { + data class Enable( + override val lens: CameraLens, + /** + * Strength is null if the default strength should be used. This is a percentage + * of the flash strength so key maps can be exported to other devices with potentially + * different strength levels. + */ + val strength: Float?, + ) : Flashlight() { override val id = ActionId.ENABLE_FLASHLIGHT } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt index 9cfdc30047..fb7ce69661 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt @@ -286,26 +286,30 @@ object ActionDataEntityMapper { ActionId.TOGGLE_FLASHLIGHT, ActionId.ENABLE_FLASHLIGHT, - ActionId.DISABLE_FLASHLIGHT, -> { val lens = entity.extras.getData(ActionEntity.EXTRA_LENS).then { LENS_MAP.getKey(it)!!.success() }.valueOrNull() ?: return null - when (actionId) { - ActionId.TOGGLE_FLASHLIGHT -> - ActionData.Flashlight.Toggle(lens) - - ActionId.ENABLE_FLASHLIGHT -> - ActionData.Flashlight.Enable(lens) - - ActionId.DISABLE_FLASHLIGHT -> - ActionData.Flashlight.Disable(lens) + val flashStrength = entity.extras.getData(ActionEntity.EXTRA_FLASH_STRENGTH).then { + it.toFloatOrNull().success() + }.valueOrNull() + when (actionId) { + ActionId.TOGGLE_FLASHLIGHT -> ActionData.Flashlight.Toggle(lens, flashStrength) + ActionId.ENABLE_FLASHLIGHT -> ActionData.Flashlight.Enable(lens, flashStrength) else -> throw Exception("don't know how to create system action for $actionId") } } + ActionId.DISABLE_FLASHLIGHT, + -> { + val lens = entity.extras.getData(ActionEntity.EXTRA_LENS).then { + LENS_MAP.getKey(it)!!.success() + }.valueOrNull() ?: return null + ActionData.Flashlight.Disable(lens) + } + ActionId.TOGGLE_DND_MODE, ActionId.ENABLE_DND_MODE, -> { @@ -614,9 +618,39 @@ object ActionDataEntityMapper { ), ) - is ActionData.Flashlight -> listOf( - EntityExtra(ActionEntity.EXTRA_LENS, LENS_MAP[data.lens]!!), - ) + is ActionData.Flashlight -> { + val lensExtra = EntityExtra(ActionEntity.EXTRA_LENS, LENS_MAP[data.lens]!!) + + when (data) { + is ActionData.Flashlight.Toggle -> buildList { + add(lensExtra) + + if (data.strength != null) { + add( + EntityExtra( + ActionEntity.EXTRA_FLASH_STRENGTH, + data.strength.toString(), + ), + ) + } + } + + is ActionData.Flashlight.Enable -> buildList { + add(lensExtra) + + if (data.strength != null) { + add( + EntityExtra( + ActionEntity.EXTRA_FLASH_STRENGTH, + data.strength.toString(), + ), + ) + } + } + + is ActionData.Flashlight.Disable -> listOf(lensExtra) + } + } is ActionData.SwitchKeyboard -> listOf( EntityExtra(ActionEntity.EXTRA_IME_ID, data.imeId), diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionErrorSnapshot.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionErrorSnapshot.kt index ba51f5ae66..bd4b67265e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionErrorSnapshot.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionErrorSnapshot.kt @@ -23,7 +23,7 @@ class LazyActionErrorSnapshot( private val soundsManager: SoundsManager, shizukuAdapter: ShizukuAdapter, ) : ActionErrorSnapshot, - IsActionSupportedUseCase by IsActionSupportedUseCaseImpl(systemFeatureAdapter) { + IsActionSupportedUseCase by IsActionSupportedUseCaseImpl(systemFeatureAdapter, cameraAdapter) { private val keyMapperImeHelper = KeyMapperImeHelper(inputMethodAdapter) private val isCompatibleImeEnabled by lazy { keyMapperImeHelper.isCompatibleImeEnabled() } @@ -34,11 +34,11 @@ class LazyActionErrorSnapshot( private val grantedPermissions: MutableMap = mutableMapOf() private val flashLenses by lazy { buildSet { - if (cameraAdapter.hasFlashFacing(CameraLens.FRONT)) { + if (cameraAdapter.getFlashInfo(CameraLens.FRONT) != null) { add(CameraLens.FRONT) } - if (cameraAdapter.hasFlashFacing(CameraLens.BACK)) { + if (cameraAdapter.getFlashInfo(CameraLens.BACK) != null) { add(CameraLens.BACK) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt index 23968da013..dfaae02006 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.actions +import android.os.Build import android.view.KeyEvent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Android @@ -240,15 +241,42 @@ class ActionUiHelper( ) is ActionData.Flashlight -> { - val resId = when (action) { - is ActionData.Flashlight.Toggle -> R.string.action_toggle_flashlight_formatted - is ActionData.Flashlight.Enable -> R.string.action_enable_flashlight_formatted - is ActionData.Flashlight.Disable -> R.string.action_disable_flashlight_formatted - } - val lensString = getString(CameraLensUtils.getLabel(action.lens)) - getString(resId, lensString) + when (action) { + is ActionData.Flashlight.Toggle -> { + if (action.strength == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + getString(R.string.action_toggle_flashlight_formatted, lensString) + } else { + getString( + R.string.action_toggle_flashlight_with_strength, + arrayOf( + lensString, + (action.strength * 100).toInt(), + ), + ) + } + } + + is ActionData.Flashlight.Enable -> { + if (action.strength == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + getString(R.string.action_enable_flashlight_formatted, lensString) + } else { + getString( + R.string.action_enable_flashlight_with_strength, + arrayOf( + lensString, + (action.strength * 100).toInt(), + ), + ) + } + } + + is ActionData.Flashlight.Disable -> getString( + R.string.action_disable_flashlight_formatted, + lensString, + ) + } } is ActionData.SwitchKeyboard -> getInputMethodLabel(action.imeId).handle( diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt index 047cd183d0..3c78e1864c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt @@ -800,7 +800,6 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.Rotation.CycleRotations, is ActionData.Flashlight.Toggle, is ActionData.Flashlight.Enable, - is ActionData.Flashlight.Disable, is ActionData.TapScreen, is ActionData.SwipeScreen, is ActionData.PinchScreen, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt index 9328a12d61..318f5846c5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt @@ -65,6 +65,8 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod ) } + ConfigFlashlightActionBottomSheet(viewModel) + ActionsScreen( modifier = modifier, state = state, @@ -300,7 +302,10 @@ private fun EmptyPreview() { ShortcutModel( icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), text = "Toggle Back flashlight", - data = ActionData.Flashlight.Toggle(lens = CameraLens.BACK), + data = ActionData.Flashlight.Toggle( + lens = CameraLens.BACK, + strength = null, + ), ), ), ), @@ -339,7 +344,10 @@ private fun LoadedPreview() { ShortcutModel( icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), text = "Toggle Back flashlight", - data = ActionData.Flashlight.Toggle(lens = CameraLens.BACK), + data = ActionData.Flashlight.Toggle( + lens = CameraLens.BACK, + strength = null, + ), ), ), isReorderingEnabled = true, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt index 1edae35a26..eb1ebf4c5a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt @@ -66,6 +66,8 @@ fun ChooseActionScreen( val state by viewModel.groups.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle() + ConfigFlashlightActionBottomSheet(viewModel) + ChooseActionScreen( modifier = modifier, state = state, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionViewModel.kt index 3bbd76d983..f26a9e13b5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionViewModel.kt @@ -16,13 +16,13 @@ import io.github.sds100.keymapper.util.ui.compose.SimpleListItemGroup import io.github.sds100.keymapper.util.ui.compose.SimpleListItemModel import io.github.sds100.keymapper.util.ui.showPopup import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -55,8 +55,7 @@ class ChooseActionViewModel( private val allGroupedListItems: List by lazy { buildListGroups() } - private val _returnAction = MutableSharedFlow() - val returnAction = _returnAction.asSharedFlow() + val returnAction = actionResult.filterNotNull().shareIn(viewModelScope, SharingStarted.Eagerly) val searchQuery = MutableStateFlow(null) @@ -85,9 +84,7 @@ class ChooseActionViewModel( showMessageForAction(actionId) } - createAction(actionId)?.let { action -> - _returnAction.emit(action) - } + createAction(actionId) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt index 6613ab7843..d12437a77a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt @@ -81,6 +81,14 @@ class ConfigActionsViewModel( buildState(keyMap, shortcuts, errorSnapshot, showDeviceDescriptors) } }.launchIn(coroutineScope) + + coroutineScope.launch { + actionResult.filterNotNull().collect { action -> + val actionUid = actionOptionsUid.value ?: return@collect + config.setActionData(actionUid, action) + actionOptionsUid.update { null } + } + } } private suspend fun getActionData(uid: String): ActionData? { @@ -164,12 +172,7 @@ class ConfigActionsViewModel( val keyMap = config.keyMap.first().dataOrNull() ?: return@launch val oldAction = keyMap.actionList.find { it.uid == actionUid } ?: return@launch - val newActionData = editAction(oldAction.data) - - if (newActionData != null) { - actionOptionsUid.update { null } - config.setActionData(actionUid, newActionData) - } + editAction(oldAction.data) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt new file mode 100644 index 0000000000..1a74d43576 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt @@ -0,0 +1,380 @@ +package io.github.sds100.keymapper.actions + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BrightnessMedium +import androidx.compose.material.icons.rounded.CameraFront +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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 +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.system.camera.CameraFlashInfo +import io.github.sds100.keymapper.system.camera.CameraLens +import io.github.sds100.keymapper.util.ui.compose.KeyMapperSliderThumb +import io.github.sds100.keymapper.util.ui.compose.OptionsHeaderRow +import io.github.sds100.keymapper.util.ui.compose.RadioButtonText +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigFlashlightActionBottomSheet(viewModel: CreateActionViewModel) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (viewModel.configFlashlightActionState != null) { + ConfigFlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = { + viewModel.configFlashlightActionState = null + }, + state = viewModel.configFlashlightActionState!!, + onSelectStrength = { + viewModel.configFlashlightActionState = + viewModel.configFlashlightActionState?.copy(flashStrength = it) + }, + onSelectLens = { + viewModel.configFlashlightActionState = + viewModel.configFlashlightActionState?.copy(selectedLens = it) + }, + onDoneClick = { + scope.launch { + sheetState.hide() + viewModel.onDoneConfigFlashlightClicked() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConfigFlashlightActionBottomSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + state: ConfigFlashlightActionState, + onSelectLens: (CameraLens) -> Unit = {}, + onSelectStrength: (Int) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + + if (state.lensData.isEmpty()) { + throw IllegalStateException("You can not configure a flashlight action if your device has no flashes.") + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier.verticalScroll(scrollState), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + textAlign = TextAlign.Center, + text = stringResource(ActionUtils.getTitle(state.actionToCreate)), + style = MaterialTheme.typography.headlineMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.CameraFront, + text = stringResource(R.string.action_config_flashlight_choose_side), + ) + + Row(modifier = Modifier.padding(horizontal = 8.dp)) { + RadioButtonText( + modifier = Modifier, + text = stringResource(R.string.lens_front), + isSelected = state.selectedLens == CameraLens.FRONT, + onSelected = { onSelectLens(CameraLens.FRONT) }, + isEnabled = state.lensData.containsKey(CameraLens.FRONT), + ) + + RadioButtonText( + modifier = Modifier, + text = stringResource(R.string.lens_back), + isSelected = state.selectedLens == CameraLens.BACK, + onSelected = { onSelectLens(CameraLens.BACK) }, + isEnabled = state.lensData.containsKey(CameraLens.BACK), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.BrightnessMedium, + text = stringResource(R.string.action_config_flashlight_brightness), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val errorText = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + stringResource(R.string.action_config_flashlight_brightness_unsupported_android_version) + } else if (!state.lensData[state.selectedLens]!!.supportsVariableStrength) { + stringResource(R.string.action_config_flashlight_brightness_unsupported) + } else { + null + } + + if (errorText != null) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = errorText, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + val interactionSource = remember { MutableInteractionSource() } + val sliderDefault = state.lensData[state.selectedLens]!!.defaultStrength + val sliderMax = state.lensData[state.selectedLens]!!.maxStrength.toFloat() + + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Slider( + modifier = Modifier.weight(1f), + value = state.flashStrength.toFloat(), + onValueChange = { onSelectStrength(it.roundToInt()) }, + enabled = errorText == null, + interactionSource = interactionSource, + thumb = { + KeyMapperSliderThumb( + interactionSource, + enabled = errorText == null, + ) + }, + valueRange = 1f..sliderMax, + steps = sliderMax.toInt(), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 4.dp), + text = "${state.flashStrength} / ${sliderMax.toInt()}", + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } + + if (errorText == null) { + Row(modifier = Modifier.padding(horizontal = 16.dp)) { + Box(modifier = Modifier.weight(1f)) { + TextButton( + modifier = Modifier.align(Alignment.TopStart), + onClick = { onSelectStrength(1) }, + ) { + Text(stringResource(R.string.action_config_flashlight_brightness_min)) + } + TextButton( + modifier = Modifier.align(Alignment.TopCenter), + onClick = { onSelectStrength(((sliderMax - 1) / 2).toInt()) }, + ) { + Text(stringResource(R.string.action_config_flashlight_brightness_half)) + } + TextButton( + modifier = Modifier.align(Alignment.TopEnd), + onClick = { onSelectStrength(sliderMax.toInt()) }, + ) { + Text(stringResource(R.string.action_config_flashlight_brightness_max)) + } + } + + Spacer(Modifier.width(8.dp)) + + AnimatedVisibility(visible = state.flashStrength != sliderDefault) { + IconButton(onClick = { onSelectStrength(sliderDefault) }) { + Icon( + Icons.Rounded.RestartAlt, + contentDescription = stringResource(R.string.slider_reset_content_description), + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = onDoneClick, + ) { + Text(stringResource(R.string.pos_done)) + } + } + + Spacer(Modifier.height(16.dp)) + } +} + +data class ConfigFlashlightActionState( + val actionToCreate: ActionId, + val selectedLens: CameraLens, + val lensData: Map, + val flashStrength: Int = 1, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewBothLenses() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + ConfigFlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = ConfigFlashlightActionState( + actionToCreate = ActionId.ENABLE_FLASHLIGHT, + selectedLens = CameraLens.BACK, + flashStrength = 3, + lensData = mapOf( + CameraLens.FRONT to CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = 5, + maxStrength = 10, + ), + CameraLens.BACK to CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = 5, + maxStrength = 10, + ), + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewOnlyBackLens() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + ConfigFlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = ConfigFlashlightActionState( + actionToCreate = ActionId.TOGGLE_FLASHLIGHT, + selectedLens = CameraLens.BACK, + flashStrength = 3, + lensData = mapOf( + CameraLens.BACK to CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = 5, + maxStrength = 10, + ), + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(apiLevel = Build.VERSION_CODES.R) +@Composable +private fun PreviewUnsupportedAndroidVersion() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + ConfigFlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = ConfigFlashlightActionState( + actionToCreate = ActionId.TOGGLE_FLASHLIGHT, + selectedLens = CameraLens.BACK, + flashStrength = 2, + lensData = mapOf( + CameraLens.BACK to CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = 5, + maxStrength = 10, + ), + ), + ), + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionUseCase.kt index c9bcdddd12..c36f84cba0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionUseCase.kt @@ -1,5 +1,8 @@ package io.github.sds100.keymapper.actions +import io.github.sds100.keymapper.system.camera.CameraAdapter +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.permissions.SystemFeatureAdapter @@ -12,11 +15,22 @@ import kotlinx.coroutines.flow.first class CreateActionUseCaseImpl( private val inputMethodAdapter: InputMethodAdapter, private val systemFeatureAdapter: SystemFeatureAdapter, + private val cameraAdapter: CameraAdapter, ) : CreateActionUseCase, - IsActionSupportedUseCase by IsActionSupportedUseCaseImpl(systemFeatureAdapter) { + IsActionSupportedUseCase by IsActionSupportedUseCaseImpl(systemFeatureAdapter, cameraAdapter) { override suspend fun getInputMethods(): List = inputMethodAdapter.inputMethods.first() + + override fun getFlashlightLenses(): Set { + return CameraLens.entries.filter { cameraAdapter.getFlashInfo(it) != null }.toSet() + } + + override fun getFlashInfo(lens: CameraLens): CameraFlashInfo? { + return cameraAdapter.getFlashInfo(lens) + } } interface CreateActionUseCase : IsActionSupportedUseCase { suspend fun getInputMethods(): List + fun getFlashlightLenses(): Set + fun getFlashInfo(lens: CameraLens): CameraFlashInfo? } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionViewModel.kt index 2dd8cdae33..36d7ee0d3c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionViewModel.kt @@ -1,6 +1,9 @@ package io.github.sds100.keymapper.actions import android.text.InputType +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import io.github.sds100.keymapper.R import io.github.sds100.keymapper.actions.pinchscreen.PinchPickCoordinateResult import io.github.sds100.keymapper.actions.swipescreen.SwipePickCoordinateResult @@ -26,6 +29,9 @@ import io.github.sds100.keymapper.util.ui.PopupViewModelImpl import io.github.sds100.keymapper.util.ui.ResourceProvider import io.github.sds100.keymapper.util.ui.navigate import io.github.sds100.keymapper.util.ui.showPopup +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update /** * Created by sds100 on 26/07/2021. @@ -39,15 +45,54 @@ class CreateActionViewModelImpl( PopupViewModel by PopupViewModelImpl(), NavigationViewModel by NavigationViewModelImpl() { - override suspend fun editAction(oldData: ActionData): ActionData? { + override val actionResult: MutableStateFlow = MutableStateFlow(null) + override var configFlashlightActionState: ConfigFlashlightActionState? by mutableStateOf(null) + + override fun onDoneConfigFlashlightClicked() { + configFlashlightActionState?.also { state -> + val flashInfo = state.lensData[state.selectedLens] ?: return + + val strengthPercent = + state.flashStrength + .takeIf { it != flashInfo.defaultStrength } + ?.let { it.toFloat() / flashInfo.maxStrength } + ?.coerceIn(0f, 1f) + + val action = when (state.actionToCreate) { + ActionId.TOGGLE_FLASHLIGHT -> ActionData.Flashlight.Toggle( + state.selectedLens, + strengthPercent, + ) + + ActionId.ENABLE_FLASHLIGHT -> ActionData.Flashlight.Enable( + state.selectedLens, + strengthPercent, + ) + + else -> return + } + + configFlashlightActionState = null + + actionResult.update { action } + } + } + + override suspend fun editAction(oldData: ActionData) { if (!oldData.isEditable()) { throw IllegalArgumentException("This action ${oldData.javaClass.name} can't be edited!") } - return configAction(oldData.id, oldData) + configAction(oldData.id, oldData)?.let { action -> + actionResult.update { action } + } } - override suspend fun createAction(id: ActionId): ActionData? = configAction(id) + override suspend fun createAction(id: ActionId) { + configAction(id)?.let { action -> + actionResult.update { action } + } + } private suspend fun configAction(actionId: ActionId, oldData: ActionData? = null): ActionData? { when (actionId) { @@ -251,25 +296,59 @@ class CreateActionViewModelImpl( return ActionData.Rotation.CycleRotations(orientations) } - ActionId.TOGGLE_FLASHLIGHT, - ActionId.ENABLE_FLASHLIGHT, + ActionId.TOGGLE_FLASHLIGHT, ActionId.ENABLE_FLASHLIGHT -> { + val lenses = useCase.getFlashlightLenses() + val selectedLens = if (oldData is ActionData.Flashlight) { + oldData.lens + } else if (lenses.contains(CameraLens.BACK)) { + CameraLens.BACK + } else { + lenses.first() + } + + val lensData = lenses.associateWith { useCase.getFlashInfo(it)!! } + val lensInfo = lensData[selectedLens] ?: lensData.values.first() + + val strength: Int = when (oldData) { + is ActionData.Flashlight.Toggle -> if (oldData.strength == null) { + lensInfo.defaultStrength + } else { + (oldData.strength * lensInfo.maxStrength).toInt() + } + + is ActionData.Flashlight.Enable -> if (oldData.strength == null) { + lensInfo.defaultStrength + } else { + (oldData.strength * lensInfo.maxStrength).toInt() + } + + else -> lensInfo.defaultStrength + } + + configFlashlightActionState = ConfigFlashlightActionState( + actionToCreate = actionId, + selectedLens = selectedLens, + lensData = lensData, + flashStrength = strength, + ) + + return null + } + ActionId.DISABLE_FLASHLIGHT, -> { - val items = CameraLens.values().map { + val items = useCase.getFlashlightLenses().map { it to getString(CameraLensUtils.getLabel(it)) } - val lens = showPopup("pick_lens", PopupUi.SingleChoice(items)) - ?: return null + if (items.size == 1) { + return ActionData.Flashlight.Disable(items.first().first) + } else { + val lens = showPopup("pick_lens", PopupUi.SingleChoice(items)) + ?: return null - val action = when (actionId) { - ActionId.TOGGLE_FLASHLIGHT -> ActionData.Flashlight.Toggle(lens) - ActionId.ENABLE_FLASHLIGHT -> ActionData.Flashlight.Enable(lens) - ActionId.DISABLE_FLASHLIGHT -> ActionData.Flashlight.Disable(lens) - else -> throw Exception("don't know how to create action for $actionId") + return ActionData.Flashlight.Disable(lens) } - - return action } ActionId.APP -> { @@ -605,6 +684,12 @@ interface CreateActionViewModel : ResourceProvider, PopupViewModel, NavigationViewModel { - suspend fun editAction(oldData: ActionData): ActionData? - suspend fun createAction(id: ActionId): ActionData? + + val actionResult: StateFlow + + var configFlashlightActionState: ConfigFlashlightActionState? + fun onDoneConfigFlashlightClicked() + + suspend fun editAction(oldData: ActionData) + suspend fun createAction(id: ActionId) } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/IsActionSupportedUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/actions/IsActionSupportedUseCase.kt index f0f4597eba..31389bcff2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/IsActionSupportedUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/IsActionSupportedUseCase.kt @@ -1,11 +1,15 @@ package io.github.sds100.keymapper.actions +import android.content.pm.PackageManager import android.os.Build +import io.github.sds100.keymapper.system.camera.CameraAdapter +import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter import io.github.sds100.keymapper.util.Error class IsActionSupportedUseCaseImpl( private val adapter: SystemFeatureAdapter, + private val cameraAdapter: CameraAdapter, ) : IsActionSupportedUseCase { override fun isSupported(id: ActionId): Error? { @@ -29,6 +33,14 @@ class IsActionSupportedUseCaseImpl( } } + if (id == ActionId.ENABLE_FLASHLIGHT || id == ActionId.DISABLE_FLASHLIGHT || id == ActionId.TOGGLE_FLASHLIGHT) { + if (cameraAdapter.getFlashInfo(CameraLens.BACK) == null && + cameraAdapter.getFlashInfo(CameraLens.FRONT) == null + ) { + return Error.SystemFeatureNotSupported(PackageManager.FEATURE_CAMERA_FLASH) + } + } + return null } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt index 7331cbda63..ec5610f224 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt @@ -242,11 +242,11 @@ class PerformActionsUseCaseImpl( } is ActionData.Flashlight.Enable -> { - result = cameraAdapter.enableFlashlight(action.lens) + result = cameraAdapter.enableFlashlight(action.lens, action.strength) } is ActionData.Flashlight.Toggle -> { - result = cameraAdapter.toggleFlashlight(action.lens) + result = cameraAdapter.toggleFlashlight(action.lens, action.strength) } is ActionData.SwitchKeyboard -> { diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintErrorSnapshot.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintErrorSnapshot.kt index abc0f4fa7b..be1ab76e8f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintErrorSnapshot.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintErrorSnapshot.kt @@ -24,11 +24,11 @@ class LazyConstraintErrorSnapshot( private val grantedPermissions: MutableMap = mutableMapOf() private val flashLenses by lazy { buildSet { - if (cameraAdapter.hasFlashFacing(CameraLens.FRONT)) { + if (cameraAdapter.getFlashInfo(CameraLens.FRONT) != null) { add(CameraLens.FRONT) } - if (cameraAdapter.hasFlashFacing(CameraLens.BACK)) { + if (cameraAdapter.getFlashInfo(CameraLens.BACK) != null) { add(CameraLens.BACK) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 4992cf2f8b..ee8b96ef7b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -7,6 +7,7 @@ import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName +import io.github.sds100.keymapper.data.entities.ActionEntity.Type import kotlinx.parcelize.Parcelize import java.util.UUID @@ -82,6 +83,7 @@ data class ActionEntity( const val EXTRA_INTENT_DESCRIPTION = "extra_intent_description" const val EXTRA_SOUND_FILE_DESCRIPTION = "extra_sound_file_description" const val EXTRA_INTENT_EXTRAS = "extra_intent_extras" + const val EXTRA_FLASH_STRENGTH = "extra_flash_strength" // DON'T CHANGE THESE. Used for JSON serialization and parsing. const val NAME_ACTION_TYPE = "type" diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/SetupGuiKeyboardBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/SetupGuiKeyboardBottomSheet.kt index ea5293dc5e..767c250ffd 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/SetupGuiKeyboardBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/SetupGuiKeyboardBottomSheet.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -16,13 +17,14 @@ 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 import androidx.compose.runtime.getValue 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.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -193,6 +195,7 @@ private fun SetupGuiKeyboardBottomSheet( verticalAlignment = Alignment.CenterVertically, ) { OutlinedButton( + modifier = Modifier.weight(1f), onClick = { onNeverShowAgainClick() scope.launch { @@ -204,7 +207,10 @@ private fun SetupGuiKeyboardBottomSheet( Text(stringResource(R.string.pos_never_show_again)) } + Spacer(modifier = Modifier.width(16.dp)) + Button( + modifier = Modifier.weight(1f), onClick = { scope.launch { sheetState.hide() @@ -261,7 +267,11 @@ private fun StepRow( @Composable private fun PreviewDpad() { KeyMapperTheme { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) SetupGuiKeyboardBottomSheet( onDismissRequest = {}, @@ -283,7 +293,11 @@ private fun PreviewDpad() { @Composable private fun PreviewDpadComplete() { KeyMapperTheme { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) SetupGuiKeyboardBottomSheet( onDismissRequest = {}, @@ -305,7 +319,11 @@ private fun PreviewDpadComplete() { @Composable private fun PreviewNoKeyRecordedComplete() { KeyMapperTheme { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) SetupGuiKeyboardBottomSheet( onDismissRequest = {}, diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt index 00af5452d9..69a099f33f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt @@ -63,26 +63,62 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { } } - override fun hasFlashFacing(lens: CameraLens): Boolean { - return cameraManager.cameraIdList.any { cameraId -> + override fun getFlashInfo(lens: CameraLens): CameraFlashInfo? { + if (getCharacteristicForLens(lens, CameraCharacteristics.FLASH_INFO_AVAILABLE) != true) { + return null + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val maxFlashStrength = getCharacteristicForLens( + lens, + CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL, + ) + + val defaultFlashStrength = getCharacteristicForLens( + lens, + CameraCharacteristics.FLASH_INFO_STRENGTH_DEFAULT_LEVEL, + ) + + return CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = defaultFlashStrength ?: 1, + maxStrength = maxFlashStrength ?: 1, + ) + } else { + return CameraFlashInfo( + supportsVariableStrength = false, + defaultStrength = 1, + maxStrength = 1, + ) + } + } + + private fun getCharacteristicForLens( + lens: CameraLens, + characteristic: CameraCharacteristics.Key, + ): T? { + for (cameraId in cameraManager.cameraIdList) { val camera = cameraManager.getCameraCharacteristics(cameraId) - val hasFlash = - camera.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: return false + val lensFacing = camera.get(CameraCharacteristics.LENS_FACING)!! val lensToCompareSdkValue = when (lens) { CameraLens.FRONT -> CameraCharacteristics.LENS_FACING_FRONT CameraLens.BACK -> CameraCharacteristics.LENS_FACING_BACK } - return hasFlash && camera.get(CameraCharacteristics.LENS_FACING) == lensToCompareSdkValue + if (lensFacing == lensToCompareSdkValue) { + return camera.get(characteristic) + } } + + return null } - override fun enableFlashlight(lens: CameraLens): Result<*> = setFlashlightMode(true, lens) + override fun enableFlashlight(lens: CameraLens, strength: Float?): Result<*> = setFlashlightMode(true, lens, strength) override fun disableFlashlight(lens: CameraLens): Result<*> = setFlashlightMode(false, lens) - override fun toggleFlashlight(lens: CameraLens): Result<*> = setFlashlightMode(!isFlashEnabledMap.value[lens]!!, lens) + override fun toggleFlashlight(lens: CameraLens, strength: Float?): Result<*> = setFlashlightMode(!isFlashEnabledMap.value[lens]!!, lens, strength) override fun isFlashlightOn(lens: CameraLens): Boolean = isFlashEnabledMap.value[lens] ?: false @@ -93,6 +129,7 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { private fun setFlashlightMode( enabled: Boolean, lens: CameraLens, + strengthPercent: Float? = null, ): Result<*> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) @@ -113,9 +150,38 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { CameraLens.BACK -> CameraCharacteristics.LENS_FACING_BACK } + val maxStrength = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getCharacteristicForLens( + lens, + CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL, + ) + } else { + null + } + + val defaultStrength = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getCharacteristicForLens( + lens, + CameraCharacteristics.FLASH_INFO_STRENGTH_DEFAULT_LEVEL, + ) + } else { + null + } + // try to find a camera with a flash if (flashAvailable && lensFacing == lensSdkValue) { - setTorchMode(cameraId, enabled) + if (enabled && maxStrength != null && defaultStrength != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val strength = if (strengthPercent == null) { + defaultStrength + } else { + (strengthPercent * maxStrength).toInt().coerceAtLeast(1) + } + + turnOnTorchWithStrengthLevel(cameraId, strength) + } else { + setTorchMode(cameraId, enabled) + } return Success(Unit) } } catch (e: CameraAccessException) { diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraAdapter.kt index 83dcfc5ba7..4718617554 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraAdapter.kt @@ -7,10 +7,23 @@ import kotlinx.coroutines.flow.Flow * Created by sds100 on 17/03/2021. */ interface CameraAdapter { - fun hasFlashFacing(lens: CameraLens): Boolean - fun enableFlashlight(lens: CameraLens): Result<*> + /** + * @return null if this lens does not have a flash. + */ + fun getFlashInfo(lens: CameraLens): CameraFlashInfo? + + /** + * @param strength is a percentage of the brightness from 0 to 1.0. Null if the default + * brightness should be used. + */ + fun enableFlashlight(lens: CameraLens, strength: Float?): Result<*> + + /** + * @param strength is a percentage of the brightness from 0 to 1.0. Null if the default + * brightness should be used. + */ + fun toggleFlashlight(lens: CameraLens, strength: Float?): Result<*> fun disableFlashlight(lens: CameraLens): Result<*> - fun toggleFlashlight(lens: CameraLens): Result<*> fun isFlashlightOn(lens: CameraLens): Boolean fun isFlashlightOnFlow(lens: CameraLens): Flow } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraFlashInfo.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraFlashInfo.kt new file mode 100644 index 0000000000..81908a05ac --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraFlashInfo.kt @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.system.camera + +data class CameraFlashInfo( + val supportsVariableStrength: Boolean, + val defaultStrength: Int, + val maxStrength: Int, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/KeyMapperSliderThumb.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/KeyMapperSliderThumb.kt new file mode 100644 index 0000000000..2ac4d27e3d --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/KeyMapperSliderThumb.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.util.ui.compose + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp + +@Composable +fun KeyMapperSliderThumb(interactionSource: MutableInteractionSource, enabled: Boolean = true) { + SliderDefaults.Thumb( + interactionSource = interactionSource, + thumbSize = DpSize(4.dp, 28.dp), + enabled = enabled, + ) +} diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/OptionsHeaderRow.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/OptionsHeaderRow.kt new file mode 100644 index 0000000000..af867d2133 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/OptionsHeaderRow.kt @@ -0,0 +1,32 @@ +package io.github.sds100.keymapper.util.ui.compose + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.unit.dp + +@Composable +fun OptionsHeaderRow(modifier: Modifier = Modifier, icon: ImageVector, text: String) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(modifier = Modifier.size(24.dp), imageVector = icon, contentDescription = null) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = text, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SliderOptionText.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SliderOptionText.kt index 61172c2bc0..5cf9059ee7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SliderOptionText.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SliderOptionText.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -35,7 +34,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme @@ -96,10 +94,7 @@ fun SliderOptionText( valueRange = valueRange, interactionSource = interactionSource, thumb = { state -> - SliderDefaults.Thumb( - interactionSource = interactionSource, - thumbSize = DpSize(4.dp, 28.dp), - ) + KeyMapperSliderThumb(interactionSource) }, steps = (((valueRange.endInclusive - valueRange.start) / stepSize.toFloat()).toInt()) - 1, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f29e353ed..d28868e383 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -982,11 +982,13 @@ Go to last app. (Double press recents) Toggle flashlight - Enable flashlight + Enable flashlight Disable flashlight Toggle %s flashlight + Toggle %s flashlight (%d%%) Enable %s flashlight + Enable %s flashlight (%d\%%) Disable %s flashlight Enable NFC @@ -1381,6 +1383,13 @@ Cancel Run these actions when you trigger the key map: + Choose side + Brightness + Min + Half + Max + Requires Android 13 or newer. + This flash does not let you change the brightness. Add constraints if you want key maps to only work in some situations. @@ -1393,4 +1402,5 @@ Logic mode Choose a constraint This key map will only run if: + From 4aa95637c7c2fe4d8c6805623c8a17d247f170a1 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 26 Mar 2025 01:44:39 -0600 Subject: [PATCH 2/5] #1560 feat: test flashlight brightness while configuring the action --- .../sds100/keymapper/actions/ActionsScreen.kt | 2 +- .../keymapper/actions/ChooseActionScreen.kt | 2 +- .../actions/ChooseActionViewModel.kt | 16 +++- .../actions/ConfigActionsViewModel.kt | 16 +++- .../ConfigFlashlightActionBottomSheet.kt | 48 ++++++++--- ...onViewModel.kt => CreateActionDelegate.kt} | 84 ++++++++++++------- .../keymapper/actions/CreateActionUseCase.kt | 27 ++++++ app/src/main/res/values/strings.xml | 1 + 8 files changed, 148 insertions(+), 48 deletions(-) rename app/src/main/java/io/github/sds100/keymapper/actions/{CreateActionViewModel.kt => CreateActionDelegate.kt} (93%) diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt index 318f5846c5..93a4aba8c5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt @@ -65,7 +65,7 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod ) } - ConfigFlashlightActionBottomSheet(viewModel) + ConfigFlashlightActionBottomSheet(viewModel.createActionDelegate) ActionsScreen( modifier = modifier, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt index eb1ebf4c5a..72acc7fa6b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt @@ -66,7 +66,7 @@ fun ChooseActionScreen( val state by viewModel.groups.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle() - ConfigFlashlightActionBottomSheet(viewModel) + ConfigFlashlightActionBottomSheet(viewModel.createActionDelegate) ChooseActionScreen( modifier = modifier, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionViewModel.kt index f26a9e13b5..4cd40593b7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionViewModel.kt @@ -9,7 +9,11 @@ import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.containsQuery import io.github.sds100.keymapper.util.getFullMessage import io.github.sds100.keymapper.util.ui.DialogResponse +import io.github.sds100.keymapper.util.ui.NavigationViewModel +import io.github.sds100.keymapper.util.ui.NavigationViewModelImpl import io.github.sds100.keymapper.util.ui.PopupUi +import io.github.sds100.keymapper.util.ui.PopupViewModel +import io.github.sds100.keymapper.util.ui.PopupViewModelImpl import io.github.sds100.keymapper.util.ui.ResourceProvider import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.util.ui.compose.SimpleListItemGroup @@ -33,7 +37,9 @@ class ChooseActionViewModel( private val useCase: CreateActionUseCase, resourceProvider: ResourceProvider, ) : ViewModel(), - CreateActionViewModel by CreateActionViewModelImpl(useCase, resourceProvider) { + ResourceProvider by resourceProvider, + PopupViewModel by PopupViewModelImpl(), + NavigationViewModel by NavigationViewModelImpl() { companion object { private val CATEGORY_ORDER = arrayOf( @@ -53,9 +59,13 @@ class ChooseActionViewModel( ) } + val createActionDelegate = + CreateActionDelegate(viewModelScope, useCase, this, this, this) + private val allGroupedListItems: List by lazy { buildListGroups() } - val returnAction = actionResult.filterNotNull().shareIn(viewModelScope, SharingStarted.Eagerly) + val returnAction = createActionDelegate.actionResult.filterNotNull() + .shareIn(viewModelScope, SharingStarted.Eagerly) val searchQuery = MutableStateFlow(null) @@ -84,7 +94,7 @@ class ChooseActionViewModel( showMessageForAction(actionId) } - createAction(actionId) + createActionDelegate.createAction(actionId) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt index d12437a77a..0afd7eb02c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt @@ -17,7 +17,11 @@ import io.github.sds100.keymapper.util.onFailure import io.github.sds100.keymapper.util.ui.DialogResponse import io.github.sds100.keymapper.util.ui.LinkType import io.github.sds100.keymapper.util.ui.NavDestination +import io.github.sds100.keymapper.util.ui.NavigationViewModel +import io.github.sds100.keymapper.util.ui.NavigationViewModelImpl import io.github.sds100.keymapper.util.ui.PopupUi +import io.github.sds100.keymapper.util.ui.PopupViewModel +import io.github.sds100.keymapper.util.ui.PopupViewModelImpl import io.github.sds100.keymapper.util.ui.ResourceProvider import io.github.sds100.keymapper.util.ui.ViewModelHelper import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo @@ -49,9 +53,13 @@ class ConfigActionsViewModel( private val config: ConfigKeyMapUseCase, private val onboarding: OnboardingUseCase, resourceProvider: ResourceProvider, -) : CreateActionViewModel by CreateActionViewModelImpl(createAction, resourceProvider), - ActionOptionsBottomSheetCallback { +) : ActionOptionsBottomSheetCallback, + ResourceProvider by resourceProvider, + PopupViewModel by PopupViewModelImpl(), + NavigationViewModel by NavigationViewModelImpl() { + val createActionDelegate = + CreateActionDelegate(coroutineScope, createAction, this, this, this) private val uiHelper = ActionUiHelper(displayAction, resourceProvider) private val _state = MutableStateFlow>(State.Loading) @@ -83,7 +91,7 @@ class ConfigActionsViewModel( }.launchIn(coroutineScope) coroutineScope.launch { - actionResult.filterNotNull().collect { action -> + createActionDelegate.actionResult.filterNotNull().collect { action -> val actionUid = actionOptionsUid.value ?: return@collect config.setActionData(actionUid, action) actionOptionsUid.update { null } @@ -172,7 +180,7 @@ class ConfigActionsViewModel( val keyMap = config.keyMap.first().dataOrNull() ?: return@launch val oldAction = keyMap.actionList.find { it.uid == actionUid } ?: return@launch - editAction(oldAction.data) + createActionDelegate.editAction(oldAction.data) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt index 1a74d43576..ecf3c20736 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt @@ -17,9 +17,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.BrightnessMedium import androidx.compose.material.icons.rounded.CameraFront +import androidx.compose.material.icons.rounded.FlashlightOn import androidx.compose.material.icons.rounded.RestartAlt import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -53,31 +55,29 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConfigFlashlightActionBottomSheet(viewModel: CreateActionViewModel) { +fun ConfigFlashlightActionBottomSheet(delegate: CreateActionDelegate) { val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - if (viewModel.configFlashlightActionState != null) { + if (delegate.configFlashlightActionState != null) { ConfigFlashlightActionBottomSheet( sheetState = sheetState, onDismissRequest = { - viewModel.configFlashlightActionState = null - }, - state = viewModel.configFlashlightActionState!!, - onSelectStrength = { - viewModel.configFlashlightActionState = - viewModel.configFlashlightActionState?.copy(flashStrength = it) + delegate.configFlashlightActionState = null }, + state = delegate.configFlashlightActionState!!, + onSelectStrength = delegate::onSelectStrength, onSelectLens = { - viewModel.configFlashlightActionState = - viewModel.configFlashlightActionState?.copy(selectedLens = it) + delegate.configFlashlightActionState = + delegate.configFlashlightActionState?.copy(selectedLens = it) }, onDoneClick = { scope.launch { sheetState.hide() - viewModel.onDoneConfigFlashlightClicked() + delegate.onDoneConfigFlashlightClick() } }, + onTestClick = delegate::onTestFlashlightConfigClick, ) } } @@ -91,6 +91,7 @@ private fun ConfigFlashlightActionBottomSheet( onSelectLens: (CameraLens) -> Unit = {}, onSelectStrength: (Int) -> Unit = {}, onDoneClick: () -> Unit = {}, + onTestClick: () -> Unit = {}, ) { val scrollState = rememberScrollState() val scope = rememberCoroutineScope() @@ -240,6 +241,27 @@ private fun ConfigFlashlightActionBottomSheet( } } } + + if (errorText == null) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.action_config_flashlight_brightness_test), + style = MaterialTheme.typography.titleSmall, + ) + + Spacer(Modifier.width(8.dp)) + + FilledTonalIconToggleButton( + checked = state.isFlashEnabled, + onCheckedChange = { onTestClick() }, + ) { + Icon(imageVector = Icons.Rounded.FlashlightOn, contentDescription = null) + } + } + } } Spacer(modifier = Modifier.height(8.dp)) @@ -282,6 +304,7 @@ data class ConfigFlashlightActionState( val selectedLens: CameraLens, val lensData: Map, val flashStrength: Int = 1, + val isFlashEnabled: Boolean, ) @OptIn(ExperimentalMaterial3Api::class) @@ -314,6 +337,7 @@ private fun PreviewBothLenses() { maxStrength = 10, ), ), + isFlashEnabled = true, ), ) } @@ -344,6 +368,7 @@ private fun PreviewOnlyBackLens() { maxStrength = 10, ), ), + isFlashEnabled = false, ), ) } @@ -374,6 +399,7 @@ private fun PreviewUnsupportedAndroidVersion() { maxStrength = 10, ), ), + isFlashEnabled = true, ), ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt similarity index 93% rename from app/src/main/java/io/github/sds100/keymapper/actions/CreateActionViewModel.kt rename to app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt index 36d7ee0d3c..571e0c5985 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt @@ -22,33 +22,46 @@ import io.github.sds100.keymapper.system.volume.VolumeStreamUtils import io.github.sds100.keymapper.util.ui.MultiChoiceItem import io.github.sds100.keymapper.util.ui.NavDestination import io.github.sds100.keymapper.util.ui.NavigationViewModel -import io.github.sds100.keymapper.util.ui.NavigationViewModelImpl import io.github.sds100.keymapper.util.ui.PopupUi import io.github.sds100.keymapper.util.ui.PopupViewModel -import io.github.sds100.keymapper.util.ui.PopupViewModelImpl import io.github.sds100.keymapper.util.ui.ResourceProvider import io.github.sds100.keymapper.util.ui.navigate import io.github.sds100.keymapper.util.ui.showPopup +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch /** * Created by sds100 on 26/07/2021. */ -class CreateActionViewModelImpl( +class CreateActionDelegate( + private val coroutineScope: CoroutineScope, private val useCase: CreateActionUseCase, + popupViewModel: PopupViewModel, + navigationViewModelImpl: NavigationViewModel, resourceProvider: ResourceProvider, -) : CreateActionViewModel, - ResourceProvider by resourceProvider, - PopupViewModel by PopupViewModelImpl(), - NavigationViewModel by NavigationViewModelImpl() { - - override val actionResult: MutableStateFlow = MutableStateFlow(null) - override var configFlashlightActionState: ConfigFlashlightActionState? by mutableStateOf(null) +) : ResourceProvider by resourceProvider, + PopupViewModel by popupViewModel, + NavigationViewModel by navigationViewModelImpl { + + val actionResult: MutableStateFlow = MutableStateFlow(null) + var configFlashlightActionState: ConfigFlashlightActionState? by mutableStateOf(null) + + init { + coroutineScope.launch { + useCase.isFlashlightEnabled().collectLatest { enabled -> + configFlashlightActionState?.also { state -> + configFlashlightActionState = state.copy(isFlashEnabled = enabled) + } + } + } + } - override fun onDoneConfigFlashlightClicked() { + fun onDoneConfigFlashlightClick() { configFlashlightActionState?.also { state -> val flashInfo = state.lensData[state.selectedLens] ?: return @@ -74,11 +87,39 @@ class CreateActionViewModelImpl( configFlashlightActionState = null + useCase.disableFlashlight() + actionResult.update { action } } } - override suspend fun editAction(oldData: ActionData) { + fun onSelectStrength(strength: Int) { + configFlashlightActionState?.also { state -> + configFlashlightActionState = state.copy(flashStrength = strength) + + if (state.isFlashEnabled) { + val lensData = state.lensData[state.selectedLens] ?: return + + useCase.setFlashlightBrightness( + state.selectedLens, + strength / lensData.maxStrength.toFloat(), + ) + } + } + } + + fun onTestFlashlightConfigClick() { + configFlashlightActionState?.also { state -> + val lensData = state.lensData[state.selectedLens] ?: return + + useCase.toggleFlashlight( + state.selectedLens, + state.flashStrength / lensData.maxStrength.toFloat(), + ) + } + } + + suspend fun editAction(oldData: ActionData) { if (!oldData.isEditable()) { throw IllegalArgumentException("This action ${oldData.javaClass.name} can't be edited!") } @@ -88,7 +129,7 @@ class CreateActionViewModelImpl( } } - override suspend fun createAction(id: ActionId) { + suspend fun createAction(id: ActionId) { configAction(id)?.let { action -> actionResult.update { action } } @@ -330,6 +371,7 @@ class CreateActionViewModelImpl( selectedLens = selectedLens, lensData = lensData, flashStrength = strength, + isFlashEnabled = useCase.isFlashlightEnabled().first(), ) return null @@ -679,17 +721,3 @@ class CreateActionViewModelImpl( } } } - -interface CreateActionViewModel : - ResourceProvider, - PopupViewModel, - NavigationViewModel { - - val actionResult: StateFlow - - var configFlashlightActionState: ConfigFlashlightActionState? - fun onDoneConfigFlashlightClicked() - - suspend fun editAction(oldData: ActionData) - suspend fun createAction(id: ActionId) -} diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionUseCase.kt index c36f84cba0..ea228a5a1b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionUseCase.kt @@ -6,7 +6,9 @@ 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.permissions.SystemFeatureAdapter +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.merge /** * Created by sds100 on 25/07/2021. @@ -27,10 +29,35 @@ class CreateActionUseCaseImpl( override fun getFlashInfo(lens: CameraLens): CameraFlashInfo? { return cameraAdapter.getFlashInfo(lens) } + + override fun toggleFlashlight(lens: CameraLens, strength: Float) { + cameraAdapter.toggleFlashlight(lens, strength) + } + + override fun disableFlashlight() { + cameraAdapter.disableFlashlight(CameraLens.FRONT) + cameraAdapter.disableFlashlight(CameraLens.BACK) + } + + override fun setFlashlightBrightness(lens: CameraLens, strength: Float) { + cameraAdapter.enableFlashlight(lens, strength) + } + + override fun isFlashlightEnabled(): Flow { + return merge( + cameraAdapter.isFlashlightOnFlow(CameraLens.FRONT), + cameraAdapter.isFlashlightOnFlow(CameraLens.BACK), + ) + } } interface CreateActionUseCase : IsActionSupportedUseCase { suspend fun getInputMethods(): List + + fun isFlashlightEnabled(): Flow + fun setFlashlightBrightness(lens: CameraLens, strength: Float) + fun toggleFlashlight(lens: CameraLens, strength: Float) + fun disableFlashlight() fun getFlashlightLenses(): Set fun getFlashInfo(lens: CameraLens): CameraFlashInfo? } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d28868e383..5549f7f384 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1388,6 +1388,7 @@ Min Half Max + Test Requires Android 13 or newer. This flash does not let you change the brightness. From e2456586b95d812b691c345d6b89ea04b475aeaf Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 27 Mar 2025 00:03:34 -0600 Subject: [PATCH 3/5] #1560 add action to change flashlight brightness --- .../sds100/keymapper/actions/ActionData.kt | 15 +- .../actions/ActionDataEntityMapper.kt | 28 +- .../sds100/keymapper/actions/ActionId.kt | 1 + .../keymapper/actions/ActionUiHelper.kt | 22 +- .../sds100/keymapper/actions/ActionUtils.kt | 10 + .../sds100/keymapper/actions/ActionsScreen.kt | 7 +- .../keymapper/actions/ChooseActionScreen.kt | 3 +- .../ConfigFlashlightActionBottomSheet.kt | 406 ------------ .../keymapper/actions/CreateActionDelegate.kt | 79 ++- .../actions/FlashlightActionBottomSheet.kt | 580 ++++++++++++++++++ .../actions/IsActionSupportedUseCase.kt | 8 + .../actions/PerformActionsUseCase.kt | 8 +- .../system/camera/AndroidCameraAdapter.kt | 174 ++++-- .../keymapper/system/camera/CameraAdapter.kt | 14 +- .../sds100/keymapper/util/ErrorUtils.kt | 2 + .../io/github/sds100/keymapper/util/Result.kt | 1 + app/src/main/res/values/strings.xml | 7 + 17 files changed, 863 insertions(+), 502 deletions(-) delete mode 100644 app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt index 5c0df55cc3..7bf9998c92 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt @@ -187,7 +187,7 @@ sealed class ActionData : Comparable { * of the flash strength so key maps can be exported to other devices with potentially * different strength levels. */ - val strength: Float?, + val strengthPercent: Float?, ) : Flashlight() { override val id = ActionId.TOGGLE_FLASHLIGHT } @@ -200,7 +200,7 @@ sealed class ActionData : Comparable { * of the flash strength so key maps can be exported to other devices with potentially * different strength levels. */ - val strength: Float?, + val strengthPercent: Float?, ) : Flashlight() { override val id = ActionId.ENABLE_FLASHLIGHT } @@ -209,6 +209,17 @@ sealed class ActionData : Comparable { data class Disable(override val lens: CameraLens) : Flashlight() { override val id = ActionId.DISABLE_FLASHLIGHT } + + @Serializable + data class ChangeStrength( + override val lens: CameraLens, + /** + * This can be positive or negative to increase/decrease respectively. + */ + val percent: Float, + ) : Flashlight() { + override val id = ActionId.CHANGE_FLASHLIGHT_STRENGTH + } } @Serializable diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt index fb7ce69661..c83b6f60b8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt @@ -286,6 +286,7 @@ object ActionDataEntityMapper { ActionId.TOGGLE_FLASHLIGHT, ActionId.ENABLE_FLASHLIGHT, + ActionId.CHANGE_FLASHLIGHT_STRENGTH, -> { val lens = entity.extras.getData(ActionEntity.EXTRA_LENS).then { LENS_MAP.getKey(it)!!.success() @@ -298,6 +299,15 @@ object ActionDataEntityMapper { when (actionId) { ActionId.TOGGLE_FLASHLIGHT -> ActionData.Flashlight.Toggle(lens, flashStrength) ActionId.ENABLE_FLASHLIGHT -> ActionData.Flashlight.Enable(lens, flashStrength) + + ActionId.CHANGE_FLASHLIGHT_STRENGTH -> { + flashStrength ?: return null + ActionData.Flashlight.ChangeStrength( + lens, + flashStrength, + ) + } + else -> throw Exception("don't know how to create system action for $actionId") } } @@ -625,11 +635,11 @@ object ActionDataEntityMapper { is ActionData.Flashlight.Toggle -> buildList { add(lensExtra) - if (data.strength != null) { + if (data.strengthPercent != null) { add( EntityExtra( ActionEntity.EXTRA_FLASH_STRENGTH, - data.strength.toString(), + data.strengthPercent.toString(), ), ) } @@ -638,17 +648,26 @@ object ActionDataEntityMapper { is ActionData.Flashlight.Enable -> buildList { add(lensExtra) - if (data.strength != null) { + if (data.strengthPercent != null) { add( EntityExtra( ActionEntity.EXTRA_FLASH_STRENGTH, - data.strength.toString(), + data.strengthPercent.toString(), ), ) } } is ActionData.Flashlight.Disable -> listOf(lensExtra) + is ActionData.Flashlight.ChangeStrength -> buildList { + add(lensExtra) + add( + EntityExtra( + ActionEntity.EXTRA_FLASH_STRENGTH, + data.percent.toString(), + ), + ) + } } } @@ -807,6 +826,7 @@ object ActionDataEntityMapper { ActionId.TOGGLE_FLASHLIGHT to "toggle_flashlight", ActionId.ENABLE_FLASHLIGHT to "enable_flashlight", ActionId.DISABLE_FLASHLIGHT to "disable_flashlight", + ActionId.CHANGE_FLASHLIGHT_STRENGTH to "change_flashlight_strength", ActionId.ENABLE_NFC to "nfc_enable", ActionId.DISABLE_NFC to "nfc_disable", diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionId.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionId.kt index 891403ad3c..c50e82757b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionId.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionId.kt @@ -89,6 +89,7 @@ enum class ActionId { TOGGLE_FLASHLIGHT, ENABLE_FLASHLIGHT, DISABLE_FLASHLIGHT, + CHANGE_FLASHLIGHT_STRENGTH, ENABLE_NFC, DISABLE_NFC, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt index dfaae02006..7ab1898c56 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt @@ -245,28 +245,28 @@ class ActionUiHelper( when (action) { is ActionData.Flashlight.Toggle -> { - if (action.strength == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + if (action.strengthPercent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { getString(R.string.action_toggle_flashlight_formatted, lensString) } else { getString( R.string.action_toggle_flashlight_with_strength, arrayOf( lensString, - (action.strength * 100).toInt(), + (action.strengthPercent * 100).toInt(), ), ) } } is ActionData.Flashlight.Enable -> { - if (action.strength == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + if (action.strengthPercent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { getString(R.string.action_enable_flashlight_formatted, lensString) } else { getString( R.string.action_enable_flashlight_with_strength, arrayOf( lensString, - (action.strength * 100).toInt(), + (action.strengthPercent * 100).toInt(), ), ) } @@ -276,6 +276,20 @@ class ActionUiHelper( R.string.action_disable_flashlight_formatted, lensString, ) + + is ActionData.Flashlight.ChangeStrength -> { + if (action.percent > 0) { + getString( + R.string.action_flashlight_increase_strength_formatted, + arrayOf(lensString, (action.percent * 100).toInt()), + ) + } else { + getString( + R.string.action_flashlight_decrease_strength_formatted, + arrayOf(lensString, (action.percent * 100).toInt()), + ) + } + } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt index 3c78e1864c..d77967ed77 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt @@ -191,6 +191,7 @@ object ActionUtils { ActionId.TOGGLE_FLASHLIGHT -> ActionCategory.CAMERA_SOUND ActionId.ENABLE_FLASHLIGHT -> ActionCategory.CAMERA_SOUND ActionId.DISABLE_FLASHLIGHT -> ActionCategory.CAMERA_SOUND + ActionId.CHANGE_FLASHLIGHT_STRENGTH -> ActionCategory.CAMERA_SOUND ActionId.SOUND -> ActionCategory.CAMERA_SOUND ActionId.ENABLE_NFC -> ActionCategory.CONNECTIVITY @@ -294,6 +295,7 @@ object ActionUtils { ActionId.TOGGLE_FLASHLIGHT -> R.string.action_toggle_flashlight ActionId.ENABLE_FLASHLIGHT -> R.string.action_enable_flashlight ActionId.DISABLE_FLASHLIGHT -> R.string.action_disable_flashlight + ActionId.CHANGE_FLASHLIGHT_STRENGTH -> R.string.action_flashlight_change_strength ActionId.ENABLE_NFC -> R.string.action_nfc_enable ActionId.DISABLE_NFC -> R.string.action_nfc_disable ActionId.TOGGLE_NFC -> R.string.action_nfc_toggle @@ -404,6 +406,7 @@ object ActionUtils { ActionId.TOGGLE_FLASHLIGHT -> R.drawable.ic_flashlight ActionId.ENABLE_FLASHLIGHT -> R.drawable.ic_flashlight ActionId.DISABLE_FLASHLIGHT -> R.drawable.ic_flashlight_off + ActionId.CHANGE_FLASHLIGHT_STRENGTH -> R.drawable.ic_flashlight ActionId.ENABLE_NFC -> R.drawable.ic_outline_nfc_24 ActionId.DISABLE_NFC -> R.drawable.ic_nfc_off ActionId.TOGGLE_NFC -> R.drawable.ic_outline_nfc_24 @@ -471,6 +474,9 @@ object ActionUtils { ActionId.TOGGLE_FLASHLIGHT, -> Build.VERSION_CODES.M + ActionId.CHANGE_FLASHLIGHT_STRENGTH, + -> Build.VERSION_CODES.TIRAMISU + ActionId.TOGGLE_KEYBOARD, ActionId.SHOW_KEYBOARD, ActionId.HIDE_KEYBOARD, @@ -533,6 +539,7 @@ object ActionUtils { ActionId.TOGGLE_FLASHLIGHT, ActionId.ENABLE_FLASHLIGHT, ActionId.DISABLE_FLASHLIGHT, + ActionId.CHANGE_FLASHLIGHT_STRENGTH, -> listOf(PackageManager.FEATURE_CAMERA_FLASH) else -> emptyList() @@ -596,6 +603,7 @@ object ActionUtils { ActionId.TOGGLE_FLASHLIGHT, ActionId.ENABLE_FLASHLIGHT, ActionId.DISABLE_FLASHLIGHT, + ActionId.CHANGE_FLASHLIGHT_STRENGTH, -> return listOf(Permission.CAMERA) ActionId.ENABLE_NFC, @@ -714,6 +722,7 @@ object ActionUtils { ActionId.TOGGLE_FLASHLIGHT -> Icons.Outlined.FlashlightOn ActionId.ENABLE_FLASHLIGHT -> Icons.Outlined.FlashlightOn ActionId.DISABLE_FLASHLIGHT -> Icons.Outlined.FlashlightOff + ActionId.CHANGE_FLASHLIGHT_STRENGTH -> Icons.Outlined.FlashlightOn ActionId.ENABLE_NFC -> Icons.Outlined.Nfc ActionId.DISABLE_NFC -> KeyMapperIcons.NfcOff ActionId.TOGGLE_NFC -> Icons.Outlined.Nfc @@ -800,6 +809,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.Rotation.CycleRotations, is ActionData.Flashlight.Toggle, is ActionData.Flashlight.Enable, + is ActionData.Flashlight.ChangeStrength, is ActionData.TapScreen, is ActionData.SwipeScreen, is ActionData.PinchScreen, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt index 93a4aba8c5..9e617c4f98 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt @@ -65,7 +65,8 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod ) } - ConfigFlashlightActionBottomSheet(viewModel.createActionDelegate) + EnableFlashlightActionBottomSheet(viewModel.createActionDelegate) + ChangeFlashlightStrengthActionBottomSheet(viewModel.createActionDelegate) ActionsScreen( modifier = modifier, @@ -304,7 +305,7 @@ private fun EmptyPreview() { text = "Toggle Back flashlight", data = ActionData.Flashlight.Toggle( lens = CameraLens.BACK, - strength = null, + strengthPercent = null, ), ), ), @@ -346,7 +347,7 @@ private fun LoadedPreview() { text = "Toggle Back flashlight", data = ActionData.Flashlight.Toggle( lens = CameraLens.BACK, - strength = null, + strengthPercent = null, ), ), ), diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt index 72acc7fa6b..5715ad53a0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt @@ -66,7 +66,8 @@ fun ChooseActionScreen( val state by viewModel.groups.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle() - ConfigFlashlightActionBottomSheet(viewModel.createActionDelegate) + EnableFlashlightActionBottomSheet(viewModel.createActionDelegate) + ChangeFlashlightStrengthActionBottomSheet(viewModel.createActionDelegate) ChooseActionScreen( modifier = modifier, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt deleted file mode 100644 index ecf3c20736..0000000000 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigFlashlightActionBottomSheet.kt +++ /dev/null @@ -1,406 +0,0 @@ -package io.github.sds100.keymapper.actions - -import android.os.Build -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.BrightnessMedium -import androidx.compose.material.icons.rounded.CameraFront -import androidx.compose.material.icons.rounded.FlashlightOn -import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconToggleButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -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 -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -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 -import androidx.compose.ui.unit.dp -import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.compose.KeyMapperTheme -import io.github.sds100.keymapper.system.camera.CameraFlashInfo -import io.github.sds100.keymapper.system.camera.CameraLens -import io.github.sds100.keymapper.util.ui.compose.KeyMapperSliderThumb -import io.github.sds100.keymapper.util.ui.compose.OptionsHeaderRow -import io.github.sds100.keymapper.util.ui.compose.RadioButtonText -import kotlinx.coroutines.launch -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ConfigFlashlightActionBottomSheet(delegate: CreateActionDelegate) { - val scope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - if (delegate.configFlashlightActionState != null) { - ConfigFlashlightActionBottomSheet( - sheetState = sheetState, - onDismissRequest = { - delegate.configFlashlightActionState = null - }, - state = delegate.configFlashlightActionState!!, - onSelectStrength = delegate::onSelectStrength, - onSelectLens = { - delegate.configFlashlightActionState = - delegate.configFlashlightActionState?.copy(selectedLens = it) - }, - onDoneClick = { - scope.launch { - sheetState.hide() - delegate.onDoneConfigFlashlightClick() - } - }, - onTestClick = delegate::onTestFlashlightConfigClick, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ConfigFlashlightActionBottomSheet( - sheetState: SheetState, - onDismissRequest: () -> Unit, - state: ConfigFlashlightActionState, - onSelectLens: (CameraLens) -> Unit = {}, - onSelectStrength: (Int) -> Unit = {}, - onDoneClick: () -> Unit = {}, - onTestClick: () -> Unit = {}, -) { - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - - if (state.lensData.isEmpty()) { - throw IllegalStateException("You can not configure a flashlight action if your device has no flashes.") - } - - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - dragHandle = null, - ) { - Column( - modifier = Modifier.verticalScroll(scrollState), - ) { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - textAlign = TextAlign.Center, - text = stringResource(ActionUtils.getTitle(state.actionToCreate)), - style = MaterialTheme.typography.headlineMedium, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Rounded.CameraFront, - text = stringResource(R.string.action_config_flashlight_choose_side), - ) - - Row(modifier = Modifier.padding(horizontal = 8.dp)) { - RadioButtonText( - modifier = Modifier, - text = stringResource(R.string.lens_front), - isSelected = state.selectedLens == CameraLens.FRONT, - onSelected = { onSelectLens(CameraLens.FRONT) }, - isEnabled = state.lensData.containsKey(CameraLens.FRONT), - ) - - RadioButtonText( - modifier = Modifier, - text = stringResource(R.string.lens_back), - isSelected = state.selectedLens == CameraLens.BACK, - onSelected = { onSelectLens(CameraLens.BACK) }, - isEnabled = state.lensData.containsKey(CameraLens.BACK), - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Rounded.BrightnessMedium, - text = stringResource(R.string.action_config_flashlight_brightness), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - val errorText = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - stringResource(R.string.action_config_flashlight_brightness_unsupported_android_version) - } else if (!state.lensData[state.selectedLens]!!.supportsVariableStrength) { - stringResource(R.string.action_config_flashlight_brightness_unsupported) - } else { - null - } - - if (errorText != null) { - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = errorText, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - } - - val interactionSource = remember { MutableInteractionSource() } - val sliderDefault = state.lensData[state.selectedLens]!!.defaultStrength - val sliderMax = state.lensData[state.selectedLens]!!.maxStrength.toFloat() - - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Slider( - modifier = Modifier.weight(1f), - value = state.flashStrength.toFloat(), - onValueChange = { onSelectStrength(it.roundToInt()) }, - enabled = errorText == null, - interactionSource = interactionSource, - thumb = { - KeyMapperSliderThumb( - interactionSource, - enabled = errorText == null, - ) - }, - valueRange = 1f..sliderMax, - steps = sliderMax.toInt(), - ) - - Spacer(Modifier.width(8.dp)) - - Text( - modifier = Modifier.padding(horizontal = 4.dp), - text = "${state.flashStrength} / ${sliderMax.toInt()}", - style = MaterialTheme.typography.labelLarge, - textAlign = TextAlign.Center, - ) - } - - if (errorText == null) { - Row(modifier = Modifier.padding(horizontal = 16.dp)) { - Box(modifier = Modifier.weight(1f)) { - TextButton( - modifier = Modifier.align(Alignment.TopStart), - onClick = { onSelectStrength(1) }, - ) { - Text(stringResource(R.string.action_config_flashlight_brightness_min)) - } - TextButton( - modifier = Modifier.align(Alignment.TopCenter), - onClick = { onSelectStrength(((sliderMax - 1) / 2).toInt()) }, - ) { - Text(stringResource(R.string.action_config_flashlight_brightness_half)) - } - TextButton( - modifier = Modifier.align(Alignment.TopEnd), - onClick = { onSelectStrength(sliderMax.toInt()) }, - ) { - Text(stringResource(R.string.action_config_flashlight_brightness_max)) - } - } - - Spacer(Modifier.width(8.dp)) - - AnimatedVisibility(visible = state.flashStrength != sliderDefault) { - IconButton(onClick = { onSelectStrength(sliderDefault) }) { - Icon( - Icons.Rounded.RestartAlt, - contentDescription = stringResource(R.string.slider_reset_content_description), - ) - } - } - } - } - - if (errorText == null) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.action_config_flashlight_brightness_test), - style = MaterialTheme.typography.titleSmall, - ) - - Spacer(Modifier.width(8.dp)) - - FilledTonalIconToggleButton( - checked = state.isFlashEnabled, - onCheckedChange = { onTestClick() }, - ) { - Icon(imageVector = Icons.Rounded.FlashlightOn, contentDescription = null) - } - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - modifier = Modifier.weight(1f), - onClick = onDoneClick, - ) { - Text(stringResource(R.string.pos_done)) - } - } - - Spacer(Modifier.height(16.dp)) - } -} - -data class ConfigFlashlightActionState( - val actionToCreate: ActionId, - val selectedLens: CameraLens, - val lensData: Map, - val flashStrength: Int = 1, - val isFlashEnabled: Boolean, -) - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun PreviewBothLenses() { - KeyMapperTheme { - val sheetState = SheetState( - skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, - ) - - ConfigFlashlightActionBottomSheet( - sheetState = sheetState, - onDismissRequest = {}, - state = ConfigFlashlightActionState( - actionToCreate = ActionId.ENABLE_FLASHLIGHT, - selectedLens = CameraLens.BACK, - flashStrength = 3, - lensData = mapOf( - CameraLens.FRONT to CameraFlashInfo( - supportsVariableStrength = true, - defaultStrength = 5, - maxStrength = 10, - ), - CameraLens.BACK to CameraFlashInfo( - supportsVariableStrength = true, - defaultStrength = 5, - maxStrength = 10, - ), - ), - isFlashEnabled = true, - ), - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun PreviewOnlyBackLens() { - KeyMapperTheme { - val sheetState = SheetState( - skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, - ) - - ConfigFlashlightActionBottomSheet( - sheetState = sheetState, - onDismissRequest = {}, - state = ConfigFlashlightActionState( - actionToCreate = ActionId.TOGGLE_FLASHLIGHT, - selectedLens = CameraLens.BACK, - flashStrength = 3, - lensData = mapOf( - CameraLens.BACK to CameraFlashInfo( - supportsVariableStrength = true, - defaultStrength = 5, - maxStrength = 10, - ), - ), - isFlashEnabled = false, - ), - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview(apiLevel = Build.VERSION_CODES.R) -@Composable -private fun PreviewUnsupportedAndroidVersion() { - KeyMapperTheme { - val sheetState = SheetState( - skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, - ) - - ConfigFlashlightActionBottomSheet( - sheetState = sheetState, - onDismissRequest = {}, - state = ConfigFlashlightActionState( - actionToCreate = ActionId.TOGGLE_FLASHLIGHT, - selectedLens = CameraLens.BACK, - flashStrength = 2, - lensData = mapOf( - CameraLens.BACK to CameraFlashInfo( - supportsVariableStrength = true, - defaultStrength = 5, - maxStrength = 10, - ), - ), - isFlashEnabled = true, - ), - ) - } -} diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt index 571e0c5985..7547a03025 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt @@ -49,20 +49,23 @@ class CreateActionDelegate( NavigationViewModel by navigationViewModelImpl { val actionResult: MutableStateFlow = MutableStateFlow(null) - var configFlashlightActionState: ConfigFlashlightActionState? by mutableStateOf(null) + var enableFlashlightActionState: EnableFlashlightActionState? by mutableStateOf(null) + var changeFlashlightStrengthActionState: ChangeFlashlightStrengthActionState? by mutableStateOf( + null, + ) init { coroutineScope.launch { useCase.isFlashlightEnabled().collectLatest { enabled -> - configFlashlightActionState?.also { state -> - configFlashlightActionState = state.copy(isFlashEnabled = enabled) + enableFlashlightActionState?.also { state -> + enableFlashlightActionState = state.copy(isFlashEnabled = enabled) } } } } - fun onDoneConfigFlashlightClick() { - configFlashlightActionState?.also { state -> + fun onDoneConfigEnableFlashlightClick() { + enableFlashlightActionState?.also { state -> val flashInfo = state.lensData[state.selectedLens] ?: return val strengthPercent = @@ -85,7 +88,7 @@ class CreateActionDelegate( else -> return } - configFlashlightActionState = null + enableFlashlightActionState = null useCase.disableFlashlight() @@ -93,9 +96,27 @@ class CreateActionDelegate( } } + fun onDoneChangeFlashlightBrightnessClick() { + changeFlashlightStrengthActionState?.also { state -> + val flashInfo = state.lensData[state.selectedLens] ?: return + + val strengthPercent = + (state.flashStrength.toFloat() / flashInfo.maxStrength).coerceIn(-1f, 1f) + + val action = ActionData.Flashlight.ChangeStrength( + state.selectedLens, + strengthPercent, + ) + + changeFlashlightStrengthActionState = null + + actionResult.update { action } + } + } + fun onSelectStrength(strength: Int) { - configFlashlightActionState?.also { state -> - configFlashlightActionState = state.copy(flashStrength = strength) + enableFlashlightActionState?.also { state -> + enableFlashlightActionState = state.copy(flashStrength = strength) if (state.isFlashEnabled) { val lensData = state.lensData[state.selectedLens] ?: return @@ -109,7 +130,7 @@ class CreateActionDelegate( } fun onTestFlashlightConfigClick() { - configFlashlightActionState?.also { state -> + enableFlashlightActionState?.also { state -> val lensData = state.lensData[state.selectedLens] ?: return useCase.toggleFlashlight( @@ -351,22 +372,22 @@ class CreateActionDelegate( val lensInfo = lensData[selectedLens] ?: lensData.values.first() val strength: Int = when (oldData) { - is ActionData.Flashlight.Toggle -> if (oldData.strength == null) { + is ActionData.Flashlight.Toggle -> if (oldData.strengthPercent == null) { lensInfo.defaultStrength } else { - (oldData.strength * lensInfo.maxStrength).toInt() + (oldData.strengthPercent * lensInfo.maxStrength).toInt() } - is ActionData.Flashlight.Enable -> if (oldData.strength == null) { + is ActionData.Flashlight.Enable -> if (oldData.strengthPercent == null) { lensInfo.defaultStrength } else { - (oldData.strength * lensInfo.maxStrength).toInt() + (oldData.strengthPercent * lensInfo.maxStrength).toInt() } else -> lensInfo.defaultStrength } - configFlashlightActionState = ConfigFlashlightActionState( + enableFlashlightActionState = EnableFlashlightActionState( actionToCreate = actionId, selectedLens = selectedLens, lensData = lensData, @@ -377,6 +398,36 @@ class CreateActionDelegate( return null } + ActionId.CHANGE_FLASHLIGHT_STRENGTH -> { + val lenses = useCase.getFlashlightLenses() + val selectedLens = if (oldData is ActionData.Flashlight.ChangeStrength) { + oldData.lens + } else if (lenses.contains(CameraLens.BACK)) { + CameraLens.BACK + } else { + lenses.first() + } + + val lensData = lenses.associateWith { useCase.getFlashInfo(it)!! } + val lensInfo = lensData[selectedLens] ?: lensData.values.first() + + val strength: Int = when (oldData) { + is ActionData.Flashlight.ChangeStrength -> { + (oldData.percent * lensInfo.maxStrength).toInt() + } + + else -> (0.1f * lensInfo.maxStrength).toInt() + } + + changeFlashlightStrengthActionState = ChangeFlashlightStrengthActionState( + selectedLens = selectedLens, + lensData = lensData, + flashStrength = strength, + ) + + return null + } + ActionId.DISABLE_FLASHLIGHT, -> { val items = useCase.getFlashlightLenses().map { diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt new file mode 100644 index 0000000000..df075c345e --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt @@ -0,0 +1,580 @@ +package io.github.sds100.keymapper.actions + +import android.os.Build +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BrightnessMedium +import androidx.compose.material.icons.rounded.CameraFront +import androidx.compose.material.icons.rounded.FlashlightOff +import androidx.compose.material.icons.rounded.FlashlightOn +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconToggleButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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 +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.system.camera.CameraFlashInfo +import io.github.sds100.keymapper.system.camera.CameraLens +import io.github.sds100.keymapper.util.ui.compose.KeyMapperSliderThumb +import io.github.sds100.keymapper.util.ui.compose.OptionsHeaderRow +import io.github.sds100.keymapper.util.ui.compose.RadioButtonText +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnableFlashlightActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.enableFlashlightActionState != null) { + EnableFlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = { + delegate.enableFlashlightActionState = null + }, + state = delegate.enableFlashlightActionState!!, + onSelectStrength = delegate::onSelectStrength, + onSelectLens = { + delegate.enableFlashlightActionState = + delegate.enableFlashlightActionState?.copy(selectedLens = it) + }, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneConfigEnableFlashlightClick() + } + }, + onTestClick = delegate::onTestFlashlightConfigClick, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EnableFlashlightActionBottomSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + state: EnableFlashlightActionState, + onSelectLens: (CameraLens) -> Unit = {}, + onSelectStrength: (Int) -> Unit = {}, + onDoneClick: () -> Unit = {}, + onTestClick: () -> Unit = {}, +) { + FlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(ActionUtils.getTitle(state.actionToCreate)), + selectedLens = state.selectedLens, + availableLenses = state.lensData.keys, + onSelectLens = onSelectLens, + onDoneClick = onDoneClick, + ) { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.BrightnessMedium, + text = stringResource(R.string.action_config_flashlight_brightness), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val errorText = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + stringResource(R.string.action_config_flashlight_brightness_unsupported_android_version) + } else if (!state.lensData[state.selectedLens]!!.supportsVariableStrength) { + stringResource(R.string.action_config_flashlight_brightness_unsupported) + } else { + null + } + + if (errorText != null) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = errorText, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + val interactionSource = remember { MutableInteractionSource() } + val sliderDefault = state.lensData[state.selectedLens]!!.defaultStrength + val sliderMax = state.lensData[state.selectedLens]!!.maxStrength.toFloat() + + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Slider( + modifier = Modifier.weight(1f), + value = state.flashStrength.toFloat(), + onValueChange = { onSelectStrength(it.roundToInt()) }, + enabled = errorText == null, + interactionSource = interactionSource, + thumb = { + KeyMapperSliderThumb( + interactionSource, + enabled = errorText == null, + ) + }, + valueRange = 1f..sliderMax, + steps = sliderMax.toInt(), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 4.dp), + text = "${state.flashStrength} / ${sliderMax.toInt()}", + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } + + if (errorText == null) { + Row(modifier = Modifier.padding(horizontal = 16.dp)) { + Box(modifier = Modifier.weight(1f)) { + TextButton( + modifier = Modifier.align(Alignment.TopStart), + onClick = { onSelectStrength(1) }, + ) { + Text(stringResource(R.string.action_config_flashlight_brightness_min)) + } + TextButton( + modifier = Modifier.align(Alignment.TopCenter), + onClick = { onSelectStrength(((sliderMax - 1) / 2).toInt()) }, + ) { + Text(stringResource(R.string.action_config_flashlight_brightness_half)) + } + TextButton( + modifier = Modifier.align(Alignment.TopEnd), + onClick = { onSelectStrength(sliderMax.toInt()) }, + ) { + Text(stringResource(R.string.action_config_flashlight_brightness_max)) + } + } + + Spacer(Modifier.width(8.dp)) + + AnimatedVisibility(visible = state.flashStrength != sliderDefault) { + IconButton(onClick = { onSelectStrength(sliderDefault) }) { + Icon( + Icons.Rounded.RestartAlt, + contentDescription = stringResource(R.string.slider_reset_content_description), + ) + } + } + } + } + + if (errorText == null) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.action_config_flashlight_brightness_test), + style = MaterialTheme.typography.titleSmall, + ) + + Spacer(Modifier.width(8.dp)) + + FilledTonalIconToggleButton( + checked = state.isFlashEnabled, + onCheckedChange = { onTestClick() }, + ) { + AnimatedContent(state.isFlashEnabled) { isEnabled -> + if (isEnabled) { + Icon( + imageVector = Icons.Rounded.FlashlightOn, + contentDescription = null, + ) + } else { + Icon( + imageVector = Icons.Rounded.FlashlightOff, + contentDescription = null, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChangeFlashlightStrengthActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.changeFlashlightStrengthActionState != null) { + ChangeFlashlightStrengthActionBottomSheet( + sheetState = sheetState, + onDismissRequest = { + delegate.changeFlashlightStrengthActionState = null + }, + state = delegate.changeFlashlightStrengthActionState!!, + onSelectStrength = { + delegate.changeFlashlightStrengthActionState = + delegate.changeFlashlightStrengthActionState?.copy(flashStrength = it) + }, + onSelectLens = { + delegate.changeFlashlightStrengthActionState = + delegate.changeFlashlightStrengthActionState?.copy(selectedLens = it) + }, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneChangeFlashlightBrightnessClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChangeFlashlightStrengthActionBottomSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + state: ChangeFlashlightStrengthActionState, + onSelectLens: (CameraLens) -> Unit = {}, + onSelectStrength: (Int) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + FlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.action_flashlight_change_strength), + selectedLens = state.selectedLens, + availableLenses = state.lensData.entries + .filter { it.value.supportsVariableStrength } + .map { it.key }.toSet(), + onSelectLens = onSelectLens, + onDoneClick = onDoneClick, + ) { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.BrightnessMedium, + text = stringResource(R.string.action_config_flashlight_brightness_factor), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val interactionSource = remember { MutableInteractionSource() } + val lensData = state.lensData[state.selectedLens]!! + val maxStrength = lensData.maxStrength + + Slider( + modifier = Modifier.weight(1f), + value = state.flashStrength.toFloat(), + onValueChange = { onSelectStrength(it.roundToInt()) }, + interactionSource = interactionSource, + thumb = { + KeyMapperSliderThumb(interactionSource) + }, + valueRange = -maxStrength.toFloat()..maxStrength.toFloat(), + // add 1 for the center value of 0. + steps = (maxStrength * 2) + 1, + ) + + Spacer(Modifier.width(8.dp)) + + val percentInt = ((state.flashStrength / maxStrength.toFloat()) * 100).toInt() + val textPercent = if (state.flashStrength > 0) { + "+$percentInt%" + } else { + "$percentInt%" + } + + Text( + modifier = Modifier.padding(horizontal = 4.dp), + text = textPercent, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FlashlightActionBottomSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + title: String, + selectedLens: CameraLens, + availableLenses: Set, + onSelectLens: (CameraLens) -> Unit = {}, + onDoneClick: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier.verticalScroll(scrollState), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + textAlign = TextAlign.Center, + text = title, + style = MaterialTheme.typography.headlineMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.CameraFront, + text = stringResource(R.string.action_config_flashlight_choose_side), + ) + + Row(modifier = Modifier.padding(horizontal = 8.dp)) { + RadioButtonText( + modifier = Modifier, + text = stringResource(R.string.lens_front), + isSelected = selectedLens == CameraLens.FRONT, + onSelected = { onSelectLens(CameraLens.FRONT) }, + isEnabled = availableLenses.contains(CameraLens.FRONT), + ) + + RadioButtonText( + modifier = Modifier, + text = stringResource(R.string.lens_back), + isSelected = selectedLens == CameraLens.BACK, + onSelected = { onSelectLens(CameraLens.BACK) }, + isEnabled = availableLenses.contains(CameraLens.BACK), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + content() + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = onDoneClick, + ) { + Text(stringResource(R.string.pos_done)) + } + } + + Spacer(Modifier.height(16.dp)) + } +} + +data class EnableFlashlightActionState( + val actionToCreate: ActionId, + val selectedLens: CameraLens, + val lensData: Map, + val flashStrength: Int = 1, + val isFlashEnabled: Boolean, +) + +data class ChangeFlashlightStrengthActionState( + val selectedLens: CameraLens, + val lensData: Map, + /** + * This can be positive or negative and must be less than +- max strength. + */ + val flashStrength: Int = 0, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewBothLenses() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + EnableFlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = EnableFlashlightActionState( + actionToCreate = ActionId.ENABLE_FLASHLIGHT, + selectedLens = CameraLens.BACK, + flashStrength = 3, + lensData = mapOf( + CameraLens.FRONT to CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = 5, + maxStrength = 10, + ), + CameraLens.BACK to CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = 5, + maxStrength = 10, + ), + ), + isFlashEnabled = true, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewOnlyBackLens() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + EnableFlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = EnableFlashlightActionState( + actionToCreate = ActionId.TOGGLE_FLASHLIGHT, + selectedLens = CameraLens.BACK, + flashStrength = 3, + lensData = mapOf( + CameraLens.BACK to CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = 5, + maxStrength = 10, + ), + ), + isFlashEnabled = false, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewOnlyBackLensChangeStrength() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + ChangeFlashlightStrengthActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = ChangeFlashlightStrengthActionState( + selectedLens = CameraLens.BACK, + flashStrength = -5, + lensData = mapOf( + CameraLens.BACK to CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = 5, + maxStrength = 10, + ), + ), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(apiLevel = Build.VERSION_CODES.R) +@Composable +private fun PreviewUnsupportedAndroidVersion() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + EnableFlashlightActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = EnableFlashlightActionState( + actionToCreate = ActionId.TOGGLE_FLASHLIGHT, + selectedLens = CameraLens.BACK, + flashStrength = 2, + lensData = mapOf( + CameraLens.BACK to CameraFlashInfo( + supportsVariableStrength = true, + defaultStrength = 5, + maxStrength = 10, + ), + ), + isFlashEnabled = true, + ), + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/IsActionSupportedUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/actions/IsActionSupportedUseCase.kt index 31389bcff2..e5f2f160a9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/IsActionSupportedUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/IsActionSupportedUseCase.kt @@ -41,6 +41,14 @@ class IsActionSupportedUseCaseImpl( } } + if (id == ActionId.CHANGE_FLASHLIGHT_STRENGTH) { + if (cameraAdapter.getFlashInfo(CameraLens.BACK)?.supportsVariableStrength != true && + cameraAdapter.getFlashInfo(CameraLens.FRONT)?.supportsVariableStrength != true + ) { + return Error.CameraVariableFlashlightStrengthUnsupported + } + } + return null } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt index ec5610f224..d97e5a9df0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt @@ -242,11 +242,15 @@ class PerformActionsUseCaseImpl( } is ActionData.Flashlight.Enable -> { - result = cameraAdapter.enableFlashlight(action.lens, action.strength) + result = cameraAdapter.enableFlashlight(action.lens, action.strengthPercent) } is ActionData.Flashlight.Toggle -> { - result = cameraAdapter.toggleFlashlight(action.lens, action.strength) + result = cameraAdapter.toggleFlashlight(action.lens, action.strengthPercent) + } + + is ActionData.Flashlight.ChangeStrength -> { + result = cameraAdapter.changeFlashlightStrength(action.lens, action.percent) } is ActionData.SwitchKeyboard -> { diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt index 69a099f33f..dbf4804dac 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt @@ -114,11 +114,32 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { return null } - override fun enableFlashlight(lens: CameraLens, strength: Float?): Result<*> = setFlashlightMode(true, lens, strength) + private fun getFlashlightCameraIdForLens(lens: CameraLens): String? { + for (cameraId in cameraManager.cameraIdList) { + val camera = cameraManager.getCameraCharacteristics(cameraId) + val lensFacing = camera.get(CameraCharacteristics.LENS_FACING)!! + + val lensToCompareSdkValue = when (lens) { + CameraLens.FRONT -> CameraCharacteristics.LENS_FACING_FRONT + CameraLens.BACK -> CameraCharacteristics.LENS_FACING_BACK + } + + val flashAvailable = cameraManager.getCameraCharacteristics(cameraId) + .get(CameraCharacteristics.FLASH_INFO_AVAILABLE)!! + + if (flashAvailable && lensFacing == lensToCompareSdkValue) { + return cameraId + } + } + + return null + } + + override fun enableFlashlight(lens: CameraLens, strengthPercent: Float?): Result<*> = setFlashlightMode(true, lens, strengthPercent) override fun disableFlashlight(lens: CameraLens): Result<*> = setFlashlightMode(false, lens) - override fun toggleFlashlight(lens: CameraLens, strength: Float?): Result<*> = setFlashlightMode(!isFlashEnabledMap.value[lens]!!, lens, strength) + override fun toggleFlashlight(lens: CameraLens, strengthPercent: Float?): Result<*> = setFlashlightMode(!isFlashEnabledMap.value[lens]!!, lens, strengthPercent) override fun isFlashlightOn(lens: CameraLens): Boolean = isFlashEnabledMap.value[lens] ?: false @@ -126,6 +147,47 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { return isFlashEnabledMap.map { it[lens] ?: false } } + override fun changeFlashlightStrength(lens: CameraLens, percent: Float): Result<*> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.TIRAMISU) + } + + try { + val cameraId = getFlashlightCameraIdForLens(lens) + + if (cameraId == null) { + return when (lens) { + CameraLens.FRONT -> Error.FrontFlashNotFound + CameraLens.BACK -> Error.BackFlashNotFound + } + } + + val currentStrength = cameraManager.getTorchStrengthLevel(cameraId) + + val maxStrength = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getCharacteristicForLens( + lens, + CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL, + ) + } else { + null + } + + if (maxStrength != null) { + val newStrength = + (currentStrength + (percent * maxStrength)) + .toInt() + .coerceIn(1, maxStrength) + + cameraManager.turnOnTorchWithStrengthLevel(cameraId, newStrength) + } + + return Success(Unit) + } catch (e: CameraAccessException) { + return convertCameraException(e) + } + } + private fun setFlashlightMode( enabled: Boolean, lens: CameraLens, @@ -135,76 +197,64 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) } - // get the CameraManager - cameraManager.apply { - for (cameraId in cameraIdList) { - try { - val flashAvailable = getCameraCharacteristics(cameraId) - .get(CameraCharacteristics.FLASH_INFO_AVAILABLE)!! - - val lensFacing = getCameraCharacteristics(cameraId) - .get(CameraCharacteristics.LENS_FACING) + try { + val cameraId = getFlashlightCameraIdForLens(lens) - val lensSdkValue = when (lens) { - CameraLens.FRONT -> CameraCharacteristics.LENS_FACING_FRONT - CameraLens.BACK -> CameraCharacteristics.LENS_FACING_BACK - } + if (cameraId == null) { + return when (lens) { + CameraLens.FRONT -> Error.FrontFlashNotFound + CameraLens.BACK -> Error.BackFlashNotFound + } + } - val maxStrength = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getCharacteristicForLens( - lens, - CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL, - ) - } else { - null - } + val maxStrength = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getCharacteristicForLens( + lens, + CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL, + ) + } else { + null + } - val defaultStrength = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getCharacteristicForLens( - lens, - CameraCharacteristics.FLASH_INFO_STRENGTH_DEFAULT_LEVEL, - ) - } else { - null - } + val defaultStrength = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getCharacteristicForLens( + lens, + CameraCharacteristics.FLASH_INFO_STRENGTH_DEFAULT_LEVEL, + ) + } else { + null + } - // try to find a camera with a flash - if (flashAvailable && lensFacing == lensSdkValue) { - if (enabled && maxStrength != null && defaultStrength != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val strength = if (strengthPercent == null) { - defaultStrength - } else { - (strengthPercent * maxStrength).toInt().coerceAtLeast(1) - } - - turnOnTorchWithStrengthLevel(cameraId, strength) - } else { - setTorchMode(cameraId, enabled) - } - return Success(Unit) - } - } catch (e: CameraAccessException) { - return when (e.reason) { - CameraAccessException.CAMERA_IN_USE -> Error.CameraInUse - CameraAccessException.CAMERA_DISCONNECTED -> Error.CameraDisconnected - CameraAccessException.CAMERA_DISABLED -> Error.CameraDisabled - CameraAccessException.CAMERA_ERROR -> Error.CameraError - CameraAccessException.MAX_CAMERAS_IN_USE -> Error.MaxCamerasInUse - else -> Error.Exception(e) - } - } catch (e: Exception) { - return Error.Exception(e) + // try to find a camera with a flash + if (enabled && maxStrength != null && defaultStrength != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val strength = if (strengthPercent == null) { + defaultStrength + } else { + (strengthPercent * maxStrength).toInt().coerceAtLeast(1) } + cameraManager.turnOnTorchWithStrengthLevel(cameraId, strength) + } else { + cameraManager.setTorchMode(cameraId, enabled) } - } - return when (lens) { - CameraLens.FRONT -> Error.FrontFlashNotFound - CameraLens.BACK -> Error.BackFlashNotFound + return Success(Unit) + } catch (e: CameraAccessException) { + return convertCameraException(e) + } catch (e: Exception) { + return Error.Exception(e) } } + private fun convertCameraException(e: CameraAccessException) = when (e.reason) { + CameraAccessException.CAMERA_IN_USE -> Error.CameraInUse + CameraAccessException.CAMERA_DISCONNECTED -> Error.CameraDisconnected + CameraAccessException.CAMERA_DISABLED -> Error.CameraDisabled + CameraAccessException.CAMERA_ERROR -> Error.CameraError + CameraAccessException.MAX_CAMERAS_IN_USE -> Error.MaxCamerasInUse + else -> Error.Exception(e) + } + private fun updateState(lens: CameraLens, enabled: Boolean) { isFlashEnabledMap.update { map -> map.toMutableMap().apply { this[lens] = enabled } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraAdapter.kt index 4718617554..32f6811f56 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/CameraAdapter.kt @@ -13,17 +13,23 @@ interface CameraAdapter { fun getFlashInfo(lens: CameraLens): CameraFlashInfo? /** - * @param strength is a percentage of the brightness from 0 to 1.0. Null if the default + * @param strengthPercent is a percentage of the brightness from 0 to 1.0. Null if the default * brightness should be used. */ - fun enableFlashlight(lens: CameraLens, strength: Float?): Result<*> + fun enableFlashlight(lens: CameraLens, strengthPercent: Float?): Result<*> /** - * @param strength is a percentage of the brightness from 0 to 1.0. Null if the default + * @param strengthPercent is a percentage of the brightness from 0 to 1.0. Null if the default * brightness should be used. */ - fun toggleFlashlight(lens: CameraLens, strength: Float?): Result<*> + fun toggleFlashlight(lens: CameraLens, strengthPercent: Float?): Result<*> fun disableFlashlight(lens: CameraLens): Result<*> fun isFlashlightOn(lens: CameraLens): Boolean fun isFlashlightOnFlow(lens: CameraLens): Flow + + /** + * @param percent This is the percentage of the max strength to increase/decrease by. Set it + * negative to decrease the strength. + */ + fun changeFlashlightStrength(lens: CameraLens, percent: Float): Result<*> } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt index a602875524..8c2f446912 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt @@ -91,6 +91,8 @@ fun Error.getFullMessage(resourceProvider: ResourceProvider): String = when (thi Error.CameraDisabled -> resourceProvider.getString(R.string.error_camera_disabled) Error.CameraDisconnected -> resourceProvider.getString(R.string.error_camera_disconnected) Error.MaxCamerasInUse -> resourceProvider.getString(R.string.error_max_cameras_in_use) + Error.CameraVariableFlashlightStrengthUnsupported -> resourceProvider.getString(R.string.error_variable_flashlight_strength_unsupported) + is Error.FailedToModifySystemSetting -> resourceProvider.getString( R.string.error_failed_to_modify_system_setting, setting, diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt index 2fdf1b60fa..485e58aad3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt @@ -96,6 +96,7 @@ sealed class Error : Result() { data object CameraDisabled : Error() data object MaxCamerasInUse : Error() data object CameraError : Error() + data object CameraVariableFlashlightStrengthUnsupported : Error() data class FailedToModifySystemSetting(val setting: String) : Error() data object FailedToChangeIme : Error() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00d8da1ce5..4e3a232869 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -821,6 +821,7 @@ Maximum number of cameras in use! No front flash No back flash + Variable flashlight strength unsupported Accessibility service needs to be enabled! The accessibility service needs to be restarted! @@ -991,6 +992,11 @@ Enable %s flashlight (%d\%%) Disable %s flashlight + Change flashlight brightness + + Brighten %s flashlight %d\%% + Dim %s flashlight %d\%% + Enable NFC Disable NFC Toggle NFC @@ -1393,6 +1399,7 @@ Test Requires Android 13 or newer. This flash does not let you change the brightness. + Brightness change Add constraints if you want key maps to only work in some situations. From 897c7fb4393e74d85be3f32b7f3e0186e4a38034 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 27 Mar 2025 00:05:39 -0600 Subject: [PATCH 4/5] #1560 docs for change flashlight brightness --- docs/user-guide/actions.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/user-guide/actions.md b/docs/user-guide/actions.md index 920a34a725..dc41f84bac 100644 --- a/docs/user-guide/actions.md +++ b/docs/user-guide/actions.md @@ -171,6 +171,12 @@ This will increase or decrease a specific one of these volume streams. ### Toggle/enable/disable flashlight +In Key Mapper 3.0+ there is the option to also set a custom brightness. This is only supported on Android 13+ and on some devices. + +### Change flashlight brightness (3.0+, Android 13.0+) + +This is only supported on devices that let you change the flashlight brightness. + ### Toggle/enable/disable NFC !!! attention "Requires ROOT permission" From 0082cac59a10a42b7f67a0f05643f8d8a3227e3a Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 27 Mar 2025 00:10:48 -0600 Subject: [PATCH 5/5] fix tests --- .../sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index af98b788c7..45b3920c81 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -111,7 +111,7 @@ class KeyMapControllerTest { private const val HOLD_DOWN_DURATION = 1000L private val TEST_ACTION: Action = Action( - data = ActionData.Flashlight.Toggle(CameraLens.BACK), + data = ActionData.Flashlight.Toggle(CameraLens.BACK, strengthPercent = null), ) private val TEST_ACTION_2: Action = Action( @@ -1098,7 +1098,7 @@ class KeyMapControllerTest { @Test fun `Long press trigger shouldn't be triggered if the constraints are changed by the actions`() = runTest(testDispatcher) { // GIVEN - val actionData = ActionData.Flashlight.Toggle(CameraLens.BACK) + val actionData = ActionData.Flashlight.Toggle(CameraLens.BACK, strengthPercent = null) val keyMap = KeyMap( trigger = singleKeyTrigger(