374 lines
14 KiB
Kotlin
374 lines
14 KiB
Kotlin
package com.openless.app
|
|
|
|
import android.accessibilityservice.AccessibilityService
|
|
import android.content.ClipboardManager
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.graphics.Rect
|
|
import android.os.Bundle
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import android.os.ResultReceiver
|
|
import android.provider.Settings
|
|
import android.util.Log
|
|
import android.view.accessibility.AccessibilityEvent
|
|
import android.view.accessibility.AccessibilityNodeInfo
|
|
import android.view.accessibility.AccessibilityWindowInfo
|
|
import java.util.concurrent.CountDownLatch
|
|
import java.util.concurrent.TimeUnit
|
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
|
|
/**
|
|
* Detects IME windows for overlay keyboard trigger mode and performs paste insertion.
|
|
*/
|
|
class OpenLessAccessibilityService : AccessibilityService() {
|
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
private val heartbeatRunnable = object : Runnable {
|
|
override fun run() {
|
|
markServiceAlive()
|
|
mainHandler.postDelayed(this, HEARTBEAT_INTERVAL_MS)
|
|
}
|
|
}
|
|
private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() }
|
|
private var lastEditableFocus: AccessibilityNodeInfo? = null
|
|
|
|
override fun onServiceConnected() {
|
|
super.onServiceConnected()
|
|
instance = this
|
|
startHeartbeat()
|
|
updateKeyboardOverlayState()
|
|
scheduleKeyboardOverlayRefresh()
|
|
}
|
|
|
|
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
|
if (event == null) return
|
|
markServiceAlive()
|
|
when (event.eventType) {
|
|
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED,
|
|
AccessibilityEvent.TYPE_WINDOWS_CHANGED,
|
|
AccessibilityEvent.TYPE_VIEW_FOCUSED -> {
|
|
rememberFocusedEditable(event)
|
|
updateKeyboardOverlayState()
|
|
scheduleKeyboardOverlayRefresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onInterrupt() = Unit
|
|
|
|
override fun onDestroy() {
|
|
mainHandler.removeCallbacks(heartbeatRunnable)
|
|
mainHandler.removeCallbacks(keyboardRefreshRunnable)
|
|
lastEditableFocus?.recycle()
|
|
lastEditableFocus = null
|
|
if (instance === this) {
|
|
instance = null
|
|
}
|
|
super.onDestroy()
|
|
}
|
|
|
|
private fun scheduleKeyboardOverlayRefresh() {
|
|
mainHandler.removeCallbacks(keyboardRefreshRunnable)
|
|
for (delayMs in KEYBOARD_REFRESH_DELAYS_MS) {
|
|
mainHandler.postDelayed(keyboardRefreshRunnable, delayMs)
|
|
}
|
|
}
|
|
|
|
private fun startHeartbeat() {
|
|
mainHandler.removeCallbacks(heartbeatRunnable)
|
|
heartbeatRunnable.run()
|
|
}
|
|
|
|
private fun updateKeyboardOverlayState() {
|
|
if (!shouldTrackKeyboard()) {
|
|
return
|
|
}
|
|
if (!canDrawOverlays()) {
|
|
return
|
|
}
|
|
val imeBounds = findInputMethodBounds()
|
|
val intent = Intent(this, OpenLessOverlayService::class.java).apply {
|
|
action = OpenLessOverlayService.ACTION_KEYBOARD_CHANGED
|
|
putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_VISIBLE, imeBounds != null)
|
|
imeBounds?.let {
|
|
putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_TOP, it.top)
|
|
putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_BOTTOM, it.bottom)
|
|
}
|
|
}
|
|
try {
|
|
Log.i(TAG, "keyboard overlay event visible=${imeBounds != null} bounds=$imeBounds")
|
|
startService(intent)
|
|
} catch (error: Throwable) {
|
|
Log.w(TAG, "send keyboard overlay event failed", error)
|
|
}
|
|
}
|
|
|
|
private fun findInputMethodBounds(): Rect? {
|
|
for (window in windows) {
|
|
if (window.type != AccessibilityWindowInfo.TYPE_INPUT_METHOD) {
|
|
continue
|
|
}
|
|
val bounds = Rect()
|
|
window.getBoundsInScreen(bounds)
|
|
if (!bounds.isEmpty) {
|
|
return bounds
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
private fun shouldTrackKeyboard(): Boolean {
|
|
return OpenLessAndroidPreferences.isKeyboardOverlayTrigger(this)
|
|
}
|
|
|
|
private fun canDrawOverlays(): Boolean {
|
|
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
|
Settings.canDrawOverlays(this)
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
|
|
private fun performPasteToFocusedField(): Boolean {
|
|
val target = findEditableTarget() ?: return false
|
|
return try {
|
|
target.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
|
|
pasteWithRetryOrSetText(target)
|
|
} finally {
|
|
target.recycle()
|
|
}
|
|
}
|
|
|
|
private fun rememberFocusedEditable(event: AccessibilityEvent) {
|
|
val source = event.source ?: return
|
|
try {
|
|
if (!source.isEditable) return
|
|
lastEditableFocus?.recycle()
|
|
lastEditableFocus = AccessibilityNodeInfo.obtain(source)
|
|
} finally {
|
|
source.recycle()
|
|
}
|
|
}
|
|
|
|
private fun findEditableTarget(): AccessibilityNodeInfo? {
|
|
lastEditableFocus?.let { cached ->
|
|
if (cached.refresh() && cached.isEditable) {
|
|
return AccessibilityNodeInfo.obtain(cached)
|
|
}
|
|
}
|
|
val root = rootInActiveWindow ?: return null
|
|
editableFocusedNode(root, AccessibilityNodeInfo.FOCUS_INPUT)?.let { return it }
|
|
editableFocusedNode(root, AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)?.let { return it }
|
|
return findEditableInTree(root, 0)
|
|
}
|
|
|
|
private fun editableFocusedNode(root: AccessibilityNodeInfo, focusType: Int): AccessibilityNodeInfo? {
|
|
val focused = root.findFocus(focusType) ?: return null
|
|
if (focused.isEditable) {
|
|
return focused
|
|
}
|
|
focused.recycle()
|
|
return null
|
|
}
|
|
|
|
private fun findEditableInTree(node: AccessibilityNodeInfo, depth: Int): AccessibilityNodeInfo? {
|
|
if (depth > MAX_EDITABLE_SEARCH_DEPTH) return null
|
|
var firstEditable: AccessibilityNodeInfo? = null
|
|
if (node.isEditable) {
|
|
if (node.isFocused) {
|
|
return AccessibilityNodeInfo.obtain(node)
|
|
}
|
|
firstEditable = AccessibilityNodeInfo.obtain(node)
|
|
}
|
|
for (index in 0 until node.childCount) {
|
|
val child = node.getChild(index) ?: continue
|
|
try {
|
|
findEditableInTree(child, depth + 1)?.let { found ->
|
|
firstEditable?.recycle()
|
|
return found
|
|
}
|
|
} finally {
|
|
child.recycle()
|
|
}
|
|
}
|
|
return firstEditable
|
|
}
|
|
|
|
private fun pasteWithRetryOrSetText(target: AccessibilityNodeInfo): Boolean {
|
|
sleepQuietly(PASTE_INITIAL_DELAY_MS)
|
|
repeat(PASTE_RETRY_COUNT) { attempt ->
|
|
if (target.performAction(AccessibilityNodeInfo.ACTION_PASTE)) {
|
|
Log.i(TAG, "paste=true attempt=${attempt + 1} package=${target.packageName}")
|
|
return true
|
|
}
|
|
sleepQuietly(PASTE_RETRY_DELAY_MS)
|
|
}
|
|
val setText = appendClipboardTextWithSetText(target)
|
|
Log.i(TAG, "paste=false setText=$setText package=${target.packageName}")
|
|
return setText
|
|
}
|
|
|
|
private fun appendClipboardTextWithSetText(target: AccessibilityNodeInfo): Boolean {
|
|
if (target.isPassword) return false
|
|
val clipboardText = clipboardText().takeIf { it.isNotEmpty() } ?: return false
|
|
val existingText = target.text?.toString().orEmpty()
|
|
val args = Bundle().apply {
|
|
putCharSequence(
|
|
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
|
|
existingText + clipboardText,
|
|
)
|
|
}
|
|
return target.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
|
|
}
|
|
|
|
private fun clipboardText(): String {
|
|
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return ""
|
|
val clip = clipboard.primaryClip ?: return ""
|
|
if (clip.itemCount <= 0) return ""
|
|
return clip.getItemAt(0)?.coerceToText(this)?.toString().orEmpty()
|
|
}
|
|
|
|
private fun sleepQuietly(delayMs: Long) {
|
|
try {
|
|
Thread.sleep(delayMs)
|
|
} catch (_: InterruptedException) {
|
|
Thread.currentThread().interrupt()
|
|
}
|
|
}
|
|
|
|
private fun captureSelectedTextFromFocusedNode(): String {
|
|
val root = rootInActiveWindow ?: return ""
|
|
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
|
|
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
|
|
focused?.let {
|
|
return try {
|
|
selectedTextFromNode(it)
|
|
} finally {
|
|
it.recycle()
|
|
}
|
|
}
|
|
return selectedTextFromTree(root)
|
|
}
|
|
|
|
private fun selectedTextFromTree(node: AccessibilityNodeInfo?): String {
|
|
if (node == null) return ""
|
|
selectedTextFromNode(node).takeIf { it.isNotBlank() }?.let { return it }
|
|
for (index in 0 until node.childCount) {
|
|
val child = node.getChild(index) ?: continue
|
|
try {
|
|
selectedTextFromTree(child).takeIf { it.isNotBlank() }?.let { return it }
|
|
} finally {
|
|
child.recycle()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
private fun selectedTextFromNode(node: AccessibilityNodeInfo): String {
|
|
val text = node.text?.toString() ?: return ""
|
|
val start = node.textSelectionStart
|
|
val end = node.textSelectionEnd
|
|
if (start < 0 || end < 0 || start == end) return ""
|
|
val from = minOf(start, end).coerceIn(0, text.length)
|
|
val to = maxOf(start, end).coerceIn(0, text.length)
|
|
if (from >= to) return ""
|
|
return text.substring(from, to)
|
|
}
|
|
|
|
private fun markServiceAlive() {
|
|
getSharedPreferences(PREFS_NAME, prefsMode())
|
|
.edit()
|
|
.putLong(PREF_KEY_LAST_HEARTBEAT, System.currentTimeMillis())
|
|
.apply()
|
|
}
|
|
|
|
companion object {
|
|
@Volatile
|
|
var instance: OpenLessAccessibilityService? = null
|
|
private set
|
|
|
|
@JvmStatic
|
|
fun pasteToFocusedField(): Boolean {
|
|
instance?.let { return it.performPasteToFocusedField() }
|
|
return sendPasteRequestToAccessibilityProcess()
|
|
}
|
|
|
|
@JvmStatic
|
|
fun captureSelectedText(): String {
|
|
return instance?.captureSelectedTextFromFocusedNode().orEmpty()
|
|
}
|
|
|
|
@JvmStatic
|
|
fun isEnabled(context: Context): Boolean {
|
|
val enabled = Settings.Secure.getInt(
|
|
context.contentResolver,
|
|
Settings.Secure.ACCESSIBILITY_ENABLED,
|
|
0,
|
|
) == 1
|
|
if (!enabled) return false
|
|
val services = Settings.Secure.getString(
|
|
context.contentResolver,
|
|
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
|
|
) ?: return false
|
|
return services.contains("${context.packageName}/${OpenLessAccessibilityService::class.java.name}")
|
|
}
|
|
|
|
@JvmStatic
|
|
fun isOperational(context: Context): Boolean {
|
|
if (!isEnabled(context)) return false
|
|
val lastHeartbeat = context
|
|
.getSharedPreferences(PREFS_NAME, prefsMode())
|
|
.getLong(PREF_KEY_LAST_HEARTBEAT, 0L)
|
|
if (lastHeartbeat <= 0L) return false
|
|
return System.currentTimeMillis() - lastHeartbeat <= HEARTBEAT_STALE_MS
|
|
}
|
|
|
|
internal fun performPasteFromCommand(): Boolean {
|
|
return instance?.performPasteToFocusedField() == true
|
|
}
|
|
|
|
private fun sendPasteRequestToAccessibilityProcess(): Boolean {
|
|
val context = OpenLessAppContext.context ?: return false
|
|
if (!isOperational(context)) return false
|
|
val latch = CountDownLatch(1)
|
|
val success = AtomicBoolean(false)
|
|
val receiver = object : ResultReceiver(null) {
|
|
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
|
|
success.set(resultCode == OpenLessAccessibilityCommandReceiver.RESULT_PASTE_SUCCESS)
|
|
latch.countDown()
|
|
}
|
|
}
|
|
return try {
|
|
val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply {
|
|
action = OpenLessAccessibilityCommandReceiver.ACTION_PASTE
|
|
putExtra(OpenLessAccessibilityCommandReceiver.EXTRA_RESULT_RECEIVER, receiver)
|
|
}
|
|
context.sendBroadcast(intent)
|
|
if (!latch.await(PASTE_COMMAND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
|
Log.w(TAG, "accessibility paste result timed out")
|
|
return false
|
|
}
|
|
success.get()
|
|
} catch (error: Throwable) {
|
|
Log.w(TAG, "send accessibility paste request failed", error)
|
|
false
|
|
}
|
|
}
|
|
|
|
@Suppress("DEPRECATION")
|
|
private fun prefsMode(): Int = Context.MODE_PRIVATE or Context.MODE_MULTI_PROCESS
|
|
|
|
private val KEYBOARD_REFRESH_DELAYS_MS = longArrayOf(120L, 360L, 900L, 1600L)
|
|
private const val MAX_EDITABLE_SEARCH_DEPTH = 4
|
|
private const val PASTE_INITIAL_DELAY_MS = 50L
|
|
private const val PASTE_RETRY_COUNT = 3
|
|
private const val PASTE_RETRY_DELAY_MS = 80L
|
|
private const val PASTE_COMMAND_TIMEOUT_MS = 800L
|
|
private const val TAG = "OpenLessAccessibility"
|
|
private const val PREFS_NAME = "openless_accessibility"
|
|
private const val PREF_KEY_LAST_HEARTBEAT = "last_heartbeat"
|
|
private const val HEARTBEAT_INTERVAL_MS = 5_000L
|
|
private const val HEARTBEAT_STALE_MS = 15_000L
|
|
}
|
|
}
|