@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.
@@ -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
+ }