Files
openless/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt
TRIPandHKLHaoBin 6f9af89583 release: OpenLess 2.0.0-Beta.4+build.20260930 (#1126)
Integrate ASR/Omni fixes, API Route, Android IME and confirmation-based vocabulary learning. Complete settings transitions and macOS single-item credential storage. Synchronize all application versions to 2.0.0-Beta.4+build.20260930.

Integrated PRs: #1116, #1120, #1121, #1123, #1124, #1067 and #1128. Linux #1117 remains independent and is excluded from automatic release.

Validated: full cross-platform CI, 18 UI motion cases, 112 frontend test files, 1245 Core tests (1 ignored) and 572 macOS native tests.

Co-authored-by: HKLHaoBin <we3q@qq.com>
2026-09-30 20:32:53 +08:00

868 lines
34 KiB
Kotlin
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 androidx.annotation.Keep
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/** Detects IME windows for overlay keyboard trigger mode and performs paste insertion. */
class OpenLessAccessibilityService : AccessibilityService() {
private val mainHandler = Handler(Looper.getMainLooper())
private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() }
private var lastEditableFocus: AccessibilityNodeInfo? = null
private var vocabularyGeneration = 0L
private var vocabularyNode: AccessibilityNodeInfo? = null
private var vocabularyDeadline = 0L
private var vocabularyStarted = 0L
private var vocabularyCard: android.view.View? = null
private val vocabularyRead = Runnable { readVocabularyText() }
private val vocabularyTimeout = Runnable { stopVocabularyObservation() }
private var vocabularyCardTimeout: Runnable? = null
@Volatile private var vocabularyEpoch = 0L
private fun postVocabulary(action: () -> Unit) {
val epoch = vocabularyEpoch
mainHandler.post {
if (instance === this && vocabularyEpoch == epoch) action()
}
}
private fun invalidateVocabularyLifecycle() {
vocabularyEpoch++
stopVocabularyObservation()
hideVocabularyCard()
}
private var vocabularyCardRequest = 0L
private fun requestVocabulary(operation: String, data: Bundle = Bundle(), observation: Boolean = false, callback: (Boolean, Bundle?) -> Unit) {
val epoch = vocabularyEpoch
val generation = vocabularyGeneration
OpenLessVocabularyIpc.request(this, operation, data) { ok, response ->
if (instance === this && vocabularyEpoch == epoch && (!observation || vocabularyGeneration == generation)) {
callback(ok, response)
}
}
}
private fun refreshVocabularyCard() {
val request = ++vocabularyCardRequest
requestVocabulary("pending") { ok, response ->
if (request == vocabularyCardRequest) showVocabularyCard(if (ok) response?.getString("json") ?: "[]" else "[]")
}
}
private fun startVocabularyObservation(generation: Long) {
if (generation == 0L || vocabularyGeneration == generation) return
stopVocabularyObservation()
vocabularyGeneration = generation
requestVocabulary("active", Bundle().apply { putLong("generation", generation) }, true) { active, response ->
val deadline = response?.getLong("deadline") ?: 0L
if (active && deadline > android.os.SystemClock.elapsedRealtime()) {
captureVocabularyNode(generation, deadline)
} else stopVocabularyObservation()
}
}
private fun captureVocabularyNode(generation: Long, deadline: Long) {
val root = rootInActiveWindow ?: run { stopVocabularyObservation(); return }
val node = try { root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) } finally { root.recycle() }
if (node == null) { stopVocabularyObservation(); return }
val packageName = node.packageName?.toString()?.lowercase().orEmpty()
if (!node.isEditable || node.isPassword || packageName.isEmpty() || packageName == this.packageName ||
listOf("keepass", "bitwarden", "1password", "lastpass", "dashlane", "termux", "password").any { packageName.contains(it) }) {
node.recycle()
stopVocabularyObservation()
return
}
vocabularyGeneration = generation
vocabularyNode = node
vocabularyStarted = android.os.SystemClock.elapsedRealtime()
vocabularyDeadline = deadline
val remaining = deadline - vocabularyStarted
if (remaining <= 0L) { stopVocabularyObservation(); return }
mainHandler.post(vocabularyRead)
mainHandler.postDelayed(vocabularyTimeout, remaining)
}
private fun stopVocabularyObservation() {
val generation = vocabularyGeneration
mainHandler.removeCallbacks(vocabularyRead)
mainHandler.removeCallbacks(vocabularyTimeout)
vocabularyNode?.recycle()
vocabularyNode = null
vocabularyGeneration = 0L
if (generation != 0L) {
OpenLessVocabularyIpc.request(this, "stop", Bundle().apply { putLong("generation", generation) }) { _, _ -> }
}
}
private fun readVocabularyText() {
if (vocabularyNode == null) return
requestVocabulary("active", Bundle().apply { putLong("generation", vocabularyGeneration) }, true) { active, _ ->
if (active) readConfirmedVocabularyText() else stopVocabularyObservation()
}
}
private fun readConfirmedVocabularyText() {
val node = vocabularyNode ?: return
if (android.os.SystemClock.elapsedRealtime() >= vocabularyDeadline || !node.refresh() || !node.isFocused || node.isPassword) {
stopVocabularyObservation()
return
}
val root = rootInActiveWindow
val sameWindow = root != null && root.windowId == node.windowId
root?.recycle()
if (!sameWindow) { stopVocabularyObservation(); return }
val text = node.text?.toString()
if (text == null || text.length > 20_000) { stopVocabularyObservation(); return }
requestVocabulary("observe", Bundle().apply {
putLong("generation", vocabularyGeneration)
putString("text", text)
}, true) { alive, _ ->
if (!alive) stopVocabularyObservation()
else if (android.os.SystemClock.elapsedRealtime() - vocabularyStarted < 1_000L) {
mainHandler.postDelayed(vocabularyRead, 100L)
}
}
}
private fun vocabularyEvent(event: AccessibilityEvent) {
val node = vocabularyNode ?: return
if (event.eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED ||
event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED ||
event.eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED) {
if (!node.refresh() || !node.isFocused || node.isPassword) stopVocabularyObservation()
}
if (event.eventType != AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) return
val source = event.source ?: return
try {
if (source != node) return
if (event.isPassword || source.isPassword) { stopVocabularyObservation(); return }
mainHandler.removeCallbacks(vocabularyRead)
mainHandler.postDelayed(vocabularyRead, 700L)
} finally { source.recycle() }
}
private fun hideVocabularyCard() {
vocabularyCardTimeout?.let { mainHandler.removeCallbacks(it) }
vocabularyCardTimeout = null
vocabularyCard?.let { view ->
try { (getSystemService(WINDOW_SERVICE) as android.view.WindowManager).removeView(view) } catch (_: Exception) { }
}
vocabularyCard = null
}
private fun showVocabularyCard(json: String) {
hideVocabularyCard()
val entries = try { org.json.JSONArray(json) } catch (_: Exception) { return }
val now = System.currentTimeMillis()
val pending = (0 until entries.length()).map { entries.getJSONObject(it) }.filter { it.optLong("expiresAtMs") > now }
if (pending.isEmpty()) return
val layout = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(16, 12, 16, 12)
setBackgroundColor(android.graphics.Color.rgb(38, 38, 42))
addView(android.widget.TextView(context).apply { text = "记住这个词?"; setTextColor(android.graphics.Color.WHITE) })
}
for (entry in pending) {
val row = android.widget.LinearLayout(this)
row.addView(android.widget.TextView(this).apply {
text = entry.optString("pattern") + " → " + entry.optString("replacement")
setTextColor(android.graphics.Color.WHITE)
maxLines = 2
}, android.widget.LinearLayout.LayoutParams(0, -2, 1f))
for (accept in listOf(true, false)) {
row.addView(android.widget.Button(this).apply {
text = if (accept) "✓" else "×"
contentDescription = if (accept) "加入词典" else "忽略"
setOnClickListener {
isEnabled = false
requestVocabulary("resolve", Bundle().apply {
putString("id", entry.getString("id"))
putBoolean("accept", accept)
}) { ok, _ ->
isEnabled = true
if (ok) refreshVocabularyCard()
else android.widget.Toast.makeText(this@OpenLessAccessibilityService, "保存失败,请重试", android.widget.Toast.LENGTH_SHORT).show()
}
}
}, android.widget.LinearLayout.LayoutParams((56 * resources.displayMetrics.density).toInt(), -2))
}
layout.addView(row)
}
val params = android.view.WindowManager.LayoutParams(
(340 * resources.displayMetrics.density).toInt().coerceAtMost(resources.displayMetrics.widthPixels),
android.view.WindowManager.LayoutParams.WRAP_CONTENT,
android.view.WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
android.graphics.PixelFormat.TRANSLUCENT
).apply { gravity = android.view.Gravity.TOP or android.view.Gravity.END; y = (48 * resources.displayMetrics.density).toInt() }
try {
(getSystemService(WINDOW_SERVICE) as android.view.WindowManager).addView(layout, params)
vocabularyCard = layout
val timeout = Runnable { refreshVocabularyCard() }
vocabularyCardTimeout = timeout
mainHandler.postDelayed(timeout, (pending.minOf { it.getLong("expiresAtMs") } - now).coerceAtLeast(1L))
} catch (_: Exception) { hideVocabularyCard() }
}
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
vocabularyEpoch++
refreshVocabularyCard()
requestVocabulary("current") { ok, response ->
val generation = if (ok) response?.getLong("generation", 0L) ?: 0L else 0L
if (vocabularyGeneration == 0L) startVocabularyObservation(generation)
}
updateKeyboardOverlayState()
scheduleKeyboardOverlayRefresh()
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event == null) return
vocabularyEvent(event)
when (event.eventType) {
AccessibilityEvent.TYPE_VIEW_CLICKED -> rememberFocusedEditable(event)
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED,
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> {
rememberFocusedEditable(event)
updateKeyboardOverlayState()
scheduleKeyboardOverlayRefresh()
}
AccessibilityEvent.TYPE_VIEW_FOCUSED,
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED -> {
rememberFocusedEditable(event)
updateKeyboardOverlayState()
scheduleKeyboardOverlayRefresh()
}
}
}
override fun onInterrupt() { invalidateVocabularyLifecycle() }
override fun onDestroy() {
invalidateVocabularyLifecycle()
mainHandler.removeCallbacks(keyboardRefreshRunnable)
invalidateEditableCache()
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 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 OpenLessPermissionBridge.canDrawOverlaysSafely(this)
}
private fun performPasteToFocusedFieldInternal(
pasteText: String? = null
): AccessibilityPasteResult {
val target = findEditableTarget()
if (target == null) {
return AccessibilityPasteResult.NO_FOCUSED_EDITOR
}
return try {
target.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
val ok = pasteWithRetryOrSetText(target, pasteText)
if (ok) {
AccessibilityPasteResult.SUCCESS
} else {
AccessibilityPasteResult.PASTE_REJECTED
}
} finally {
target.recycle()
}
}
private fun rememberFocusedEditable(event: AccessibilityEvent) {
val source = event.source ?: return
try {
if (OpenLessAccessibilityTarget.isPasteTarget(source)) {
cacheEditableTarget(source)
return
}
editableFocusedNode(source, AccessibilityNodeInfo.FOCUS_INPUT)?.let { focused ->
cacheEditableTarget(focused)
focused.recycle()
return
}
editableFocusedNode(source, AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)?.let { focused ->
cacheEditableTarget(focused)
focused.recycle()
}
} finally {
source.recycle()
}
}
private fun invalidateEditableCache() {
lastEditableFocus?.recycle()
lastEditableFocus = null
}
private fun findEditableTarget(): AccessibilityNodeInfo? {
lastEditableFocus?.let { cached ->
if (cached.refresh() && OpenLessAccessibilityTarget.isPasteTarget(cached)) {
return AccessibilityNodeInfo.obtain(cached)
}
}
val activeRoot = rootInActiveWindow
val activePackage = activeRoot?.packageName?.toString()
var pasteTargetsInActive = 0
if (activeRoot != null) {
try {
pasteTargetsInActive = countPasteTargetsInTree(activeRoot, 0)
findEditableInRoot(activeRoot)?.let { found ->
return found
}
} finally {
activeRoot.recycle()
}
}
for (window in windows) {
if (window.type == AccessibilityWindowInfo.TYPE_INPUT_METHOD) {
continue
}
val root = window.root ?: continue
try {
findEditableInRoot(root)?.let { found ->
return found
}
} finally {
root.recycle()
}
}
Log.w(
TAG,
"findEditableTarget failed activeRoot=$activePackage windowCount=${windows.size} hadCache=${lastEditableFocus != null} pasteTargetsInActive=$pasteTargetsInActive",
)
invalidateEditableCache()
return null
}
private fun findEditableInRoot(root: AccessibilityNodeInfo): AccessibilityNodeInfo? {
editableFocusedNode(root, AccessibilityNodeInfo.FOCUS_INPUT)?.let { fresh ->
cacheEditableTarget(fresh)
return fresh
}
editableFocusedNode(root, AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)?.let { fresh ->
cacheEditableTarget(fresh)
return fresh
}
lastEditableFocus?.let { cached ->
if (OpenLessAccessibilityTarget.isValidCachedEditable(cached, root)) {
return AccessibilityNodeInfo.obtain(cached)
}
}
return findEditableInTree(root, 0)?.also { found ->
cacheEditableTarget(found)
}
}
private fun editableFocusedNode(
root: AccessibilityNodeInfo,
focusType: Int,
): AccessibilityNodeInfo? {
val focused = root.findFocus(focusType) ?: return null
return try {
if (OpenLessAccessibilityTarget.isPasteTarget(focused)) {
AccessibilityNodeInfo.obtain(focused)
} else {
null
}
} finally {
focused.recycle()
}
}
private fun findEditableInTree(
node: AccessibilityNodeInfo,
depth: Int,
): AccessibilityNodeInfo? {
if (depth > MAX_EDITABLE_SEARCH_DEPTH) return null
var firstCandidate: AccessibilityNodeInfo? = null
if (OpenLessAccessibilityTarget.isPasteTarget(node)) {
if (node.isFocused) {
return AccessibilityNodeInfo.obtain(node)
}
firstCandidate = AccessibilityNodeInfo.obtain(node)
}
for (index in 0 until node.childCount) {
val child = node.getChild(index) ?: continue
try {
findEditableInTree(child, depth + 1)?.let { found ->
firstCandidate?.recycle()
return found
}
} finally {
child.recycle()
}
}
return firstCandidate
}
private fun countPasteTargetsInTree(node: AccessibilityNodeInfo, depth: Int): Int {
if (depth > MAX_EDITABLE_SEARCH_DEPTH) return 0
var count = if (OpenLessAccessibilityTarget.isPasteTarget(node)) 1 else 0
for (index in 0 until node.childCount) {
val child = node.getChild(index) ?: continue
try {
count += countPasteTargetsInTree(child, depth + 1)
} finally {
child.recycle()
}
}
return count
}
private fun cacheEditableTarget(target: AccessibilityNodeInfo) {
lastEditableFocus?.recycle()
lastEditableFocus = AccessibilityNodeInfo.obtain(target)
}
private fun pasteWithRetryOrSetText(
target: AccessibilityNodeInfo,
pasteText: String? = null,
): Boolean {
val effectiveText = pasteText?.takeIf { it.isNotEmpty() } ?: clipboardText()
if (effectiveText.isEmpty()) {
return false
}
val beforeText = nodeText(target)
sleepQuietly(PASTE_INITIAL_DELAY_MS)
repeat(PASTE_RETRY_COUNT) { attempt ->
if (target.performAction(AccessibilityNodeInfo.ACTION_PASTE)) {
sleepQuietly(PASTE_VERIFY_DELAY_MS)
if (
target.refresh() &&
pasteAppearsApplied(beforeText, nodeText(target), effectiveText)
) {
Log.i(
TAG,
"paste=true verified attempt=${attempt + 1} package=${target.packageName}",
)
return true
}
Log.w(
TAG,
"paste=unverified attempt=${attempt + 1} package=${target.packageName}",
)
}
sleepQuietly(PASTE_RETRY_DELAY_MS)
}
val setText = appendClipboardTextWithSetText(target, effectiveText)
sleepQuietly(PASTE_VERIFY_DELAY_MS)
val verified =
setText &&
target.refresh() &&
pasteAppearsApplied(beforeText, nodeText(target), effectiveText)
Log.i(
TAG,
"paste=false setText=$setText verified=$verified package=${target.packageName}",
)
return verified
}
private fun nodeText(target: AccessibilityNodeInfo): String {
return target.text?.toString().orEmpty()
}
private fun pasteAppearsApplied(
beforeText: String,
afterText: String,
clipboardText: String,
): Boolean {
return OpenLessPasteVerification.pasteAppearsApplied(beforeText, afterText, clipboardText)
}
private fun appendClipboardTextWithSetText(
target: AccessibilityNodeInfo,
pasteText: String,
): Boolean {
if (target.isPassword) return false
val existingText = target.text?.toString().orEmpty()
val args =
Bundle().apply {
putCharSequence(
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
existingText + pasteText,
)
}
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 ""
try {
val focused =
root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
focused?.let {
return try {
selectedTextFromNode(it)
} finally {
it.recycle()
}
}
return selectedTextFromTree(root)
} finally {
root.recycle()
}
}
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)
}
companion object {
private fun sendVocabularyCommand(operation: String, generation: Long = 0L) {
val context = OpenLessAppContext.context ?: return
val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply {
action = OpenLessAccessibilityCommandReceiver.ACTION_VOCABULARY
putExtra("operation", operation)
putExtra("generation", generation)
}
try { context.sendBroadcast(intent) } catch (_: Throwable) { }
}
internal fun handleVocabularyCommand(intent: Intent) {
val service = instance ?: return
val generation = intent.getLongExtra("generation", 0L)
service.postVocabulary {
when (intent.getStringExtra("operation")) {
"arm" -> service.startVocabularyObservation(generation)
"disarm" -> if (service.vocabularyGeneration == generation) service.stopVocabularyObservation()
"refresh" -> service.refreshVocabularyCard()
}
}
}
@Keep @JvmStatic fun armVocabularyObservation(generation: Long) = sendVocabularyCommand("arm", generation)
@Keep @JvmStatic fun disarmVocabularyObservation(generation: Long) = sendVocabularyCommand("disarm", generation)
@Keep @JvmStatic fun showVocabularySuggestions(json: String) = sendVocabularyCommand("refresh")
/** Matches [isEnabled] / Settings.Secure component id format (full class name). */
@JvmStatic
fun serviceComponentId(): String =
"${BuildConfig.APPLICATION_ID}/${OpenLessAccessibilityService::class.java.name}"
@Volatile
var instance: OpenLessAccessibilityService? = null
private set
@JvmStatic
@Keep
fun pasteToFocusedField(): Boolean {
return pasteToFocusedFieldWithResult("") == AccessibilityPasteResult.SUCCESS
}
@JvmStatic
@Keep
fun pasteToFocusedFieldResult(text: String): String {
return pasteToFocusedFieldWithResult(text).reason
}
@JvmStatic
@Keep
fun captureSelectedText(): String {
instance?.let {
return it.captureSelectedTextFromFocusedNode()
}
return captureSelectedTextFromAccessibilityProcess()
}
@JvmStatic
@Keep
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 OpenLessAccessibilityComponentIds.enabledListContains(
services,
serviceComponentId(),
)
}
@JvmStatic
@Keep
fun pingAccessibilityProcess(context: Context): Boolean {
if (!isEnabled(context)) return false
if (instance != null) {
return true
}
val pingResult =
sendAccessibilityCommand(
OpenLessAccessibilityCommandReceiver.ACTION_PING,
PING_COMMAND_TIMEOUT_MS,
)
return pingResult == AccessibilityPasteResult.SUCCESS
}
internal fun performPasteFromCommand(pasteText: String? = null): AccessibilityPasteResult {
return instance?.performPasteToFocusedFieldInternal(pasteText)
?: AccessibilityPasteResult.SERVICE_NOT_CONNECTED
}
internal fun captureSelectedTextFromCommand(): String? {
return instance?.captureSelectedTextFromFocusedNode()
}
private fun pasteToFocusedFieldWithResult(pasteText: String): AccessibilityPasteResult {
instance?.let {
return it.performPasteToFocusedFieldInternal(pasteText)
}
return sendAccessibilityCommand(
OpenLessAccessibilityCommandReceiver.ACTION_PASTE,
PASTE_COMMAND_TIMEOUT_MS,
pasteText,
)
}
private fun sendAccessibilityCommand(
action: String,
timeoutMs: Long = PASTE_COMMAND_TIMEOUT_MS,
pasteText: String? = null,
): AccessibilityPasteResult {
val context =
OpenLessAppContext.context ?: return AccessibilityPasteResult.SERVICE_NOT_CONNECTED
val latch = CountDownLatch(1)
val resultHolder = AtomicReference(AccessibilityPasteResult.TIMEOUT)
val receiver =
object : ResultReceiver(null) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
resultHolder.set(AccessibilityPasteResult.fromCode(resultCode))
latch.countDown()
}
}
var broadcastSent = false
return try {
val intent =
Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply {
this.action = action
putExtra(
OpenLessAccessibilityCommandReceiver.EXTRA_RESULT_RECEIVER,
receiver,
)
if (!pasteText.isNullOrEmpty()) {
putExtra(
OpenLessAccessibilityCommandReceiver.EXTRA_PASTE_TEXT,
pasteText,
)
}
}
context.sendBroadcast(intent)
broadcastSent = true
try {
if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) {
Log.w(TAG, "accessibility command timed out action=$action")
AccessibilityPasteResult.TIMEOUT
} else {
resultHolder.get()
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
Log.w(
TAG,
"accessibility command interrupted after broadcast action=$action",
error,
)
AccessibilityPasteResult.IPC_PROTOCOL_ERROR
}
} catch (error: Throwable) {
Log.w(
TAG,
"send accessibility command failed action=$action broadcastSent=$broadcastSent",
error,
)
if (broadcastSent) {
AccessibilityPasteResult.IPC_PROTOCOL_ERROR
} else {
AccessibilityPasteResult.SERVICE_NOT_CONNECTED
}
}
}
private fun captureSelectedTextFromAccessibilityProcess(): String {
val context = OpenLessAppContext.context ?: return ""
val latch = CountDownLatch(1)
val selectedText = AtomicReference("")
val receiver =
object : ResultReceiver(null) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == AccessibilityPasteResult.SUCCESS.code) {
selectedText.set(
resultData
?.getString(
OpenLessAccessibilityCommandReceiver.EXTRA_SELECTED_TEXT
)
.orEmpty()
)
}
latch.countDown()
}
}
return try {
val intent =
Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply {
action = OpenLessAccessibilityCommandReceiver.ACTION_CAPTURE_SELECTED_TEXT
putExtra(
OpenLessAccessibilityCommandReceiver.EXTRA_RESULT_RECEIVER,
receiver,
)
}
context.sendBroadcast(intent)
if (latch.await(SELECTION_COMMAND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
selectedText.get()
} else {
Log.w(TAG, "accessibility selection command timed out")
""
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
Log.w(TAG, "accessibility selection command interrupted", error)
""
} catch (error: Throwable) {
Log.w(TAG, "send accessibility selection command failed", error)
""
}
}
private val KEYBOARD_REFRESH_DELAYS_MS = longArrayOf(120L, 360L, 900L, 1600L)
private const val PASTE_INITIAL_DELAY_MS = 50L
private const val PASTE_VERIFY_DELAY_MS = 80L
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 PING_COMMAND_TIMEOUT_MS = 500L
private const val SELECTION_COMMAND_TIMEOUT_MS = 500L
private const val MAX_EDITABLE_SEARCH_DEPTH = 8
private const val TAG = "OpenLessAccessibility"
}
}