diff --git a/app/src/main/java/com/gaurav/avnc/ui/vnc/KeyHandler.kt b/app/src/main/java/com/gaurav/avnc/ui/vnc/KeyHandler.kt index 4b770793..5954b109 100644 --- a/app/src/main/java/com/gaurav/avnc/ui/vnc/KeyHandler.kt +++ b/app/src/main/java/com/gaurav/avnc/ui/vnc/KeyHandler.kt @@ -84,6 +84,7 @@ import com.gaurav.avnc.vnc.XTKeyCode */ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym: Boolean, prefs: AppPreferences) { + var processedEventObserver: ((KeyEvent) -> Unit)? = null var enableMacOSCompatibility = false private var hasSentShiftDown = false @@ -268,6 +269,8 @@ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym: private fun postProcessEvent(event: KeyEvent) { if (event.keyCode == KeyEvent.KEYCODE_SHIFT_LEFT || event.keyCode == KeyEvent.KEYCODE_SHIFT_RIGHT) hasSentShiftDown = event.action == KeyEvent.ACTION_DOWN + + processedEventObserver?.invoke(event) } /************************************************************************************ diff --git a/app/src/main/java/com/gaurav/avnc/ui/vnc/VirtualKeys.kt b/app/src/main/java/com/gaurav/avnc/ui/vnc/VirtualKeys.kt index b8394ea6..63e1a3cc 100644 --- a/app/src/main/java/com/gaurav/avnc/ui/vnc/VirtualKeys.kt +++ b/app/src/main/java/com/gaurav/avnc/ui/vnc/VirtualKeys.kt @@ -9,11 +9,29 @@ package com.gaurav.avnc.ui.vnc import android.annotation.SuppressLint +import android.content.Context +import android.os.SystemClock +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.GestureDetector.SimpleOnGestureListener +import android.view.KeyCharacterMap +import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration -import androidx.databinding.BindingAdapter +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.HorizontalScrollView +import android.widget.ToggleButton +import androidx.core.content.ContextCompat +import androidx.core.view.children +import androidx.core.view.doOnLayout +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 import com.gaurav.avnc.databinding.VirtualKeysBinding +import kotlin.math.sign + /** * Virtual keys allow the user to input keys which are not normally found on @@ -25,8 +43,13 @@ class VirtualKeys(activity: VncActivity) { private val pref = activity.viewModel.pref.input private val keyHandler = activity.keyHandler + private val frameView = activity.binding.frameView private val stub = activity.binding.virtualKeysStub + private val toggleKeys = mutableSetOf() + private val lockedToggleKeys = mutableSetOf() + private val keyCharMap by lazy { KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD) } private var openedWithKb = false + private var textPageIndex = 2 val container: View? get() = stub.root @@ -55,64 +78,241 @@ class VirtualKeys(activity: VncActivity) { } fun releaseMetaKeys() { - val binding = stub.binding as? VirtualKeysBinding - binding?.apply { - superBtn.isChecked = false - shiftBtn.isChecked = false - ctrlBtn.isChecked = false - altBtn.isChecked = false + toggleKeys.forEach { + if (it.isChecked) + it.isChecked = false + } + } + + private fun releaseUnlockedMetaKeys() { + toggleKeys.forEach { + if (it.isChecked && !lockedToggleKeys.contains(it)) + it.isChecked = false } } + private fun onAfterKeyEvent(event: KeyEvent) { + if (event.action == KeyEvent.ACTION_UP && !KeyEvent.isModifierKey(event.keyCode)) + releaseUnlockedMetaKeys() + } + private fun init() { if (stub.isInflated) return stub.viewStub?.inflate() val binding = stub.binding as VirtualKeysBinding - binding.h = keyHandler - binding.showAll = pref.vkShowAll - binding.hideBtn.setOnClickListener { hide() } + initControls(binding) + initKeys(binding) + binding.tmpPageHost.doOnLayout { binding.root.post { initPager(binding) } } + keyHandler.processedEventObserver = ::onAfterKeyEvent } -} -/** - * When a View is touched, we schedule a callback to to simulate a click. - * As long as finger stays on the view, we keep repeating this callback. - * - * Another option here is to send VNC KeyEvent(down) on [MotionEvent.ACTION_DOWN] - * and then send VNC KeyEvent(up) on [MotionEvent.ACTION_UP]. - */ -@BindingAdapter("isRepeatable") -fun repeatableKeyBinding(keyView: View, repeatable: Boolean) { - if (!repeatable) - return - - keyView.setOnTouchListener(object : View.OnTouchListener { - private var doRepeat = false - - private fun repeat(v: View) { - if (doRepeat) { - v.performClick() - v.postDelayed({ repeat(v) }, ViewConfiguration.getKeyRepeatDelay().toLong()) + /** + * This is a wierd way to do things, but I have not found an alternative yet. + * + * Basically, the optimal UX is: + * - Have several 'pages' of keys & controls. User can flip through them with horizontal swipe. + * - Take minimal horizontal space, i.e. don't use 'fill_parent' for width. If virtual keys are + * stretched to full width in landscape mode, it leaves very little vertical room for FrameView. + * + * But using [ViewPager2] creates certain problems: + * - It doesn't support 'wrap_content', so we have to use a fixed width & height. + * - It also requires child views to use 'fill_parent' for width & height. + * - Cannot directly add child views to ViewPager2 in single XML layout. + * + * To workaround these limitations, all pages are initially attached to a LinearLayout. + * Once layout is complete (to allow proper calculation to width & height), the pages are removed + * from LinearLayout and attached to ViewPager2 via [PagerAdapter]. + */ + private fun initPager(binding: VirtualKeysBinding) { + val pages = binding.tmpPageHost.children.toList().filter { pref.vkShowAll || it != binding.secondaryKeyPage } + val maxPageWidth = pages.maxOf { it.width } + val maxPageHeight = pages.maxOf { it.height } + + pages.forEach { + binding.tmpPageHost.removeView(it) + it.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + + textPageIndex = pages.indexOf(binding.textPage) + + binding.pager.let { + it.offscreenPageLimit = pages.size + it.adapter = PagerAdapter(pages) + it.layoutParams = it.layoutParams.apply { + width = maxPageWidth + it.paddingLeft + it.paddingRight + height = maxPageHeight + it.paddingTop + it.paddingBottom } + it.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + if (position == textPageIndex) { + binding.textBox.requestFocus() + } + } + }) } - @SuppressLint("ClickableViewAccessibility") - override fun onTouch(v: View, event: MotionEvent): Boolean { - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - doRepeat = true - v.postDelayed({ repeat(v) }, ViewConfiguration.getKeyRepeatTimeout().toLong()) + binding.tmpPageHost.visibility = View.GONE + } + + private inner class PagerAdapter(private val views: List) : RecyclerView.Adapter() { + private inner class ViewHolder(v: View) : RecyclerView.ViewHolder(v) + + override fun getItemCount() = views.size + override fun getItemViewType(position: Int) = position + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(views[viewType]) + override fun onBindViewHolder(holder: ViewHolder, position: Int) {} + } + + private fun initControls(binding: VirtualKeysBinding) { + binding.toggleKeyboard.setOnClickListener { + @Suppress("DEPRECATION") + ContextCompat.getSystemService(frameView.context, InputMethodManager::class.java) + ?.toggleSoftInput(0, 0) + } + binding.textPageBackBtn.setOnClickListener { + binding.pager.setCurrentItem(0, true) + } + binding.textBox.setOnEditorActionListener { _, _, _ -> + handleTextBoxAction(binding.textBox) + true + } + binding.textBox.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) frameView.requestFocus() + } + binding.closeBtn.setOnClickListener { + hide() + } + } + + private fun initKeys(binding: VirtualKeysBinding) { + initToggleKey(binding.vkSuper, KeyEvent.KEYCODE_META_LEFT) + initToggleKey(binding.vkShift, KeyEvent.KEYCODE_SHIFT_RIGHT) // See if we can switch to 'LEFT' versions of these + initToggleKey(binding.vkAlt, KeyEvent.KEYCODE_ALT_RIGHT) + initToggleKey(binding.vkCtrl, KeyEvent.KEYCODE_CTRL_RIGHT) + + initNormalKey(binding.vkEsc, KeyEvent.KEYCODE_ESCAPE) + initNormalKey(binding.vkTab, KeyEvent.KEYCODE_TAB) + initNormalKey(binding.vkHome, KeyEvent.KEYCODE_MOVE_HOME) + initNormalKey(binding.vkEnd, KeyEvent.KEYCODE_MOVE_END) + initNormalKey(binding.vkPageUp, KeyEvent.KEYCODE_PAGE_UP) + initNormalKey(binding.vkPageDown, KeyEvent.KEYCODE_PAGE_DOWN) + initNormalKey(binding.vkInsert, KeyEvent.KEYCODE_INSERT) + initNormalKey(binding.vkDelete, KeyEvent.KEYCODE_FORWARD_DEL) + + initNormalKey(binding.vkLeft, KeyEvent.KEYCODE_DPAD_LEFT) + initNormalKey(binding.vkRight, KeyEvent.KEYCODE_DPAD_RIGHT) + initNormalKey(binding.vkUp, KeyEvent.KEYCODE_DPAD_UP) + initNormalKey(binding.vkDown, KeyEvent.KEYCODE_DPAD_DOWN) + + initNormalKey(binding.vkF1, KeyEvent.KEYCODE_F1) + initNormalKey(binding.vkF2, KeyEvent.KEYCODE_F2) + initNormalKey(binding.vkF3, KeyEvent.KEYCODE_F3) + initNormalKey(binding.vkF4, KeyEvent.KEYCODE_F4) + initNormalKey(binding.vkF5, KeyEvent.KEYCODE_F5) + initNormalKey(binding.vkF6, KeyEvent.KEYCODE_F6) + initNormalKey(binding.vkF7, KeyEvent.KEYCODE_F7) + initNormalKey(binding.vkF8, KeyEvent.KEYCODE_F8) + initNormalKey(binding.vkF9, KeyEvent.KEYCODE_F9) + initNormalKey(binding.vkF10, KeyEvent.KEYCODE_F10) + initNormalKey(binding.vkF11, KeyEvent.KEYCODE_F11) + initNormalKey(binding.vkF12, KeyEvent.KEYCODE_F12) + } + + + private fun initToggleKey(key: ToggleButton, keyCode: Int) { + key.setOnCheckedChangeListener { _, isChecked -> + keyHandler.onKeyEvent(keyCode, isChecked) + if (!isChecked) lockedToggleKeys.remove(key) + } + key.setOnLongClickListener { + key.toggle() + if (key.isChecked) lockedToggleKeys.add(key) + true + } + toggleKeys.add(key) + } + + private fun initNormalKey(key: View, keyCode: Int) { + check(key !is ToggleButton) { "use initToggleKey()" } + key.setOnClickListener { keyHandler.onKey(keyCode) } + makeKeyRepeatable(key) + } + + /** + * When a View is touched, we schedule a callback to to simulate a click. + * As long as finger stays on the view, we keep repeating this callback. + */ + private fun makeKeyRepeatable(keyView: View) { + keyView.setOnTouchListener(object : View.OnTouchListener { + private var doRepeat = false + + private fun repeat(v: View) { + if (doRepeat) { + v.performClick() + v.postDelayed({ repeat(v) }, ViewConfiguration.getKeyRepeatDelay().toLong()) } + } - MotionEvent.ACTION_POINTER_DOWN, - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> { - doRepeat = false + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + doRepeat = true + v.postDelayed({ repeat(v) }, ViewConfiguration.getKeyRepeatTimeout().toLong()) + } + + MotionEvent.ACTION_POINTER_DOWN, + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + doRepeat = false + } } + return false } - return false + }) + } + + private fun handleTextBoxAction(textBox: EditText) { + val text = textBox.text?.ifEmpty { "\n" }?.toString() ?: return + val events = keyCharMap.getEvents(text.toCharArray()) + + if (events == null || text.contains('ç', true)) + keyHandler.onKeyEvent(KeyEvent(SystemClock.uptimeMillis(), text, 0, 0)) + else + events.forEach { keyHandler.onKeyEvent(it) } + + textBox.setText("") + } +} + +/** + * Horizontal scroll view with support for horizontally scrollable child views, e.g. ViewPager. + */ +class NestableHorizontalScrollView(context: Context, attributeSet: AttributeSet? = null) : HorizontalScrollView(context, attributeSet) { + /** + * Direction of current horizontal scrolling. + * See [canScrollHorizontally]. + */ + private var hScrollDirection = 0 + private val gestureDetector = GestureDetector(context, object : SimpleOnGestureListener() { + override fun onDown(e: MotionEvent): Boolean { + hScrollDirection = 0 + return true + } + + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean { + hScrollDirection = distanceX.sign.toInt() + return true } }) + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + gestureDetector.onTouchEvent(ev) + if (hScrollDirection != 0 && !canScrollHorizontally(hScrollDirection)) + return false + + return super.onInterceptTouchEvent(ev) + } } diff --git a/app/src/main/res/drawable/bg_selectable.xml b/app/src/main/res/drawable/bg_selectable.xml new file mode 100644 index 00000000..0487ddbd --- /dev/null +++ b/app/src/main/res/drawable/bg_selectable.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_toggle_button.xml b/app/src/main/res/drawable/bg_toggle_button.xml index 145bf647..ad14ed55 100644 --- a/app/src/main/res/drawable/bg_toggle_button.xml +++ b/app/src/main/res/drawable/bg_toggle_button.xml @@ -10,4 +10,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/layout/virtual_keys.xml b/app/src/main/res/layout/virtual_keys.xml index bd37dd58..9e151b6c 100644 --- a/app/src/main/res/layout/virtual_keys.xml +++ b/app/src/main/res/layout/virtual_keys.xml @@ -10,249 +10,264 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> - - - - - - - - - - - + android:backgroundTintMode="multiply" + android:contentDescription="Virtual Keys" + tools:ignore="HardcodedText"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 548d2ccc..0108dc63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,6 +70,8 @@ SSH host SSH port Key password + Send text to server + A fast and secure VNC client. Remotely view and control any device running a VNC server.\n\n\nAVNC is an open source and copyleft software — made with help from many contributors. Server added diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 7339dcef..078ecc88 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -127,11 +127,14 @@ @@ -139,18 +142,12 @@ 36dp - - \ No newline at end of file