Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,19 @@
android:enabled="true"
android:exported="false" />

<service
android:name=".TermuxAccessibilityService"
android:enabled="true"
android:exported="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService"/>
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config"/>
</service>

<service
android:name=".apis.JobSchedulerAPI$JobSchedulerService"
android:permission="android.permission.BIND_JOB_SERVICE"
Expand Down
26 changes: 26 additions & 0 deletions app/src/main/java/com/termux/api/TermuxAccessibilityService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.termux.api;

import com.termux.shared.logger.Logger;

import android.accessibilityservice.AccessibilityService;
import android.view.accessibility.AccessibilityEvent;

public class TermuxAccessibilityService extends AccessibilityService {

private static final String LOG_TAG = "AccessibilityService";

public static TermuxAccessibilityService instance;

@Override
protected void onServiceConnected() {
super.onServiceConnected();
Logger.logDebug(LOG_TAG, "onServiceConnected");
instance = this;
}

@Override
public void onAccessibilityEvent(AccessibilityEvent event) {}

@Override
public void onInterrupt() {}
}
4 changes: 4 additions & 0 deletions app/src/main/java/com/termux/api/TermuxApiReceiver.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import android.provider.Settings;
import android.widget.Toast;

import com.termux.api.apis.AccessibilityAPI;
import com.termux.api.apis.AudioAPI;
import com.termux.api.apis.BatteryStatusAPI;
import com.termux.api.apis.BrightnessAPI;
Expand Down Expand Up @@ -84,6 +85,9 @@ private void doWork(Context context, Intent intent) {
}

switch (apiMethod) {
case "Accessibility":
AccessibilityAPI.onReceive(this, context, intent);
break;
case "AudioInfo":
AudioAPI.onReceive(this, context, intent);
break;
Expand Down
282 changes: 282 additions & 0 deletions app/src/main/java/com/termux/api/apis/AccessibilityAPI.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
package com.termux.api.apis;

import android.content.Context;
import android.content.Intent;

import com.termux.api.TermuxApiReceiver;
import com.termux.api.util.ResultReturner;
import com.termux.shared.logger.Logger;

import java.io.PrintWriter;

import com.termux.api.TermuxAccessibilityService;
import android.view.accessibility.AccessibilityNodeInfo;
import android.accessibilityservice.GestureDescription;
import android.graphics.Path;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.TransformerException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import java.io.StringWriter;

import android.graphics.Rect;

import android.provider.Settings;

import android.view.accessibility.AccessibilityManager;
import android.accessibilityservice.AccessibilityServiceInfo;
import android.content.pm.ServiceInfo;
import java.util.List;
import android.accessibilityservice.AccessibilityService;
import android.os.Bundle;

import android.view.Display;
import android.accessibilityservice.AccessibilityService.TakeScreenshotCallback;
import android.accessibilityservice.AccessibilityService.ScreenshotResult;
import android.hardware.HardwareBuffer;
import android.graphics.ColorSpace;
import android.graphics.Bitmap;
import java.io.OutputStream;
import java.io.IOException;
import java.lang.reflect.Field;

public class AccessibilityAPI {

private static final String LOG_TAG = "AccessibilityAPI";

public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, Intent intent) {
Logger.logDebug(LOG_TAG, "onReceive");

boolean isAccessibilityEnabled = isAccessibilityServiceEnabled(context, TermuxAccessibilityService.class);
if (!isAccessibilityEnabled) {
Intent accessibilityIntent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
accessibilityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(accessibilityIntent);
}

if (intent.hasExtra("dump")) {
dump(apiReceiver, intent);
} else if (intent.hasExtra("click")) {
click(intent.getIntExtra("x", 0), intent.getIntExtra("y", 0), intent.getIntExtra("duration", 1));
returnEmptyString(apiReceiver, intent);
} else if (intent.hasExtra("type")) {
type(intent.getStringExtra("type"));
returnEmptyString(apiReceiver, intent);
} else if (intent.hasExtra("global-action")) {
performGlobalAction(intent.getStringExtra("global-action"));
returnEmptyString(apiReceiver, intent);
} else if (intent.hasExtra("screenshot")) {
screenshot(apiReceiver, context, intent);
}
}

// Necessary for void functions not to hang.
private static void returnEmptyString(TermuxApiReceiver apiReceiver, Intent intent) {
ResultReturner.returnData(apiReceiver, intent, out -> {});
}

// [The Stack Overflow answer 14923144](https://stackoverflow.com/a/14923144)
public static boolean isAccessibilityServiceEnabled(Context context, Class<? extends AccessibilityService> service) {
AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
List<AccessibilityServiceInfo> enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK);

for (AccessibilityServiceInfo enabledService : enabledServices) {
ServiceInfo enabledServiceInfo = enabledService.getResolveInfo().serviceInfo;
if (enabledServiceInfo.packageName.equals(context.getPackageName()) && enabledServiceInfo.name.equals(service.getName()))
return true;
}

return false;
}

private static void click(int x, int y, int millisecondsDuration) {
Path swipePath = new Path();
swipePath.moveTo(x, y);
GestureDescription.Builder gestureBuilder = new GestureDescription.Builder();
gestureBuilder.addStroke(new GestureDescription.StrokeDescription(swipePath, 0, millisecondsDuration));
TermuxAccessibilityService.instance.dispatchGesture(gestureBuilder.build(), null, null);
}

// The aim of this function is to give a compatible output with `adb` `uiautomator dump`.
private static void dump(TermuxApiReceiver apiReceiver, Intent intent) {
AccessibilityNodeInfo node = TermuxAccessibilityService.instance.getRootInActiveWindow();
// On Signal *App permissions* for instance
if (node == null) {
ResultReturner.returnData(apiReceiver, intent, out -> {});
return;
}

String swString = dumpAuxiliary(node);

ResultReturner.returnData(apiReceiver, intent, out -> {
out.write(swString);
});
}

private static String dumpAuxiliary(AccessibilityNodeInfo node) {
// Create a DocumentBuilder
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = null;
try {
builder = factory.newDocumentBuilder();
} catch (ParserConfigurationException parserConfigurationException) {
Logger.logDebug(LOG_TAG, "ParserConfigurationException");
}

// Create a new Document
Document document = builder.newDocument();

// Create root element
Element root = document.createElement("hierarchy");
document.appendChild(root);

dumpNodeAuxiliary(document, root, node);

// Write as XML
TransformerFactory transformerFactory = TransformerFactory.newInstance();
Transformer transformer = null;
try {
transformer = transformerFactory.newTransformer();
} catch (TransformerException transformerException) {
Logger.logDebug(LOG_TAG, "TransformerException transformerFactory.newTransformer");
}
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
// Necessary to not have surrogate pairs for emojis, see [Benjamin_Loison/Voice_assistant/issues/83#issue-3661619](https://codeberg.org/Benjamin_Loison/Voice_assistant/issues/83#issue-3661619)
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-16");
DOMSource source = new DOMSource(document);

StringWriter sw = new StringWriter();
StreamResult result = new StreamResult(sw);
try {
transformer.transform(source, result);
} catch (TransformerException transformerException) {
Logger.logDebug(LOG_TAG, "TransformerException transformer.transform");
}
return sw.toString();
}

private static void dumpNodeAuxiliary(Document document, Element element, AccessibilityNodeInfo node) {
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo nodeChild = node.getChild(i);
// May be faced randomly, see [Benjamin-Loison/android/issues/28#issuecomment-3975714760](https://github.com/Benjamin-Loison/android/issues/28#issuecomment-3975714760)
if (nodeChild == null)
{
continue;
}
Element elementChild = document.createElement("node");

elementChild.setAttribute("index", String.valueOf(i));

elementChild.setAttribute("text", getCharSequenceAsString(nodeChild.getText()));

String nodeChildViewIdResourceName = nodeChild.getViewIdResourceName();
elementChild.setAttribute("resource-id", nodeChildViewIdResourceName != null ? nodeChildViewIdResourceName : "");

elementChild.setAttribute("class", nodeChild.getClassName().toString());

elementChild.setAttribute("package", nodeChild.getPackageName().toString());

elementChild.setAttribute("content-desc", getCharSequenceAsString(nodeChild.getContentDescription()));

elementChild.setAttribute("checkable", String.valueOf(nodeChild.isCheckable()));

elementChild.setAttribute("checked", String.valueOf(nodeChild.isChecked()));

elementChild.setAttribute("clickable", String.valueOf(nodeChild.isClickable()));

elementChild.setAttribute("enabled", String.valueOf(nodeChild.isEnabled()));

elementChild.setAttribute("focusable", String.valueOf(nodeChild.isFocusable()));

elementChild.setAttribute("focused", String.valueOf(nodeChild.isFocused()));

elementChild.setAttribute("scrollable", String.valueOf(nodeChild.isScrollable()));

elementChild.setAttribute("long-clickable", String.valueOf(nodeChild.isLongClickable()));

elementChild.setAttribute("password", String.valueOf(nodeChild.isPassword()));

elementChild.setAttribute("selected", String.valueOf(nodeChild.isSelected()));

Rect nodeChildBounds = new Rect();
nodeChild.getBoundsInScreen(nodeChildBounds);
elementChild.setAttribute("bounds", nodeChildBounds.toShortString());

elementChild.setAttribute("drawing-order", String.valueOf(nodeChild.getDrawingOrder()));

elementChild.setAttribute("hint", getCharSequenceAsString(nodeChild.getHintText()));

element.appendChild(elementChild);
dumpNodeAuxiliary(document, elementChild, nodeChild);
}
}

private static String getCharSequenceAsString(CharSequence charSequence) {
return charSequence != null ? charSequence.toString() : "";
}

private static void type(String toType) {
AccessibilityNodeInfo focusedNode = TermuxAccessibilityService.instance.getRootInActiveWindow().findFocus(AccessibilityNodeInfo.FOCUS_INPUT);
Bundle arguments = new Bundle();
arguments.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, toType);
focusedNode.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, arguments);
}

private static void performGlobalAction(String globalActionString) {
String fieldName = "GLOBAL_ACTION_" + globalActionString.toUpperCase();
Field field = null;
try {
field = AccessibilityService.class.getDeclaredField(fieldName);
} catch (NoSuchFieldException noSuchFieldException) {
Logger.logDebug(LOG_TAG, "NoSuchFieldException");
}
Object globalActionObject = null;
try {
globalActionObject = field.get(null);
} catch(IllegalAccessException illegalAccessException) {
Logger.logDebug(LOG_TAG, "IllegalAccessException");
}
int globalActionInt = (int)globalActionObject;
TermuxAccessibilityService.instance.performGlobalAction(globalActionInt);
}

private static void screenshot(TermuxApiReceiver apiReceiver, final Context context, Intent intent) {
TermuxAccessibilityService.instance.takeScreenshot(
Display.DEFAULT_DISPLAY,
context.getMainExecutor(),
new TakeScreenshotCallback() {

@Override
public void onSuccess(ScreenshotResult screenshotResult) {
Logger.logDebug(LOG_TAG, "onSuccess");
HardwareBuffer buffer = screenshotResult.getHardwareBuffer();
ColorSpace colorSpace = screenshotResult.getColorSpace();

Bitmap bitmap = Bitmap.wrapHardwareBuffer(buffer, colorSpace);

ResultReturner.returnData(apiReceiver, intent, new ResultReturner.BinaryOutput()
{
@Override
public void writeResult(OutputStream out) throws IOException {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
}
});
}

@Override
public void onFailure(int errorCode) {
Logger.logDebug(LOG_TAG, "onFailure: " + errorCode);
}
}
);
}
}
7 changes: 7 additions & 0 deletions app/src/main/res/xml/accessibility_service_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:canRetrieveWindowContent="true"
android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews"
android:canPerformGestures="true"
android:canTakeScreenshot="true"/>