app/src/main/java/dev/equwal/assistkey/engine/ScreenButton.kt (4544 bytes)
1 package dev.equwal.assistkey.engine 2 3 import android.accessibilityservice.AccessibilityService 4 import android.content.Context 5 import android.graphics.Canvas 6 import android.graphics.Color 7 import android.graphics.Paint 8 import android.graphics.PixelFormat 9 import android.view.Gravity 10 import android.view.MotionEvent 11 import android.view.View 12 import android.view.ViewConfiguration 13 import android.view.WindowManager 14 import kotlin.math.abs 15 16 /** 17 * A small round button that floats above every app. 18 * 19 * The key filter draws it in an accessibility overlay window, which needs no 20 * permission of its own. It is on the screen only while a binding for it 21 * exists, so a user who does not want it never sees it. A tap runs the 22 * binding. A drag moves the button, and the place is kept. 23 * 24 * Android has its own accessibility button. It is not used: Android shows that 25 * button to every user as soon as a service asks for it, bound or not. 26 * 27 * Drawn for e-ink: a white disc with a black ring, no shadow, no animation. 28 */ 29 class ScreenButton(private val service: AccessibilityService, private val onTap: () -> Unit) { 30 31 private val wm = service.getSystemService(WindowManager::class.java) 32 private val prefs = service.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 33 private val d = service.resources.displayMetrics.density 34 private val size = (48 * d).toInt() 35 private var view: View? = null 36 37 private val params = WindowManager.LayoutParams( 38 size, size, 39 WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, 40 WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, 41 PixelFormat.TRANSLUCENT 42 ).apply { gravity = Gravity.TOP or Gravity.START } 43 44 val showing: Boolean get() = view != null 45 46 fun show() { 47 if (view != null) return 48 val screen = service.resources.displayMetrics 49 params.x = prefs.getInt(K_X, screen.widthPixels - size - (12 * d).toInt()) 50 params.y = prefs.getInt(K_Y, screen.heightPixels * 2 / 3) 51 val v = Disc(service) 52 runCatching { wm.addView(v, params) }.onSuccess { view = v } 53 } 54 55 fun hide() { 56 view?.let { runCatching { wm.removeView(it) } } 57 view = null 58 } 59 60 private inner class Disc(c: Context) : View(c) { 61 private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { 62 style = Paint.Style.STROKE; strokeWidth = 3 * d; color = Color.BLACK 63 } 64 private val paper = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = Color.WHITE } 65 private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = Color.BLACK } 66 private val slop = ViewConfiguration.get(c).scaledTouchSlop 67 private var downX = 0f 68 private var downY = 0f 69 private var startX = 0 70 private var startY = 0 71 private var dragged = false 72 73 override fun onDraw(canvas: Canvas) { 74 val r = width / 2f 75 canvas.drawCircle(r, r, r - 2 * d, paper) 76 canvas.drawCircle(r, r, r - 3 * d, ring) 77 canvas.drawCircle(r, r, r * 0.30f, dot) 78 } 79 80 override fun onTouchEvent(e: MotionEvent): Boolean { 81 when (e.actionMasked) { 82 MotionEvent.ACTION_DOWN -> { 83 downX = e.rawX; downY = e.rawY 84 startX = params.x; startY = params.y 85 dragged = false 86 } 87 MotionEvent.ACTION_MOVE -> { 88 val dx = e.rawX - downX 89 val dy = e.rawY - downY 90 if (!dragged && (abs(dx) > slop || abs(dy) > slop)) dragged = true 91 if (dragged) { 92 params.x = startX + dx.toInt() 93 params.y = startY + dy.toInt() 94 runCatching { wm.updateViewLayout(this, params) } 95 } 96 } 97 MotionEvent.ACTION_UP -> { 98 if (dragged) { 99 prefs.edit().putInt(K_X, params.x).putInt(K_Y, params.y).apply() 100 } else { 101 performClick() 102 } 103 } 104 } 105 return true 106 } 107 108 override fun performClick(): Boolean { 109 super.performClick() 110 onTap() 111 return true 112 } 113 } 114 115 private companion object { 116 const val PREFS = "assistkey_screen_button" 117 const val K_X = "x" 118 const val K_Y = "y" 119 } 120 }