From df711a0e98b5d0520648615ec40b49b458b1a4a9 Mon Sep 17 00:00:00 2001 From: Tino Wehe Date: Tue, 12 Sep 2023 13:40:35 +0200 Subject: [PATCH 01/25] Feature: Add Pinch (in/out) support + add pinch support +- fix max finger count on swipe and pinch gestures +- change icons for swipe and pinch gestures +- --- .../sds100/keymapper/actions/ActionData.kt | 14 + .../actions/ActionDataEntityMapper.kt | 65 ++++ .../sds100/keymapper/actions/ActionId.kt | 1 + .../sds100/keymapper/actions/ActionUtils.kt | 6 +- .../keymapper/actions/BaseActionUiHelper.kt | 25 ++ .../actions/CreateActionViewModel.kt | 38 +++ .../actions/PerformActionsUseCase.kt | 4 + .../PichPickDisplayCoordinateFragment.kt | 149 ++++++++++ .../pinchscreen/PinchPickCoordinateResult.kt | 19 ++ .../PinchPickDisplayCoordinateViewModel.kt | 220 ++++++++++++++ .../SwipePickDisplayCoordinateViewModel.kt | 9 +- .../keymapper/data/entities/ActionEntity.kt | 2 +- .../accessibility/IAccessibilityService.kt | 2 + .../accessibility/MyAccessibilityService.kt | 63 +++- .../io/github/sds100/keymapper/util/Inject.kt | 7 + .../github/sds100/keymapper/util/MathUtils.kt | 20 ++ .../keymapper/util/ui/NavDestination.kt | 6 + .../keymapper/util/ui/NavigationViewModel.kt | 16 + .../keymapper/util/ui/ResourceProvider.kt | 5 + .../res/drawable/ic_outline_pinch_app_24.xml | 5 + .../res/drawable/ic_outline_swipe_app_24.xml | 6 + .../fragment_pinch_pick_coordinates.xml | 279 ++++++++++++++++++ app/src/main/res/navigation/nav_app.xml | 27 +- app/src/main/res/values/strings.xml | 20 +- 24 files changed, 985 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PichPickDisplayCoordinateFragment.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PinchPickCoordinateResult.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt create mode 100644 app/src/main/res/drawable/ic_outline_pinch_app_24.xml create mode 100644 app/src/main/res/drawable/ic_outline_swipe_app_24.xml create mode 100644 app/src/main/res/layout/fragment_pinch_pick_coordinates.xml 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 87ab039469..6d678f43ad 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 @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.actions +import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.display.Orientation import io.github.sds100.keymapper.system.intents.IntentTarget @@ -320,6 +321,19 @@ sealed class ActionData { override val id = ActionId.SWIPE_SCREEN } + @Serializable + data class PinchScreen( + val x: Int, + val y: Int, + val radius: Int, + val pinchType: PinchScreenType, + val fingerCount: Int, + val duration: Int, + val description: String? + ) : ActionData() { + override val id = ActionId.PINCH_SCREEN + } + @Serializable data class PhoneCall( val number: String 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 1ef7431b4c..f7c5a9e203 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 @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.actions +import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.Extra import io.github.sds100.keymapper.data.entities.getData @@ -14,6 +15,7 @@ import io.github.sds100.keymapper.util.success import io.github.sds100.keymapper.util.then import io.github.sds100.keymapper.util.valueOrNull import splitties.bitflags.hasFlag +import timber.log.Timber /** * Created by sds100 on 13/03/2021. @@ -30,6 +32,7 @@ object ActionDataEntityMapper { ActionEntity.Type.URL -> ActionId.URL ActionEntity.Type.TAP_COORDINATE -> ActionId.TAP_SCREEN ActionEntity.Type.SWIPE_COORDINATE -> ActionId.SWIPE_SCREEN + ActionEntity.Type.PINCH_COORDINATE -> ActionId.PINCH_SCREEN ActionEntity.Type.INTENT -> ActionId.INTENT ActionEntity.Type.PHONE_CALL -> ActionId.PHONE_CALL ActionEntity.Type.SOUND -> ActionId.SOUND @@ -149,6 +152,60 @@ object ActionDataEntityMapper { ) } + ActionId.PINCH_SCREEN -> { + val splitData = entity.data.trim().split(',') + + var x = 0; + var y = 0; + var pinchType = PinchScreenType.PINCH_IN; + var radius = 0; + var fingerCount = 2; + var duration = 250; + + if (splitData.isNotEmpty()) { + x = splitData[0].trim().toInt() + } + + if (splitData.size >= 2) { + y = splitData[1].trim().toInt() + } + + if (splitData.size >= 3) { + radius = splitData[2].trim().toInt() + } + + if (splitData.size >= 4) { + val tempType = splitData[3].trim(); + + pinchType = if (tempType == PinchScreenType.PINCH_IN.name) { + PinchScreenType.PINCH_IN; + } else { + PinchScreenType.PINCH_OUT; + } + } + + if (splitData.size >= 5) { + fingerCount = splitData[4].trim().toInt().coerceAtLeast(2) + } + + if (splitData.size >= 6) { + duration = splitData[5].trim().toInt() + } + + val description = entity.extras.getData(ActionEntity.EXTRA_COORDINATE_DESCRIPTION) + .valueOrNull() + + ActionData.PinchScreen( + x = x, + y = y, + radius = radius, + pinchType = pinchType, + fingerCount = fingerCount, + duration = duration, + description = description + ) + } + ActionId.INTENT -> { val target = entity.extras.getData(ActionEntity.EXTRA_INTENT_TARGET).then { INTENT_TARGET_MAP.getKey(it).success() @@ -417,6 +474,7 @@ object ActionDataEntityMapper { is ActionData.PhoneCall -> ActionEntity.Type.PHONE_CALL is ActionData.TapScreen -> ActionEntity.Type.TAP_COORDINATE is ActionData.SwipeScreen -> ActionEntity.Type.SWIPE_COORDINATE + is ActionData.PinchScreen -> ActionEntity.Type.PINCH_COORDINATE is ActionData.Text -> ActionEntity.Type.TEXT_BLOCK is ActionData.Url -> ActionEntity.Type.URL is ActionData.Sound -> ActionEntity.Type.SOUND @@ -457,6 +515,7 @@ object ActionDataEntityMapper { is ActionData.PhoneCall -> data.number is ActionData.TapScreen -> "${data.x},${data.y}" is ActionData.SwipeScreen -> "${data.xStart},${data.yStart},${data.xEnd},${data.yEnd},${data.fingerCount},${data.duration}" + is ActionData.PinchScreen -> "${data.x},${data.y},${data.radius},${data.pinchType},${data.fingerCount},${data.duration}" is ActionData.Text -> data.text is ActionData.Url -> data.url is ActionData.Sound -> data.soundUid @@ -554,6 +613,12 @@ object ActionDataEntityMapper { } }.toList() + is ActionData.PinchScreen -> sequence { + if (!data.description.isNullOrBlank()) { + yield(Extra(ActionEntity.EXTRA_COORDINATE_DESCRIPTION, data.description)) + } + }.toList() + is ActionData.Text -> emptyList() is ActionData.Url -> emptyList() 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 7e47c7129f..0c54fda3e1 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 @@ -10,6 +10,7 @@ enum class ActionId { KEY_EVENT, TAP_SCREEN, SWIPE_SCREEN, + PINCH_SCREEN, TEXT, URL, INTENT, 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 a0ea5ef07b..9b31914558 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 @@ -40,6 +40,7 @@ object ActionUtils { ActionId.KEY_EVENT -> ActionCategory.INPUT ActionId.TAP_SCREEN -> ActionCategory.INPUT ActionId.SWIPE_SCREEN -> ActionCategory.INPUT + ActionId.PINCH_SCREEN -> ActionCategory.INPUT ActionId.TEXT -> ActionCategory.INPUT ActionId.OPEN_VOICE_ASSISTANT -> ActionCategory.APPS @@ -257,6 +258,7 @@ object ActionUtils { ActionId.KEY_EVENT -> R.string.action_input_key_event ActionId.TAP_SCREEN -> R.string.action_tap_screen ActionId.SWIPE_SCREEN -> R.string.action_swipe_screen + ActionId.PINCH_SCREEN -> R.string.action_pinch_screen ActionId.TEXT -> R.string.action_input_text ActionId.URL -> R.string.action_open_url ActionId.INTENT -> R.string.action_send_intent @@ -366,7 +368,8 @@ object ActionUtils { ActionId.KEY_CODE -> R.drawable.ic_q_24 ActionId.KEY_EVENT -> R.drawable.ic_q_24 ActionId.TAP_SCREEN -> R.drawable.ic_outline_touch_app_24 - ActionId.SWIPE_SCREEN -> R.drawable.ic_outline_touch_app_24 + ActionId.SWIPE_SCREEN -> R.drawable.ic_outline_swipe_app_24 + ActionId.PINCH_SCREEN -> R.drawable.ic_outline_pinch_app_24 ActionId.TEXT -> R.drawable.ic_outline_short_text_24 ActionId.URL -> R.drawable.ic_outline_link_24 ActionId.INTENT -> null @@ -603,6 +606,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.Flashlight.Disable, is ActionData.TapScreen, is ActionData.SwipeScreen, + is ActionData.PinchScreen, is ActionData.Text, is ActionData.Url, is ActionData.PhoneCall, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/BaseActionUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/actions/BaseActionUiHelper.kt index 462e905dfa..e97e083c69 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/BaseActionUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/BaseActionUiHelper.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.actions import android.view.KeyEvent import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType import io.github.sds100.keymapper.mappings.DisplayActionUseCase import io.github.sds100.keymapper.mappings.Mapping import io.github.sds100.keymapper.system.camera.CameraLensUtils @@ -310,6 +311,30 @@ abstract class BaseActionUiHelper, A : Action>( ) } + is ActionData.PinchScreen -> if (action.description.isNullOrBlank()) { + val pinchTypeDisplayName = if (action.pinchType == PinchScreenType.PINCH_IN) { + getString(R.string.hint_coordinate_type_PINCH_IN) + } else { + getString(R.string.hint_coordinate_type_PINCH_OUT) + } + + getString( + R.string.description_pinch_coordinate_default, + arrayOf(pinchTypeDisplayName, action.fingerCount, action.x, action.y, action.radius, action.duration) + ) + } else { + val pinchTypeDisplayName = if (action.pinchType == PinchScreenType.PINCH_IN) { + getString(R.string.hint_coordinate_type_PINCH_IN) + } else { + getString(R.string.hint_coordinate_type_PINCH_OUT) + } + + getString( + R.string.description_pinch_coordinate_with_description, + arrayOf(pinchTypeDisplayName, action.fingerCount, action.x, action.y, action.radius, action.duration, action.description) + ) + } + is ActionData.Text -> getString(R.string.description_text_block, action.text) is ActionData.Url -> getString(R.string.description_url, action.url) is ActionData.Sound -> getString(R.string.description_sound, action.soundDescription) 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 462c4fd8b0..e172daba7b 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 @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.actions import android.text.InputType import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.actions.pinchscreen.PinchPickCoordinateResult import io.github.sds100.keymapper.actions.swipescreen.SwipePickCoordinateResult import io.github.sds100.keymapper.actions.tapscreen.PickCoordinateResult import io.github.sds100.keymapper.system.camera.CameraLens @@ -363,6 +364,43 @@ class CreateActionViewModelImpl( ) } + ActionId.PINCH_SCREEN -> { + val oldResult = if (oldData is ActionData.PinchScreen) { + PinchPickCoordinateResult( + oldData.x, + oldData.y, + oldData.radius, + oldData.pinchType, + oldData.fingerCount, + oldData.duration, + oldData.description ?: "" + ) + } else { + null + } + + val result = navigate( + "pick_pinch_coordinate_for_action", + NavDestination.PickPinchCoordinate(oldResult) + ) ?: return null + + val description = if (result.description.isEmpty()) { + null + } else { + result.description + } + + return ActionData.PinchScreen( + result.x, + result.y, + result.radius, + result.pinchType, + result.fingerCount, + result.duration, + description + ) + } + ActionId.TEXT -> { val oldText = if (oldData is ActionData.Text) { oldData.text 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 98059017c2..5f464784c7 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 @@ -273,6 +273,10 @@ class PerformActionsUseCaseImpl( result = accessibilityService.swipeScreen(action.xStart, action.yStart, action.xEnd, action.yEnd, action.fingerCount, action.duration, inputEventType) } + is ActionData.PinchScreen -> { + result = accessibilityService.pinchScreen(action.x, action.y, action.radius, action.pinchType, action.fingerCount, action.duration, inputEventType) + } + is ActionData.Text -> { keyMapperImeMessenger.inputText(action.text) result = Success(Unit) diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PichPickDisplayCoordinateFragment.kt b/app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PichPickDisplayCoordinateFragment.kt new file mode 100644 index 0000000000..ebb11cc2e3 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PichPickDisplayCoordinateFragment.kt @@ -0,0 +1,149 @@ +package io.github.sds100.keymapper.actions.pinchscreen + +import android.annotation.SuppressLint +import android.graphics.ImageDecoder +import android.graphics.Point +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.getSystemService +import androidx.core.graphics.decodeBitmap +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.* +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import io.github.sds100.keymapper.databinding.FragmentPinchPickCoordinatesBinding +import io.github.sds100.keymapper.system.files.FileUtils +import io.github.sds100.keymapper.util.* +import io.github.sds100.keymapper.util.ui.showPopups +import kotlinx.coroutines.flow.collectLatest +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class PinchPickDisplayCoordinateFragment : Fragment() { + companion object { + const val EXTRA_RESULT = "extra_result" + } + + private val args: PinchPickDisplayCoordinateFragmentArgs by navArgs() + private val requestKey: String by lazy { args.requestKey } + + private val viewModel: PinchPickDisplayCoordinateViewModel by viewModels { + Inject.pinchCoordinateActionTypeViewModel(requireContext()) + } + + private val screenshotLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { + it ?: return@registerForActivityResult + + val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.createSource(requireContext().contentResolver, it) + .decodeBitmap { _, _ -> } + } else { + MediaStore.Images.Media.getBitmap(requireContext().contentResolver, it) + } + + val displaySize = Point().apply { + val windowManager: WindowManager = requireContext().getSystemService()!! + windowManager.defaultDisplay.getRealSize(this) + } + + viewModel.selectedScreenshot(bitmap, displaySize) + } + + /** + * Scoped to the lifecycle of the fragment's view (between onCreateView and onDestroyView) + */ + private var _binding: FragmentPinchPickCoordinatesBinding? = null + val binding: FragmentPinchPickCoordinatesBinding + get() = _binding!! + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + args.result?.let { + viewModel.loadResult(Json.decodeFromString(it)) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + FragmentPinchPickCoordinatesBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + _binding = this + + return this.root + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.viewModel = viewModel + + viewModel.showPopups(this, binding) + + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + findNavController().navigateUp() + } + + binding.appBar.setNavigationOnClickListener { + findNavController().navigateUp() + } + + viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.bitmap.collectLatest { bitmap -> + if (bitmap == null) { + binding.imageViewScreenshot.setImageDrawable(null) + } else { + binding.imageViewScreenshot.setImageBitmap(bitmap) + } + } + } + + viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { + binding.imageViewScreenshot.pointCoordinates.collectLatest { point -> + if (point != null) { + viewModel.onScreenshotTouch( + point.x.toFloat() / binding.imageViewScreenshot.width, + point.y.toFloat() / binding.imageViewScreenshot.height + ) + } + } + } + + viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.returnResult.collectLatest { result -> + setFragmentResult( + requestKey, + bundleOf(EXTRA_RESULT to Json.encodeToString(result)) + ) + + findNavController().navigateUp() + } + } + + binding.setOnSelectScreenshotClick { + screenshotLauncher.launch(FileUtils.MIME_TYPE_IMAGES) + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PinchPickCoordinateResult.kt b/app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PinchPickCoordinateResult.kt new file mode 100644 index 0000000000..0f1d2d2f3c --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PinchPickCoordinateResult.kt @@ -0,0 +1,19 @@ +package io.github.sds100.keymapper.actions.pinchscreen + +import kotlinx.serialization.Serializable + +enum class PinchScreenType { + PINCH_IN, + PINCH_OUT +} + +@Serializable +data class PinchPickCoordinateResult( + val x: Int, + val y: Int, + val radius: Int, + val pinchType: PinchScreenType, + val fingerCount: Int, + val duration: Int, + val description: String +) diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt new file mode 100644 index 0000000000..0f71697fff --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt @@ -0,0 +1,220 @@ +package io.github.sds100.keymapper.actions.pinchscreen + +import android.graphics.Bitmap +import android.graphics.Point +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.util.ui.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +class PinchPickDisplayCoordinateViewModel( + resourceProvider: ResourceProvider +) : ViewModel(), ResourceProvider by resourceProvider, PopupViewModel by PopupViewModelImpl() { + + private val pinchTypes = arrayOf(PinchScreenType.PINCH_IN.name, PinchScreenType.PINCH_OUT.name) + private val pinchTypesDisplayValues = arrayOf(getString(R.string.hint_coordinate_type_PINCH_IN), getString(R.string.hint_coordinate_type_PINCH_OUT)) + val pinchTypeSpinnerAdapter = ArrayAdapter(getContext(), android.R.layout.simple_spinner_dropdown_item, pinchTypesDisplayValues) + + private val x = MutableStateFlow(null) + private val y = MutableStateFlow(null) + private val radius = MutableStateFlow(null) + private val pinchType = MutableStateFlow(PinchScreenType.PINCH_IN) + private val fingerCount = MutableStateFlow(2) + private val duration = MutableStateFlow(null) + + private val _bitmap = MutableStateFlow(null) + private val _returnResult = MutableSharedFlow() + + private val description: MutableStateFlow = MutableStateFlow(null) + + val xString = x.map { + it ?: return@map "" + + it.toString() + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + val yString = y.map { + it ?: return@map "" + + it.toString() + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + val radiusString = radius.map { + it ?: return@map "" + + it.toString() + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + val pinchTypeSpinnerSelection = pinchType.map { + it ?: return@map 0 + + this.pinchTypes.indexOf(it.name) + }.stateIn(viewModelScope, SharingStarted.Lazily, 0) + + val fingerCountString = fingerCount.map { + it ?: return@map "" + + it.toString() + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + val durationString = duration.map { + it ?: return@map "" + + it.toString() + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + val bitmap = _bitmap.asStateFlow() + val returnResult = _returnResult.asSharedFlow() + + val isSelectStartEndSwitchEnabled:StateFlow = combine(bitmap) { + bitmap?.value != null + }.stateIn(viewModelScope, SharingStarted.Lazily, false) + + private val isCoordinatesValid: StateFlow = combine(x, y, radius, pinchType) { x, y, radius, pinchType -> + x ?: return@combine false + y ?: return@combine false + radius ?: return@combine false + pinchType ?: return@combine false + + x >= 0 && y >= 0 && radius >= 0 && (pinchType == PinchScreenType.PINCH_IN || pinchType == PinchScreenType.PINCH_OUT) + }.stateIn(viewModelScope, SharingStarted.Lazily, false) + + private val isOptionsValid: StateFlow = combine(fingerCount, duration) { fingerCount, duration -> + fingerCount ?: return@combine false + duration ?: return@combine false + + fingerCount in 2..9 && duration > 0 + }.stateIn(viewModelScope, SharingStarted.Lazily, false) + + val isDoneButtonEnabled: StateFlow = combine(isCoordinatesValid, isOptionsValid) { isCoordinatesValid, isOptionsValid -> + isCoordinatesValid && isOptionsValid + }.stateIn(viewModelScope, SharingStarted.Lazily, false) + + fun selectedScreenshot(newBitmap: Bitmap, displaySize: Point) { + //check whether the height and width of the bitmap match the display size, even when it is rotated. + if ( + (displaySize.x != newBitmap.width + && displaySize.y != newBitmap.height) && + + (displaySize.y != newBitmap.width + && displaySize.x != newBitmap.height) + ) { + viewModelScope.launch { + val snackBar = PopupUi.SnackBar( + message = getString(R.string.toast_incorrect_screenshot_resolution) + ) + + showPopup("incorrect_resolution", snackBar) + } + + return + } + + _bitmap.value = newBitmap + } + + fun setX(x: String) { + this.x.value = x.toIntOrNull() + } + + fun setY(y: String) { + this.y.value = y.toIntOrNull() + } + + fun setRadius(radius: String) { + this.radius.value = radius.toIntOrNull() + } + + private fun setPinchType(type: String) { + if (type == PinchScreenType.PINCH_IN.name) { + this.pinchType.value = PinchScreenType.PINCH_IN + } else { + this.pinchType.value = PinchScreenType.PINCH_OUT + } + } + + fun setFingerCount(fingerCount: String) { + this.fingerCount.value = fingerCount.toIntOrNull() + } + + fun setDuration(duration: String) { + this.duration.value = duration.toIntOrNull() + } + + /** + * [screenshotXRatio] The ratio between the point where the user pressed to the width of the image. + * [screenshotYRatio] The ratio between the point where the user pressed to the height of the image. + */ + fun onScreenshotTouch(screenshotXRatio: Float, screenshotYRatio: Float) { + bitmap.value?.let { + + val displayX = it.width * screenshotXRatio + val displayY = it.height * screenshotYRatio + + x.value = displayX.roundToInt() + y.value = displayY.roundToInt() + } + } + + fun onDoneClick() { + viewModelScope.launch { + val x = x.value ?: return@launch + val y = y.value ?: return@launch + val radius = radius.value ?: return@launch + val pinchType = pinchType.value ?: return@launch + val fingerCount = fingerCount.value ?: return@launch + val duration = duration.value ?: return@launch + + val description = showPopup( + "coordinate_description", + PopupUi.Text( + getString(R.string.hint_tap_coordinate_title), + allowEmpty = true, + text = description.value ?: "" + ) + ) ?: return@launch + + _returnResult.emit(PinchPickCoordinateResult(x, y, radius, pinchType, fingerCount, duration, description)) + } + } + + fun onPinchTypeSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + this.setPinchType(pinchTypes[position]) + } + + fun loadResult(result: PinchPickCoordinateResult) { + viewModelScope.launch { + x.value = result.x + y.value = result.y + radius.value = result.radius + pinchType.value = result.pinchType + fingerCount.value = result.fingerCount + duration.value = result.duration + description.value = result.description + } + } + + override fun onCleared() { + bitmap.value?.recycle() + _bitmap.value = null + + super.onCleared() + } + + @Suppress("UNCHECKED_CAST") + class Factory( + private val resourceProvider: ResourceProvider + ) : ViewModelProvider.NewInstanceFactory() { + + override fun create(modelClass: Class): T { + return PinchPickDisplayCoordinateViewModel(resourceProvider) as T + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt index 57a9bb2d6e..71c29111ce 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt @@ -9,7 +9,6 @@ import io.github.sds100.keymapper.R import io.github.sds100.keymapper.util.ui.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import timber.log.Timber import kotlin.math.roundToInt enum class ScreenshotTouchType { @@ -20,8 +19,8 @@ class SwipePickDisplayCoordinateViewModel( resourceProvider: ResourceProvider ) : ViewModel(), ResourceProvider by resourceProvider, PopupViewModel by PopupViewModelImpl() { - public val screenshotTouchTypeStart = ScreenshotTouchType.START; - public val screenshotTouchTypeEnd = ScreenshotTouchType.END; + val screenshotTouchTypeStart = ScreenshotTouchType.START + val screenshotTouchTypeEnd = ScreenshotTouchType.END private val xStart = MutableStateFlow(null) private val yStart = MutableStateFlow(null) private val xEnd = MutableStateFlow(null) @@ -91,7 +90,7 @@ class SwipePickDisplayCoordinateViewModel( fingerCount ?: return@combine false duration ?: return@combine false - fingerCount >= 1 && duration > 0 + fingerCount in 1..9 && duration > 0 }.stateIn(viewModelScope, SharingStarted.Lazily, false) val isDoneButtonEnabled: StateFlow = combine(isCoordinatesValid, isOptionsValid) { isCoordinatesValid, isOptionsValid -> @@ -100,7 +99,7 @@ class SwipePickDisplayCoordinateViewModel( fun selectedScreenshot(newBitmap: Bitmap, displaySize: Point) { - _screenshotTouchType.value = ScreenshotTouchType.START; + _screenshotTouchType.value = ScreenshotTouchType.START //check whether the height and width of the bitmap match the display size, even when it is rotated. if ( 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 4f47173504..ef1249b87c 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 @@ -126,7 +126,7 @@ data class ActionEntity( enum class Type { //DONT CHANGE THESE - APP, APP_SHORTCUT, KEY_EVENT, TEXT_BLOCK, URL, SYSTEM_ACTION, TAP_COORDINATE, SWIPE_COORDINATE, INTENT, PHONE_CALL, SOUND + APP, APP_SHORTCUT, KEY_EVENT, TEXT_BLOCK, URL, SYSTEM_ACTION, TAP_COORDINATE, SWIPE_COORDINATE, PINCH_COORDINATE, INTENT, PHONE_CALL, SOUND } constructor( diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/IAccessibilityService.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/IAccessibilityService.kt index c9799b5d34..6a475570e3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/IAccessibilityService.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/IAccessibilityService.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.system.accessibility import android.os.Build import androidx.annotation.RequiresApi +import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType import io.github.sds100.keymapper.util.InputEventType import io.github.sds100.keymapper.util.Result import kotlinx.coroutines.flow.Flow @@ -14,6 +15,7 @@ interface IAccessibilityService { fun tapScreen(x: Int, y: Int, inputEventType: InputEventType): Result<*> fun swipeScreen(xStart: Int, yStart: Int, xEnd: Int, yEnd: Int, fingerCount: Int, duration: Int, inputEventType: InputEventType): Result<*> + fun pinchScreen(x: Int, y: Int, radius: Int, pinchType: PinchScreenType, fingerCount: Int, duration: Int, inputEventType: InputEventType): Result<*> val isFingerprintGestureDetectionAvailable: Boolean diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/MyAccessibilityService.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/MyAccessibilityService.kt index 78df3b5392..9942524f24 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/MyAccessibilityService.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/MyAccessibilityService.kt @@ -18,6 +18,7 @@ import androidx.core.os.bundleOf import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType import io.github.sds100.keymapper.api.Api import io.github.sds100.keymapper.api.IKeyEventReceiver import io.github.sds100.keymapper.api.IKeyEventReceiverCallback @@ -36,6 +37,9 @@ import timber.log.Timber class MyAccessibilityService : AccessibilityService(), LifecycleOwner, IAccessibilityService { + // virtual distance between fingers on multitouch gestures + private val fingerGestureDistance = 10L + /** * Broadcast receiver for all intents sent from within the app. */ @@ -396,7 +400,6 @@ class MyAccessibilityService : AccessibilityService(), LifecycleOwner, IAccessib } override fun swipeScreen(xStart: Int, yStart: Int, xEnd: Int, yEnd: Int, fingerCount: Int, duration: Int, inputEventType: InputEventType): Result<*> { - Timber.d("ACCESSIBILITY SWIPE SCREEN %d, %d, %d, %d, %s, %d, %s", xStart, yStart, xEnd, yEnd, fingerCount, duration, inputEventType); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val pStart = Point(xStart, yStart) @@ -410,12 +413,10 @@ class MyAccessibilityService : AccessibilityService(), LifecycleOwner, IAccessib p.lineTo(pEnd.x.toFloat(), pEnd.y.toFloat()) gestureBuilder.addStroke(StrokeDescription(p, 0, duration.toLong())) } else { - // virtual distance between the fingers - val fingerDistance = 10L // segments between fingers val segmentCount = fingerCount - 1 // the line of the perpendicular line which will be created to place the virtual fingers on it - val perpendicularLineLength = (fingerDistance * fingerCount).toInt() + val perpendicularLineLength = (fingerGestureDistance * fingerCount).toInt() // the length of each segment between fingers val segmentLength = perpendicularLineLength / segmentCount // perpendicular line of the start swipe point @@ -426,9 +427,6 @@ class MyAccessibilityService : AccessibilityService(), LifecycleOwner, IAccessib val perpendicularLineEnd = getPerpendicularOfLine(pEnd, pStart, perpendicularLineLength, true); - - val startFingerCoordinatesList = mutableListOf() - val endFingerCoordinatesList = mutableListOf() // this is the angle between start and end point to rotate all virtual fingers on the perpendicular lines in the same direction val angle = angleBetweenPoints(Point(xStart, yStart), Point(xEnd, yEnd)) - 90; @@ -446,8 +444,6 @@ class MyAccessibilityService : AccessibilityService(), LifecycleOwner, IAccessib p.moveTo(startFingerCoordinateWithOffset.x.toFloat(), startFingerCoordinateWithOffset.y.toFloat()) p.lineTo(endFingerCoordinateWithOffset.x.toFloat(), endFingerCoordinateWithOffset.y.toFloat()) - //startFingerCoordinatesList.add(startFingerCoordinateWithOffset) - //endFingerCoordinatesList.add(endFingerCoordinateWithOffset) gestureBuilder.addStroke(StrokeDescription(p, 0, duration.toLong())) } @@ -465,6 +461,55 @@ class MyAccessibilityService : AccessibilityService(), LifecycleOwner, IAccessib return Error.SdkVersionTooLow(Build.VERSION_CODES.N) } + override fun pinchScreen( + x: Int, + y: Int, + radius: Int, + pinchType: PinchScreenType, + fingerCount: Int, + duration: Int, + inputEventType: InputEventType + ): Result<*> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + + val gestureBuilder = GestureDescription.Builder() + + var startCoordinates = arrayListOf() + var endCoordinates = arrayListOf() + + if (pinchType == PinchScreenType.PINCH_IN) { + for (index in 0..fingerCount) { + endCoordinates.add(Point(x, y)); + } + startCoordinates = distributePointsOnCircle(Point(x, y), radius.toFloat(), fingerCount) + } else { + for (index in 0..fingerCount) { + startCoordinates.add(Point(x, y)); + } + endCoordinates = distributePointsOnCircle(Point(x, y), radius.toFloat(), fingerCount) + } + + for (index in 0 until startCoordinates.size) { + val p = Path() + p.moveTo(startCoordinates[index].x.toFloat(), startCoordinates[index].y.toFloat()) + p.lineTo(endCoordinates[index].x.toFloat(), endCoordinates[index].y.toFloat()) + + gestureBuilder.addStroke(StrokeDescription(p, 0, duration.toLong())) + } + + val success = dispatchGesture(gestureBuilder.build(), null, null); + + return if (success) { + Success(Unit) + } else { + Error.FailedToDispatchGesture + } + + } + + return Error.SdkVersionTooLow(Build.VERSION_CODES.N) + } + override fun findFocussedNode(focus: Int): AccessibilityNodeModel? { return findFocus(focus)?.toModel() } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt index 39434975be..9635f2a8a5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt @@ -10,6 +10,7 @@ import io.github.sds100.keymapper.actions.TestActionUseCaseImpl import io.github.sds100.keymapper.actions.keyevent.ChooseKeyCodeViewModel import io.github.sds100.keymapper.actions.keyevent.ConfigKeyEventActionViewModel import io.github.sds100.keymapper.actions.keyevent.ConfigKeyEventUseCaseImpl +import io.github.sds100.keymapper.actions.pinchscreen.PinchPickDisplayCoordinateViewModel import io.github.sds100.keymapper.actions.sound.ChooseSoundFileUseCaseImpl import io.github.sds100.keymapper.actions.sound.ChooseSoundFileViewModel import io.github.sds100.keymapper.actions.swipescreen.SwipePickDisplayCoordinateViewModel @@ -133,6 +134,12 @@ object Inject { ) } + fun pinchCoordinateActionTypeViewModel(context: Context): PinchPickDisplayCoordinateViewModel.Factory { + return PinchPickDisplayCoordinateViewModel.Factory( + ServiceLocator.resourceProvider(context) + ) + } + fun configKeyMapViewModel( ctx: Context ): ConfigKeyMapViewModel.Factory { diff --git a/app/src/main/java/io/github/sds100/keymapper/util/MathUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/MathUtils.kt index fa87ee6d33..24da4e6881 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/MathUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/MathUtils.kt @@ -1,11 +1,13 @@ package io.github.sds100.keymapper.util +import android.R.attr import android.graphics.Point import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.hypot import kotlin.math.sin + data class Line( val start: Point, val end: Point @@ -52,3 +54,21 @@ fun movePointByDistanceAndAngle(p: Point, distance: Int, degrees: Double): Point fun angleBetweenPoints(p1: Point, p2: Point): Double { return rad2deg(atan2((p2.y - p1.y).toDouble(), (p2.x - p1.x).toDouble())) } + +fun distributePointsOnCircle(circleCenter: Point, circleRadius: Float, numPoints: Int): ArrayList { + + val points = arrayListOf() + var angle: Double = 0.0 + val step = (2 * Math.PI) / numPoints + + for (index in 0 .. numPoints) { + points.add(Point( + (circleCenter.x + circleRadius * cos(angle)).toInt().coerceAtLeast(0), + (circleCenter.y + circleRadius * sin(angle)).toInt().coerceAtLeast(0) + )) + angle += step; + } + + return points; + +} \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt index 62ee45ed6e..235dd117a6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.util.ui import io.github.sds100.keymapper.actions.ActionData +import io.github.sds100.keymapper.actions.pinchscreen.PinchPickCoordinateResult import io.github.sds100.keymapper.actions.sound.ChooseSoundFileResult import io.github.sds100.keymapper.actions.swipescreen.SwipePickCoordinateResult import io.github.sds100.keymapper.actions.tapscreen.PickCoordinateResult @@ -24,6 +25,7 @@ sealed class NavDestination { const val ID_KEY_EVENT = "key_event" const val ID_PICK_COORDINATE = "pick_coordinate" const val ID_PICK_SWIPE_COORDINATE = "pick_swipe_coordinate" + const val ID_PICK_PINCH_COORDINATE = "pick_pinch_coordinate" const val ID_CONFIG_INTENT = "config_intent" const val ID_CHOOSE_ACTIVITY = "choose_activity" const val ID_CHOOSE_SOUND = "choose_sound" @@ -46,6 +48,7 @@ sealed class NavDestination { is ConfigKeyEventAction -> ID_KEY_EVENT is PickCoordinate -> ID_PICK_COORDINATE is PickSwipeCoordinate -> ID_PICK_SWIPE_COORDINATE + is PickPinchCoordinate -> ID_PICK_PINCH_COORDINATE is ConfigIntent -> ID_CONFIG_INTENT ChooseActivity -> ID_CHOOSE_ACTIVITY ChooseSound -> ID_CHOOSE_SOUND @@ -80,6 +83,9 @@ sealed class NavDestination { data class PickSwipeCoordinate(val result: SwipePickCoordinateResult? = null) : NavDestination() + data class PickPinchCoordinate(val result: PinchPickCoordinateResult? = null) : + NavDestination() + data class ConfigIntent(val result: ConfigIntentResult? = null) : NavDestination() diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt index 859f120065..e4aa4a6513 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt @@ -12,6 +12,8 @@ import io.github.sds100.keymapper.actions.ActionData import io.github.sds100.keymapper.actions.ChooseActionFragment import io.github.sds100.keymapper.actions.keyevent.ChooseKeyCodeFragment import io.github.sds100.keymapper.actions.keyevent.ConfigKeyEventActionFragment +import io.github.sds100.keymapper.actions.pinchscreen.PinchPickCoordinateResult +import io.github.sds100.keymapper.actions.pinchscreen.PinchPickDisplayCoordinateFragment import io.github.sds100.keymapper.actions.sound.ChooseSoundFileFragment import io.github.sds100.keymapper.actions.sound.ChooseSoundFileResult import io.github.sds100.keymapper.actions.swipescreen.SwipePickCoordinateResult @@ -157,6 +159,13 @@ fun NavigationViewModel.setupNavigation(fragment: Fragment) { NavAppDirections.swipePickDisplayCoordinate(requestKey, json) } + is NavDestination.PickPinchCoordinate -> { + val json = destination.result?.let { + Json.encodeToString(it) + } + + NavAppDirections.pinchPickDisplayCoordinate(requestKey, json) + } is NavDestination.ConfigIntent -> { val json = destination.result?.let { Json.encodeToString(it) @@ -237,6 +246,13 @@ fun NavigationViewModel.sendNavResultFromBundle( onNavResult(NavResult(requestKey, result)) } + NavDestination.ID_PICK_PINCH_COORDINATE -> { + val json = bundle.getString(PinchPickDisplayCoordinateFragment.EXTRA_RESULT)!! + val result = Json.decodeFromString(json) + + onNavResult(NavResult(requestKey, result)) + } + NavDestination.ID_CONFIG_INTENT -> { val json = bundle.getString(ConfigIntentFragment.EXTRA_RESULT)!! val result = Json.decodeFromString(json) diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/ResourceProvider.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/ResourceProvider.kt index e95444c1de..90d0e204bf 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/ResourceProvider.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/ResourceProvider.kt @@ -49,6 +49,10 @@ class ResourceProviderImpl( return ctx.color(color) } + override fun getContext(): Context { + return ctx; + } + fun onThemeChange() { coroutineScope.launch { onThemeChange.emit(Unit) @@ -65,4 +69,5 @@ interface ResourceProvider { fun getText(@StringRes resId: Int): CharSequence fun getDrawable(@DrawableRes resId: Int): Drawable fun getColor(@ColorRes color: Int): Int + fun getContext(): Context; } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_outline_pinch_app_24.xml b/app/src/main/res/drawable/ic_outline_pinch_app_24.xml new file mode 100644 index 0000000000..1752dab910 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_pinch_app_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_swipe_app_24.xml b/app/src/main/res/drawable/ic_outline_swipe_app_24.xml new file mode 100644 index 0000000000..274d5166f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_swipe_app_24.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/layout/fragment_pinch_pick_coordinates.xml b/app/src/main/res/layout/fragment_pinch_pick_coordinates.xml new file mode 100644 index 0000000000..9550f7b3d2 --- /dev/null +++ b/app/src/main/res/layout/fragment_pinch_pick_coordinates.xml @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +