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>
868 lines
34 KiB
Kotlin
868 lines
34 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 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"
|
||
}
|
||
}
|