@desklink/react-native 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +18 -0
- package/README.md +131 -0
- package/android/build.gradle +29 -0
- package/android/gradle.properties +1 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +199 -0
- package/android/src/main/java/expo/modules/desklink/DesktopRenderer.kt +284 -0
- package/android/src/main/java/expo/modules/desklink/DesktopSession.kt +478 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +1019 -0
- package/android/src/main/java/expo/modules/desklink/ImeDeletion.kt +20 -0
- package/expo-module.config.json +6 -0
- package/index.ts +1 -0
- package/package.json +33 -0
- package/src/DesktopView.tsx +61 -0
- package/src/DesktopView.web.tsx +56 -0
- package/src/availability.ts +5 -0
- package/src/availability.web.ts +2 -0
- package/src/index.ts +21 -0
- package/src/native.ts +65 -0
- package/src/native.web.ts +1164 -0
- package/src/protocol.ts +151 -0
- package/src/stickyModifiers.ts +66 -0
- package/src/useDesktopSession.ts +661 -0
- package/src/webKeyboardMotion.ts +57 -0
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
package expo.modules.desklink
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Canvas
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.graphics.Paint
|
|
7
|
+
import android.graphics.Path
|
|
8
|
+
import android.graphics.Point
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.os.SystemClock
|
|
11
|
+
import android.text.InputType
|
|
12
|
+
import android.util.Log
|
|
13
|
+
import android.view.HapticFeedbackConstants
|
|
14
|
+
import android.view.InputDevice
|
|
15
|
+
import android.view.KeyEvent
|
|
16
|
+
import android.view.MotionEvent
|
|
17
|
+
import android.view.SurfaceView
|
|
18
|
+
import android.view.View
|
|
19
|
+
import android.view.ViewConfiguration
|
|
20
|
+
import android.view.WindowManager
|
|
21
|
+
import android.view.inputmethod.BaseInputConnection
|
|
22
|
+
import android.view.inputmethod.EditorInfo
|
|
23
|
+
import android.view.inputmethod.InputConnection
|
|
24
|
+
import android.view.inputmethod.InputConnectionWrapper
|
|
25
|
+
import android.view.inputmethod.InputMethodManager
|
|
26
|
+
import android.widget.EditText
|
|
27
|
+
import android.view.ViewGroup.LayoutParams
|
|
28
|
+
import androidx.core.view.ViewCompat
|
|
29
|
+
import androidx.core.view.WindowInsetsAnimationCompat
|
|
30
|
+
import androidx.core.view.WindowInsetsCompat
|
|
31
|
+
import expo.modules.kotlin.AppContext
|
|
32
|
+
import expo.modules.kotlin.views.ExpoView
|
|
33
|
+
import org.webrtc.VideoTrack
|
|
34
|
+
import kotlin.math.abs
|
|
35
|
+
import kotlin.math.hypot
|
|
36
|
+
import kotlin.math.max
|
|
37
|
+
import kotlin.math.min
|
|
38
|
+
|
|
39
|
+
private const val TAG = "DesklinkView"
|
|
40
|
+
|
|
41
|
+
/** The closest the picture can be zoomed: surface pixels per desktop pixel. */
|
|
42
|
+
private const val MAX_SCALE = 2.5f
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Desktop pixels one wheel detent stands for, so a two-finger scroll moves the
|
|
46
|
+
* content about as far as the fingers moved at any zoom.
|
|
47
|
+
*/
|
|
48
|
+
private const val PIXELS_PER_DETENT = 120f
|
|
49
|
+
|
|
50
|
+
/** Wheel steps smaller than this wait for more movement rather than flood the channel. */
|
|
51
|
+
private const val MIN_WHEEL_STEP = 0.05f
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The live desktop surface.
|
|
55
|
+
*
|
|
56
|
+
* Gesture handling lives here rather than in JavaScript on purpose: a pointer
|
|
57
|
+
* drag produces tens of events a second, and routing each one through the
|
|
58
|
+
* bridge would add latency and jitter to the one interaction the user judges the
|
|
59
|
+
* whole feature by. This view owns the picture's zoom and position, the gesture
|
|
60
|
+
* decisions, the native keyboard, and the release of held state when it is
|
|
61
|
+
* detached.
|
|
62
|
+
*
|
|
63
|
+
* The gestures are the ones mature remote-desktop viewers settled on:
|
|
64
|
+
*
|
|
65
|
+
* - tap: click where the finger lands; two taps: a double click on the same spot;
|
|
66
|
+
* - press and hold: a right click on release, or drag after it to hold the left
|
|
67
|
+
* button (select text, move a window);
|
|
68
|
+
* - one finger: move around a zoomed-in desktop; on the whole desktop,
|
|
69
|
+
* move its pointer, which follows the finger without pressing a button;
|
|
70
|
+
* - two fingers: scroll the desktop under them, or pinch to zoom (and move) the
|
|
71
|
+
* picture; a quick two-finger tap is a right click.
|
|
72
|
+
*/
|
|
73
|
+
class DesktopView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
74
|
+
private var surfaceView: SurfaceView? = null
|
|
75
|
+
private var renderer: DesktopRenderer? = null
|
|
76
|
+
private val keyboard = RemoteKeyboard(context)
|
|
77
|
+
private var session: DesktopSession? = null
|
|
78
|
+
private var sinkAttached: VideoTrack? = null
|
|
79
|
+
|
|
80
|
+
/** Encoded surface size, reported by the engine through the control channel. */
|
|
81
|
+
private var surfaceWidth = 0
|
|
82
|
+
private var surfaceHeight = 0
|
|
83
|
+
|
|
84
|
+
// The picture's placement: surface pixels per desktop pixel, and where the
|
|
85
|
+
// desktop's top-left corner sits in this view. `fitted` keeps a picture that
|
|
86
|
+
// was showing the whole desktop showing it when the view changes size.
|
|
87
|
+
private var scale = 1f
|
|
88
|
+
private var originX = 0f
|
|
89
|
+
private var originY = 0f
|
|
90
|
+
private var fitted = true
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How much of this view's bottom the phone's keyboard, and the app's own
|
|
94
|
+
* controls above it, cover. The picture is placed in the rest, so it rises
|
|
95
|
+
* with the keyboard instead of going under it.
|
|
96
|
+
*/
|
|
97
|
+
private var covered = 0f
|
|
98
|
+
|
|
99
|
+
/** Room the app keeps above the keyboard for its own controls, in view pixels. */
|
|
100
|
+
private var keyboardClearance = 0f
|
|
101
|
+
|
|
102
|
+
/** The keyboard's full height while it moves, from the animation's bounds. */
|
|
103
|
+
private var keyboardTravel = 0
|
|
104
|
+
|
|
105
|
+
/** Where the desktop's pointer was last sent, in desktop pixels; null until a touch sends it. */
|
|
106
|
+
private var pointerAt: Pair<Int, Int>? = null
|
|
107
|
+
private var mouseInput = false
|
|
108
|
+
private val pointerMark = PointerMark(context)
|
|
109
|
+
|
|
110
|
+
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
|
|
111
|
+
/**
|
|
112
|
+
* How close a second tap lands to count as a double click on the first
|
|
113
|
+
* tap's point. The platform's double-tap slop (100 dp) spans a third of a
|
|
114
|
+
* fitted 4K desktop, which would merge taps on different targets.
|
|
115
|
+
*/
|
|
116
|
+
private val doubleTapSlop = dp(40f).toFloat()
|
|
117
|
+
|
|
118
|
+
private enum class Gesture { NONE, PENDING, LETTERBOX, PAN, HOVER, ARMED, DRAG, TWO, PINCH, SCROLL, SPENT }
|
|
119
|
+
|
|
120
|
+
private var gesture = Gesture.NONE
|
|
121
|
+
private var downX = 0f
|
|
122
|
+
private var downY = 0f
|
|
123
|
+
private var downOnPicture = false
|
|
124
|
+
private var lastX = 0f
|
|
125
|
+
private var lastY = 0f
|
|
126
|
+
private var twoStart = 0L
|
|
127
|
+
private var startSpan = 0f
|
|
128
|
+
private var lastSpan = 0f
|
|
129
|
+
private var startFocusX = 0f
|
|
130
|
+
private var startFocusY = 0f
|
|
131
|
+
private var lastFocusX = 0f
|
|
132
|
+
private var lastFocusY = 0f
|
|
133
|
+
private var wheelX = 0f
|
|
134
|
+
private var wheelY = 0f
|
|
135
|
+
private var lastTapAt = 0L
|
|
136
|
+
private var lastTapX = 0f
|
|
137
|
+
private var lastTapY = 0f
|
|
138
|
+
private var lastTapPoint: Pair<Int, Int>? = null
|
|
139
|
+
|
|
140
|
+
/** A held finger arms the right click and the left-button drag. */
|
|
141
|
+
private val longPress = Runnable {
|
|
142
|
+
if (gesture != Gesture.PENDING) return@Runnable
|
|
143
|
+
val at = point(downX, downY) ?: return@Runnable
|
|
144
|
+
gesture = Gesture.ARMED
|
|
145
|
+
lastTapPoint = null
|
|
146
|
+
session?.let { pointerTo(it, "move", at) }
|
|
147
|
+
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** While the app holds a sticky modifier, what the keyboard types is the app's to chord. */
|
|
151
|
+
private var keyboardCaptured = false
|
|
152
|
+
|
|
153
|
+
/** Chorded keys that are down on the desktop, by Android key code. */
|
|
154
|
+
private val chordKeysDown = mutableSetOf<Int>()
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The keyboard's own animation, frame by frame: the picture moves with it
|
|
158
|
+
* rather than jumping once the keyboard has finished arriving.
|
|
159
|
+
*/
|
|
160
|
+
private val keyboardFollower = object : WindowInsetsAnimationCompat.Callback(
|
|
161
|
+
WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE,
|
|
162
|
+
) {
|
|
163
|
+
override fun onStart(
|
|
164
|
+
animation: WindowInsetsAnimationCompat,
|
|
165
|
+
bounds: WindowInsetsAnimationCompat.BoundsCompat,
|
|
166
|
+
): WindowInsetsAnimationCompat.BoundsCompat {
|
|
167
|
+
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) keyboardTravel = bounds.upperBound.bottom
|
|
168
|
+
return bounds
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
override fun onProgress(insets: WindowInsetsCompat, running: List<WindowInsetsAnimationCompat>): WindowInsetsCompat {
|
|
172
|
+
if (running.any { it.typeMask and WindowInsetsCompat.Type.ime() != 0 }) {
|
|
173
|
+
followKeyboard(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
|
|
174
|
+
}
|
|
175
|
+
return insets
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
|
179
|
+
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) settleKeyboard()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
init {
|
|
184
|
+
setBackgroundColor(Color.BLACK)
|
|
185
|
+
clipChildren = true
|
|
186
|
+
addView(keyboard, LayoutParams(dp(1f), dp(1f)))
|
|
187
|
+
addView(pointerMark, LayoutParams(pointerMark.size, pointerMark.size))
|
|
188
|
+
setOnTouchListener { _, event -> handleTouch(event) }
|
|
189
|
+
ViewCompat.setWindowInsetsAnimationCallback(this, keyboardFollower)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fun setSession(next: DesktopSession?) {
|
|
193
|
+
if (session === next) return
|
|
194
|
+
detachSink()
|
|
195
|
+
cancelGesture()
|
|
196
|
+
chordKeysDown.clear()
|
|
197
|
+
keyboardCaptured = false
|
|
198
|
+
// The view goes first: its surface callbacks run on the renderer's thread,
|
|
199
|
+
// which must still be there to answer them.
|
|
200
|
+
surfaceView?.let { removeView(it) }
|
|
201
|
+
renderer?.release()
|
|
202
|
+
renderer = null
|
|
203
|
+
surfaceView = null
|
|
204
|
+
session = next
|
|
205
|
+
pointerAt = null
|
|
206
|
+
mouseInput = false
|
|
207
|
+
placePointer()
|
|
208
|
+
if (next != null) {
|
|
209
|
+
// Hardware decoder frames are GPU textures; the renderer shares the
|
|
210
|
+
// session's EGL context so it can read them.
|
|
211
|
+
val created = DesktopRenderer(
|
|
212
|
+
next.eglBase,
|
|
213
|
+
onFirstFrame = { post { Log.i(TAG, "first frame presented"); session?.markPresented() } },
|
|
214
|
+
onFrameSize = { width, height -> post { adoptFrameSize(width, height) } },
|
|
215
|
+
)
|
|
216
|
+
renderer = created
|
|
217
|
+
surfaceView = SurfaceView(context).also {
|
|
218
|
+
it.holder.addCallback(created)
|
|
219
|
+
addView(it, 0, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
220
|
+
}
|
|
221
|
+
layoutPicture()
|
|
222
|
+
}
|
|
223
|
+
Log.i(TAG, "view session assigned=${next != null}")
|
|
224
|
+
attachSink()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Geometry from the engine, in encoded-surface pixels. */
|
|
228
|
+
fun setSurfaceSize(width: Int, height: Int) {
|
|
229
|
+
if (width <= 0 || height <= 0) return
|
|
230
|
+
if (surfaceWidth == width && surfaceHeight == height) return
|
|
231
|
+
// A new surface size invalidates any held drag: releasing it at coordinates
|
|
232
|
+
// the user never pointed at is worse than letting go.
|
|
233
|
+
if (gesture == Gesture.DRAG) session?.sendCancel()
|
|
234
|
+
cancelGesture()
|
|
235
|
+
surfaceWidth = width
|
|
236
|
+
surfaceHeight = height
|
|
237
|
+
fitted = true
|
|
238
|
+
// A point on the old geometry says nothing about the new one.
|
|
239
|
+
pointerAt = null
|
|
240
|
+
layoutPicture()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The decoded picture's size stands in until the engine reports its own. */
|
|
244
|
+
private fun adoptFrameSize(width: Int, height: Int) {
|
|
245
|
+
Log.i(TAG, "frame resolution: ${width}x$height")
|
|
246
|
+
if (surfaceWidth == 0 || surfaceHeight == 0) setSurfaceSize(width, height)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Called when props are (re)applied, so a late session still gets a renderer. */
|
|
250
|
+
fun requestGeometryRefresh() {
|
|
251
|
+
attachSink()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fun showKeyboard() {
|
|
255
|
+
if (session?.inputEnabled != true) return
|
|
256
|
+
keyboard.requestFocus()
|
|
257
|
+
inputMethods().showSoftInput(keyboard, InputMethodManager.SHOW_IMPLICIT)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fun hideKeyboard() {
|
|
261
|
+
inputMethods().hideSoftInputFromWindow(keyboard.windowToken, 0)
|
|
262
|
+
keyboard.clearFocus()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fun disarmInput() {
|
|
266
|
+
hideKeyboard()
|
|
267
|
+
cancelGesture()
|
|
268
|
+
chordKeysDown.clear()
|
|
269
|
+
lastTapAt = 0L
|
|
270
|
+
lastTapPoint = null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fun captureKeyboard(captured: Boolean) {
|
|
274
|
+
keyboardCaptured = captured
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Show the whole desktop again. */
|
|
278
|
+
fun fitToView() {
|
|
279
|
+
fitted = true
|
|
280
|
+
layoutPicture()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Room the app keeps above the keyboard for its own controls, in points. */
|
|
284
|
+
fun setKeyboardClearance(points: Float) {
|
|
285
|
+
val next = points * resources.displayMetrics.density
|
|
286
|
+
if (next == keyboardClearance) return
|
|
287
|
+
keyboardClearance = next
|
|
288
|
+
settleKeyboard()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Where the keyboard is now, with no animation running. */
|
|
292
|
+
private fun settleKeyboard() {
|
|
293
|
+
keyboardTravel = 0
|
|
294
|
+
val insets = ViewCompat.getRootWindowInsets(this)
|
|
295
|
+
val ime = WindowInsetsCompat.Type.ime()
|
|
296
|
+
followKeyboard(if (insets != null && insets.isVisible(ime)) insets.getInsets(ime).bottom else 0)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Place the picture above a keyboard whose top is `bottom` pixels up from the
|
|
301
|
+
* window's edge. The app's controls ride on the keyboard, so their share
|
|
302
|
+
* grows with it: nothing is reserved for them while the keyboard is down.
|
|
303
|
+
*/
|
|
304
|
+
private fun followKeyboard(bottom: Int) {
|
|
305
|
+
if (!isAttachedToWindow || height == 0) return
|
|
306
|
+
val travel = if (keyboardTravel > 0) keyboardTravel else bottom
|
|
307
|
+
val shown = if (travel > 0) (bottom.toFloat() / travel).coerceIn(0f, 1f) else 0f
|
|
308
|
+
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
|
309
|
+
val windowBottom = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
310
|
+
windowManager.currentWindowMetrics.bounds.bottom
|
|
311
|
+
} else {
|
|
312
|
+
val size = Point()
|
|
313
|
+
@Suppress("DEPRECATION")
|
|
314
|
+
windowManager.defaultDisplay.getRealSize(size)
|
|
315
|
+
size.y
|
|
316
|
+
}
|
|
317
|
+
val location = IntArray(2)
|
|
318
|
+
getLocationOnScreen(location)
|
|
319
|
+
val overlap = (location[1] + height - (windowBottom - bottom)).coerceAtLeast(0)
|
|
320
|
+
coverBottom(if (overlap > 0) overlap + keyboardClearance * shown else 0f)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The keyboard took (or gave back) the bottom of the view. A picture that
|
|
325
|
+
* fits what is left is centred in it; a larger one is moved so the pointer,
|
|
326
|
+
* where a tap just put the caret, stays in sight — or, with no pointer yet,
|
|
327
|
+
* so the middle of what was shown stays in the middle.
|
|
328
|
+
*/
|
|
329
|
+
private fun coverBottom(next: Float) {
|
|
330
|
+
if (abs(next - covered) < 0.5f) return
|
|
331
|
+
val before = visibleHeight()
|
|
332
|
+
covered = next
|
|
333
|
+
val visible = visibleHeight()
|
|
334
|
+
val at = pointerAt
|
|
335
|
+
if (at == null) {
|
|
336
|
+
originY += (visible - before) / 2f
|
|
337
|
+
} else {
|
|
338
|
+
val y = originY + (at.second + 0.5f) * scale
|
|
339
|
+
val margin = min(dp(56f).toFloat(), visible / 4f)
|
|
340
|
+
if (y > visible - margin) originY -= y - (visible - margin)
|
|
341
|
+
}
|
|
342
|
+
layoutPicture()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private fun visibleHeight(): Float = max(1f, height - covered)
|
|
346
|
+
|
|
347
|
+
private fun inputMethods() = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
348
|
+
|
|
349
|
+
private fun attachSink() {
|
|
350
|
+
val active = session ?: return
|
|
351
|
+
active.frameSink = { track ->
|
|
352
|
+
val target = renderer
|
|
353
|
+
if (target != null && sinkAttached !== track) {
|
|
354
|
+
sinkAttached?.removeSink(target)
|
|
355
|
+
track.addSink(target)
|
|
356
|
+
sinkAttached = track
|
|
357
|
+
Log.i(TAG, "video sink attached; view=${width}x$height")
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
fun detachSink() {
|
|
363
|
+
renderer?.let { sinkAttached?.removeSink(it) }
|
|
364
|
+
sinkAttached = null
|
|
365
|
+
session?.frameSink = null
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
369
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
370
|
+
// A rotation: keep what the user was looking at.
|
|
371
|
+
if (oldw > 0 && oldh > 0 && !fitted) {
|
|
372
|
+
originX += (w - oldw) / 2f
|
|
373
|
+
originY += (h - oldh) / 2f
|
|
374
|
+
}
|
|
375
|
+
layoutPicture()
|
|
376
|
+
// The view's place in the window moved, and with it what the keyboard covers.
|
|
377
|
+
post { settleKeyboard() }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
override fun onAttachedToWindow() {
|
|
381
|
+
super.onAttachedToWindow()
|
|
382
|
+
post { settleKeyboard() }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
|
386
|
+
super.onLayout(changed, l, t, r, b)
|
|
387
|
+
// This view is a linear layout, which would line the mark up after the
|
|
388
|
+
// picture; it belongs at the origin, moved by its translation alone.
|
|
389
|
+
pointerMark.layout(0, 0, pointerMark.size, pointerMark.size)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---- Zoom and position -------------------------------------------------
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The whole view decides the fitted size, so the keyboard coming up moves
|
|
396
|
+
* the picture rather than shrinking it.
|
|
397
|
+
*/
|
|
398
|
+
private fun fitScale(): Float {
|
|
399
|
+
if (width == 0 || height == 0 || surfaceWidth == 0 || surfaceHeight == 0) return 1f
|
|
400
|
+
return min(width.toFloat() / surfaceWidth, height.toFloat() / surfaceHeight)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Re-derive the placement from `fitted`, the zoom limits and the edges. */
|
|
404
|
+
private fun layoutPicture() {
|
|
405
|
+
if (surfaceWidth == 0 || surfaceHeight == 0 || width == 0 || height == 0) return
|
|
406
|
+
val fit = fitScale()
|
|
407
|
+
scale = if (fitted) fit else scale.coerceIn(fit, max(fit, MAX_SCALE))
|
|
408
|
+
fitted = scale <= fit * 1.001f
|
|
409
|
+
clampOrigin()
|
|
410
|
+
publishTransform()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A picture smaller than what is visible is centred on that axis; a larger
|
|
415
|
+
* one covers it. Above the keyboard, only the uncovered part counts.
|
|
416
|
+
*/
|
|
417
|
+
private fun clampOrigin() {
|
|
418
|
+
val pictureWidth = surfaceWidth * scale
|
|
419
|
+
val pictureHeight = surfaceHeight * scale
|
|
420
|
+
val visible = visibleHeight()
|
|
421
|
+
originX = if (pictureWidth <= width) (width - pictureWidth) / 2f else originX.coerceIn(width - pictureWidth, 0f)
|
|
422
|
+
originY = if (pictureHeight <= visible) (visible - pictureHeight) / 2f else originY.coerceIn(visible - pictureHeight, 0f)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private fun zoomAround(focusX: Float, focusY: Float, factor: Float) {
|
|
426
|
+
val fit = fitScale()
|
|
427
|
+
val next = (scale * factor).coerceIn(fit, max(fit, MAX_SCALE))
|
|
428
|
+
val applied = next / scale
|
|
429
|
+
originX = focusX - (focusX - originX) * applied
|
|
430
|
+
originY = focusY - (focusY - originY) * applied
|
|
431
|
+
scale = next
|
|
432
|
+
fitted = scale <= fit * 1.001f
|
|
433
|
+
clampOrigin()
|
|
434
|
+
publishTransform()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private fun panBy(dx: Float, dy: Float) {
|
|
438
|
+
originX += dx
|
|
439
|
+
originY += dy
|
|
440
|
+
clampOrigin()
|
|
441
|
+
publishTransform()
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private fun publishTransform() {
|
|
445
|
+
renderer?.setTransform(scale, originX, originY)
|
|
446
|
+
placePointer()
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Send the desktop's pointer somewhere, and show it there. */
|
|
450
|
+
private fun pointerTo(active: DesktopSession, phase: String, at: Pair<Int, Int>, withButton: Boolean = false) {
|
|
451
|
+
active.sendPointer(phase, at.first, at.second, withButton)
|
|
452
|
+
if (!mouseInput) showPointer(at)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private fun showPointer(at: Pair<Int, Int>) {
|
|
456
|
+
pointerAt = at
|
|
457
|
+
placePointer()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Put the pointer mark's tip on the desktop point the pointer was last sent to. */
|
|
461
|
+
private fun placePointer() {
|
|
462
|
+
val at = pointerAt
|
|
463
|
+
if (at == null || surfaceWidth == 0 || surfaceHeight == 0) {
|
|
464
|
+
pointerMark.visibility = INVISIBLE
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
pointerMark.translationX = originX + (at.first + 0.5f) * scale - pointerMark.hotspot
|
|
468
|
+
pointerMark.translationY = originY + (at.second + 0.5f) * scale - pointerMark.hotspot
|
|
469
|
+
pointerMark.visibility = VISIBLE
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private fun dp(value: Float): Int = Math.round(value * resources.displayMetrics.density)
|
|
473
|
+
|
|
474
|
+
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
|
475
|
+
if (event.isFromSource(InputDevice.SOURCE_MOUSE) &&
|
|
476
|
+
(event.actionMasked == MotionEvent.ACTION_HOVER_ENTER || event.actionMasked == MotionEvent.ACTION_HOVER_MOVE)
|
|
477
|
+
) {
|
|
478
|
+
mouseInput = true
|
|
479
|
+
pointerAt = null
|
|
480
|
+
placePointer()
|
|
481
|
+
}
|
|
482
|
+
return super.dispatchGenericMotionEvent(event)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** A point in this view → desktop pixels, or null when it is off the picture. */
|
|
486
|
+
private fun point(x: Float, y: Float): Pair<Int, Int>? {
|
|
487
|
+
if (surfaceWidth == 0 || surfaceHeight == 0) return null
|
|
488
|
+
val mappedX = Math.floor(((x - originX) / scale).toDouble()).toInt()
|
|
489
|
+
val mappedY = Math.floor(((y - originY) / scale).toDouble()).toInt()
|
|
490
|
+
if (mappedX < 0 || mappedY < 0 || mappedX >= surfaceWidth || mappedY >= surfaceHeight) return null
|
|
491
|
+
return mappedX to mappedY
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The same, held to the picture's edge: a drag may leave it and must still end somewhere. */
|
|
495
|
+
private fun clampedPoint(x: Float, y: Float): Pair<Int, Int>? {
|
|
496
|
+
if (surfaceWidth == 0 || surfaceHeight == 0) return null
|
|
497
|
+
val mappedX = Math.floor(((x - originX) / scale).toDouble()).toInt().coerceIn(0, surfaceWidth - 1)
|
|
498
|
+
val mappedY = Math.floor(((y - originY) / scale).toDouble()).toInt().coerceIn(0, surfaceHeight - 1)
|
|
499
|
+
return mappedX to mappedY
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---- Gestures ------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
private fun cancelGesture() {
|
|
505
|
+
removeCallbacks(longPress)
|
|
506
|
+
gesture = Gesture.NONE
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private fun handleTouch(event: MotionEvent): Boolean {
|
|
510
|
+
val active = session ?: return true
|
|
511
|
+
if (!active.inputEnabled) {
|
|
512
|
+
cancelGesture()
|
|
513
|
+
return true
|
|
514
|
+
}
|
|
515
|
+
mouseInput = event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || event.isFromSource(InputDevice.SOURCE_MOUSE)
|
|
516
|
+
if (mouseInput && pointerAt != null) {
|
|
517
|
+
pointerAt = null
|
|
518
|
+
placePointer()
|
|
519
|
+
}
|
|
520
|
+
when (event.actionMasked) {
|
|
521
|
+
MotionEvent.ACTION_DOWN -> {
|
|
522
|
+
downOnPicture = point(event.x, event.y) != null
|
|
523
|
+
gesture = if (fitted && !downOnPicture) Gesture.LETTERBOX else Gesture.PENDING
|
|
524
|
+
downX = event.x
|
|
525
|
+
downY = event.y
|
|
526
|
+
lastX = event.x
|
|
527
|
+
lastY = event.y
|
|
528
|
+
removeCallbacks(longPress)
|
|
529
|
+
if (gesture == Gesture.PENDING) postDelayed(longPress, ViewConfiguration.getLongPressTimeout().toLong())
|
|
530
|
+
else lastTapPoint = null
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
MotionEvent.ACTION_POINTER_DOWN -> {
|
|
534
|
+
removeCallbacks(longPress)
|
|
535
|
+
lastTapPoint = null
|
|
536
|
+
when (gesture) {
|
|
537
|
+
// A second finger ends a drag where the first one is.
|
|
538
|
+
Gesture.DRAG -> endDrag(active, lastX, lastY)
|
|
539
|
+
Gesture.PENDING, Gesture.PAN, Gesture.HOVER, Gesture.ARMED -> {}
|
|
540
|
+
Gesture.LETTERBOX -> if (event.pointerCount != 2 || point(event.getX(event.actionIndex), event.getY(event.actionIndex)) == null) {
|
|
541
|
+
gesture = Gesture.SPENT
|
|
542
|
+
return true
|
|
543
|
+
}
|
|
544
|
+
// A third finger is no gesture of ours; nothing it does is sent.
|
|
545
|
+
Gesture.SCROLL -> {
|
|
546
|
+
flushWheel(active, force = true)
|
|
547
|
+
gesture = Gesture.SPENT
|
|
548
|
+
return true
|
|
549
|
+
}
|
|
550
|
+
Gesture.TWO, Gesture.PINCH -> {
|
|
551
|
+
gesture = Gesture.SPENT
|
|
552
|
+
return true
|
|
553
|
+
}
|
|
554
|
+
else -> return true
|
|
555
|
+
}
|
|
556
|
+
if (event.pointerCount == 2) {
|
|
557
|
+
gesture = Gesture.TWO
|
|
558
|
+
twoStart = SystemClock.uptimeMillis()
|
|
559
|
+
startSpan = span(event)
|
|
560
|
+
lastSpan = startSpan
|
|
561
|
+
startFocusX = focusX(event)
|
|
562
|
+
startFocusY = focusY(event)
|
|
563
|
+
lastFocusX = startFocusX
|
|
564
|
+
lastFocusY = startFocusY
|
|
565
|
+
wheelX = 0f
|
|
566
|
+
wheelY = 0f
|
|
567
|
+
} else {
|
|
568
|
+
gesture = Gesture.SPENT
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
MotionEvent.ACTION_MOVE -> when (gesture) {
|
|
573
|
+
Gesture.PENDING -> if (hypot(event.x - downX, event.y - downY) > touchSlop) {
|
|
574
|
+
removeCallbacks(longPress)
|
|
575
|
+
lastTapPoint = null
|
|
576
|
+
// The whole desktop has nowhere to move to, so the finger moves the
|
|
577
|
+
// desktop's pointer instead, without a button.
|
|
578
|
+
gesture = if (!fitted) Gesture.PAN else if (downOnPicture) Gesture.HOVER else Gesture.LETTERBOX
|
|
579
|
+
lastX = event.x
|
|
580
|
+
lastY = event.y
|
|
581
|
+
if (gesture == Gesture.HOVER) hoverTo(active, event.x, event.y)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
Gesture.HOVER -> hoverTo(active, event.x, event.y)
|
|
585
|
+
|
|
586
|
+
Gesture.PAN -> {
|
|
587
|
+
panBy(event.x - lastX, event.y - lastY)
|
|
588
|
+
lastX = event.x
|
|
589
|
+
lastY = event.y
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
Gesture.ARMED -> if (hypot(event.x - downX, event.y - downY) > touchSlop) {
|
|
593
|
+
val start = clampedPoint(downX, downY) ?: return true
|
|
594
|
+
pointerTo(active, "down", start)
|
|
595
|
+
gesture = Gesture.DRAG
|
|
596
|
+
dragTo(active, event.x, event.y)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
Gesture.DRAG -> dragTo(active, event.x, event.y)
|
|
600
|
+
|
|
601
|
+
Gesture.TWO, Gesture.PINCH, Gesture.SCROLL -> if (event.pointerCount >= 2) twoFingers(active, event)
|
|
602
|
+
|
|
603
|
+
else -> {}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
MotionEvent.ACTION_POINTER_UP -> {
|
|
607
|
+
if (gesture == Gesture.TWO && SystemClock.uptimeMillis() - twoStart < ViewConfiguration.getDoubleTapTimeout()) {
|
|
608
|
+
// Two fingers that landed and lifted without moving: a right click,
|
|
609
|
+
// on the picture only — the letterbox is not the desktop's edge.
|
|
610
|
+
point(startFocusX, startFocusY)?.let { rightClick(active, it) }
|
|
611
|
+
}
|
|
612
|
+
if (gesture == Gesture.SCROLL) flushWheel(active, force = true)
|
|
613
|
+
// What the remaining finger does next is not a new gesture.
|
|
614
|
+
gesture = Gesture.SPENT
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
MotionEvent.ACTION_UP -> {
|
|
618
|
+
removeCallbacks(longPress)
|
|
619
|
+
when (gesture) {
|
|
620
|
+
Gesture.PENDING -> tap(active, event.x, event.y)
|
|
621
|
+
Gesture.HOVER -> hoverTo(active, event.x, event.y)
|
|
622
|
+
Gesture.ARMED -> point(downX, downY)?.let { rightClick(active, it) }
|
|
623
|
+
Gesture.DRAG -> endDrag(active, event.x, event.y)
|
|
624
|
+
else -> {}
|
|
625
|
+
}
|
|
626
|
+
gesture = Gesture.NONE
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
MotionEvent.ACTION_CANCEL -> {
|
|
630
|
+
removeCallbacks(longPress)
|
|
631
|
+
if (gesture == Gesture.DRAG) active.sendCancel()
|
|
632
|
+
gesture = Gesture.NONE
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return true
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* A click where the finger landed. A second tap soon after and close by is
|
|
640
|
+
* sent to the first tap's desktop point, so the desktop counts a double click
|
|
641
|
+
* rather than two clicks a few pixels apart.
|
|
642
|
+
*/
|
|
643
|
+
private fun tap(active: DesktopSession, x: Float, y: Float) {
|
|
644
|
+
val now = SystemClock.uptimeMillis()
|
|
645
|
+
val repeat = now - lastTapAt < ViewConfiguration.getDoubleTapTimeout() &&
|
|
646
|
+
hypot(x - lastTapX, y - lastTapY) < doubleTapSlop
|
|
647
|
+
val at = (if (repeat) lastTapPoint else null) ?: point(x, y) ?: return
|
|
648
|
+
pointerTo(active, "down", at)
|
|
649
|
+
pointerTo(active, "up", at)
|
|
650
|
+
lastTapAt = now
|
|
651
|
+
lastTapX = x
|
|
652
|
+
lastTapY = y
|
|
653
|
+
lastTapPoint = at
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private fun hoverTo(active: DesktopSession, x: Float, y: Float) {
|
|
657
|
+
clampedPoint(x, y)?.let { pointerTo(active, "move", it) }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private fun dragTo(active: DesktopSession, x: Float, y: Float) {
|
|
661
|
+
lastX = x
|
|
662
|
+
lastY = y
|
|
663
|
+
clampedPoint(x, y)?.let { pointerTo(active, "move", it, withButton = true) }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private fun endDrag(active: DesktopSession, x: Float, y: Float) {
|
|
667
|
+
clampedPoint(x, y)?.let { pointerTo(active, "up", it) } ?: active.sendCancel()
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private fun rightClick(active: DesktopSession, at: Pair<Int, Int>) {
|
|
671
|
+
active.sendRightClick(at.first, at.second)
|
|
672
|
+
if (!mouseInput) showPointer(at)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private fun twoFingers(active: DesktopSession, event: MotionEvent) {
|
|
676
|
+
val currentSpan = span(event)
|
|
677
|
+
val fx = focusX(event)
|
|
678
|
+
val fy = focusY(event)
|
|
679
|
+
if (gesture == Gesture.TWO) {
|
|
680
|
+
// Fingers that spread or close are zooming; fingers that travel together
|
|
681
|
+
// are scrolling. Decide once, so a scroll never turns into a zoom halfway.
|
|
682
|
+
if (abs(currentSpan - startSpan) > touchSlop * 2) {
|
|
683
|
+
gesture = Gesture.PINCH
|
|
684
|
+
} else if (hypot(fx - startFocusX, fy - startFocusY) > touchSlop) {
|
|
685
|
+
gesture = Gesture.SCROLL
|
|
686
|
+
// The desktop scrolls whatever is under its pointer.
|
|
687
|
+
point(fx, fy)?.let { pointerTo(active, "move", it) }
|
|
688
|
+
}
|
|
689
|
+
lastSpan = currentSpan
|
|
690
|
+
lastFocusX = fx
|
|
691
|
+
lastFocusY = fy
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
if (gesture == Gesture.PINCH) {
|
|
695
|
+
if (lastSpan > 0f && currentSpan > 0f) zoomAround(fx, fy, currentSpan / lastSpan)
|
|
696
|
+
panBy(fx - lastFocusX, fy - lastFocusY)
|
|
697
|
+
} else {
|
|
698
|
+
// Content follows the fingers: moving them up scrolls the page down.
|
|
699
|
+
wheelX -= (fx - lastFocusX) / scale / PIXELS_PER_DETENT
|
|
700
|
+
wheelY -= (fy - lastFocusY) / scale / PIXELS_PER_DETENT
|
|
701
|
+
flushWheel(active, force = false)
|
|
702
|
+
}
|
|
703
|
+
lastSpan = currentSpan
|
|
704
|
+
lastFocusX = fx
|
|
705
|
+
lastFocusY = fy
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private fun flushWheel(active: DesktopSession, force: Boolean) {
|
|
709
|
+
if (!force && abs(wheelX) < MIN_WHEEL_STEP && abs(wheelY) < MIN_WHEEL_STEP) return
|
|
710
|
+
if (wheelX == 0f && wheelY == 0f) return
|
|
711
|
+
active.sendWheel(wheelX.coerceIn(-10f, 10f), wheelY.coerceIn(-10f, 10f))
|
|
712
|
+
wheelX = 0f
|
|
713
|
+
wheelY = 0f
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private fun span(event: MotionEvent): Float =
|
|
717
|
+
hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1))
|
|
718
|
+
|
|
719
|
+
private fun focusX(event: MotionEvent): Float = (event.getX(0) + event.getX(1)) / 2f
|
|
720
|
+
|
|
721
|
+
private fun focusY(event: MotionEvent): Float = (event.getY(0) + event.getY(1)) / 2f
|
|
722
|
+
|
|
723
|
+
override fun onDetachedFromWindow() {
|
|
724
|
+
// Unmounting must not leave a button held on the far desktop.
|
|
725
|
+
removeCallbacks(longPress)
|
|
726
|
+
runCatching { session?.sendCancel() }
|
|
727
|
+
chordKeysDown.clear()
|
|
728
|
+
detachSink()
|
|
729
|
+
hideKeyboard()
|
|
730
|
+
super.onDetachedFromWindow()
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
fun release() {
|
|
734
|
+
setSession(null)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* A transparent single-line editor that exists only to own the input
|
|
739
|
+
* connection: the platform's keyboard, composing region and selection model
|
|
740
|
+
* are the user's, and forwarding the *committed* text keeps them intact.
|
|
741
|
+
*/
|
|
742
|
+
private inner class RemoteKeyboard(context: Context) : EditText(context) {
|
|
743
|
+
private val localDeleteKeysDown = mutableSetOf<Int>()
|
|
744
|
+
|
|
745
|
+
init {
|
|
746
|
+
background = null
|
|
747
|
+
setTextColor(Color.TRANSPARENT)
|
|
748
|
+
// No automatic capitals: the desktop decides what a word is, and a shell
|
|
749
|
+
// command that arrives as "Ls" is wrong.
|
|
750
|
+
inputType = InputType.TYPE_CLASS_TEXT
|
|
751
|
+
imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI
|
|
752
|
+
isSingleLine = true
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
|
756
|
+
if (session?.inputEnabled != true) return true
|
|
757
|
+
if (forwardModifierOrNamedKey(event)) return true
|
|
758
|
+
if (forwardChord(event)) return true
|
|
759
|
+
val character = event.unicodeChar
|
|
760
|
+
if (character >= 32) {
|
|
761
|
+
val text = String(Character.toChars(character))
|
|
762
|
+
if (keyboardCaptured) session?.emitKeyboard(mapOf("text" to text)) else session?.sendText(text)
|
|
763
|
+
return true
|
|
764
|
+
}
|
|
765
|
+
return super.onKeyDown(keyCode, event)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** One tap of a named key: to the desktop, or to the app while it holds a modifier. */
|
|
769
|
+
private fun tapKey(name: String) {
|
|
770
|
+
if (keyboardCaptured) {
|
|
771
|
+
session?.emitKeyboard(mapOf("key" to name))
|
|
772
|
+
return
|
|
773
|
+
}
|
|
774
|
+
session?.sendKey(name, emptyList(), true)
|
|
775
|
+
session?.sendKey(name, emptyList(), false)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* The keyboard typed while the app holds a sticky modifier.
|
|
780
|
+
*
|
|
781
|
+
* The word it was still composing was typed before the modifier and has not
|
|
782
|
+
* gone yet, so it goes first, as text; only what the keyboard added to it is
|
|
783
|
+
* the chord's. The keyboard then forgets the word and starts afresh, or it
|
|
784
|
+
* would send the whole word again when it finishes it, chord key included.
|
|
785
|
+
*/
|
|
786
|
+
private fun captureTyped(text: String) {
|
|
787
|
+
val buffer = editableText
|
|
788
|
+
val start = BaseInputConnection.getComposingSpanStart(buffer)
|
|
789
|
+
val end = BaseInputConnection.getComposingSpanEnd(buffer)
|
|
790
|
+
val pending = if (start in 0 until end) buffer.substring(start, end) else ""
|
|
791
|
+
// A keyboard that rewrote its word (an autocorrection) is finishing it,
|
|
792
|
+
// not adding a key.
|
|
793
|
+
val extends = text.startsWith(pending)
|
|
794
|
+
val plain = if (extends) pending else text
|
|
795
|
+
val added = if (extends) text.substring(pending.length) else ""
|
|
796
|
+
if (plain.isNotEmpty()) session?.sendText(plain)
|
|
797
|
+
if (added.isNotEmpty()) session?.emitKeyboard(mapOf("text" to added))
|
|
798
|
+
buffer.clear()
|
|
799
|
+
post { inputMethods().restartInput(this) }
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
|
803
|
+
if (session?.inputEnabled != true) return true
|
|
804
|
+
if (forwardModifierOrNamedKey(event)) return true
|
|
805
|
+
if (forwardChord(event)) return true
|
|
806
|
+
return super.onKeyUp(keyCode, event)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
|
810
|
+
val base = super.onCreateInputConnection(outAttrs) ?: return null
|
|
811
|
+
return object : InputConnectionWrapper(base, true) {
|
|
812
|
+
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
|
813
|
+
if (session?.inputEnabled != true) return true
|
|
814
|
+
if (keyboardCaptured && !text.isNullOrEmpty()) {
|
|
815
|
+
captureTyped(text.toString())
|
|
816
|
+
return true
|
|
817
|
+
}
|
|
818
|
+
if (!text.isNullOrEmpty()) session?.sendText(text.toString())
|
|
819
|
+
return super.commitText(text, newCursorPosition)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
|
823
|
+
if (session?.inputEnabled != true) return true
|
|
824
|
+
// A chord is one key, not the start of a word: it goes as it is typed.
|
|
825
|
+
if (keyboardCaptured && !text.isNullOrEmpty()) {
|
|
826
|
+
captureTyped(text.toString())
|
|
827
|
+
return true
|
|
828
|
+
}
|
|
829
|
+
return super.setComposingText(text, newCursorPosition)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
|
833
|
+
if (session?.inputEnabled != true) return true
|
|
834
|
+
val buffer = editableText
|
|
835
|
+
val (backspaces, deletes) = remoteDeletionCounts(
|
|
836
|
+
beforeLength, afterLength, selectionStart, selectionEnd,
|
|
837
|
+
BaseInputConnection.getComposingSpanStart(buffer),
|
|
838
|
+
BaseInputConnection.getComposingSpanEnd(buffer),
|
|
839
|
+
)
|
|
840
|
+
repeat(backspaces) { tapKey("Backspace") }
|
|
841
|
+
repeat(deletes) { tapKey("Delete") }
|
|
842
|
+
return super.deleteSurroundingText(beforeLength, afterLength)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
override fun sendKeyEvent(event: KeyEvent): Boolean {
|
|
846
|
+
if (session?.inputEnabled != true) return true
|
|
847
|
+
if (forwardModifierOrNamedKey(event)) return true
|
|
848
|
+
if (forwardChord(event)) return true
|
|
849
|
+
return super.sendKeyEvent(event)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
override fun performEditorAction(actionCode: Int): Boolean {
|
|
853
|
+
if (session?.inputEnabled != true) return true
|
|
854
|
+
if (actionCode == EditorInfo.IME_ACTION_DONE || actionCode == EditorInfo.IME_ACTION_GO ||
|
|
855
|
+
actionCode == EditorInfo.IME_ACTION_SEND || actionCode == EditorInfo.IME_ACTION_NEXT
|
|
856
|
+
) {
|
|
857
|
+
tapKey("Enter")
|
|
858
|
+
return true
|
|
859
|
+
}
|
|
860
|
+
return super.performEditorAction(actionCode)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* The letter or digit a chorded key stands for, or null when it is not a
|
|
867
|
+
* chord. `unicodeChar` cannot be used for these: with Control held it is the
|
|
868
|
+
* ASCII control character, not the key the desktop has to press. Alt alone is
|
|
869
|
+
* not a chord: it is how a hardware keyboard composes a character (AltGr).
|
|
870
|
+
*/
|
|
871
|
+
private fun chordCharacter(event: KeyEvent): String? {
|
|
872
|
+
if (!event.isCtrlPressed && !event.isMetaPressed) return null
|
|
873
|
+
return chordKeyCharacter(event.keyCode)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** The letter or digit a chord key stands for, whatever modifiers are held. */
|
|
877
|
+
private fun chordKeyCharacter(keyCode: Int): String? = when (keyCode) {
|
|
878
|
+
in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> ('a' + (keyCode - KeyEvent.KEYCODE_A)).toString()
|
|
879
|
+
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> ('0' + (keyCode - KeyEvent.KEYCODE_0)).toString()
|
|
880
|
+
else -> null
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Forward a chord key down or up. The up is forwarded even when the
|
|
885
|
+
* modifier that made it a chord was released first; otherwise the desktop
|
|
886
|
+
* keeps the key held and its own key repeat floods it.
|
|
887
|
+
*/
|
|
888
|
+
private fun forwardChord(event: KeyEvent): Boolean {
|
|
889
|
+
val chord = chordCharacter(event)
|
|
890
|
+
if (chord != null) {
|
|
891
|
+
val down = event.action == KeyEvent.ACTION_DOWN
|
|
892
|
+
session?.sendCharacter(chord, heldModifiers(event), down = down)
|
|
893
|
+
if (down) chordKeysDown.add(event.keyCode) else chordKeysDown.remove(event.keyCode)
|
|
894
|
+
return true
|
|
895
|
+
}
|
|
896
|
+
if (event.action == KeyEvent.ACTION_UP && chordKeysDown.remove(event.keyCode)) {
|
|
897
|
+
val character = chordKeyCharacter(event.keyCode)
|
|
898
|
+
if (character != null) {
|
|
899
|
+
session?.sendCharacter(character, emptyList(), down = false)
|
|
900
|
+
return true
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return false
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private fun heldModifiers(event: KeyEvent): List<String> {
|
|
907
|
+
val modifiers = mutableListOf<String>()
|
|
908
|
+
if (event.isCtrlPressed) modifiers.add("Control")
|
|
909
|
+
if (event.isAltPressed) modifiers.add("Alt")
|
|
910
|
+
if (event.isMetaPressed) modifiers.add("Meta")
|
|
911
|
+
if (event.isShiftPressed) modifiers.add("Shift")
|
|
912
|
+
return modifiers
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Modifiers and keys with no character travel as key events so the desktop
|
|
917
|
+
* sees the real chord — Ctrl+C has to be Ctrl+C there, not a pasted "c".
|
|
918
|
+
*/
|
|
919
|
+
private fun forwardModifierOrNamedKey(event: KeyEvent): Boolean {
|
|
920
|
+
if (event.action == KeyEvent.ACTION_UP && localDeleteKeysDown.remove(event.keyCode)) return true
|
|
921
|
+
if (event.action == KeyEvent.ACTION_DOWN &&
|
|
922
|
+
(event.keyCode == KeyEvent.KEYCODE_DEL || event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL)
|
|
923
|
+
) {
|
|
924
|
+
val buffer = editableText
|
|
925
|
+
val (backspaces, deletes) = remoteDeletionCounts(
|
|
926
|
+
if (event.keyCode == KeyEvent.KEYCODE_DEL) 1 else 0,
|
|
927
|
+
if (event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL) 1 else 0,
|
|
928
|
+
selectionStart, selectionEnd,
|
|
929
|
+
BaseInputConnection.getComposingSpanStart(buffer),
|
|
930
|
+
BaseInputConnection.getComposingSpanEnd(buffer),
|
|
931
|
+
)
|
|
932
|
+
if (backspaces + deletes == 0) {
|
|
933
|
+
localDeleteKeysDown.add(event.keyCode)
|
|
934
|
+
return false
|
|
935
|
+
}
|
|
936
|
+
localDeleteKeysDown.remove(event.keyCode)
|
|
937
|
+
}
|
|
938
|
+
val name = when (event.keyCode) {
|
|
939
|
+
KeyEvent.KEYCODE_CTRL_LEFT -> "Control"
|
|
940
|
+
KeyEvent.KEYCODE_CTRL_RIGHT -> "ControlRight"
|
|
941
|
+
KeyEvent.KEYCODE_SHIFT_LEFT -> "Shift"
|
|
942
|
+
KeyEvent.KEYCODE_SHIFT_RIGHT -> "ShiftRight"
|
|
943
|
+
KeyEvent.KEYCODE_ALT_LEFT -> "Alt"
|
|
944
|
+
KeyEvent.KEYCODE_ALT_RIGHT -> "AltGraph"
|
|
945
|
+
KeyEvent.KEYCODE_META_LEFT, KeyEvent.KEYCODE_META_RIGHT -> "Meta"
|
|
946
|
+
KeyEvent.KEYCODE_DEL -> "Backspace"
|
|
947
|
+
KeyEvent.KEYCODE_FORWARD_DEL -> "Delete"
|
|
948
|
+
KeyEvent.KEYCODE_ENTER -> "Enter"
|
|
949
|
+
KeyEvent.KEYCODE_TAB -> "Tab"
|
|
950
|
+
KeyEvent.KEYCODE_ESCAPE -> "Escape"
|
|
951
|
+
KeyEvent.KEYCODE_DPAD_LEFT -> "ArrowLeft"
|
|
952
|
+
KeyEvent.KEYCODE_DPAD_RIGHT -> "ArrowRight"
|
|
953
|
+
KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp"
|
|
954
|
+
KeyEvent.KEYCODE_DPAD_DOWN -> "ArrowDown"
|
|
955
|
+
KeyEvent.KEYCODE_MOVE_HOME -> "Home"
|
|
956
|
+
KeyEvent.KEYCODE_MOVE_END -> "End"
|
|
957
|
+
KeyEvent.KEYCODE_PAGE_UP -> "PageUp"
|
|
958
|
+
KeyEvent.KEYCODE_PAGE_DOWN -> "PageDown"
|
|
959
|
+
else -> return false
|
|
960
|
+
}
|
|
961
|
+
// A plain key pressed while a sticky modifier waits is the app's to
|
|
962
|
+
// chord. Only the press is held back: a release still goes, so a key
|
|
963
|
+
// that was down before the capture cannot stick.
|
|
964
|
+
if (keyboardCaptured && event.action == KeyEvent.ACTION_DOWN && event.hasNoModifiers() &&
|
|
965
|
+
!KeyEvent.isModifierKey(event.keyCode)
|
|
966
|
+
) {
|
|
967
|
+
tapKey(name)
|
|
968
|
+
return true
|
|
969
|
+
}
|
|
970
|
+
session?.sendKey(name, heldModifiers(event), event.action == KeyEvent.ACTION_DOWN)
|
|
971
|
+
return true
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* The desktop's pointer, drawn at a size a phone can see. A desktop's own
|
|
979
|
+
* cursor is a few screen pixels tall once a large desktop is fitted to a phone,
|
|
980
|
+
* and some capture sources leave it out altogether; this shows where the next
|
|
981
|
+
* click lands. Black with a white edge, so it reads on light and dark pages.
|
|
982
|
+
*/
|
|
983
|
+
private class PointerMark(context: Context) : View(context) {
|
|
984
|
+
private val unit = context.resources.displayMetrics.density
|
|
985
|
+
|
|
986
|
+
/** Where the tip sits inside this view, leaving room for the edge and its shadow. */
|
|
987
|
+
val hotspot = 3f * unit
|
|
988
|
+
val size = Math.round(28f * unit)
|
|
989
|
+
|
|
990
|
+
private val arrow = Path().apply {
|
|
991
|
+
// An arrow pointer, tip first, in points.
|
|
992
|
+
val outline = floatArrayOf(0f, 0f, 0f, 19.25f, 4.73f, 14.96f, 7.92f, 22.22f, 11f, 20.9f, 7.92f, 13.86f, 14.08f, 13.86f)
|
|
993
|
+
moveTo(hotspot + outline[0] * unit, hotspot + outline[1] * unit)
|
|
994
|
+
for (index in 2 until outline.size step 2) lineTo(hotspot + outline[index] * unit, hotspot + outline[index + 1] * unit)
|
|
995
|
+
close()
|
|
996
|
+
}
|
|
997
|
+
private val edge = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
998
|
+
style = Paint.Style.STROKE
|
|
999
|
+
color = Color.WHITE
|
|
1000
|
+
strokeWidth = 2.2f * unit
|
|
1001
|
+
strokeJoin = Paint.Join.ROUND
|
|
1002
|
+
setShadowLayer(1.5f * unit, 0f, 1f * unit, Color.argb(90, 0, 0, 0))
|
|
1003
|
+
}
|
|
1004
|
+
private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
1005
|
+
style = Paint.Style.FILL
|
|
1006
|
+
color = Color.BLACK
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
init {
|
|
1010
|
+
visibility = INVISIBLE
|
|
1011
|
+
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
override fun onDraw(canvas: Canvas) {
|
|
1015
|
+
// The edge is stroked on the outline and the fill covers its inner half.
|
|
1016
|
+
canvas.drawPath(arrow, edge)
|
|
1017
|
+
canvas.drawPath(arrow, fill)
|
|
1018
|
+
}
|
|
1019
|
+
}
|