1
0
forked from BRT/arc

Merge branch 'dev' into main: align main with dev content (dev takes precedence in all conflicts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

# Conflicts:
#	database/app_data.db
#	views/DeviceWidget.cpp
#	views/DeviceWidget.h
This commit is contained in:
trnsmkot 2026-05-25 15:55:28 +05:00
commit d031eed7d2
546 changed files with 55670 additions and 741 deletions

16
.gitignore vendored
View File

@ -6,6 +6,10 @@
.DS_Store
.idea/
# Mock server state
tests/mock_server/.mock_state.json
tests/mock_server/.mock_screenshots/
cmake_install.cmake
CMakeCache.txt
Makefile
@ -21,3 +25,15 @@ Makefile
/.ninja_log
/ARCDesktopProject_autogen/
/ARCDesktopProject
/Contents/
/application.log
/database/app_data.db
/build_x64/
/tests/mock_server/scenarios/pool/
/.cache/
/.claude/
/dumps/
/ARC_autogen/
/build_test/
/build_x64/
/tests/mock_server/scenarios/

81
BUILD.md Normal file
View File

@ -0,0 +1,81 @@
# ARCDesktopProject — сборка и запуск
## Windows (x64)
### Требования
- Visual Studio 2022
- Qt 6.9.0 (msvc2022_64)
- vcpkg с пакетом `tesseract:x64-windows`
### Сборка
```bash
mkdir build_x64
cd build_x64
cmake .. -G "Visual Studio 17 2022" -A x64 -DCMAKE_PREFIX_PATH="C:/Qt/6.9.0/msvc2022_64" -DCMAKE_BUILD_TYPE=Release
cmake --build . --config Release
```
### Деплой Qt DLL
```bash
"C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARCDesktopProject.exe
```
### Копирование vcpkg DLL
Скопировать все DLL из vcpkg в папку с exe:
```bash
copy C:\Users\User\.vcpkg-clion\vcpkg\installed\x64-windows\bin\*.dll .\Release\
```
### База данных
БД не включена в сборку. Перед первым запуском скопировать файл БД:
```bash
copy database\app_data.db .\Release\database\app_data.db
```
Папка `database/` создаётся автоматически при сборке. Путь к БД настраивается в `config.ini` (ключ `db/dbPath`), по умолчанию `./database/app_data.db`.
### Запуск
```bash
cd Release
ARCDesktopProject.exe
```
---
## macOS
> TODO: инструкция для macOS будет добавлена позже.
### Требования
- Qt 6 (`brew install qt`)
- Tesseract + Leptonica (`brew install tesseract leptonica`)
### Сборка
```bash
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build .
```
### База данных
Перед первым запуском скопировать файл БД:
```bash
mkdir -p database
cp ../database/app_data.db ./database/app_data.db
```
### Запуск
```bash
./ARCDesktopProject
```

View File

@ -1,82 +1,302 @@
cmake_minimum_required(VERSION 3.16)
project(ARCDesktopProject LANGUAGES CXX)
project(ARC LANGUAGES CXX)
#mkdir build
#cd build
#cmake .. -A x64 -DCMAKE_PREFIX_PATH="C:/Qt/6.9.0/msvc2022_64" -DCMAKE_BUILD_TYPE=Release
#cmake .. -G "Visual Studio 17 2022" -A x64 -DCMAKE_PREFIX_PATH="C:/Qt/6.9.0/msvc2022_64" -DCMAKE_BUILD_TYPE=Release
#cmake --build . --config Release
#"C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARC.exe
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
add_compile_definitions(QT_MESSAGELOGCONTEXT)
if (WIN32)
# Укажи путь к Qt6
set(CMAKE_PREFIX_PATH "C:/Qt/6.9.0/msvc2022_arm64")
list(APPEND CMAKE_PREFIX_PATH "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows")
#vcpkg install tesseract:arm64-windows
set(Tesseract_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows/share/tesseract")
# Определяем целевую архитектуру.
# CMAKE_GENERATOR_PLATFORM (-A flag) самый надёжный способ определения при кросс-компиляции.
# На ARM64 хосте CMAKE_SYSTEM_PROCESSOR может быть "ARM64" даже при сборке под x64,
# поэтому сначала проверяем явные x64-признаки.
if (CMAKE_GENERATOR_PLATFORM STREQUAL "x64"
OR CMAKE_GENERATOR_PLATFORM STREQUAL "Win64"
OR VCPKG_TARGET_TRIPLET MATCHES "x64")
message(STATUS "Detected x64 target architecture")
set(CMAKE_PREFIX_PATH "C:/Qt/6.9.0/msvc2022_64")
list(APPEND CMAKE_PREFIX_PATH "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows")
set(Tesseract_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows/share/tesseract")
set(VCPKG_BIN_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows/bin")
elseif (VCPKG_TARGET_TRIPLET MATCHES "arm64"
OR CMAKE_SYSTEM_PROCESSOR STREQUAL "ARM64"
OR CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64"
OR CMAKE_CXX_COMPILER MATCHES "arm64")
message(STATUS "Detected ARM64 target architecture")
set(CMAKE_PREFIX_PATH "C:/Qt/6.9.0/msvc2022_arm64")
list(APPEND CMAKE_PREFIX_PATH "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows")
set(Tesseract_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows/share/tesseract")
set(VCPKG_BIN_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows/bin")
else ()
message(STATUS "Detected x64 target architecture (default)")
set(CMAKE_PREFIX_PATH "C:/Qt/6.9.0/msvc2022_64")
list(APPEND CMAKE_PREFIX_PATH "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows")
set(Tesseract_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows/share/tesseract")
set(VCPKG_BIN_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows/bin")
endif ()
find_package(Tesseract CONFIG REQUIRED)
elseif (APPLE)
# Укажи путь к Qt6
set(CMAKE_PREFIX_PATH "/opt/homebrew/opt/qt")
include_directories("/opt/homebrew/opt/tesseract/include")
link_directories("/opt/homebrew/opt/tesseract/lib")
include_directories("/opt/homebrew/include")
link_directories("/opt/homebrew/lib")
endif ()
find_package(Qt6 REQUIRED COMPONENTS
Network
Widgets
Sql
Xml
)
file(GLOB_RECURSE DB_SOURCES
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/database/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/database/*.h"
)
file(GLOB_RECURSE VIEW_SOURCES
file(GLOB_RECURSE VIEW_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/views/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/views/*.h"
)
file(GLOB_RECURSE SERVICES_SOURCES
file(GLOB_RECURSE SERVICES_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/services/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/services/*.h"
)
file(GLOB_RECURSE MODELS_SOURCES
file(GLOB_RECURSE MODELS_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/models/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/models/*.h"
)
add_executable(ARCDesktopProject
file(GLOB_RECURSE ANDROID_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/android/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/android/*.h"
)
if (WIN32)
add_executable(ARC WIN32
main.cpp
app.rc
${VIEW_SOURCES}
${SERVICES_SOURCES}
${DB_SOURCES}
${MODELS_SOURCES}
${ANDROID_SOURCES}
views/bank/BankSettingsWindow.cpp
views/bank/BankSettingsWindow.h
views/bank/BankSetupWizard.cpp
views/bank/BankSetupWizard.h
views/log/FileLogWindow.cpp
views/log/FileLogWindow.h
views/log/LogDetailWindow.cpp
views/log/LogDetailWindow.h
database/dao/GeneralLogDAO.cpp
database/dao/GeneralLogDAO.h
android/dushanbe/CommonScript.cpp
android/dushanbe/CommonScript.h
android/dushanbe/GetCardInfoScript.cpp
android/dushanbe/GetCardInfoScript.h
android/dushanbe/GetLastTransactionsScript.cpp
android/dushanbe/GetLastTransactionsScript.h
android/dushanbe/GetProfileInfoScript.cpp
android/dushanbe/GetProfileInfoScript.h
android/dushanbe/LoginAndCheckAccountsScript.cpp
android/dushanbe/LoginAndCheckAccountsScript.h
android/dushanbe/ScreenXmlParser.cpp
android/dushanbe/ScreenXmlParser.h
)
elseif (APPLE)
add_executable(ARC
main.cpp
${VIEW_SOURCES}
${SERVICES_SOURCES}
${DB_SOURCES}
${MODELS_SOURCES}
${ANDROID_SOURCES}
views/bank/BankSettingsWindow.cpp
views/bank/BankSettingsWindow.h
views/bank/BankSetupWizard.cpp
views/bank/BankSetupWizard.h
views/log/FileLogWindow.cpp
views/log/FileLogWindow.h
views/log/LogDetailWindow.cpp
views/log/LogDetailWindow.h
database/dao/GeneralLogDAO.cpp
database/dao/GeneralLogDAO.h
android/dushanbe/CommonScript.cpp
android/dushanbe/CommonScript.h
android/dushanbe/GetCardInfoScript.cpp
android/dushanbe/GetCardInfoScript.h
android/dushanbe/GetLastTransactionsScript.cpp
android/dushanbe/GetLastTransactionsScript.h
android/dushanbe/GetProfileInfoScript.cpp
android/dushanbe/GetProfileInfoScript.h
android/dushanbe/LoginAndCheckAccountsScript.cpp
android/dushanbe/LoginAndCheckAccountsScript.h
android/dushanbe/ScreenXmlParser.cpp
android/dushanbe/ScreenXmlParser.h
)
endif ()
if (WIN32)
target_link_libraries(ARCDesktopProject
target_link_libraries(ARC
Qt6::Widgets
Qt6::Xml
Qt6::Sql
Qt6::Network
Tesseract::libtesseract
)
# Копирование изображений в директорию сборки
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/database" DESTINATION "${CMAKE_BINARY_DIR}")
# file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/database/app_data.db" DESTINATION "${CMAKE_BINARY_DIR}/database")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/tutorials" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/images" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata" DESTINATION "${CMAKE_BINARY_DIR}")
# tessdata/ канонический путь, ожидаемый Tesseract 5+ через TESSDATA_PREFIX/datapath
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata" DESTINATION "${CMAKE_BINARY_DIR}/tessdata")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata" DESTINATION "${CMAKE_BINARY_DIR}/tessdata")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
# adb копируется в post-build чтобы не падать при configure если adb.exe занят
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
foreach(_adb_file ${_adb_files})
get_filename_component(_adb_fname "${_adb_file}" NAME)
add_custom_command(TARGET ARC POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ARC>/tools/adb/windows"
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_adb_file}" "$<TARGET_FILE_DIR:ARC>/tools/adb/windows/${_adb_fname}"
)
endforeach()
add_custom_command(TARGET ARC POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_FILE_DIR:ARC>/database"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/tutorials"
"$<TARGET_FILE_DIR:ARC>/tutorials"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/images"
"$<TARGET_FILE_DIR:ARC>/images"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata"
"$<TARGET_FILE_DIR:ARC>/rus.traineddata"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata"
"$<TARGET_FILE_DIR:ARC>/eng.traineddata"
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_FILE_DIR:ARC>/tessdata"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata"
"$<TARGET_FILE_DIR:ARC>/tessdata/rus.traineddata"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata"
"$<TARGET_FILE_DIR:ARC>/tessdata/eng.traineddata"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/config.ini"
"$<TARGET_FILE_DIR:ARC>/config.ini"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png"
"$<TARGET_FILE_DIR:ARC>/no_devices.png"
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_FILE_DIR:ARC>/assets"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk"
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
)
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
file(GLOB _openssl_dlls "${VCPKG_BIN_DIR}/libssl*.dll"
"${VCPKG_BIN_DIR}/libcrypto*.dll")
foreach(_dll ${_openssl_dlls})
add_custom_command(TARGET ARC POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_dll}" "$<TARGET_FILE_DIR:ARC>"
)
endforeach()
# Copy mock-server test scripts into the build so they ship with the archive
add_custom_command(TARGET ARC POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/tests/mock_server"
"$<TARGET_FILE_DIR:ARC>/tests/mock_server"
)
# Copy Tesseract, Leptonica, and their transitive dependency DLLs
file(GLOB _tesseract_dlls "${VCPKG_BIN_DIR}/tesseract*.dll"
"${VCPKG_BIN_DIR}/leptonica*.dll"
"${VCPKG_BIN_DIR}/libpng*.dll"
"${VCPKG_BIN_DIR}/jpeg*.dll"
"${VCPKG_BIN_DIR}/tiff*.dll"
"${VCPKG_BIN_DIR}/zlib*.dll"
"${VCPKG_BIN_DIR}/gif*.dll"
"${VCPKG_BIN_DIR}/libwebp*.dll"
"${VCPKG_BIN_DIR}/libsharpyuv*.dll"
"${VCPKG_BIN_DIR}/openjp2*.dll"
"${VCPKG_BIN_DIR}/archive*.dll"
"${VCPKG_BIN_DIR}/libcurl*.dll"
"${VCPKG_BIN_DIR}/lz4*.dll"
"${VCPKG_BIN_DIR}/zstd*.dll"
"${VCPKG_BIN_DIR}/liblzma*.dll"
"${VCPKG_BIN_DIR}/bz2*.dll"
"${VCPKG_BIN_DIR}/iconv*.dll"
"${VCPKG_BIN_DIR}/charset*.dll"
"${VCPKG_BIN_DIR}/libxml2*.dll")
foreach(_dll ${_tesseract_dlls})
add_custom_command(TARGET ARC POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_dll}" "$<TARGET_FILE_DIR:ARC>"
)
endforeach()
elseif (APPLE)
target_link_libraries(ARCDesktopProject
target_link_libraries(ARC
Qt6::Widgets
Qt6::Sql
Qt6::Xml
Qt6::Network
tesseract
leptonica
)
# Копирование изображений в директорию сборки
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/tutorials" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/images" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata" DESTINATION "${CMAKE_BINARY_DIR}")
# tessdata/ канонический путь, ожидаемый Tesseract 5+ через TESSDATA_PREFIX/datapath
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata" DESTINATION "${CMAKE_BINARY_DIR}/tessdata")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata" DESTINATION "${CMAKE_BINARY_DIR}/tessdata")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
endif ()
target_include_directories(ARCDesktopProject PRIVATE
target_include_directories(ARC PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/views
${CMAKE_CURRENT_SOURCE_DIR}/database
${CMAKE_CURRENT_SOURCE_DIR}/services
${CMAKE_CURRENT_SOURCE_DIR}/models
${CMAKE_CURRENT_SOURCE_DIR}/android
)

136
README.md Normal file
View File

@ -0,0 +1,136 @@
# ARC Desktop
Десктопное Qt6/C++ приложение для мониторинга и автоматизации Android-приложений мобильных банков через ADB. Принимает задачи с бэкенда (получение профиля/балансов, выписка по транзакциям, создание платежей), исполняет их в реальном приложении банка на подключённом устройстве и возвращает результат.
> **Репозиторий:** `git@git.brtservice.io:BRT/arc.git`
---
## Быстрый старт
```bash
git clone git@git.brtservice.io:BRT/arc.git
cd arc
# macOS
brew install qt tesseract leptonica
mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build .
./ARCDesktopProject
```
Подробнее — см. [BUILD.md](BUILD.md) (macOS + Windows).
При первом запуске потребуется токен авторизации; настройки лежат в [`config.ini`](config.ini).
---
## Документация
Все актуальные материалы в каталоге [`docs/`](docs/). Точка входа — [docs/index.md](docs/index.md).
### Архитектура и фичи
| Документ | О чём |
|---|---|
| [docs/index.md](docs/index.md) | Общая схема приложения, потоки, технологии |
| [docs/events.md](docs/events.md) | Жизненный цикл задачи: API → БД → выполнение скриптом → отчёт |
| [docs/bank-setup-wizard.md](docs/bank-setup-wizard.md) | `BankSetupWizard` — мастер заведения банковского профиля (Ozon, Black, Dushanbe) |
| [docs/status-sync.md](docs/status-sync.md) | Синхронизация online/offline статусов устройств и сервиса |
| [docs/black_tasks.md](docs/black_tasks.md) | Скрипты для банка Black (Birbank) |
| [docs/ozon_tasks.md](docs/ozon_tasks.md) | Скрипты для Ozon |
### Backend API
| Документ | О чём |
|---|---|
| [docs/api/auth.md](docs/api/auth.md) | Логин, refresh, хранение токенов |
| [docs/api/profile.md](docs/api/profile.md) | `bank_profile` и `material` (карты) |
| [docs/api/device.md](docs/api/device.md) | Регистрация десктопов и Android-устройств, отправка скриншотов |
| [docs/api/task.md](docs/api/task.md) | Получение задач, отправка результатов |
| [docs/api/network.md](docs/api/network.md) | Полная карта HTTP-вызовов приложения |
| [docs/api/openapi.json](docs/api/openapi.json) | OpenAPI спецификация бэкенда |
### Сборка и тесты
| Документ | О чём |
|---|---|
| [BUILD.md](BUILD.md) | Сборка под macOS и Windows |
| [tests/mock_server/README.md](tests/mock_server/README.md) | Локальный mock-бэкенд для интеграционных тестов |
| [tests/mock_server/WINDOWS.md](tests/mock_server/WINDOWS.md) | Особенности запуска моков на Windows |
| [tutorials/ui_info.md](tutorials/ui_info.md) | Туториал по UI приложения |
---
## Архитектура
```
MainWindow (Qt6 GUI)
├── DeviceScreener (QThread) — опрос ADB-устройств, скриншоты
├── EventHandler (QThread) — оркестратор задач, запуск bank-скриптов
└── NetworkService (QThread) — REST API + monitoring relay
```
```
android/<bank>/ — автоматизация конкретного банка (ozon, black, dushanbe, ...)
├── *Script.{h,cpp} — CommonScript, LoginAndCheckAccountsScript, PayByCardScript, GetLastTransactionsScript, ...
└── ScreenXmlParser — парсинг ADB UI dump
android/adb/ — обёртка над adb (скриншот, tap, swipe, input, uiautomator dump)
android/ocr/ — Tesseract + утилиты поиска текста на скриншоте
android/dump/ — захват UI-дампов
database/ — SQLite через DatabaseManager (singleton) + DAO-слой
Таблицы: devices → applications → accounts → transactions/events
models/ — POCO: DeviceInfo, AccountInfo, EventInfo, TransactionInfo, ...
services/ — фоновые потоки (DeviceScreener, EventHandler, NetworkService, AppLogger, LogArchiveSender)
views/ — Qt-виджеты: MainWindow, DeviceWidget, BankSetupWizard, AccountWindow, ...
```
Полная диаграмма потоков и сигналов — [docs/index.md](docs/index.md), детально по задачам — [docs/events.md](docs/events.md).
---
## Технологии
- **Qt 6** (Widgets, Network, Sql, Xml)
- **C++17**, CMake (опционально Ninja)
- **SQLite** (через QtSql)
- **ADB** (платформенные бинари в `tools/adb/`)
- **Tesseract OCR** + Leptonica (`eng.traineddata`, `rus.traineddata`)
Поддерживаемые банки (в текущем `config.ini`): **Ozon**, **Black** (Birbank), **Dushanbe City Bank**. Список расширяется через `[apps] list=` в [`config.ini`](config.ini).
---
## Конфигурация
Главный файл — [`config.ini`](config.ini):
| Секция | Назначение |
|---|---|
| `[common]` | Версия, валюты, токен |
| `[apps] list=` | Активные банки |
| `[db] dbPath=` | Путь к SQLite (`./database/app_data.db`) |
| `[net]` | `apiBase`, пути ручек, мониторинг-релей |
| `[<bank>]` | `android_package_name`, `currency`, `country`, `app_version`, ... |
Схема БД — [`migrations.sql`](migrations.sql) (5 таблиц).
---
## Полезные пути
- Логи приложения: `application.log` (в корне), архив отправляется через `LogArchiveSender` на `monitoringBase`
- ADB-бинари для Windows: `tools/adb/windows/`
- Тестовые скриншоты/XML: `assets/<bank>/`
- Иконки и ресурсы: `images/`, `arc.icns`, `arc.ico`, `app.rc`
---
## Скрипты бэкенда и операционные ссылки
- Backend API (prod): см. `apiBase` в [`config.ini`](config.ini)
- Monitoring relay: `142.93.102.40:8080` — релеит `/monitoring/log` и `/monitoring/dump` в Telegram (systemd `arc-monitoring.service`)

786
android/adb/AdbUtils.cpp Normal file
View File

@ -0,0 +1,786 @@
#include "AdbUtils.h"
#include <QProcess>
#include <qDebug>
#include <QString>
#include <QCoreApplication>
#include <QFileInfo>
#include <QRegularExpression>
#include <QThread>
#include "dump/TaskDumpRecorder.h"
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
}
QString AdbUtils::adbPath() {
#ifdef Q_OS_WIN
QString path = QCoreApplication::applicationDirPath() + "/tools/adb/windows/adb.exe";
#else
QString path = QCoreApplication::applicationDirPath() + "/tools/adb/macos/adb";
#endif
static bool checked = false;
if (!checked) {
checked = true;
QFileInfo fi(path);
if (!fi.exists()) {
qWarning() << "[AdbUtils] adb not found at:" << path;
} else if (!fi.isExecutable()) {
qWarning() << "[AdbUtils] adb is not executable:" << path;
} else {
qDebug() << "[AdbUtils] adb path:" << path;
}
}
return path;
}
QString AdbUtils::adbDir() {
return QFileInfo(adbPath()).absolutePath();
}
bool AdbUtils::startProcess(const QString &program, const QStringList &args,
QString *stdOut, QString *stdErr) {
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(program, args);
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start process:" << program << args
<< "error:" << process.errorString();
if (stdErr) {
*stdErr = process.errorString();
}
return false;
}
if (!process.waitForFinished(30000) ||
process.exitStatus() != QProcess::NormalExit ||
process.exitCode() != 0) {
if (stdErr) {
*stdErr = QString::fromUtf8(process.readAllStandardError());
}
qWarning() << "[AdbUtils] Process failed:" << program << args
<< "exitCode:" << process.exitCode()
<< "stderr:" << (stdErr ? *stdErr : QString::fromUtf8(process.readAllStandardError()));
return false;
}
if (stdOut) {
*stdOut = QString::fromUtf8(process.readAllStandardOutput());
}
return true;
}
// adb shell dumpsys package ru.rshb.dbo | grep permission
// TODO доделать обработку пермишинов
// adb shell pm revoke ru.rshb.dbo android.permission.WRITE_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.READ_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_COARSE_LOCATION; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_FINE_LOCATION
// adb shell cmd appops set ru.rshb.dbo POST_NOTIFICATION ignore
/**
* Возвращает список найденных устройств в виде пар (ID устройства и Статус)
* @return [(id, status), ...]
*/
QList<QPair<QString, QString> > AdbUtils::getListDevices() {
const QString adb = adbPath();
const QString dir = adbDir();
// Убиваем чужой adb-сервер (возможно другой версии) и запускаем свой,
// чтобы избежать конфликта версий (например, scrcpy adb 35 vs bundled adb 36)
static bool serverRestarted = false;
if (!serverRestarted) {
serverRestarted = true;
QProcess kill;
kill.setWorkingDirectory(dir);
kill.start(adb, {"kill-server"});
kill.waitForFinished(5000);
qDebug() << "[AdbUtils] Killed existing adb server";
QProcess start;
start.setWorkingDirectory(dir);
start.start(adb, {"start-server"});
start.waitForFinished(10000);
qDebug() << "[AdbUtils] Started own adb server";
}
QProcess process;
process.setWorkingDirectory(dir);
process.start(adb, {"devices"});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb devices:" << process.errorString();
return {};
}
process.waitForFinished(10000);
const QString output = process.readAllStandardOutput();
QList<QPair<QString, QString> > devices;
const QStringList lines = output.split('\n');
for (int i = 1; i < lines.size(); ++i) {
QString line = lines.at(i).trimmed();
if (line.isEmpty()) {
continue;
}
/**
* device устройство готово к использованию.
* offline устройство подключено, но не может быть использовано.
* unauthorized устройство не авторизовано для работы с ADB (нужно подтвердить запрос на подключение на самом устройстве).
* recovery устройство в режиме восстановления.
*/
if (QStringList parts = line.split('\t'); parts.size() == 2) {
const QString &id = parts.at(0);
const QString &status = parts.at(1);
devices.append(qMakePair(id, status));
}
}
return devices;
}
QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &packageName) {
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {
"-s", deviceId,
"shell", "pidof", packageName
});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb pidof:" << process.errorString();
return {};
}
process.waitForFinished();
QString pid = process.readAllStandardOutput();
return pid.replace("\n", "");
}
// Логирует длинный вывод построчно с префиксом, иначе Qt-логгер режет строку.
static void logMultiline(const char *tag, const QString &text) {
if (text.isEmpty()) {
qDebug().noquote() << tag << "(empty)";
return;
}
const QStringList lines = text.split(QRegularExpression("\r?\n"));
for (const QString &line : lines) {
if (!line.isEmpty()) qDebug().noquote() << tag << line;
}
}
// Прогоняет adb-команду и возвращает stdout (stderr игнорируется — этим функциям важен только stdout).
static QString adbShellStdout(const QStringList &args, int timeoutMs = 10000) {
QProcess p;
p.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
p.start(AdbUtils::adbPath(), args);
if (!p.waitForStarted(5000)) return {};
p.waitForFinished(timeoutMs);
return QString::fromUtf8(p.readAllStandardOutput());
}
// Возвращает {launcherActivity, summary}. launcherActivity вида "pkg/.MainActivity" или пустая строка.
static QPair<QString, QString> probePackageState(const QString &deviceId, const QString &packageName) {
const QString resolveOut = adbShellStdout(
{"-s", deviceId, "shell", "cmd", "package", "resolve-activity", "--brief", packageName});
QString launcher;
// Формат: первая строка — приоритет, вторая — "pkg/activity".
for (const QString &raw : resolveOut.split(QRegularExpression("\r?\n"))) {
const QString line = raw.trimmed();
if (line.contains('/') && line.startsWith(packageName + "/")) {
launcher = line;
break;
}
}
const QString dump = adbShellStdout(
{"-s", deviceId, "shell", "dumpsys", "package", packageName}, 15000);
auto pickFlag = [&dump](const QString &key) -> QString {
const QRegularExpression re(key + "=(\\S+)");
const auto m = re.match(dump);
return m.hasMatch() ? m.captured(1) : QStringLiteral("?");
};
const bool installed = dump.contains("Package [" + packageName + "]");
const QString enabled = pickFlag("enabled");
const QString stopped = pickFlag("stopped");
const QString hidden = pickFlag("hidden");
const bool hasLauncherCategory = dump.contains("android.intent.category.LAUNCHER");
const QString summary = QString(
"installed=%1 enabled=%2 stopped=%3 hidden=%4 hasLauncherCategory=%5 resolveActivity=%6")
.arg(installed ? "true" : "false", enabled, stopped, hidden,
hasLauncherCategory ? "true" : "false",
launcher.isEmpty() ? "(none)" : launcher);
return {launcher, summary};
}
bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &packageName) {
// Pre-flight: состояние пакета. Без этого по логу нельзя отличить
// "нет иконки" от "disabled" от "hidden" от "не установлен".
const auto [launcherActivity, stateSummary] = probePackageState(deviceId, packageName);
qDebug().noquote() << "[AdbUtils::tryToStartApplication] device=" << deviceId
<< "pkg=" << packageName << "state:" << stateSummary;
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {
"-s", deviceId,
"shell", "monkey",
"-p", packageName,
"-c", "android.intent.category.LAUNCHER",
"1"
});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb monkey:" << process.errorString();
return false;
}
process.waitForFinished();
const QString stdOut = QString::fromUtf8(process.readAllStandardOutput());
const QString stdErr = QString::fromUtf8(process.readAllStandardError());
const int exitCode = process.exitCode();
const QProcess::ExitStatus exitStatus = process.exitStatus();
// Полный вывод monkey пишем построчно — Qt-логгер режет длинные QString,
// из-за чего хвост (`Events injected` / `No activities found`) терялся.
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey exitStatus="
<< exitStatus << "exitCode=" << exitCode;
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
if (stdOut.contains("Events injected: 1")) {
return true;
}
// Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code,
// в отличие от monkey, который при пустом resolve просто молча печатает "No activities found".
if (!launcherActivity.isEmpty()) {
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
<< "пробую am start -n" << launcherActivity;
QProcess am;
am.setWorkingDirectory(adbDir());
am.start(adbPath(),
{"-s", deviceId, "shell", "am", "start", "-W", "-n", launcherActivity});
am.waitForStarted(5000);
am.waitForFinished(15000);
const QString amOut = QString::fromUtf8(am.readAllStandardOutput());
const QString amErr = QString::fromUtf8(am.readAllStandardError());
qDebug().noquote() << "[AdbUtils::tryToStartApplication] am exitCode=" << am.exitCode();
logMultiline("[AdbUtils::tryToStartApplication] am stdout:", amOut);
logMultiline("[AdbUtils::tryToStartApplication] am stderr:", amErr);
// `am start -W` печатает "Status: ok" при успехе.
if (am.exitCode() == 0 && amOut.contains("Status: ok")) {
return true;
}
} else {
qWarning() << "[AdbUtils::tryToStartApplication] нет launcher-активити для" << packageName
<< "— приложение либо не установлено, либо disabled/hidden, либо без LAUNCHER intent-filter";
}
return false;
}
bool AdbUtils::tryToKillApplication(const QString &deviceId, const QString &packageName) {
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {
"-s", deviceId,
"shell", "am",
"force-stop", packageName
});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb force-stop:" << process.errorString();
return false;
}
process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
}
// idx - временный лог
// adb -s 6edc4a47 shell 'uiautomator dump /sdcard/uidump.xml' && adb -s 6edc4a47 pull /sdcard/uidump.xml step_1.xml && adb -s 6edc4a47 shell 'rm /sdcard/uidump.xml'
QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot) {
const QString adb = adbPath();
for (int retry = 0; retry < 3; ++retry) {
// 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android)
bool dumpOk = false;
for (int attempt = 0; attempt < 3; ++attempt) {
if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) {
dumpOk = true;
break;
}
qWarning() << "[AdbUtils] uiautomator dump failed, attempt" << (attempt + 1) << "of 3";
QThread::msleep(1000);
}
if (!dumpOk) {
qWarning() << "Failed to dump UI hierarchy after 3 attempts.";
return {};
}
// 2. Optionally pull and save the XML file
if (screenshot) {
if (!startProcess(adb, {"-s", deviceId, "pull", "/sdcard/uidump.xml",
QString("step_%1.xml").arg(idx)})) {
qWarning() << "Failed to pull UIDump XML.";
return {};
}
}
// 3. Read the XML content
QString xmlOut;
if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) {
qWarning() << "Failed to read UIDump XML.";
return {};
}
// 4. Remove temp XML file
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"});
// 5. Минимальная санитарная проверка: дамп не пустой, достаточного размера
// и содержит хотя бы одну ноду с непустым text или content-desc.
// Этого хватает, чтобы отсеять WebView-shell-only состояния Ozon
// (там в шелле labeled нод вообще нет), и при этом не отбрасывать
// экраны RN/Compose (Dushanbe, Black), где интерактив строится на
// LinearLayout/View с content-desc, без Button/text/EditText.
static const QRegularExpression reNonEmpty(R"((text|content-desc)="[^"]+")");
const bool hasContent = reNonEmpty.match(xmlOut).hasMatch();
if (xmlOut.trimmed().isEmpty() || !xmlOut.contains("<?xml")
|| xmlOut.size() < 500 || !hasContent) {
qWarning() << "[AdbUtils] getScreenDumpAsXml: bad dump (size=" << xmlOut.size()
<< "hasContent=" << hasContent
<< "), retry" << (retry + 1) << "of 3";
QThread::msleep(1500);
continue;
}
// 6. Screenshot (if needed)
if (screenshot) {
startProcess(adb, {"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"});
startProcess(adb, {"-s", deviceId, "pull", "/sdcard/screen.png",
QString("screen_step_%1.png").arg(idx)});
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/screen.png"});
}
// 7. Обрезка к началу XML
QByteArray xmlBytes = xmlOut.toUtf8();
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
xmlBytes = xmlBytes.mid(start);
}
const QString cleanXml = QString::fromUtf8(xmlBytes);
if (auto *rec = TaskDumpRecorder::current()) {
rec->saveXml(idx, cleanXml);
}
return cleanXml;
}
qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries";
return {};
}
QString AdbUtils::getRawScreenDumpAsXml(const QString &deviceId) {
const QString adb = adbPath();
// 1. Дампим иерархию (uiautomator может умирать — до 3 попыток)
bool dumpOk = false;
for (int attempt = 0; attempt < 3; ++attempt) {
if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) {
dumpOk = true;
break;
}
QThread::msleep(500);
}
if (!dumpOk) {
qWarning() << "[AdbUtils] getRawScreenDumpAsXml: uiautomator dump failed";
return {};
}
// 2. Читаем
QString xmlOut;
if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) {
qWarning() << "[AdbUtils] getRawScreenDumpAsXml: cat failed";
return {};
}
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"});
// 3. Обрезка к началу XML; в отличие от getScreenDumpAsXml — без фильтра по контенту,
// оператору важно видеть РЕАЛЬНОЕ состояние экрана (PIN, сплэш, WebView и т.д.).
QByteArray xmlBytes = xmlOut.toUtf8();
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
xmlBytes = xmlBytes.mid(start);
}
return QString::fromUtf8(xmlBytes);
}
bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
const QString adb = adbPath();
struct Step { QStringList args; };
Step steps[] = {
{{"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"}},
{{"-s", deviceId, "pull", "/sdcard/screen.png", name + ".png"}},
{{"-s", deviceId, "shell", "rm", "/sdcard/screen.png"}}
};
for (const auto &step : steps) {
QString stdErr;
if (!startProcess(adb, step.args, nullptr, &stdErr)) {
qWarning() << "ADB command failed:" << step.args.join(' ');
qWarning() << "Error:" << stdErr;
return false;
}
}
return true;
}
QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] captureScreenshotBytes failed to start for" << deviceId
<< "error:" << process.errorString();
return {};
}
if (!process.waitForFinished(10000) || process.exitCode() != 0) {
qWarning() << "[AdbUtils] captureScreenshotBytes failed for" << deviceId;
return {};
}
return process.readAllStandardOutput();
}
bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) {
const QString adb = adbPath();
struct Step { QStringList args; };
Step steps[] = {
{{"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"}},
{{"-s", deviceId, "pull", "/sdcard/uidump.xml", name + ".xml"}},
{{"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}},
{{"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"}}
};
for (const auto &step : steps) {
QString stdErr;
if (!startProcess(adb, step.args, nullptr, &stdErr)) {
qWarning() << "ADB command failed:" << step.args.join(' ');
qWarning() << "Error:" << stdErr;
return false;
}
}
return true;
}
bool AdbUtils::makeTap(const QString &deviceId, const int x, const int y) {
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {
"-s", deviceId,
"shell", "input",
"tap", QString::number(x), QString::number(y)
});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb tap:" << process.errorString();
return false;
}
process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
}
bool AdbUtils::enableAdbIMEKeyboard(const QString &deviceId) {
const QString adb = adbPath();
const QString imeId = QStringLiteral("com.android.adbkeyboard/.AdbIME");
// Проверяем, установлен ли ADB Keyboard
QString installedList;
startProcess(adb, {"-s", deviceId, "shell", "pm", "list", "packages", "com.android.adbkeyboard"}, &installedList);
bool freshlyInstalled = false;
if (!installedList.contains("com.android.adbkeyboard")) {
const QString apkPath = QCoreApplication::applicationDirPath() + "/assets/ADBKeyboard.apk";
qDebug() << "[AdbUtils] ADB Keyboard not installed, installing from:" << apkPath;
QString installErr;
if (!startProcess(adb, {"-s", deviceId, "install", apkPath}, nullptr, &installErr)) {
qWarning() << "[AdbUtils] Failed to install ADB Keyboard:" << installErr;
return false;
}
qDebug() << "[AdbUtils] ADB Keyboard installed successfully";
freshlyInstalled = true;
}
// Пакет мог быть отключён производителем (pm disable-user).
// Пытаемся включить; ошибку не считаем фатальной (на некоторых прошивках команда
// отвергается, но сам IME при этом включается).
{
QString pmOut, pmErr;
if (!startProcess(adb, {"-s", deviceId, "shell", "pm", "enable", "com.android.adbkeyboard"}, &pmOut, &pmErr)) {
qWarning() << "[AdbUtils] pm enable com.android.adbkeyboard failed:" << pmErr.trimmed()
<< "(продолжаем)";
}
}
// ime enable с retry: сразу после 'pm install' InputMethodManagerService
// может ещё не подхватить новый IME и возвращать "Unknown input method ... cannot be enabled".
// Ретраим до появления IME в списке всех зарегистрированных (ime list -a).
const int enableAttempts = freshlyInstalled ? 10 : 3;
bool imeEnabled = false;
QString lastErr;
for (int attempt = 0; attempt < enableAttempts; ++attempt) {
QString out, err;
if (startProcess(adb, {"-s", deviceId, "shell", "ime", "enable", imeId}, &out, &err)) {
qDebug() << "[AdbUtils] ime enable:" << out.trimmed();
imeEnabled = true;
break;
}
lastErr = err.trimmed();
qDebug() << "[AdbUtils] ime enable attempt" << attempt + 1 << "/" << enableAttempts
<< "failed:" << lastErr << "— ждём, пока IMMS перечитает IME";
QThread::msleep(1000);
}
if (!imeEnabled) {
qWarning() << "[AdbUtils] ime enable failed after" << enableAttempts
<< "attempts. Last error:" << lastErr;
return false;
}
// Верификация: IME должен появиться в списке разрешённых
{
QString enabledList;
startProcess(adb, {"-s", deviceId, "shell", "ime", "list", "-s"}, &enabledList);
if (!enabledList.contains(imeId)) {
qWarning() << "[AdbUtils] ADB Keyboard отсутствует в 'ime list -s' после 'ime enable'."
<< "Вывод:" << enabledList.trimmed()
<< "(прошивка/OEM может блокировать сторонние IME)";
return false;
}
}
// ime set
{
QString out, err;
if (!startProcess(adb, {"-s", deviceId, "shell", "ime", "set", imeId}, &out, &err)) {
qWarning() << "[AdbUtils] ime set failed:" << err.trimmed();
return false;
}
qDebug() << "[AdbUtils] ime set:" << out.trimmed();
}
// Верификация: активный IME должен совпасть с нашим
{
QString defaultIme;
startProcess(adb, {"-s", deviceId, "shell", "settings", "get", "secure", "default_input_method"}, &defaultIme);
const QString trimmed = defaultIme.trimmed();
if (!trimmed.contains(imeId)) {
qWarning() << "[AdbUtils] default_input_method =" << trimmed
<< "— ожидали" << imeId
<< "(прошивка откатывает смену IME)";
return false;
}
qDebug() << "[AdbUtils] Active IME verified:" << trimmed;
}
return true;
}
bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) {
QProcess process;
process.setWorkingDirectory(adbDir());
// Кодируем UTF-8 → base64 и шлём через ADB_INPUT_B64. Это исключает любые проблемы
// shell-кавычек на устройстве с пробелами, скобками и не-ASCII символами
// (типа "Озон Банк (Ozon)"). ADBKeyboard декодирует base64 и вызывает commitText.
const QString b64 = QString::fromLatin1(text.toUtf8().toBase64());
process.start(adbPath(), {
"-s", deviceId,
"shell", "am broadcast -a ADB_INPUT_B64 --es msg " + b64
});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString();
return false;
}
process.waitForFinished();
qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode()
<< "text:" << text
<< "output:" << process.readAllStandardOutput().trimmed()
<< "error:" << process.readAllStandardError().trimmed();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
}
bool AdbUtils::disableAdbIMEKeyboard(const QString &deviceId) {
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {"-s", deviceId, "shell", "ime", "disable",
"com.android.adbkeyboard/.AdbIME"});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb ime disable:" << process.errorString();
return false;
}
process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
}
bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, const int x2, const int y2) {
return makeSwipe(deviceId, x1, y1, x2, y2, -1);
}
bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, const int x2, const int y2, int durationMs) {
QStringList args = {
"-s", deviceId,
"shell", "input",
"swipe",
QString::number(x1), QString::number(y1),
QString::number(x2), QString::number(y2)
};
if (durationMs > 0) args << QString::number(durationMs);
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), args);
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb swipe:" << process.errorString();
return false;
}
process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
}
bool AdbUtils::inputText(const QString &deviceId, const QString &text) {
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {
"-s", deviceId,
"shell", "input",
"text", text
});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb input text:" << process.errorString();
return false;
}
process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
}
bool AdbUtils::hideKeyboard(const QString &deviceId) {
// Метод 1: KEYCODE_ESCAPE (111) — работает на большинстве Android-устройств,
// но игнорируется на некоторых прошивках (например, TECNO/HiOS).
{
QProcess p;
p.setWorkingDirectory(adbDir());
p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "111"});
if (p.waitForStarted(5000)) p.waitForFinished(3000);
}
// Метод 2: KEYCODE_BACK (4) — Android посылает BACK сначала в IME; если
// IME (клавиатура) видим — он его потребляет и закрывает, не уходя
// на предыдущий экран. На TECNO/HiOS это часто единственный рабочий
// вариант. Если клавиатура уже скрыта (после метода 1), BACK уйдёт
// в Activity — нежелательно. Поэтому сначала проверим через dumpsys
// input_method, видна ли всё ещё клавиатура.
bool imeVisible = false;
{
QProcess p;
p.setWorkingDirectory(adbDir());
p.start(adbPath(), {"-s", deviceId, "shell", "dumpsys", "input_method"});
if (p.waitForStarted(5000) && p.waitForFinished(5000)) {
const QString out = QString::fromUtf8(p.readAllStandardOutput());
imeVisible = out.contains(QStringLiteral("mInputShown=true"))
|| out.contains(QStringLiteral("mServedInputConnection=")) // fallback heuristic
&& !out.contains(QStringLiteral("mInputShown=false"));
}
}
if (imeVisible) {
qDebug() << "[AdbUtils] hideKeyboard: ESC didn't dismiss IME, trying BACK";
QProcess p;
p.setWorkingDirectory(adbDir());
p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "4"});
if (p.waitForStarted(5000)) p.waitForFinished(3000);
}
return true;
}
bool AdbUtils::goBack(const QString &deviceId) {
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {
"-s", deviceId,
"shell", "input",
"keyevent", "4"
});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString();
return false;
}
process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
}
QSet<QString> AdbUtils::getListApps(const QString &deviceId) {
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {
"-s", deviceId,
"shell", "pm list",
"packages", "-3"
});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb pm list:" << process.errorString();
return {};
}
process.waitForFinished();
const QString output = process.readAllStandardOutput();
QSet<QString> apps;
const QStringList lines = output.split("\n");
for (int i = 0; i < lines.size(); ++i) {
QString line = lines.at(i).trimmed().replace("package:", "");
if (line.isEmpty()) {
continue;
}
apps.insert(line);
}
return apps;
}
QString AdbUtils::getDeviceTimezone(const QString &deviceId) {
QString out;
if (!startProcess(adbPath(), {"-s", deviceId, "shell", "getprop", "persist.sys.timezone"}, &out)) {
return {};
}
return out.trimmed();
}
QString AdbUtils::getAppVersion(const QString &deviceId, const QString &packageName) {
if (deviceId.isEmpty() || packageName.isEmpty()) {
return {};
}
QString out;
if (!startProcess(adbPath(),
{"-s", deviceId, "shell", "dumpsys", "package", packageName},
&out)) {
return {};
}
static const QRegularExpression re(R"(versionName=([^\s]+))");
const QRegularExpressionMatch m = re.match(out);
return m.hasMatch() ? m.captured(1).trimmed() : QString();
}
void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) {
const QString adb = adbPath();
// Проверяем, включён ли экран (mWakefulness=Awake)
QString powerState;
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "power"}, &powerState);
const bool isAwake = powerState.contains("mWakefulness=Awake");
if (!isAwake) {
// Включаем экран
startProcess(adb, {"-s", deviceId, "shell", "input", "keyevent", "KEYCODE_WAKEUP"});
QThread::msleep(500);
qDebug() << "[AdbUtils] unlockScreen: woke up device" << deviceId;
}
// Проверяем, залочен ли экран (mDreamingSleepToken / mShowingLockscreen)
QString wmState;
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "window"}, &wmState);
const bool isLocked = wmState.contains("mDreamingSleepToken") || wmState.contains("mShowingLockscreen=true")
|| wmState.contains("StatusBar");
if (isLocked) {
// Свайп вверх для разблокировки
const int x = screenWidth / 2;
makeSwipe(deviceId, x, screenHeight * 3 / 4, x, screenHeight / 4);
QThread::msleep(500);
qDebug() << "[AdbUtils] unlockScreen: swiped to unlock" << deviceId;
}
}

66
android/adb/AdbUtils.h Normal file
View File

@ -0,0 +1,66 @@
#pragma once
#include <QList>
#include <QObject>
class AdbUtils final : public QObject {
Q_OBJECT
public:
explicit AdbUtils(QObject *parent = nullptr);
static QString adbPath();
static QList<QPair<QString, QString> > getListDevices();
static QString tryToGetRunningAppPid(const QString &deviceId, const QString &packageName);
static bool tryToStartApplication(const QString &deviceId, const QString &packageName);
static bool tryToKillApplication(const QString &deviceId, const QString &packageName);
static QString getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot = false);
// Сырой дамп без фильтра "useful UI" — для ручного превью оператора:
// отдаёт что есть на экране, даже если это PIN-пад / сплэш / WebView-shell.
static QString getRawScreenDumpAsXml(const QString &deviceId);
static bool takeScreenshot(const QString &deviceId, const QString &name);
// Захватывает скриншот напрямую в память (PNG-байты), без временного файла
static QByteArray captureScreenshotBytes(const QString &deviceId);
static bool takeXmlDump(const QString &deviceId, const QString &name);
static bool makeTap(const QString &deviceId, int x, int y);
static bool enableAdbIMEKeyboard(const QString &deviceId);
static bool inputAdbIMEText(const QString &deviceId, const QString &text);
static bool disableAdbIMEKeyboard(const QString &deviceId);
static bool makeSwipe(const QString &deviceId, int x1, int y1, int x2, int y2);
static bool makeSwipe(const QString &deviceId, int x1, int y1, int x2, int y2, int durationMs);
static bool inputText(const QString &deviceId, const QString &text);
static bool hideKeyboard(const QString &deviceId);
static bool goBack(const QString &deviceId);
static void unlockScreen(const QString &deviceId, int screenWidth, int screenHeight);
static QSet<QString> getListApps(const QString &deviceId);
// Возвращает таймзону устройства (напр. "Asia/Bangkok"), либо пустую строку при ошибке
static QString getDeviceTimezone(const QString &deviceId);
// Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии
static QString getAppVersion(const QString &deviceId, const QString &packageName);
private:
static QString adbDir();
static bool startProcess(const QString &program, const QStringList &args,
QString *stdOut = nullptr, QString *stdErr = nullptr);
};

View File

@ -0,0 +1,293 @@
#include "CommonScript.h"
#include <QJsonArray>
#include <QJsonObject>
#include <QRandomGenerator>
#include <QRegularExpression>
#include <QSettings>
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/DeviceDAO.h"
#include "dao/BankProfileDAO.h"
#include "net/NetworkService.h"
namespace Black {
CommonScript::CommonScript(QObject *parent)
: QObject(parent) {
}
CommonScript::~CommonScript() = default;
void CommonScript::start() {
doStart();
emit finished();
}
Node CommonScript::swipeToButton(
const QString &deviceId,
const QString &text, int &counter, const int width, const int height
) {
for (int i = 0; i < 10; ++i) {
if (Node button = xmlScreenParser.findButtonNode(text, true); button.x() > 0 && button.y() > 0) {
return button;
}
const int x = width / 2;
const int offset = height / 4;
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
}
return {};
}
bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode) {
// BlackBanx использует EditText с password=true, а не кнопочную сетку
Node editText = xmlScreenParser.findPinCodeEditText();
if (editText.isEmpty()) {
qWarning() << "[Black::inputPinCode] PIN EditText not found";
return false;
}
// Тапаем по полю для фокуса
AdbUtils::makeTap(deviceId, editText.x(), editText.y());
QThread::msleep(500);
// Вводим PIN через текстовый ввод
AdbUtils::inputText(deviceId, pinCode);
QThread::msleep(500);
return true;
}
bool CommonScript::runAppAndGoToHomeScreen(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
const int width,
const int height
) {
// Разблокируем экран перед запуском
AdbUtils::unlockScreen(deviceId, width, height);
// Убить приложение если запущено
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
qDebug() << "[Black] Process already running, pid:" << pid;
AdbUtils::getScreenDumpAsXml(deviceId, 1000);
AdbUtils::tryToKillApplication(deviceId, packageName);
}
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
AdbUtils::takeScreenshot(deviceId, "run_app_error");
qWarning() << "[Black] Process has not started";
return false;
}
// Даем время на запуск
QThread::msleep(4500);
int counter = 0;
bool findAndInputPincode = false;
while (true) {
counter++;
if (counter > 10) {
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
return true;
}
// Проверяем экран ввода PIN-кода
if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) {
inputPinCode(deviceId, pinCode);
findAndInputPincode = true;
QThread::msleep(3000);
continue;
}
QThread::msleep(1000);
}
}
void CommonScript::postAccountsData(const QList<QPair<MaterialInfo, Node>> &accounts) {
if (accounts.isEmpty()) {
return;
}
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonArray list;
for (const QPair<MaterialInfo, Node> &pair : accounts) {
QJsonObject obj;
MaterialInfo account = pair.first;
obj["appName"] = account.appCode;
obj["lastNumbers"] = account.lastNumbers;
obj["amount"] = account.amount;
obj["description"] = account.description;
obj["currency"] = account.currency;
obj["status"] = materialStatusToString(account.status);
list.append(obj);
}
worker->setDeviceId(accounts.first().first.deviceId);
worker->setPayload(QJsonDocument(list).toJson());
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
connect(worker, &NetworkService::finished, thread, &QThread::quit);
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
void CommonScript::postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction) {
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonObject obj;
obj["id"] = transaction.id;
obj["amount"] = transaction.amount;
obj["name"] = transaction.name;
obj["fee"] = transaction.fee;
obj["status"] = transactionStatusToString(transaction.status);
obj["type"] = transactionTypeToString(transaction.type);
obj["phone"] = transaction.phone;
obj["description"] = transaction.description;
obj["updatedDescription"] = transaction.updatedDescription;
obj["bankName"] = transaction.bankName;
obj["bankTime"] = transaction.bankTime;
obj["bankTransactionId"] = transaction.bankTrExternalId;
worker->setDeviceId(account.deviceId);
worker->addExtra("lastNumbers", account.lastNumbers);
worker->addExtra("transactionId", transaction.id);
worker->setPayload(QJsonDocument(obj).toJson());
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &NetworkService::insertTransactionData);
connect(worker, &NetworkService::finished, thread, &QThread::quit);
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
void CommonScript::updateTransactionData(
const MaterialInfo &account,
const int transactionId,
const TransactionStatus status,
const QString &oldDesc,
const QString &newDesc
) {
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonObject obj;
obj["id"] = transactionId;
obj["status"] = transactionStatusToString(status);
obj["description"] = newDesc;
obj["updatedDescription"] = oldDesc;
worker->setDeviceId(account.deviceId);
worker->addExtra("lastNumbers", account.lastNumbers);
worker->setPayload(QJsonDocument(obj).toJson());
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &NetworkService::updateTransactionData);
connect(worker, &NetworkService::finished, thread, &QThread::quit);
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
qWarning() << "[Black] createBankProfileIfNeeded: skipping — fullName or phone is empty";
return {};
}
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
const int currencyCode = currencyCodes.value(app.currency.toUpper(), 840);
const QStringList parts = app.fullName.split(' ', Qt::SkipEmptyParts);
// Нормализуем телефон: оставляем только цифры, добавляем код страны и "+"
const QSettings settings("config.ini", QSettings::IniFormat);
const QString phoneCode = settings.value(appCode + "/phone_code").toString();
QString phone = app.phone;
phone.remove(QRegularExpression("[^0-9]"));
if (!phoneCode.isEmpty() && !phone.startsWith(phoneCode)) {
if (phone.startsWith('8') && phoneCode != "8") {
phone.replace(0, 1, phoneCode);
} else {
phone.prepend(phoneCode);
}
}
phone.prepend('+');
const DeviceInfo device = DeviceDAO::getDeviceById(deviceId);
QJsonObject profileBody;
profileBody["first_name"] = parts.value(0);
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : QString("");
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
profileBody["bank_name"] = appCode;
profileBody["phone_number"] = phone;
const double balance = (parsedBalance >= 0.0) ? parsedBalance : app.amount;
profileBody["balance"] = QString::number(qRound64(balance * 100));
profileBody["state"] = (app.status == "active") ? "enabled" : "disabled";
profileBody["device_id"] = device.apiId;
profileBody["currency"] = currencyCode;
NetworkService ns;
ns.setDeviceId(deviceId);
ns.setPayload(QJsonDocument(profileBody).toJson());
if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id);
ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) {
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
qWarning() << "[Black] createBankProfileIfNeeded:" << err;
return err;
}
qDebug() << "[Black] createBankProfileIfNeeded: postBankProfile done";
} else {
ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile();
qDebug() << "[Black] createBankProfileIfNeeded: patchBankProfile done";
}
return {};
}
void CommonScript::stop() {
m_running = false;
}
bool CommonScript::closeGooglePlayBannerIfVisible(
const QString &deviceId, const int width, const int height
) {
// 1. Системный диалог запроса разрешения Android (permissioncontroller) — тапаем "ЗАПРЕТИТЬ"
if (Node denyBtn = xmlScreenParser.tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
qDebug() << "[Black] System permission dialog detected mid-task, tapping 'ЗАПРЕТИТЬ'...";
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
return true;
}
// 2. Google Play in-app update bottom sheet
Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height);
if (gpNode.isEmpty()) return false;
qDebug() << "[Black] Google Play update banner detected mid-task, closing...";
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
return true;
}
} // namespace Black

View File

@ -0,0 +1,78 @@
#pragma once
#include <QObject>
#include "ScreenXmlParser.h"
#include "db/TransactionInfo.h"
namespace Black {
class CommonScript : public QObject {
Q_OBJECT
public:
explicit CommonScript(QObject *parent = nullptr);
~CommonScript() override;
protected:
virtual void doStart() = 0;
// Ввод PIN-кода через EditText (BlackBanx использует текстовое поле, а не кнопки)
bool inputPinCode(const QString &deviceId, const QString &pinCode);
bool runAppAndGoToHomeScreen(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
int width,
int height
);
Node swipeToButton(
const QString &deviceId,
const QString &text,
int &counter, int width, int height
);
void postAccountsData(const QList<QPair<MaterialInfo, Node>> &accounts);
void postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction);
void updateTransactionData(
const MaterialInfo &account,
int transactionId,
TransactionStatus status,
const QString &oldDesc,
const QString &newDesc
);
// Создаёт bank_profile на сервере если ещё не создан.
// Возвращает пустую строку при успехе, текст ошибки при провале.
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
// Если виден баннер Google Play "Доступно обновление" или системный
// диалог разрешений — закрывает его и перепарсивает XML. Безопасно
// вызывать на любом экране; возвращает true если что-то было закрыто.
bool closeGooglePlayBannerIfVisible(
const QString &deviceId, int width, int height
);
bool m_running = true;
ScreenXmlParser xmlScreenParser;
int m_counter = 0;
signals:
void finished();
void finishedWithResult(const QString &result);
public slots:
virtual void start() final;
virtual void stop() final;
private:
Q_DISABLE_COPY(CommonScript);
};
} // namespace Black

View File

@ -0,0 +1,164 @@
#include "GetCardInfoScript.h"
#include <QThread>
#include <QJsonDocument>
#include <QJsonObject>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h"
namespace Black {
GetCardInfoScript::GetCardInfoScript(
QString deviceId,
QString appCode,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)) {
}
GetCardInfoScript::~GetCardInfoScript() = default;
void GetCardInfoScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("black", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
// Читаем текущий экран
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Black::GetCardInfo] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
}
// 1. Парсим баланс с домашнего экрана
const double balance = xmlScreenParser.parseBalanceFromHomeScreen();
qDebug() << "[Black::GetCardInfo] balance from home:" << balance;
BankProfileDAO::updateAmount(app.id, balance);
// 2. Нажимаем "Tu CVU" на домашнем экране
Node cvuButton = xmlScreenParser.findButtonNode("Tu CVU", false);
if (cvuButton.isEmpty()) {
m_error = "Cannot find Tu CVU button: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
AdbUtils::makeTap(m_deviceId, cvuButton.x(), cvuButton.y());
QThread::msleep(2000);
// 3. Ждём экран CVU
bool cvuScreenFound = false;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isCVUScreen()) {
cvuScreenFound = true;
break;
}
QThread::msleep(1000);
}
if (!cvuScreenFound) {
m_error = "CVU screen not detected: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// 4. Парсим номер CVU
const QString cvuNumber = xmlScreenParser.parseCVUNumber();
qDebug() << "[Black::GetCardInfo] CVU number:" << cvuNumber;
if (cvuNumber.isEmpty()) {
m_error = "Cannot parse CVU number: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// 5. Сохраняем аккаунт в БД (lastNumbers = последние 4 цифры CVU)
const QString lastNumbers = cvuNumber.right(4);
MaterialInfo account;
account.deviceId = device.id;
account.appCode = m_appCode;
account.lastNumbers = lastNumbers;
account.cardNumber = cvuNumber;
account.amount = balance;
account.currency = "ARS";
account.description = "CVU";
account.status = MaterialStatus::Off;
account.updateTime = QDateTime::currentDateTimeUtc();
if (!MaterialDAO::upsertAccount(account)) {
qWarning() << "[Black::GetCardInfo] Failed to upsert account";
}
// 6. Отправляем материал на сервер (если ещё нет material_id)
const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
if (savedAccount.id != -1 && !updatedApp.bankProfileId.isEmpty()) {
if (savedAccount.materialId.isEmpty()) {
NetworkService ns;
ns.setDeviceId(m_deviceId);
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
QJsonObject materialBody;
materialBody["bank_profile_id"] = updatedApp.bankProfileId;
materialBody["card_number"] = savedAccount.cardNumber;
materialBody["name"] = updatedApp.fullName;
materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled";
materialBody["currency"] = currencyCodes.value(savedAccount.currency.toUpper(), 32);
ns.setPayload(QJsonDocument(materialBody).toJson());
ns.addExtra("account_id", savedAccount.id);
ns.postMaterial();
qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers;
} else {
qDebug() << "[Black::GetCardInfo] material already exists for CVU" << lastNumbers << ", skipping";
}
} else {
qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material";
}
qDebug() << "[Black::GetCardInfo] Done. CVU:" << cvuNumber << "balance:" << balance;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
}
} // namespace Black

View File

@ -0,0 +1,27 @@
#pragma once
#include "CommonScript.h"
namespace Black {
class GetCardInfoScript final : public CommonScript {
Q_OBJECT
public:
explicit GetCardInfoScript(
QString deviceId,
QString appCode,
QObject *parent = nullptr
);
~GetCardInfoScript() override;
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
QString m_error;
};
} // namespace Black

View File

@ -0,0 +1,166 @@
#include "GetLastTransactionsScript.h"
#include <QThread>
#include <QTimeZone>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "AppLogger.h"
namespace Black {
GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId,
QString appCode,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)) {
}
GetLastTransactionsScript::~GetLastTransactionsScript() = default;
void GetLastTransactionsScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Если не на домашнем экране — перезапускаем приложение
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Black::GetLastTransactions] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
qWarning() << m_error;
AppLogger::log("black/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Тапаем "Ver más" рядом с "Tu actividad" для перехода на экран транзакций
Node verMasBtn = xmlScreenParser.findVerMasAfterTuActividad();
if (verMasBtn.isEmpty()) {
// Попробуем проскроллить вниз чтобы увидеть "Tu actividad" + "Ver más"
for (int i = 0; i < 5; ++i) {
const int x = device.screenWidth / 2;
const int offset = device.screenHeight / 4;
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
verMasBtn = xmlScreenParser.findVerMasAfterTuActividad();
if (!verMasBtn.isEmpty()) break;
}
}
if (verMasBtn.isEmpty()) {
m_error = "Button 'Ver más' not found: " + m_deviceId;
qWarning() << m_error;
emit finishedWithResult(m_error);
return;
}
qDebug() << "[Black::GetLastTransactions] Tapping 'Ver más' at" << verMasBtn.x() << verMasBtn.y();
AdbUtils::makeTap(m_deviceId, verMasBtn.x(), verMasBtn.y());
QThread::msleep(2000);
// Ждём загрузки экрана транзакций
// Сперва появляется экран загрузки (Tu Actividad без транзакций),
// затем подгружаются транзакции
bool loaded = false;
for (int attempt = 0; attempt < 15; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (xmlScreenParser.isTransactionListScreen()) {
loaded = true;
break;
}
if (xmlScreenParser.isTransactionLoadingScreen()) {
qDebug() << "[Black::GetLastTransactions] Loading screen detected, waiting for transactions, attempt" << attempt + 1 << "/ 15";
} else {
qDebug() << "[Black::GetLastTransactions] Waiting for transaction screen, attempt" << attempt + 1 << "/ 15";
}
QThread::msleep(1500);
}
if (!loaded) {
// Проверяем: если экран загрузки есть, но транзакций нет — это нормально (пустой аккаунт)
if (xmlScreenParser.isTransactionLoadingScreen()) {
qDebug() << "[Black::GetLastTransactions] No transactions found (empty account)";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
emit finishedWithResult(m_error);
return;
}
m_error = "Transaction list screen not loaded: " + m_deviceId;
qWarning() << m_error;
emit finishedWithResult(m_error);
return;
}
// Находим accountId для сохранения транзакций
int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
if (!accounts.isEmpty()) {
accountId = accounts.first().id;
qDebug() << "[Black::GetLastTransactions] Using accountId:" << accountId;
} else {
qWarning() << "[Black::GetLastTransactions] No account found for" << m_deviceId << m_appCode;
}
// Берём все транзакции что видны на экране
const QTimeZone argTz("America/Argentina/Buenos_Aires");
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
QList<TransactionInfo> pageItems = xmlScreenParser.parseTransactionItems();
qDebug() << "[Black::GetLastTransactions] Found" << pageItems.size() << "transactions on screen";
for (const TransactionInfo &tx : pageItems) {
TransactionInfo saveTx = tx;
saveTx.bankName = "black";
saveTx.status = TransactionStatus::Complete;
saveTx.timestamp = QDateTime::currentDateTimeUtc();
// Парсим дату из bankTime: "21 de febrero de 2026 - 09:39"
QDateTime parsed = xmlScreenParser.parseSpanishDate(saveTx.bankTime);
if (parsed.isValid()) {
parsed.setTimeZone(argTz);
saveTx.completeTime = parsed;
}
qDebug() << "[Black::GetLastTransactions]"
<< "date:" << saveTx.bankTime
<< "desc:" << saveTx.description
<< "name:" << saveTx.name
<< "amount:" << (saveTx.amount >= 0 ? "+" : "") + QString::number(saveTx.amount, 'f', 2);
// Сохраняем в БД
if (accountId != -1) {
saveTx.accountId = accountId;
if (TransactionDAO::insertTransactionIfNotExists(saveTx)) {
qDebug() << "[Black::GetLastTransactions] Saved transaction to DB";
}
}
}
qDebug() << "[Black::GetLastTransactions] Total transactions:" << pageItems.size();
// Возвращаемся на домашний экран
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
emit finishedWithResult(m_error);
}
} // namespace Black

View File

@ -0,0 +1,27 @@
#pragma once
#include "CommonScript.h"
namespace Black {
class GetLastTransactionsScript final : public CommonScript {
Q_OBJECT
public:
explicit GetLastTransactionsScript(
QString deviceId,
QString appCode,
QObject *parent = nullptr
);
~GetLastTransactionsScript() override;
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
QString m_error;
};
} // namespace Black

View File

@ -0,0 +1,188 @@
#include "GetProfileInfoScript.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h"
namespace Black {
GetProfileInfoScript::GetProfileInfoScript(
QString deviceId,
QString appCode,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)) {
}
GetProfileInfoScript::~GetProfileInfoScript() = default;
void GetProfileInfoScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
// Читаем текущий экран
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Black::GetProfileInfo] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// 1. Тапаем по кнопке "¡Hola!" (аватар с именем) на домашнем экране
Node holaNode = xmlScreenParser.findHolaButton();
if (holaNode.isEmpty()) {
m_error = "Cannot find Hola button on home screen: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// Парсим имя и баланс с домашнего экрана
const QString fullName = xmlScreenParser.parseUserNameFromHomeScreen();
const double balance = xmlScreenParser.parseBalanceFromHomeScreen();
qDebug() << "[Black::GetProfileInfo] fullName from home:" << fullName << "balance:" << balance;
// Сохраняем баланс
BankProfileDAO::updateAmount(app.id, balance);
AdbUtils::makeTap(m_deviceId, holaNode.x(), holaNode.y());
QThread::msleep(2000);
// 2. Ждём боковое меню
bool sideMenuFound = false;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (xmlScreenParser.isSideMenu()) {
sideMenuFound = true;
break;
}
QThread::msleep(1000);
}
if (!sideMenuFound) {
m_error = "Side menu not detected: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// 3. Тапаем "Tu Información"
Node tuInfoNode = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Tu Información"));
if (tuInfoNode.isEmpty()) {
m_error = "Cannot find Tu Información button: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
AdbUtils::makeTap(m_deviceId, tuInfoNode.x(), tuInfoNode.y());
QThread::msleep(2500);
// 4. Ждём страницу профиля "Tu información"
bool profileFound = false;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (xmlScreenParser.isProfilePage()) {
profileFound = true;
break;
}
QThread::msleep(1000);
}
if (!profileFound) {
m_error = "Profile page not detected: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// 5. Парсим данные профиля (первый экран: nombre, apellido, email)
const QString nombre = xmlScreenParser.parseProfileNombre();
const QString apellido = xmlScreenParser.parseProfileApellido();
const QString email = xmlScreenParser.parseProfileEmail();
// 6. Скроллим вниз чтобы увидеть телефон
const int x = device.screenWidth / 2;
const int fromY = device.screenHeight * 3 / 4;
const int toY = device.screenHeight / 4;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
const QString phone = xmlScreenParser.parseProfilePhone();
// Собираем полное имя: "имя фамилия"
QString parsedFullName;
if (!nombre.isEmpty() && !apellido.isEmpty()) {
parsedFullName = nombre + " " + apellido;
} else if (!nombre.isEmpty()) {
parsedFullName = nombre;
} else {
parsedFullName = fullName; // fallback с домашнего экрана
}
qDebug() << "[Black::GetProfileInfo] nombre:" << nombre
<< "apellido:" << apellido
<< "email:" << email
<< "phone:" << phone
<< "fullName:" << parsedFullName;
// 7. Сохраняем в БД
if (!BankProfileDAO::updateProfileInfo(app.id, parsedFullName, phone, email)) {
qWarning() << "[Black::GetProfileInfo] Failed to save profile info to DB";
}
// 7. Создаём bank profile если нет
if (const QString err = createBankProfileIfNeeded(m_deviceId, m_appCode); !err.isEmpty()) {
m_error = err;
emit finishedWithResult(m_error);
return;
}
// 8. Отправляем данные аккаунтов на сервер если нет material_id
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
QList<QPair<MaterialInfo, Node>> accountsToPost;
for (const auto &acc : allAccounts) {
if (acc.materialId.isEmpty()) {
accountsToPost.append({acc, Node()});
}
}
if (!accountsToPost.isEmpty()) {
postAccountsData(accountsToPost);
}
emit finishedWithResult(m_error);
}
} // namespace Black

View File

@ -0,0 +1,27 @@
#pragma once
#include "CommonScript.h"
namespace Black {
class GetProfileInfoScript final : public CommonScript {
Q_OBJECT
public:
~GetProfileInfoScript() override;
explicit GetProfileInfoScript(
QString deviceId,
QString appCode,
QObject *parent = nullptr
);
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
QString m_error;
};
} // namespace Black

View File

@ -0,0 +1,68 @@
#include "LoginAndCheckAccountsScript.h"
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "AppLogger.h"
#include "GetProfileInfoScript.h"
namespace Black {
LoginAndCheckAccountsScript::LoginAndCheckAccountsScript(
QString deviceId,
QString appCode,
QObject *parent,
bool pinCheckOnly
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_pinCheckOnly(pinCheckOnly) {
}
LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
void LoginAndCheckAccountsScript::doStart() {
// m_deviceId = ADB serial, device.id = android_id (для БД)
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
m_error = "Cant open the home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
qWarning() << m_error;
AppLogger::log("black/LoginAndCheck", m_deviceId, m_error, {}, "FETCH_PROFILE");
BankProfileDAO::updateComment(app.id,
"Не прошла проверка " + QDateTime::currentDateTime().toString("dd.MM.yyyy HH:mm:ss"));
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
if (m_pinCheckOnly) {
emit finishedWithResult(m_error);
return;
}
// Проверяем профиль и аккаунты (приложение уже на home screen)
GetProfileInfoScript profileScript(m_deviceId, m_appCode);
profileScript.start();
emit finishedWithResult(m_error);
}
} // namespace Black

View File

@ -0,0 +1,29 @@
#pragma once
#include "CommonScript.h"
namespace Black {
class LoginAndCheckAccountsScript final : public CommonScript {
Q_OBJECT
public:
~LoginAndCheckAccountsScript() override;
explicit LoginAndCheckAccountsScript(
QString deviceId,
QString appCode,
QObject *parent = nullptr,
bool pinCheckOnly = false
);
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
const bool m_pinCheckOnly;
QString m_error;
};
} // namespace Black

View File

@ -0,0 +1,384 @@
#include "PayByCardScript.h"
#include <QProcess>
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "AppLogger.h"
namespace Black {
PayByCardScript::PayByCardScript(
MaterialInfo account,
QString cardNumber,
double amount,
QObject *parent
) : CommonScript(parent),
m_account(std::move(account)),
m_cardNumber(std::move(cardNumber)),
m_amount(amount) {
}
PayByCardScript::~PayByCardScript() = default;
void PayByCardScript::doStart() {
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_account.deviceId;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
makePayment(device.adbSerial, app.pinCode, device.screenWidth, device.screenHeight);
emit finishedWithResult(m_error);
}
void PayByCardScript::makePayment(
const QString &deviceId,
const QString &pinCode,
const int width,
const int height
) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
// 0. Убеждаемся что на домашнем экране
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Black::PayByCard] Not on home screen, restarting app...";
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
if (!runAppAndGoToHomeScreen(deviceId, app.package, pinCode, width, height)) {
m_error = "Cannot reach home screen: " + DeviceDAO::getDeviceById(m_account.deviceId).name + " (" + deviceId + ")";
qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error, {}, "CREATE_TRANSACTION");
AdbUtils::tryToKillApplication(deviceId, app.package);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
}
// 1. Тапаем "Enviar a\nCBU o alias" на домашнем экране
Node enviarBtn = xmlScreenParser.findNodeByContentDesc("Enviar a\nCBU o alias", true);
if (enviarBtn.isEmpty()) {
// Попробуем проскроллить
for (int i = 0; i < 5; ++i) {
const int x = width / 2;
const int offset = height / 4;
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
enviarBtn = xmlScreenParser.findNodeByContentDesc("Enviar a\nCBU o alias", true);
if (!enviarBtn.isEmpty()) break;
}
}
if (enviarBtn.isEmpty()) {
m_error = "Button 'Enviar a CBU o alias' not found: " + deviceId;
qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Black::PayByCard] Tapping 'Enviar a CBU o alias'";
AdbUtils::makeTap(deviceId, enviarBtn.x(), enviarBtn.y());
QThread::msleep(2000);
// 2. Ждём экран ввода номера карты (CBU/CVU/Alias)
bool onInputScreen = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isPayInputCardNumberScreen()) {
onInputScreen = true;
break;
}
qDebug() << "[Black::PayByCard] Waiting for input card screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!onInputScreen) {
m_error = "Input card number screen not loaded: " + deviceId;
qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// Вводим номер карты в EditText
Node editText = xmlScreenParser.findEditTextByHint("Ingresar CBU, CVU o Alias");
if (editText.isEmpty()) {
editText = xmlScreenParser.findFirstEditText();
}
if (editText.isEmpty()) {
m_error = "EditText for card number not found: " + deviceId;
qWarning() << m_error;
return;
}
AdbUtils::makeTap(deviceId, editText.x(), editText.y());
QThread::msleep(500);
AdbUtils::inputText(deviceId, m_cardNumber);
QThread::msleep(1000);
// Нажимаем "Continuar"
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node continuarBtn = xmlScreenParser.findNodeByContentDesc("Continuar");
if (continuarBtn.isEmpty()) {
continuarBtn = xmlScreenParser.findButtonNode("Continuar", false);
}
if (continuarBtn.isEmpty()) {
m_error = "Button 'Continuar' not found on input card screen: " + deviceId;
qWarning() << m_error;
return;
}
qDebug() << "[Black::PayByCard] Tapping 'Continuar' after card number input";
AdbUtils::makeTap(deviceId, continuarBtn.x(), continuarBtn.y());
QThread::msleep(2000);
// 3. Ждём экран подтверждения получателя (pay_transfer_confirm)
bool onTransferConfirm = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isPayTransferConfirmScreen()) {
onTransferConfirm = true;
break;
}
qDebug() << "[Black::PayByCard] Waiting for transfer confirm screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!onTransferConfirm) {
m_error = "Transfer confirm screen not loaded: " + deviceId;
qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// Нажимаем "Transferir"
Node transferirBtn = xmlScreenParser.findNodeByContentDesc("Transferir");
if (transferirBtn.isEmpty()) {
transferirBtn = xmlScreenParser.findButtonNode("Transferir", false);
}
if (transferirBtn.isEmpty()) {
m_error = "Button 'Transferir' not found on transfer confirm screen: " + deviceId;
qWarning() << m_error;
return;
}
qDebug() << "[Black::PayByCard] Tapping 'Transferir' on confirm screen";
AdbUtils::makeTap(deviceId, transferirBtn.x(), transferirBtn.y());
QThread::msleep(2000);
// 4. Ждём экран ввода суммы (pay_input_sum)
bool onInputSum = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isPayInputSumScreen()) {
onInputSum = true;
break;
}
qDebug() << "[Black::PayByCard] Waiting for input sum screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!onInputSum) {
m_error = "Input sum screen not loaded: " + deviceId;
qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// Вводим сумму в EditText
Node sumEditText = xmlScreenParser.findFirstEditText();
if (sumEditText.isEmpty()) {
m_error = "EditText for sum not found: " + deviceId;
qWarning() << m_error;
return;
}
AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y());
QThread::msleep(500);
// Удаляем содержимое поля (там "0,00") — удаляем пока не станет "0,00" или пусто
for (int d = 0; d < 20; ++d) {
QProcess proc;
proc.start(AdbUtils::adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "67"});
proc.waitForFinished(2000);
}
QThread::msleep(500);
// Проверяем что осталось в поле после очистки
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node afterClear = xmlScreenParser.findFirstEditText();
qDebug() << "[Black::PayByCard] After clear, field text:" << afterClear.text;
// Поле работает как ввод в центах: каждая цифра сдвигает значение влево
// Для 500,00 нужно ввести "50000"
const QString amountStr = QString::number(static_cast<int>(m_amount * 100));
AdbUtils::inputText(deviceId, amountStr);
QThread::msleep(1000);
// Проверяем что сумма ввелась верно
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node currentEdit = xmlScreenParser.findFirstEditText();
qDebug() << "[Black::PayByCard] Sum field text:" << currentEdit.text;
// Если сумма неверная — пробуем ещё раз: удаляем всё и вводим заново
const QString expectedAmount = QString::number(static_cast<int>(m_amount));
QString cleanText = currentEdit.text.trimmed();
cleanText.remove('.');
if (cleanText.endsWith(",00")) cleanText.chop(3);
if (cleanText != expectedAmount) {
qDebug() << "[Black::PayByCard] Sum mismatch (" << cleanText << "!=" << expectedAmount << "), retrying...";
AdbUtils::makeTap(deviceId, currentEdit.x(), currentEdit.y());
QThread::msleep(300);
for (int d = 0; d < 20; ++d) {
QProcess proc;
proc.start(AdbUtils::adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "67"});
proc.waitForFinished(2000);
}
QThread::msleep(500);
AdbUtils::inputText(deviceId, amountStr);
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
currentEdit = xmlScreenParser.findFirstEditText();
qDebug() << "[Black::PayByCard] Sum field text (retry):" << currentEdit.text;
}
// Нажимаем "Continuar"
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node continuarSumBtn = xmlScreenParser.findNodeByContentDesc("Continuar");
if (continuarSumBtn.isEmpty()) {
continuarSumBtn = xmlScreenParser.findButtonNode("Continuar", false);
}
if (continuarSumBtn.isEmpty()) {
m_error = "Button 'Continuar' not found on input sum screen: " + deviceId;
qWarning() << m_error;
return;
}
qDebug() << "[Black::PayByCard] Tapping 'Continuar' after sum input";
AdbUtils::makeTap(deviceId, continuarSumBtn.x(), continuarSumBtn.y());
QThread::msleep(2000);
// 5. Ждём экран финального подтверждения (pay_confirm)
bool onPayConfirm = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isPayConfirmScreen()) {
onPayConfirm = true;
break;
}
qDebug() << "[Black::PayByCard] Waiting for pay confirm screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!onPayConfirm) {
m_error = "Pay confirm screen not loaded: " + deviceId;
qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// Парсим имя получателя из "Vas a transferir \na ИМЯ"
const QString recipientName = xmlScreenParser.parseRecipientNameFromConfirm();
qDebug() << "[Black::PayByCard] Recipient name:" << recipientName;
// Нажимаем "Transferir" для финального подтверждения
Node finalTransferirBtn = xmlScreenParser.findNodeByContentDesc("Transferir");
if (finalTransferirBtn.isEmpty()) {
finalTransferirBtn = xmlScreenParser.findButtonNode("Transferir", false);
}
if (finalTransferirBtn.isEmpty()) {
m_error = "Button 'Transferir' not found on pay confirm screen: " + deviceId;
qWarning() << m_error;
return;
}
qDebug() << "[Black::PayByCard] Tapping final 'Transferir'";
AdbUtils::makeTap(deviceId, finalTransferirBtn.x(), finalTransferirBtn.y());
QThread::msleep(3000);
// 5.5. Проверяем экран ввода PIN-кода
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Black::PayByCard] PIN code screen detected, entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
}
// 6. Ждём экран успеха (pay_success) — "Dinero enviado!"
bool onSuccess = false;
for (int attempt = 0; attempt < 15; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isPaySuccessScreen()) {
onSuccess = true;
break;
}
// Если PIN-код появился позже — вводим
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Black::PayByCard] PIN code screen detected (retry), entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
qDebug() << "[Black::PayByCard] Waiting for success screen, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!onSuccess) {
m_error = "Pay success screen not loaded: " + deviceId;
qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Black::PayByCard] Payment successful!";
// Парсим данные с экрана успеха
const QString bankTime = xmlScreenParser.parsePaySuccessDateTime();
const QString coelsaId = xmlScreenParser.parsePaySuccessCoelsaId();
qDebug() << "[Black::PayByCard] DateTime:" << bankTime << "Id Coelsa:" << coelsaId;
// Делаем скриншот экрана успеха как receipt
const QByteArray receiptImage = AdbUtils::captureScreenshotBytes(deviceId);
// Сохраняем транзакцию
TransactionInfo tx;
tx.accountId = m_account.id;
tx.amount = -m_amount; // исходящий перевод
tx.fee = 0;
tx.status = TransactionStatus::Complete;
tx.type = TransactionType::Card;
tx.name = recipientName;
tx.description = "Transferencia a " + recipientName;
tx.phone = m_cardNumber;
tx.bankName = "black";
tx.bankTime = bankTime;
tx.bankTrExternalId = coelsaId;
tx.timestamp = QDateTime::currentDateTimeUtc();
tx.completeTime = QDateTime::currentDateTimeUtc();
tx.receiptImage = receiptImage;
const int txId = TransactionDAO::insertTransaction(tx);
if (txId != -1) {
qDebug() << "[Black::PayByCard] Transaction saved to DB, id:" << txId;
if (!receiptImage.isEmpty()) {
TransactionDAO::updateReceiptImage(txId, receiptImage);
}
} else {
qWarning() << "[Black::PayByCard] Failed to save transaction to DB";
}
}
} // namespace Black

View File

@ -0,0 +1,36 @@
#pragma once
#include "CommonScript.h"
namespace Black {
class PayByCardScript final : public CommonScript {
Q_OBJECT
public:
PayByCardScript(
MaterialInfo account,
QString cardNumber,
double amount,
QObject *parent = nullptr
);
~PayByCardScript() override;
protected:
void doStart() override;
private:
const MaterialInfo m_account;
const QString m_cardNumber;
const double m_amount;
QString m_error;
void makePayment(
const QString &deviceId,
const QString &pinCode,
int width,
int height
);
};
} // namespace Black

View File

@ -0,0 +1,578 @@
#include "ScreenXmlParser.h"
#include <QMap>
#include <QRegularExpression>
#include <QXmlStreamReader>
#include <QDomDocument>
#include "android/xml/Node.h"
namespace Black {
static const QRegularExpression boundsRegex(R"(\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\])");
ScreenXmlParser::ScreenXmlParser(QObject *parent) : QObject(parent) {
}
ScreenXmlParser::~ScreenXmlParser() = default;
void ScreenXmlParser::parseAndSaveXml(const QString &xml) {
QDomDocument doc;
doc.setContent(xml);
m_xml = doc.documentElement();
m_nodes.clear();
QXmlStreamReader reader(xml);
while (!reader.atEnd()) {
reader.readNext();
if (reader.isStartElement() && reader.name() == u"node") {
Node node;
if (reader.attributes().hasAttribute("bounds")) {
QString bounds = reader.attributes().value("bounds").toString();
if (QRegularExpressionMatch match = boundsRegex.match(bounds); match.hasMatch()) {
node.setBounds(
match.captured(1).toInt(),
match.captured(2).toInt(),
match.captured(3).toInt(),
match.captured(4).toInt()
);
}
}
if (reader.attributes().hasAttribute("text")) {
node.text = reader.attributes().value("text").toString();
}
if (reader.attributes().hasAttribute("content-desc")) {
node.contentDesc = reader.attributes().value("content-desc").toString();
}
if (reader.attributes().hasAttribute("class")) {
node.className = reader.attributes().value("class").toString();
}
if (reader.attributes().hasAttribute("clickable")) {
node.clickable = reader.attributes().value("clickable").toString() == "true";
}
if (reader.attributes().hasAttribute("focusable")) {
node.focusable = reader.attributes().value("focusable").toString() == "true";
}
if (reader.attributes().hasAttribute("resource-id")) {
node.resourceId = reader.attributes().value("resource-id").toString();
}
if (reader.attributes().hasAttribute("hint")) {
node.hint = reader.attributes().value("hint").toString();
}
if (reader.attributes().hasAttribute("password")) {
node.password = reader.attributes().value("password").toString() == "true";
}
m_nodes.append(node);
}
}
}
const QList<Node> &ScreenXmlParser::findAllNodes() const {
return m_nodes;
}
Node ScreenXmlParser::findTextNode(const QString &text) {
for (const Node &node : m_nodes) {
if (node.text.trimmed() == text) {
return node;
}
}
return {};
}
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) {
for (const Node &node : m_nodes) {
if (node.contentDesc == contentDesc) {
return node;
}
}
return {};
}
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc, bool contains) {
for (const Node &node : m_nodes) {
if (contains) {
if (node.contentDesc.contains(contentDesc)) {
return node;
}
} else {
if (node.contentDesc == contentDesc) {
return node;
}
}
}
return {};
}
Node ScreenXmlParser::findButtonNode(const QString &text, bool contains) {
for (const Node &node : m_nodes) {
if (!node.clickable) continue;
if (contains) {
if (node.text.contains(text) || node.contentDesc.contains(text)) {
return node;
}
} else {
if (node.text.trimmed() == text || node.contentDesc == text) {
return node;
}
}
}
return {};
}
Node ScreenXmlParser::findFirstEditText() {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText") {
return node;
}
}
return {};
}
bool ScreenXmlParser::isPinCodeScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("¿Olvidaste tu PIN?"))) {
return true;
}
if (node.contentDesc.contains(QString::fromUtf8("Ingresá tu PIN"))) {
return true;
}
}
return false;
}
Node ScreenXmlParser::findPinCodeEditText() {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText" && node.password) {
return node;
}
}
return {};
}
bool ScreenXmlParser::isHomeScreen() {
bool hasSaldo = false;
bool hasInicio = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == "Saldo disponible") {
hasSaldo = true;
}
if (node.contentDesc == "Inicio") {
hasInicio = true;
}
if (hasSaldo && hasInicio) {
return true;
}
}
return false;
}
bool ScreenXmlParser::isSideMenu() {
bool hasTuInfo = false;
bool hasCerrar = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Tu Información")) {
hasTuInfo = true;
}
if (node.contentDesc == QString::fromUtf8("Cerrar sesión")) {
hasCerrar = true;
}
if (hasTuInfo && hasCerrar) {
return true;
}
}
return false;
}
Node ScreenXmlParser::findHolaButton() {
for (const Node &node : m_nodes) {
if (node.clickable && node.contentDesc.startsWith(QString::fromUtf8("¡Hola!"))) {
return node;
}
}
return {};
}
QString ScreenXmlParser::parseUserNameFromHomeScreen() {
// content-desc: "¡Hola!\nveronica cintia heredia \nvc"
for (const Node &node : m_nodes) {
if (node.contentDesc.startsWith(QString::fromUtf8("¡Hola!"))) {
QStringList lines = node.contentDesc.split('\n');
if (lines.size() >= 2) {
return lines[1].trimmed();
}
}
}
return {};
}
double ScreenXmlParser::parseBalanceFromHomeScreen() {
// Баланс идёт после "Saldo disponible", формат: "$1.857,00" или "$0,00"
bool afterSaldo = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == "Saldo disponible") {
afterSaldo = true;
continue;
}
if (afterSaldo && node.contentDesc.startsWith("$")) {
// "$1.857,00" → "1857.00"
QString raw = node.contentDesc.mid(1); // убираем "$"
raw.remove('.'); // убираем разделитель тысяч
raw.replace(',', '.'); // запятая → точка
bool ok = false;
double val = raw.toDouble(&ok);
if (ok) return val;
}
}
return 0.0;
}
bool ScreenXmlParser::isProfilePage() {
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Tu información")) {
return true;
}
}
return false;
}
QString ScreenXmlParser::parseProfileNombre() {
for (const Node &node : m_nodes) {
if (node.hint == "Nombre" && !node.text.isEmpty()) {
return node.text.trimmed();
}
}
return {};
}
QString ScreenXmlParser::parseProfileApellido() {
for (const Node &node : m_nodes) {
if (node.hint == "Apellido" && !node.text.isEmpty()) {
return node.text.trimmed();
}
}
return {};
}
QString ScreenXmlParser::parseProfileEmail() {
for (const Node &node : m_nodes) {
if (node.hint == "E-mail" && !node.text.isEmpty()) {
return node.text.trimmed();
}
}
return {};
}
QString ScreenXmlParser::parseProfilePhone() {
for (const Node &node : m_nodes) {
if (node.hint == QString::fromUtf8("Teléfono celular") && !node.text.isEmpty()) {
return node.text.trimmed();
}
}
return {};
}
bool ScreenXmlParser::isCVUScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc == "CVU") {
return true;
}
}
return false;
}
QString ScreenXmlParser::parseCVUNumber() {
// CVU номер идёт после content-desc="CVU", это 22-значный номер
bool afterCVU = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == "CVU") {
afterCVU = true;
continue;
}
if (afterCVU && !node.contentDesc.isEmpty()) {
// Проверяем что это числовая строка (CVU — 22 цифры)
QString cvu = node.contentDesc.trimmed();
bool allDigits = true;
for (const QChar &c : cvu) {
if (!c.isDigit()) {
allDigits = false;
break;
}
}
if (allDigits && !cvu.isEmpty()) {
return cvu;
}
}
}
return {};
}
Node ScreenXmlParser::findVerMasAfterTuActividad() {
// "Ver más" идёт сразу после "Tu actividad" на домашнем экране
bool afterTuActividad = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == "Tu actividad") {
afterTuActividad = true;
continue;
}
if (afterTuActividad && node.contentDesc == QString::fromUtf8("Ver más") && node.clickable) {
return node;
}
// Если встретили другую секцию — прекращаем поиск
if (afterTuActividad && node.contentDesc == "Tarjetas") {
break;
}
}
return {};
}
bool ScreenXmlParser::isTransactionLoadingScreen() {
// Экран загрузки: заголовок "Tu Actividad" есть, но транзакций нет
// Может содержать "Aún no tienes actividad"
bool hasTuActividad = false;
bool hasTransferencia = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == "Tu Actividad") {
hasTuActividad = true;
}
if (node.contentDesc.startsWith("Transferencia")) {
hasTransferencia = true;
}
}
return hasTuActividad && !hasTransferencia;
}
bool ScreenXmlParser::isTransactionListScreen() {
// Экран транзакций определяется по заголовку "Tu Actividad" (с большой A)
// + наличие транзакций (ImageView с "Transferencia")
// На домашнем экране — "Tu actividad" (с маленькой a)
bool hasTuActividad = false;
bool hasTransferencia = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == "Tu Actividad") {
hasTuActividad = true;
}
if (node.contentDesc.startsWith("Transferencia")) {
hasTransferencia = true;
}
}
return hasTuActividad && hasTransferencia;
}
QList<TransactionInfo> ScreenXmlParser::parseTransactionItems() {
// Транзакции — ImageView с content-desc формата:
// "Transferencia\n21 de febrero de 2026 - 09:39\n$-1.857,\n00"
// "Transferencia externa de NOELIA ISABEL LEZCANO\n21 de febrero de 2026 - 09:35\n$123,\n45"
QList<TransactionInfo> result;
for (const Node &node : m_nodes) {
if (node.className != "android.widget.ImageView") continue;
if (!node.contentDesc.startsWith("Transferencia")) continue;
QStringList lines = node.contentDesc.split('\n');
if (lines.size() < 3) continue;
TransactionInfo tx;
// Строка 1: описание, может содержать имя отправителя
// "Transferencia" или "Transferencia externa de NOELIA ISABEL LEZCANO"
const QString descLine = lines[0].trimmed();
tx.description = descLine;
if (descLine.contains("externa de ")) {
tx.name = descLine.mid(descLine.indexOf("externa de ") + 11).trimmed();
}
// Строка 2: дата "21 de febrero de 2026 - 09:39"
tx.bankTime = lines[1].trimmed();
// Строки 3+: сумма "$-1.857,\n00" → объединяем
QString amountStr;
for (int i = 2; i < lines.size(); ++i) {
amountStr += lines[i].trimmed();
}
// "$-1.857,00" → убираем "$", "." (тысячный разделитель), заменяем "," на "."
amountStr.remove('$');
amountStr.remove(' ');
bool isNegative = amountStr.contains('-');
amountStr.remove('-');
amountStr.remove('.'); // тысячный разделитель
amountStr.replace(',', '.');
bool ok = false;
tx.amount = amountStr.toDouble(&ok);
if (ok && isNegative) tx.amount = -tx.amount;
tx.type = TransactionType::Card;
result.append(tx);
}
return result;
}
QDateTime ScreenXmlParser::parseSpanishDate(const QString &dateStr) {
// Формат: "21 de febrero de 2026 - 09:39"
static const QMap<QString, int> spanishMonths = {
{"enero", 1}, {"febrero", 2}, {"marzo", 3}, {"abril", 4},
{"mayo", 5}, {"junio", 6}, {"julio", 7}, {"agosto", 8},
{"septiembre", 9}, {"octubre", 10}, {"noviembre", 11}, {"diciembre", 12}
};
QRegularExpression re(R"((\d{1,2})\s+de\s+(\w+)\s+de\s+(\d{4})\s*-\s*(\d{2}):(\d{2}))");
QRegularExpressionMatch match = re.match(dateStr);
if (!match.hasMatch()) return {};
const int day = match.captured(1).toInt();
const QString monthName = match.captured(2).toLower();
const int year = match.captured(3).toInt();
const int hour = match.captured(4).toInt();
const int minute = match.captured(5).toInt();
const int month = spanishMonths.value(monthName, 0);
if (month == 0) return {};
return QDateTime(QDate(year, month, day), QTime(hour, minute));
}
bool ScreenXmlParser::isPayInputCardNumberScreen() {
for (const Node &node : m_nodes) {
if (node.hint == "Ingresar CBU, CVU o Alias") {
return true;
}
}
return false;
}
bool ScreenXmlParser::isPayTransferConfirmScreen() {
// Экран с именем получателя, Alias/CBU и кнопкой "Transferir"
// Но без "Vas a transferir" (это уже финальный экран)
bool hasTransferir = false;
bool hasVasATransferir = false;
bool hasAlias = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == "Transferir" && node.clickable) {
hasTransferir = true;
}
if (node.contentDesc.startsWith("Vas a transferir")) {
hasVasATransferir = true;
}
if (node.contentDesc.startsWith("Alias:")) {
hasAlias = true;
}
}
return hasTransferir && hasAlias && !hasVasATransferir;
}
bool ScreenXmlParser::isPayInputSumScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("Cuánto vas a transferír"))) {
return true;
}
}
return false;
}
bool ScreenXmlParser::isPayConfirmScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc.startsWith("Vas a transferir")) {
return true;
}
}
return false;
}
bool ScreenXmlParser::isPaySuccessScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc == "Dinero enviado!") {
return true;
}
}
return false;
}
QString ScreenXmlParser::parseRecipientNameFromConfirm() {
// "Vas a transferir \na JOEL ARCHENTI"
for (const Node &node : m_nodes) {
if (node.contentDesc.startsWith("Vas a transferir")) {
QStringList lines = node.contentDesc.split('\n');
if (lines.size() >= 2) {
QString nameLine = lines[1].trimmed();
// Убираем "a " в начале
if (nameLine.startsWith("a ")) {
nameLine = nameLine.mid(2);
}
return nameLine.trimmed();
}
}
}
return {};
}
QString ScreenXmlParser::parsePaySuccessDateTime() {
// Дата/время: "12/03/26 04:01" — содержит "/" и ":"
for (const Node &node : m_nodes) {
const QString desc = node.contentDesc.trimmed();
if (desc.contains('/') && desc.contains(':') && desc.length() <= 20) {
return desc;
}
}
return {};
}
QString ScreenXmlParser::parsePaySuccessCoelsaId() {
// "Id Coelsa" → следующий node содержит ID
bool afterIdCoelsa = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == "Id Coelsa") {
afterIdCoelsa = true;
continue;
}
if (afterIdCoelsa && !node.contentDesc.isEmpty()) {
return node.contentDesc.trimmed();
}
}
return {};
}
Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText" && node.hint == hint) {
return node;
}
}
return {};
}
Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int screenHeight) {
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) {
return node;
}
}
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("Доступно обновление")) {
Node tapNode;
const int cx = screenWidth / 2;
const int cy = screenHeight / 20;
tapNode.setBounds(cx, cy, cx + 1, cy + 1);
return tapNode;
}
}
return {};
}
Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() {
for (const Node &node : m_nodes) {
if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button"
&& node.clickable) {
return node;
}
}
for (const Node &node : m_nodes) {
if (node.clickable && node.text.trimmed() == QString::fromUtf8("ЗАПРЕТИТЬ")) {
return node;
}
}
return {};
}
} // namespace Black

View File

@ -0,0 +1,125 @@
#pragma once
#include <QObject>
#include <QDomElement>
#include "android/xml/Node.h"
#include "db/MaterialInfo.h"
#include "db/TransactionInfo.h"
namespace Black {
class ScreenXmlParser final : public QObject {
Q_OBJECT
public:
explicit ScreenXmlParser(QObject *parent = nullptr);
~ScreenXmlParser() override;
void parseAndSaveXml(const QString &xml);
const QList<Node> &findAllNodes() const;
Node findTextNode(const QString &text);
Node findNodeByContentDesc(const QString &contentDesc);
Node findNodeByContentDesc(const QString &contentDesc, bool contains);
Node findButtonNode(const QString &text, bool contains);
Node findFirstEditText();
// Определяет экран ввода PIN-кода по content-desc "¿Olvidaste tu PIN?"
bool isPinCodeScreen();
// Находит EditText с password=true для ввода PIN
Node findPinCodeEditText();
bool isHomeScreen();
// Определяет боковое меню по content-desc "Tu Información" + "Cerrar sesión"
bool isSideMenu();
// Находит кнопку "¡Hola!" (аватар с именем) на домашнем экране
Node findHolaButton();
// Парсит имя пользователя из content-desc "¡Hola!\nимя\nинициалы"
QString parseUserNameFromHomeScreen();
// Парсит баланс с домашнего экрана (content-desc "$0,00" после "Saldo disponible")
double parseBalanceFromHomeScreen();
// Определяет страницу профиля "Tu información"
bool isProfilePage();
// Парсит данные профиля по hint полей
QString parseProfileNombre(); // hint="Nombre"
QString parseProfileApellido(); // hint="Apellido"
QString parseProfileEmail(); // hint="E-mail"
QString parseProfilePhone(); // hint="Teléfono celular"
// Определяет экран CVU (окно поверх домашнего экрана)
bool isCVUScreen();
// Парсит номер CVU (22-значный номер после content-desc="CVU")
QString parseCVUNumber();
// Находит кнопку "Ver más" рядом с "Tu actividad" (для перехода на экран транзакций)
Node findVerMasAfterTuActividad();
// Определяет экран загрузки транзакций (заголовок "Tu Actividad", но транзакций ещё нет)
bool isTransactionLoadingScreen();
// Определяет экран списка транзакций (заголовок "Tu Actividad" + есть транзакции)
bool isTransactionListScreen();
// Парсит все транзакции из content-desc ImageView элементов
// Формат: "Transferencia\nDD de MES de YYYY - HH:MM\n$-1.857,\n00"
QList<TransactionInfo> parseTransactionItems();
// Парсит дату на испанском: "21 de febrero de 2026 - 09:39" → QDateTime
static QDateTime parseSpanishDate(const QString &dateStr);
// --- Экраны оплаты ---
// Экран ввода CBU/CVU/Alias (hint="Ingresar CBU, CVU o Alias")
bool isPayInputCardNumberScreen();
// Экран подтверждения получателя (кнопка "Transferir" + имя получателя)
bool isPayTransferConfirmScreen();
// Экран ввода суммы ("¿Cuánto vas a transferír" + EditText + "Continuar")
bool isPayInputSumScreen();
// Экран финального подтверждения ("Vas a transferir" + "Transferir")
bool isPayConfirmScreen();
// Экран успешного перевода ("Dinero enviado!")
bool isPaySuccessScreen();
// Парсит имя получателя из "Vas a transferir \na ИМЯ"
QString parseRecipientNameFromConfirm();
// Парсит дату/время с экрана успеха: "12/03/26 04:01"
QString parsePaySuccessDateTime();
// Парсит Id Coelsa с экрана успеха
QString parsePaySuccessCoelsaId();
// Находит EditText по hint
Node findEditTextByHint(const QString &hint);
// Находит кнопку закрытия Google Play in-app update bottom sheet
// ("com.android.vending" оверлей "Доступно обновление"), если он показан
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге разрешений Android
Node tryToFindSystemPermissionDenyButton();
private:
QList<Node> m_nodes;
QDomElement m_xml;
};
} // namespace Black

View File

@ -0,0 +1,327 @@
#include "TaskDumpRecorder.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QMetaObject>
#include <QPointer>
#include <QProcess>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QTextStream>
#include <QThread>
#include <QThreadStorage>
#include <qDebug>
#include "adb/AdbUtils.h"
#include "dao/SettingsDAO.h"
#include "net/NetworkService.h"
#include "LogArchiveSender.h"
namespace {
QThreadStorage<TaskDumpRecorder *> &tlsRecorder() {
static QThreadStorage<TaskDumpRecorder *> tls;
return tls;
}
QPointer<NetworkService> &networkServiceRef() {
static QPointer<NetworkService> ref;
return ref;
}
QString dumpsRoot() {
// Пишем в пользовательский temp — UAC/Program Files не мешают.
QString base = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
if (base.isEmpty()) base = QDir::tempPath();
return base + "/arc_dumps";
}
QString sanitize(const QString &raw, int maxLen = 40) {
static const QRegularExpression re(QStringLiteral("[^\\p{L}\\p{N}_-]+"));
QString s = raw;
s.replace(re, "_");
if (s.size() > maxLen) s = s.left(maxLen);
while (s.endsWith('_')) s.chop(1);
while (s.startsWith('_')) s = s.mid(1);
return s;
}
QString extractScreenTag(const QString &xml) {
static const QRegularExpression re(QStringLiteral("text=\"([^\"]{4,40})\""));
static const QRegularExpression noiseRe(QStringLiteral("^[0-9\\s,\\.\\-+₽$€%]+$"));
auto it = re.globalMatch(xml);
while (it.hasNext()) {
QString text = it.next().captured(1).trimmed();
if (text.isEmpty() || noiseRe.match(text).hasMatch()) continue;
QString tag = sanitize(text);
if (!tag.isEmpty()) return tag;
}
return QStringLiteral("screen");
}
bool zipFolder(const QString &folder, const QString &archivePath) {
#ifdef Q_OS_WIN
const QString cmd = QString(
"Compress-Archive -Path '%1\\*' -DestinationPath '%2' -Force"
).arg(QDir::toNativeSeparators(folder), QDir::toNativeSeparators(archivePath));
QProcess p;
p.start("powershell", {"-NoProfile", "-Command", cmd});
if (!p.waitForStarted(5000) || !p.waitForFinished(180000)) {
qWarning() << "[TaskDumpRecorder] zip failed:" << p.errorString();
return false;
}
if (p.exitCode() != 0) {
qWarning() << "[TaskDumpRecorder] zip exit=" << p.exitCode()
<< "stderr:" << QString::fromUtf8(p.readAllStandardError()).trimmed();
return false;
}
return true;
#else
const QFileInfo fi(folder);
QProcess p;
p.setWorkingDirectory(fi.absolutePath());
p.start("zip", {"-rq", archivePath, fi.fileName()});
if (!p.waitForStarted(5000) || !p.waitForFinished(180000)) {
qWarning() << "[TaskDumpRecorder] zip failed:" << p.errorString();
return false;
}
return p.exitCode() == 0;
#endif
}
QByteArray readFileBytes(const QString &path) {
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) return {};
QByteArray bytes = f.readAll();
f.close();
return bytes;
}
} // namespace
void TaskDumpRecorder::setNetworkService(NetworkService *service) {
networkServiceRef() = service;
}
NetworkService *TaskDumpRecorder::networkService() {
return networkServiceRef().data();
}
TaskDumpRecorder *TaskDumpRecorder::current() {
return tlsRecorder().hasLocalData() ? tlsRecorder().localData() : nullptr;
}
void TaskDumpRecorder::begin(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta) {
if (auto *prev = current()) {
prev->finishInternal(QStringLiteral("interrupted (new task started before previous finished)"));
// Не вызываем `delete prev` — QThreadStorage<T*> сам удалит предыдущий
// указатель при замене через setLocalData.
}
auto *rec = new TaskDumpRecorder(bank, taskKind, adbDeviceId, meta);
tlsRecorder().setLocalData(rec);
}
void TaskDumpRecorder::finish(const QString &errorMessage) {
auto *rec = current();
if (!rec) return;
rec->finishInternal(errorMessage);
// setLocalData(nullptr) сам вызовет delete на текущем указателе.
tlsRecorder().setLocalData(nullptr);
}
TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta)
: m_bank(sanitize(bank)),
m_taskKind(sanitize(taskKind)),
m_adbDeviceIdRaw(adbDeviceId),
m_adbDeviceIdSafe(sanitize(adbDeviceId)),
m_meta(meta) {
// Короткий суффикс для имени папки — чтобы визуально отличать задачи,
// но не раздувать путь. Берём materialId (первые 8 символов — UUID segment)
// + сумму, если есть.
QString folderTail;
if (!m_meta.materialId.isEmpty()) {
folderTail = sanitize(m_meta.materialId.left(8));
}
if (m_meta.amount != 0) {
const QString amt = QString::number(static_cast<qint64>(m_meta.amount));
folderTail = folderTail.isEmpty() ? amt : folderTail + "_" + amt;
}
const QString ts = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
const QString sub = folderTail.isEmpty()
? QString("%1_%2").arg(ts, m_adbDeviceIdSafe)
: QString("%1_%2_%3").arg(ts, m_adbDeviceIdSafe, folderTail);
m_folder = QString("%1/%2_%3/%4")
.arg(dumpsRoot(), m_bank, m_taskKind, sub);
if (!QDir().mkpath(m_folder)) {
qWarning() << "[TaskDumpRecorder] failed to mkpath" << m_folder;
m_folder.clear();
return;
}
qDebug() << "[TaskDumpRecorder] begin:" << m_folder;
}
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
const QString apiToken = SettingsDAO::get("api_key");
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
QStringList head{QStringLiteral("[TASK_ERROR]")};
if (!apiToken.isEmpty())
head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped());
head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped());
head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped());
QString body = QStringLiteral("Device: %1").arg(m_adbDeviceIdRaw.toHtmlEscaped());
if (!m_meta.materialId.isEmpty())
body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped());
if (m_meta.amount != 0)
body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
if (!m_meta.phone.isEmpty())
body += QStringLiteral("\nPhone: %1").arg(m_meta.phone.toHtmlEscaped());
if (!m_meta.card.isEmpty())
body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped());
body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped());
return QStringLiteral("<b>%1</b>\n%2\n%3")
.arg(head.join(QStringLiteral(" ")), timestamp.toHtmlEscaped(), body);
}
void TaskDumpRecorder::saveXml(int counter, const QString &xml) {
if (m_folder.isEmpty() || xml.isEmpty()) return;
const QString tag = extractScreenTag(xml);
const QString fname = QString("%1/%2_%3.xml")
.arg(m_folder,
QString("%1").arg(counter, 4, 10, QChar('0')),
tag);
QFile f(fname);
if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
f.write(xml.toUtf8());
f.close();
} else {
qWarning() << "[TaskDumpRecorder] failed to write" << fname << f.errorString();
}
}
void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
if (m_finished) return;
m_finished = true;
if (m_folder.isEmpty()) return;
if (errorMessage.isEmpty()) {
QDir(m_folder).removeRecursively();
qDebug() << "[TaskDumpRecorder] success — removed" << m_folder;
return;
}
qWarning() << "[TaskDumpRecorder] task failed:" << errorMessage
<< "folder:" << m_folder;
// Финальный скриншот текущего экрана
if (!m_adbDeviceIdRaw.isEmpty()) {
const QByteArray png = AdbUtils::captureScreenshotBytes(m_adbDeviceIdRaw);
if (!png.isEmpty()) {
QFile f(m_folder + "/_final_screen.png");
if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
f.write(png);
f.close();
}
}
}
// Метаданные
{
QFile ef(m_folder + "/_error.txt");
if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
QTextStream ts(&ef);
ts.setEncoding(QStringConverter::Utf8);
ts << "bank: " << m_bank << "\n"
<< "task: " << m_taskKind << "\n"
<< "device: " << m_adbDeviceIdRaw << "\n";
if (!m_meta.materialId.isEmpty()) ts << "material_id: " << m_meta.materialId << "\n";
if (m_meta.amount != 0) ts << "amount: " << static_cast<qint64>(m_meta.amount) << "\n";
if (!m_meta.phone.isEmpty()) ts << "phone: " << m_meta.phone << "\n";
if (!m_meta.card.isEmpty()) ts << "card: " << m_meta.card << "\n";
ts << "time: " << QDateTime::currentDateTime().toString(Qt::ISODate) << "\n"
<< "error: " << errorMessage << "\n";
ef.close();
}
}
const QString folder = m_folder;
const QString archivePath = m_folder + ".zip";
const QString text = buildCaption(errorMessage);
// Финальный скриншот шлём отдельным полем `image`, поэтому сразу читаем
// его в память, пока папка ещё цела.
QByteArray finalScreenshot;
{
const QString screenPath = folder + "/_final_screen.png";
if (QFile::exists(screenPath)) {
finalScreenshot = readFileBytes(screenPath);
}
}
QThread *bg = QThread::create([folder, archivePath, text, finalScreenshot]() {
QByteArray archiveBytes;
if (zipFolder(folder, archivePath)) {
archiveBytes = readFileBytes(archivePath);
if (archiveBytes.isEmpty()) {
qWarning() << "[TaskDumpRecorder] zip is empty, sending text-only dump for" << folder;
}
} else {
qWarning() << "[TaskDumpRecorder] zip failed, sending text-only dump for" << folder;
}
QDir(folder).removeRecursively();
QFile::remove(archivePath);
NetworkService *svc = networkServiceRef().data();
if (svc) {
qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService"
<< "archive.size=" << archiveBytes.size()
<< "image.size=" << finalScreenshot.size()
<< "text.size=" << text.size();
const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump",
Qt::QueuedConnection,
Q_ARG(QString, text),
Q_ARG(QByteArray, archiveBytes),
Q_ARG(QByteArray, finalScreenshot));
if (!dispatched) {
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
"slot not found or wrong signature for" << folder;
}
} else {
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
<< "(setNetworkService was not called at app startup?)";
}
// Следом — application.log + БД (+ WAL/SHM) отдельным архивом.
// Запускаем уже ПОСЛЕ того, как task-дамп поставлен в очередь
// отправки, чтобы порядок прихода на relay был "дамп → логи".
LogArchiveSender::dispatch(QStringLiteral("TASK_ERROR"));
});
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
bg->start();
}
TaskDumpRecorder::Scope::Scope(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta,
const QString *errorPtr)
: m_errorPtr(errorPtr), m_owned(false) {
// Вложенные скоупы не перетирают рекордер родителя — иначе внутренний
// скрипт прервал бы запись внешней задачи и сгенерировал ложный дамп
// с пометкой "interrupted".
if (TaskDumpRecorder::current() == nullptr) {
TaskDumpRecorder::begin(bank, taskKind, adbDeviceId, meta);
m_owned = true;
}
}
TaskDumpRecorder::Scope::~Scope() {
if (m_owned) {
TaskDumpRecorder::finish(m_errorPtr ? *m_errorPtr : QString());
}
}

View File

@ -0,0 +1,69 @@
#pragma once
#include <QString>
class NetworkService;
// Мета-поля задачи. Пустые/нулевые значения не попадают в text дампа
// и в имя папки — выводятся только те, что релевантны конкретному типу задачи.
struct TaskDumpMeta {
QString materialId;
double amount = 0; // 0 = нет
QString phone; // пусто = нет
QString card; // пусто = нет
};
class TaskDumpRecorder {
public:
// NetworkService, через который дамп отправляется на бэкенд (/monitoring/dump).
// Вызывать один раз при старте приложения. Без него рекордер собирает дамп,
// но не отправляет его — только пишет warning в лог.
static void setNetworkService(NetworkService *service);
// Доступ к зарегистрированному NetworkService для других потребителей,
// которые тоже хотят дернуть /monitoring/dump (отправка ручных логов).
static NetworkService* networkService();
// Возвращает рекордер текущего потока (nullptr если задача не начата).
static TaskDumpRecorder* current();
// RAII-скоуп: при создании регистрирует рекордер на текущий поток,
// при разрушении — финализирует (успех/ошибка определяются по errorPtr).
// Если в текущем потоке уже есть активный рекордер (вложенный вызов из
// другого скрипта), новый скоуп становится no-op — родитель остаётся
// владельцем дампа, а внутренние скрины пишутся в его папку.
class Scope {
public:
Scope(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta,
const QString *errorPtr);
~Scope();
Scope(const Scope &) = delete;
Scope &operator=(const Scope &) = delete;
private:
const QString *m_errorPtr;
bool m_owned;
};
// Сохраняет XML-дамп в папку задачи. Имя файла: NNNN_<screenTag>.xml.
void saveXml(int counter, const QString &xml);
private:
TaskDumpRecorder(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta);
static void begin(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta);
static void finish(const QString &errorMessage);
void finishInternal(const QString &errorMessage);
QString buildCaption(const QString &errorMessage) const;
QString m_folder;
QString m_bank;
QString m_taskKind;
QString m_adbDeviceIdRaw;
QString m_adbDeviceIdSafe;
TaskDumpMeta m_meta;
bool m_finished = false;
};

View File

@ -0,0 +1,359 @@
#include "CommonScript.h"
#include <QJsonArray>
#include <QJsonObject>
#include <QRandomGenerator>
#include <QRegularExpression>
#include <QSettings>
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/DeviceDAO.h"
#include "dao/BankProfileDAO.h"
#include "net/NetworkService.h"
namespace Dushanbe {
CommonScript::CommonScript(QObject *parent)
: QObject(parent) {
}
CommonScript::~CommonScript() = default;
void CommonScript::start() {
doStart();
emit finished();
}
Node CommonScript::swipeToButton(
const QString &deviceId,
const QString &text, int &counter, const int width, const int height
) {
for (int i = 0; i < 10; ++i) {
if (Node button = xmlScreenParser.findButtonNode(text, true); button.x() > 0 && button.y() > 0) {
return button;
}
const int x = width / 2;
const int offset = height / 4;
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
}
return {};
}
bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode) {
if (pinCode.isEmpty()) {
qWarning() << "[Dushanbe::inputPinCode] pinCode is EMPTY — nothing to enter!";
return false;
}
// Ждём, пока на экране появятся все уникальные цифры из PIN.
// DC Next на некоторых устройствах успевает показать заголовок
// "Введите код доступа" раньше, чем дорисует кнопочную сетку, —
// если начать тапать в этот момент, часть цифр не найдётся и PIN
// не введётся целиком.
bool seenInPin[10] = {false};
for (const QChar &d : pinCode) {
if (!d.isDigit()) continue;
const int idx = d.digitValue();
if (idx >= 0 && idx <= 9) seenInPin[idx] = true;
}
auto allDigitsPresent = [this, &seenInPin]() {
for (int d = 0; d < 10; ++d) {
if (!seenInPin[d]) continue;
if (xmlScreenParser.findNodeByContentDesc(QString::number(d)).isEmpty()) return false;
}
return true;
};
for (int i = 0; i < 6 && !allDigitsPresent(); ++i) {
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
}
if (!allDigitsPresent()) {
qWarning() << "[Dushanbe::inputPinCode] keypad incomplete after wait; aborting";
return false;
}
qDebug() << "[Dushanbe::inputPinCode] entering" << pinCode.length() << "digits";
// DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9")
for (const QChar &digit : pinCode) {
Node button = xmlScreenParser.findNodeByContentDesc(QString(digit));
if (button.isEmpty()) {
qWarning() << "[Dushanbe::inputPinCode] Button not found for digit:" << digit;
return false;
}
AdbUtils::makeTap(deviceId, button.x(), button.y());
QThread::msleep(300);
}
return true;
}
bool CommonScript::runAppAndGoToHomeScreen(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
const int width,
const int height
) {
AdbUtils::unlockScreen(deviceId, width, height);
// Быстрый path: если приложение уже запущено — не перезапускаем сразу,
// а смотрим текущий экран. Если это Home — готово. Если PIN — вводим
// и ждём Home. Только если не смогли — убиваем и рестартим.
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
qDebug() << "[Dushanbe] Process already running, pid:" << pid;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe] Fast path: already on Home screen";
return true;
}
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe] Fast path: PIN screen, entering PIN";
if (inputPinCode(deviceId, pinCode)) {
QThread::msleep(3000);
for (int i = 0; i < 10; ++i) {
xmlScreenParser.parseAndSaveXml(
AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe] Fast path: Home reached after PIN";
return true;
}
QThread::msleep(1000);
}
}
}
qDebug() << "[Dushanbe] Fast path failed, restarting app";
AdbUtils::tryToKillApplication(deviceId, packageName);
}
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
AdbUtils::takeScreenshot(deviceId, "run_app_error");
qWarning() << "[Dushanbe] Process has not started";
return false;
}
QThread::msleep(4500);
int counter = 0;
bool findAndInputPincode = false;
while (true) {
counter++;
if (counter > 20) {
qWarning() << "[Dushanbe] runAppAndGoToHomeScreen: too many attempts, giving up";
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe] Home reached on attempt" << counter;
return true;
}
if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe] PIN screen detected on attempt" << counter << "entering PIN";
if (inputPinCode(deviceId, pinCode)) {
findAndInputPincode = true;
} else {
// Клавиатура могла не дорисоваться или PIN ввёлся частично —
// оставляем флаг false, следующая итерация повторит ввод,
// когда кнопки появятся.
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter << "— will retry";
}
QThread::msleep(3000);
continue;
}
// Диагностика: что реально на экране?
QStringList sample;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.contentDesc.isEmpty() && n.text.isEmpty()) continue;
QString s = !n.contentDesc.isEmpty() ? n.contentDesc : n.text;
s.replace('\n', ' ');
if (s.size() > 60) s = s.left(60) + "...";
sample.append(s);
if (sample.size() >= 8) break;
}
qDebug() << "[Dushanbe] attempt" << counter << "not home, not PIN. Sample:" << sample;
QThread::msleep(1000);
}
}
void CommonScript::postAccountsData(const QList<QPair<MaterialInfo, Node>> &accounts) {
if (accounts.isEmpty()) {
return;
}
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonArray list;
for (const QPair<MaterialInfo, Node> &pair : accounts) {
QJsonObject obj;
MaterialInfo account = pair.first;
obj["appName"] = account.appCode;
obj["lastNumbers"] = account.lastNumbers;
obj["amount"] = account.amount;
obj["description"] = account.description;
obj["currency"] = account.currency;
obj["status"] = materialStatusToString(account.status);
list.append(obj);
}
worker->setDeviceId(accounts.first().first.deviceId);
worker->setPayload(QJsonDocument(list).toJson());
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
connect(worker, &NetworkService::finished, thread, &QThread::quit);
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
void CommonScript::postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction) {
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonObject obj;
obj["id"] = transaction.id;
obj["amount"] = transaction.amount;
obj["name"] = transaction.name;
obj["fee"] = transaction.fee;
obj["status"] = transactionStatusToString(transaction.status);
obj["type"] = transactionTypeToString(transaction.type);
obj["phone"] = transaction.phone;
obj["description"] = transaction.description;
obj["updatedDescription"] = transaction.updatedDescription;
obj["bankName"] = transaction.bankName;
obj["bankTime"] = transaction.bankTime;
obj["bankTransactionId"] = transaction.bankTrExternalId;
worker->setDeviceId(account.deviceId);
worker->addExtra("lastNumbers", account.lastNumbers);
worker->addExtra("transactionId", transaction.id);
worker->setPayload(QJsonDocument(obj).toJson());
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &NetworkService::insertTransactionData);
connect(worker, &NetworkService::finished, thread, &QThread::quit);
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
void CommonScript::updateTransactionData(
const MaterialInfo &account,
const int transactionId,
const TransactionStatus status,
const QString &oldDesc,
const QString &newDesc
) {
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonObject obj;
obj["id"] = transactionId;
obj["status"] = transactionStatusToString(status);
obj["description"] = newDesc;
obj["updatedDescription"] = oldDesc;
worker->setDeviceId(account.deviceId);
worker->addExtra("lastNumbers", account.lastNumbers);
worker->setPayload(QJsonDocument(obj).toJson());
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &NetworkService::updateTransactionData);
connect(worker, &NetworkService::finished, thread, &QThread::quit);
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
qWarning() << "[Dushanbe] createBankProfileIfNeeded: skipping — fullName or phone is empty";
return {};
}
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
const int currencyCode = currencyCodes.value(app.currency.toUpper(), 840);
const QStringList parts = app.fullName.split(' ', Qt::SkipEmptyParts);
const QSettings settings("config.ini", QSettings::IniFormat);
const QString phoneCode = settings.value(appCode + "/phone_code").toString();
QString phone = app.phone;
phone.remove(QRegularExpression("[^0-9]"));
if (!phoneCode.isEmpty() && !phone.startsWith(phoneCode)) {
if (phone.startsWith('8') && phoneCode != "8") {
phone.replace(0, 1, phoneCode);
} else {
phone.prepend(phoneCode);
}
}
phone.prepend('+');
const DeviceInfo device = DeviceDAO::getDeviceById(deviceId);
QJsonObject profileBody;
profileBody["first_name"] = parts.value(0);
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : QString("");
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
profileBody["bank_name"] = appCode;
profileBody["phone_number"] = phone;
const double balance = (parsedBalance >= 0.0) ? parsedBalance : app.amount;
profileBody["balance"] = QString::number(qRound64(balance * 100));
profileBody["state"] = (app.status == "active") ? "enabled" : "disabled";
profileBody["device_id"] = device.apiId;
profileBody["currency"] = currencyCode;
NetworkService ns;
ns.setDeviceId(deviceId);
ns.setPayload(QJsonDocument(profileBody).toJson());
if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id);
ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) {
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err;
return err;
}
qDebug() << "[Dushanbe] createBankProfileIfNeeded: postBankProfile done";
} else {
ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile();
qDebug() << "[Dushanbe] createBankProfileIfNeeded: patchBankProfile done";
}
return {};
}
void CommonScript::stop() {
m_running = false;
}
bool CommonScript::closeGooglePlayBannerIfVisible(
const QString &deviceId, const int width, const int height
) {
// 1. Системный диалог запроса разрешения Android (permissioncontroller) — тапаем "ЗАПРЕТИТЬ"
if (Node denyBtn = xmlScreenParser.tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
qDebug() << "[Dushanbe] System permission dialog detected mid-task, tapping 'ЗАПРЕТИТЬ'...";
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
return true;
}
// 2. Google Play in-app update bottom sheet
Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height);
if (gpNode.isEmpty()) return false;
qDebug() << "[Dushanbe] Google Play update banner detected mid-task, closing...";
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
return true;
}
} // namespace Dushanbe

View File

@ -0,0 +1,78 @@
#pragma once
#include <QObject>
#include "ScreenXmlParser.h"
#include "db/TransactionInfo.h"
namespace Dushanbe {
class CommonScript : public QObject {
Q_OBJECT
public:
explicit CommonScript(QObject *parent = nullptr);
~CommonScript() override;
protected:
virtual void doStart() = 0;
bool inputPinCode(const QString &deviceId, const QString &pinCode);
bool runAppAndGoToHomeScreen(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
int width,
int height
);
Node swipeToButton(
const QString &deviceId,
const QString &text,
int &counter, int width, int height
);
void postAccountsData(const QList<QPair<MaterialInfo, Node>> &accounts);
void postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction);
void updateTransactionData(
const MaterialInfo &account,
int transactionId,
TransactionStatus status,
const QString &oldDesc,
const QString &newDesc
);
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
// Если виден баннер Google Play "Доступно обновление" — закрывает его и перепарсивает XML.
// Безопасно вызывать на любом экране; возвращает true если баннер был закрыт.
bool closeGooglePlayBannerIfVisible(
const QString &deviceId, int width, int height
);
bool m_running = true;
ScreenXmlParser xmlScreenParser;
int m_counter = 0;
QByteArray m_resultScreenshot;
signals:
void finished();
void finishedWithResult(const QString &result);
void screenshotReady(const QByteArray &screenshot);
public slots:
virtual void start() final;
virtual void stop() final;
private:
Q_DISABLE_COPY(CommonScript);
};
} // namespace Dushanbe

View File

@ -0,0 +1,307 @@
#include "GetCardInfoScript.h"
#include <QThread>
#include <QJsonDocument>
#include <QJsonObject>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h"
namespace Dushanbe {
GetCardInfoScript::GetCardInfoScript(
QString deviceId,
QString appCode,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)) {
}
GetCardInfoScript::~GetCardInfoScript() = default;
void GetCardInfoScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::GetCardInfo] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
}
// Парсим баланс с домашнего экрана
const double balance = xmlScreenParser.parseBalanceFromHomeScreen();
qDebug() << "[Dushanbe::GetCardInfo] balance:" << balance;
if (!BankProfileDAO::updateAmount(app.id, balance)) {
qWarning() << "[Dushanbe::GetCardInfo] Failed to update balance for app" << app.id;
}
// Тапаем "Карты" на домашнем экране
Node cardsButton = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Карты"));
if (cardsButton.x() == 0 && cardsButton.y() == 0) {
m_error = "Cannot find Cards button: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
AdbUtils::makeTap(m_deviceId, cardsButton.x(), cardsButton.y());
QThread::msleep(3000);
// Ждём экран "Мои карты"
bool cardsScreenFound = false;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isCardsScreen()) {
cardsScreenFound = true;
break;
}
QThread::msleep(1000);
}
if (!cardsScreenFound) {
m_error = "Cards screen not detected: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// Парсим карты из списка (lastNumbers, баланс)
const QList<ScreenXmlParser::CardInfo> cards = xmlScreenParser.parseCards();
qDebug() << "[Dushanbe::GetCardInfo] Found" << cards.size() << "cards in list";
if (cards.isEmpty()) {
m_error = "No cards found: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// Находим все clickable ImageView с "****" — это карточки в списке
// Пропускаем кредитные карты
QList<Node> cardNodes;
for (const Node &node : xmlScreenParser.findAllNodes()) {
if (node.className == "android.widget.ImageView" && node.clickable &&
node.contentDesc.contains("****") &&
!node.contentDesc.contains(QString::fromUtf8("Кредитная карта"))) {
cardNodes.append(node);
}
}
// Кликаем по каждой карте для получения полного номера
for (int i = 0; i < cardNodes.size(); ++i) {
if (i >= cards.size()) break;
const Node &cardNode = cardNodes[i];
AdbUtils::makeTap(m_deviceId, cardNode.x(), cardNode.y());
QThread::msleep(2000);
// Ждём экран детали карты
bool detailFound = false;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isCardDetailScreen()) {
detailFound = true;
break;
}
QThread::msleep(1000);
}
if (!detailFound) {
qWarning() << "[Dushanbe::GetCardInfo] Card detail screen not found for card" << i;
continue;
}
// Ждём загрузки баланса (может быть 0 пока данные подтягиваются)
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
QString fullNumber = xmlScreenParser.parseFullCardNumber();
// Баланс берём с домашнего экрана (parseBalanceFromHomeScreen выше):
// в Душанбе все карты привязаны к одному счёту, и на экране детали
// карты баланс часто отображается как placeholder "0,00 TJS" пока
// данные подгружаются с сервера.
const double cardBalance = balance;
const QString holder = xmlScreenParser.parseCardDetailHolder();
const QString fullNumberClean = QString(fullNumber).remove(' ');
qDebug() << "[Dushanbe::GetCardInfo] Card" << i << ":" << fullNumber
<< "balance:" << cardBalance << "holder:" << holder;
// Без полного номера карты материал не создаём и на сервер не отправляем —
// защита от записей с пустым card_number (см. Ozon::GetCardInfo).
if (fullNumberClean.isEmpty()) {
qWarning() << "[Dushanbe::GetCardInfo] Full card number not parsed for card" << i
<< "(home-screen lastNumbers=" << cards[i].lastNumbers << ") — skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1500);
continue;
}
const QString lastNumbers = fullNumberClean.right(4);
// Достаём номер счёта: Настройки карты → Реквизиты карты → таб "Реквизиты счета".
// ВРЕМЕННО ОТКЛЮЧЕНО
QString accountNumber;
// const Node settingsBtn = xmlScreenParser.findCardSettingsButton();
// if (settingsBtn.x() > 0) {
// AdbUtils::makeTap(m_deviceId, settingsBtn.x(), settingsBtn.y());
// QThread::msleep(1500);
//
// bool sheetFound = false;
// for (int attempt = 0; attempt < 5; ++attempt) {
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// if (xmlScreenParser.isCardSettingsBottomSheet()) {
// sheetFound = true;
// break;
// }
// QThread::msleep(1000);
// }
//
// if (sheetFound) {
// const Node requisitesItem = xmlScreenParser.findCardRequisitesMenuItem();
// if (requisitesItem.x() > 0) {
// AdbUtils::makeTap(m_deviceId, requisitesItem.x(), requisitesItem.y());
// QThread::msleep(1500);
//
// bool reqScreenFound = false;
// for (int attempt = 0; attempt < 5; ++attempt) {
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// if (xmlScreenParser.isCardRequisitesScreen()) {
// reqScreenFound = true;
// break;
// }
// QThread::msleep(1000);
// }
//
// if (reqScreenFound) {
// const Node accountTab = xmlScreenParser.findAccountRequisitesTab();
// if (accountTab.x() > 0) {
// AdbUtils::makeTap(m_deviceId, accountTab.x(), accountTab.y());
// QThread::msleep(1500);
//
// // Несколько попыток — данные могут подгружаться
// for (int attempt = 0; attempt < 5; ++attempt) {
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// accountNumber = xmlScreenParser.parseAccountNumber();
// if (!accountNumber.isEmpty()) break;
// QThread::msleep(1000);
// }
// qDebug() << "[Dushanbe::GetCardInfo] accountNumber for card" << i
// << ":" << accountNumber;
// } else {
// qWarning() << "[Dushanbe::GetCardInfo] Account tab not found for card" << i;
// }
// } else {
// qWarning() << "[Dushanbe::GetCardInfo] Requisites screen not detected for card" << i;
// }
// } else {
// qWarning() << "[Dushanbe::GetCardInfo] 'Реквизиты карты' menu item not found for card" << i;
// }
// } else {
// qWarning() << "[Dushanbe::GetCardInfo] Card settings bottom sheet not detected for card" << i;
// }
//
// // Возврат на экран детали карты — bottom sheet и/или экран Реквизитов
// for (int attempt = 0; attempt < 4; ++attempt) {
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// if (xmlScreenParser.isCardDetailScreen()) break;
// AdbUtils::goBack(m_deviceId);
// QThread::msleep(1200);
// }
// } else {
// qWarning() << "[Dushanbe::GetCardInfo] 'Настройки карты' button not found for card" << i;
// }
MaterialInfo account;
account.deviceId = device.id;
account.appCode = m_appCode;
account.lastNumbers = lastNumbers;
account.cardNumber = QString(fullNumber).remove(' ');
account.accountNumber = accountNumber;
account.amount = cardBalance;
account.currency = "TJS";
account.description = cards[i].description.isEmpty() ? holder : cards[i].description;
account.status = MaterialStatus::Off;
account.updateTime = QDateTime::currentDateTimeUtc();
qDebug() << "[Dushanbe::GetCardInfo] Saving card:" << "lastNumbers=" << lastNumbers
<< "cardNumber=" << account.cardNumber << "amount=" << cardBalance;
if (!MaterialDAO::upsertAccount(account)) {
qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers;
}
// Отправляем на сервер если нет material_id
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
if (!updatedApp.bankProfileId.isEmpty()) {
const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
if (savedAccount.id != -1 && savedAccount.materialId.isEmpty()) {
NetworkService ns;
ns.setDeviceId(m_deviceId);
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
QJsonObject materialBody;
materialBody["bank_profile_id"] = updatedApp.bankProfileId;
materialBody["card_number"] = QString(fullNumber).remove(' ');
materialBody["account_number"] = accountNumber;
materialBody["name"] = updatedApp.fullName;
materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled";
materialBody["currency"] = currencyCodes.value(savedAccount.currency.toUpper(), 972);
ns.setPayload(QJsonDocument(materialBody).toJson());
ns.addExtra("account_id", savedAccount.id);
ns.postMaterial();
qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers;
}
}
// Назад к списку карт
AdbUtils::goBack(m_deviceId);
QThread::msleep(1500);
// Ждём возврата на экран "Мои карты"
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isCardsScreen()) break;
QThread::msleep(1000);
}
}
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
}
} // namespace Dushanbe

View File

@ -0,0 +1,27 @@
#pragma once
#include "CommonScript.h"
namespace Dushanbe {
class GetCardInfoScript final : public CommonScript {
Q_OBJECT
public:
explicit GetCardInfoScript(
QString deviceId,
QString appCode,
QObject *parent = nullptr
);
~GetCardInfoScript() override;
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
QString m_error;
};
} // namespace Dushanbe

View File

@ -0,0 +1,918 @@
#include "GetLastTransactionsScript.h"
#include <QJsonDocument>
#include <QRegularExpression>
#include <QSet>
#include <QThread>
#include <QTimeZone>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "AppLogger.h"
#include <QCoreApplication>
// Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50",
// "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0,
// потому что требует, чтобы вся строка была валидным числом.
static double parseDushanbeAmount(const QString &raw) {
QString s = raw;
s.remove(QRegularExpression(R"([^\d,.\-])"));
s.replace(',', '.');
const int firstDot = s.indexOf('.');
if (firstDot >= 0) {
QString head = s.left(firstDot);
QString tail = s.mid(firstDot + 1);
tail.remove('.');
s = head + '.' + tail;
}
return s.toDouble();
}
static QString recognizeTextFromImage(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
qWarning() << "[Dushanbe::OCR] Failed to init Tesseract";
delete api;
return {};
}
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) {
api->End(); delete api;
return {};
}
api->SetImage(pix);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&pix);
api->End();
delete api;
return result;
}
// Точечный OCR: находит "Номер операции" через общий OCR, затем кропает строку правее
// и распознаёт только цифры. Работает с любым разрешением экрана.
static QString recognizeOperationId(const QByteArray &pngData) {
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) return {};
const int w = pixGetWidth(pix);
const int h = pixGetHeight(pix);
// 1. Полный OCR с координатами слов — ищем "операции"
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
pixDestroy(&pix); delete api;
return {};
}
api->SetImage(pix);
api->Recognize(nullptr);
int opLineY = -1;
int opLineH = 0;
bool prevWasNomer = false;
tesseract::ResultIterator *ri = api->GetIterator();
if (ri) {
do {
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
if (word) {
const QString w_str = QString::fromUtf8(word);
delete[] word;
if (w_str.contains(QString::fromUtf8("омер")) ||
w_str.contains(QString::fromUtf8("omep"))) {
prevWasNomer = true;
continue;
}
if (prevWasNomer &&
(w_str.contains(QString::fromUtf8("операции")) ||
w_str.contains(QString::fromUtf8("onepaции")))) {
int x1, y1, x2, y2;
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
opLineY = y1;
opLineH = y2 - y1;
break;
}
prevWasNomer = false;
}
} while (ri->Next(tesseract::RIL_WORD));
}
api->End();
delete api;
if (opLineY < 0) {
pixDestroy(&pix);
return {};
}
// 2. Кропаем правую половину этой строки и OCR только цифры
const int cropY = qMax(0, opLineY - opLineH / 2);
const int cropH = opLineH * 2;
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
Pix *cropped = pixClipRectangle(pix, box, nullptr);
boxDestroy(&box);
pixDestroy(&pix);
if (!cropped) return {};
auto *api2 = new tesseract::TessBaseAPI();
if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) {
pixDestroy(&cropped); delete api2;
return {};
}
api2->SetVariable("tessedit_char_whitelist", "0123456789");
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
api2->SetImage(cropped);
char *text = api2->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&cropped);
api2->End();
delete api2;
result.remove(QRegularExpression("[^0-9]"));
if (result.length() >= 8) {
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
return result;
}
return {};
}
struct OcrTransactionDetail {
QString date;
QString time;
QString operationId;
QString provider;
QString senderAccount;
QString receiverAccount;
double amount = 0.0;
double fee = 0.0;
QString status;
};
static OcrTransactionDetail parseDetailFromOcr(const QString &ocrText) {
OcrTransactionDetail d;
const QStringList lines = ocrText.split('\n');
for (const QString &rawLine : lines) {
const QString line = rawLine.trimmed();
// "Дата операции: 05.04.2026" или "Дата операции:" на отдельной строке
if (line.contains(QString::fromUtf8("Дата операции"))) {
QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))");
if (auto m = re.match(line); m.hasMatch()) d.date = m.captured(1);
}
// "Время операции: 18:19:54"
else if (line.contains(QString::fromUtf8("Время операции"))) {
QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))");
if (auto m = re.match(line); m.hasMatch()) d.time = m.captured(1);
}
// "Номер операции: 1598279763"
else if (line.contains(QString::fromUtf8("омер операции"))) {
QRegularExpression re(R"((\d{5,}))");
if (auto m = re.match(line); m.hasMatch()) d.operationId = m.captured(1);
}
// "Поставщик: DC (по номеру карты)"
else if (line.contains(QString::fromUtf8("Поставщик"))) {
QRegularExpression re(R"(Поставщик[:\s]*(.+))");
if (auto m = re.match(line); m.hasMatch()) d.provider = m.captured(1).trimmed();
}
// "Счет отправителя: 9762***3258"
else if (line.contains(QString::fromUtf8("Счет отправителя")) ||
line.contains(QString::fromUtf8("чет отправителя"))) {
QRegularExpression re(R"((\d[\d*]+))");
if (auto m = re.match(line); m.hasMatch()) d.senderAccount = m.captured(1);
}
// "Счет получателя: 9762000186263942" или "992111100664"
else if (line.contains(QString::fromUtf8("Счет получателя")) ||
line.contains(QString::fromUtf8("чет получателя"))) {
QRegularExpression re(R"((\d{6,}))");
if (auto m = re.match(line); m.hasMatch()) d.receiverAccount = m.captured(1);
}
// "Сумма операции: 1.00"
else if (line.contains(QString::fromUtf8("Сумма операции")) ||
line.contains(QString::fromUtf8("умма операции"))) {
QRegularExpression re(R"((\d+[\.,]\d{2}))");
if (auto m = re.match(line); m.hasMatch()) {
QString val = m.captured(1);
val.replace(',', '.');
d.amount = val.toDouble();
}
}
// "Комиссия: 0.00"
else if (line.contains(QString::fromUtf8("Комиссия")) ||
line.contains(QString::fromUtf8("омиссия"))) {
QRegularExpression re(R"((\d+[\.,]\d{2}))");
if (auto m = re.match(line); m.hasMatch()) {
QString val = m.captured(1);
val.replace(',', '.');
d.fee = val.toDouble();
}
}
// "Статус: Успешный"
else if (line.contains(QString::fromUtf8("Статус"))) {
if (line.contains(QString::fromUtf8("Успешн"))) {
d.status = QString::fromUtf8("Успешный");
}
}
}
// Fallback: если дата/время не найдены в "ключ: значение", ищем паттерном
if (d.date.isEmpty()) {
QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))");
if (auto m = re.match(ocrText); m.hasMatch()) d.date = m.captured(1);
}
if (d.time.isEmpty()) {
QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))");
auto it = re.globalMatch(ocrText);
QStringList times;
while (it.hasNext()) times.append(it.next().captured(1));
if (times.size() >= 2) d.time = times[1];
else if (times.size() == 1) d.time = times[0];
}
if (ocrText.contains(QString::fromUtf8("Успешн"))) d.status = QString::fromUtf8("Успешный");
return d;
}
namespace Dushanbe {
GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId,
QString appCode,
double searchAmount,
QDateTime searchTime,
QString materialId,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)),
m_materialId(std::move(materialId)) {
}
GetLastTransactionsScript::~GetLastTransactionsScript() = default;
void GetLastTransactionsScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
qDebug() << "[Dushanbe::GetLastTransactions] device.id=" << device.id << "adbSerial=" << device.adbSerial
<< "appCode=" << m_appCode << "app.id=" << app.id
<< "pinCode.length=" << app.pinCode.length() << "package=" << app.package;
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::GetLastTransactions] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
qWarning() << m_error;
AppLogger::log("dushanbe/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Тапаем "История" в нижнем меню
Node historyBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История"));
if (historyBtn.x() == 0 && historyBtn.y() == 0) {
m_error = "Cannot find History button: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
AdbUtils::makeTap(m_deviceId, historyBtn.x(), historyBtn.y());
QThread::msleep(3000);
// Ждём экран истории
bool historyLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// Проверяем диалог "Ошибка" — нажимаем "ОК" и обновляем свайпом вниз
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) {
Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК"));
if (!okBtn.isEmpty()) {
qDebug() << "[Dushanbe::GetLastTransactions] Error dialog detected, tapping OK";
AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y());
QThread::msleep(1000);
}
continue;
}
if (xmlScreenParser.isHistoryScreen()) {
historyLoaded = true;
break;
}
QThread::msleep(1500);
}
if (!historyLoaded) {
m_error = "History screen not loaded: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// Переключаемся на вкладку "Выписка" (по умолчанию открыта "Операции")
Node vypiskaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Выписка"));
if (vypiskaBtn.isEmpty()) {
m_error = "Cannot find 'Выписка' tab: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
qDebug() << "[Dushanbe::GetLastTransactions] Switching to 'Выписка' tab";
AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y());
QThread::msleep(3000);
// Pull-to-refresh: свайп сверху вниз для обновления списка
{
const int x = device.screenWidth / 2;
const int fromY = device.screenHeight / 3;
const int toY = device.screenHeight * 2 / 3;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(3000);
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
}
// Сохраняем дамп Выписки для анализа формата
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Закрываем возможный диалог "Ошибка" на Выписке
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) {
Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК"));
if (!okBtn.isEmpty()) {
qDebug() << "[Dushanbe::GetLastTransactions] Error dialog on Выписка, tapping OK";
AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y());
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
}
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Находим accountId
int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
if (!accounts.isEmpty()) {
accountId = accounts.first().id;
} else {
qWarning() << "[Dushanbe::GetLastTransactions] No account found";
}
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate);
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
const QTimeZone tjTz("Asia/Dushanbe");
const int maxScrolls = 10;
auto interrupted = []() {
return QThread::currentThread()->isInterruptionRequested();
};
struct VypiskaItem {
double amount = 0.0;
QString time; // "21:31:21"
QString date; // "05.04.2026"
bool dateReliable = false; // true если дата из content-desc (P2P), false если из groupDates
Node node;
};
for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
if (interrupted()) {
m_error = "Interrupted";
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// Верх нижнего меню: минимальный y1() среди табов и FAB "Сканировать".
// Тап по строке с y >= navTopY - navMargin промахивается и попадает в FAB/таб.
int navTopY = device.screenHeight;
for (const QString &lbl : {
QString::fromUtf8("Главная"), QString::fromUtf8("История"),
QString::fromUtf8("Переводы"), QString::fromUtf8("Сканировать")}) {
Node nav = xmlScreenParser.findNodeByContentDesc(lbl);
if (!nav.isEmpty() && nav.y1() > 0 && nav.y1() < navTopY) navTopY = nav.y1();
}
const int navMargin = 20;
qDebug() << "[Dushanbe::GetLastTransactions] navTopY=" << navTopY;
// 1. Собираем список дат-групп + извлекаем "встроенную" верхнюю транзакцию,
// которая впечатана в content-desc родительского ImageView "Обновлено: ..."
// вместо отдельного android.view.View (самая свежая запись в Выписке).
const QDate todayLocal = QDateTime::currentDateTimeUtc().toTimeZone(tjTz).date();
QStringList groupDates;
static const QRegularExpression reDate(R"(^\d{2}\.\d{2}\.\d{4}$)");
static const QRegularExpression reEmbTime(R"(^\s*(\d{2}:\d{2}:\d{2})\s*$)");
Node updatedImgNode;
VypiskaItem embedded;
bool hasEmbedded = false;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.widget.ImageView") continue;
if (!n.contentDesc.startsWith(QString::fromUtf8("Обновлено"))) continue;
updatedImgNode = n;
const QStringList descLines = n.contentDesc.split('\n');
for (const QString &line : descLines) {
const QString t = line.trimmed();
if (reDate.match(t).hasMatch()) {
groupDates.append(t);
} else if (t.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0) {
groupDates.append(todayLocal.toString("dd.MM.yyyy"));
} else if (t.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0) {
groupDates.append(todayLocal.addDays(-1).toString("dd.MM.yyyy"));
}
}
// Паттерн встроенной: ...\ncard\ncard\namount\nописание\n HH:MM:SS\n...
for (int i = 4; i < descLines.size(); ++i) {
const auto tm = reEmbTime.match(descLines[i]);
if (!tm.hasMatch()) continue;
bool ok = false;
const double amt = descLines[i - 2].trimmed().toDouble(&ok);
if (!ok) continue;
embedded.amount = amt;
embedded.time = tm.captured(1);
if (!groupDates.isEmpty()) embedded.date = groupDates.first();
hasEmbedded = true;
break;
}
break;
}
// 2. Собираем View-транзакции (формат Выписки)
// Известные форматы content-desc (всё через '\n'):
// a) DCWallet (старый, 5 строк): DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
// b) DCWallet (новый, 6 строк): DCWallet*WDC...\nphone-card-\ncard_owner\namount\nописание\n HH:MM:SS
// c) P2P: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
// Позиция amount уехала между версиями приложения, поэтому ищем по
// паттерну "[-]?digits.dd" среди всех строк — устойчиво к перестановке полей.
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
static const QRegularExpression reDateOrToday(
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)");
QList<VypiskaItem> items;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.view.View") continue;
const QStringList lines = n.contentDesc.split('\n');
if (lines.size() < 5) continue;
const QString last = lines.last().trimmed();
if (!reTime.match(last).hasMatch()) continue;
// Определяем формат: если первая строка — дата/Сегодня/Вчера, это P2P формат
const QString firstLine = lines.first().trimmed();
const bool isP2P = reDateOrToday.match(firstLine).hasMatch();
if (!isP2P && !n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue;
VypiskaItem it;
bool ok = false;
// Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному
// DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые
// поля не подпадают под паттерн.
for (const QString &line : lines) {
if (!reAmountLine.match(line.trimmed()).hasMatch()) continue;
it.amount = line.trimmed().toDouble(&ok);
if (ok) break;
}
if (!ok) continue;
it.time = last;
it.node = n;
// В P2P формате дата встроена в первую строку — надёжная
if (isP2P) {
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.toString("dd.MM.yyyy");
else if (firstLine.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
else
it.date = firstLine;
it.dateReliable = true;
}
items.append(it);
}
// 2a. Если нашли встроенную транзакцию — создаём синтетический Node с
// координатами тапа над первой "реальной" View-записью и добавляем
// как первый элемент списка.
if (hasEmbedded && !updatedImgNode.isEmpty()) {
int firstViewY1 = -1;
int firstViewRowH = 0;
for (const VypiskaItem &v : items) {
if (firstViewY1 < 0 || v.node.y1() < firstViewY1) {
firstViewY1 = v.node.y1();
firstViewRowH = v.node.height();
}
}
const int topY = updatedImgNode.y1();
// Если первая реальная View-запись уже находится у верха ImageView
// (значит скролл поднял контент и встроенная запись ушла за экран),
// пропускаем embedded — её визуально нет.
const int minEmbeddedSpace = 120;
const bool embeddedVisible = firstViewY1 < 0 || (firstViewY1 - topY) >= minEmbeddedSpace;
if (embeddedVisible) {
int tapY;
if (firstViewY1 > 0 && firstViewRowH > 0) {
// Между встроенной записью и первой "реальной" View лежит
// заголовок даты (~0.4 * rowH). Ставим тап по центру
// строки: firstViewY1 - headerH - rowH/2 ≈ firstViewY1 - rowH * 0.9.
// Дополнительно ограничиваем диапазоном [topY + rowH/2 .. firstViewY1 - 40].
tapY = firstViewY1 - (firstViewRowH * 9 / 10);
const int minY = topY + firstViewRowH / 2;
const int maxY = firstViewY1 - 40;
if (tapY < minY) tapY = minY;
if (tapY > maxY) tapY = maxY;
} else {
tapY = topY + 200;
}
Node synth;
synth.setBounds(updatedImgNode.x1(), tapY - 10, updatedImgNode.x2(), tapY + 10);
synth.className = "android.view.View";
synth.contentDesc = updatedImgNode.contentDesc;
synth.clickable = true;
embedded.node = synth;
items.prepend(embedded);
qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx added:"
<< "amount=" << embedded.amount << "time=" << embedded.time
<< "date=" << embedded.date << "tapY=" << tapY;
} else {
qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx skipped "
"(scrolled off): firstViewY1=" << firstViewY1 << "topY=" << topY;
}
}
// 3. Сортируем по Y и группируем по разрывам (> 30px → новая группа-день)
std::sort(items.begin(), items.end(), [](const VypiskaItem &a, const VypiskaItem &b) {
return a.node.y1() < b.node.y1();
});
int groupIdx = 0;
int prevY2 = -1;
for (VypiskaItem &it : items) {
if (prevY2 >= 0 && it.node.y1() - prevY2 > 30) ++groupIdx;
// Не перезаписываем дату если она уже установлена (P2P формат)
if (it.date.isEmpty() && groupIdx < groupDates.size())
it.date = groupDates[groupIdx];
prevY2 = it.node.y2();
}
qDebug() << "[Dushanbe::GetLastTransactions] scroll=" << scroll
<< "items:" << items.size() << "groups:" << groupDates
<< "hasEmbedded:" << hasEmbedded;
// Логируем все items для отладки
for (int i = 0; i < items.size(); ++i) {
qDebug() << "[Dushanbe::GetLastTransactions] item[" << i << "] amount=" << items[i].amount
<< "date=" << items[i].date << "time=" << items[i].time
<< "y=" << items[i].node.y();
}
const double searchAbs = qAbs(m_searchAmount);
for (const auto &tx : items) {
const double absListAmt = qAbs(tx.amount);
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
const bool amountX100 = qAbs(absListAmt * 100.0 - searchAbs) < 0.01;
if (!amountDirect && !amountX100) continue;
const bool matchedX100 = amountX100 && !amountDirect;
qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount
<< (matchedX100 ? "(scaled x100)" : "(direct)")
<< "date:" << tx.date << "time:" << tx.time;
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
// DCWallet транзакции получают дату из groupDates ImageView, которая
// неправильно присваивается при скролле — не фильтруем по ней.
if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) {
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
if (listDate.isValid() && searchLocalDate.isValid()) {
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
if (dayDiff > 3) {
qDebug() << "[Dushanbe::GetLastTransactions] Date pre-filter skip:"
<< tx.date << "vs" << searchLocalDate.toString("dd.MM.yyyy")
<< "diff=" << dayDiff << "days";
continue;
}
}
}
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи)
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
if (listTime.isValid() && searchLocalTime.isValid()) {
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m
qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:"
<< tx.time << "vs" << searchLocalTime.toString("HH:mm:ss")
<< "diff=" << diffSecs << "sec";
continue;
}
}
}
Node txNode = tx.node;
if (txNode.x() == 0 && txNode.y() == 0) {
qWarning() << "[Dushanbe::GetLastTransactions] Cannot find node for transaction";
continue;
}
// Строка под нижним меню — тап попадёт в FAB "Сканировать" или таб.
// Пропускаем кандидата, ищем совпадение выше; если таких нет — скролл.
if (txNode.y() >= navTopY - navMargin) {
qDebug() << "[Dushanbe::GetLastTransactions] Candidate y=" << txNode.y()
<< "behind nav (navTopY=" << navTopY << "), skipping, will scroll";
continue;
}
// Тап по левому краю ряда — уходим от центра экрана, где FAB "Сканировать".
const int tapX = txNode.x1() + 80;
AdbUtils::makeTap(m_deviceId, tapX, txNode.y());
QThread::msleep(3000);
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
OcrTransactionDetail detail;
bool detailLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml):
// одна нода со всем content-desc через \n. Лейблы без двоеточий, сумма —
// отдельной строкой со знаком сразу после даты+времени.
// "Успешный платёж\n21.05.2026 16:58:32\n-10\nКарта\n...\nПолучатель\n...\nЗакрыть"
for (const Node &n : xmlScreenParser.findAllNodes()) {
const QString cd = n.contentDesc;
if (!cd.contains(QString::fromUtf8("Карта"))) continue;
if (!cd.contains(QString::fromUtf8("Получатель"))) continue;
if (!cd.contains(QString::fromUtf8("Операция"))) continue;
const QStringList lines = cd.split('\n', Qt::SkipEmptyParts);
if (lines.size() < 6) continue;
// lines[0] — заголовок: "Успешный платёж" / "Поступление" / ...
if (lines[0].contains(QString::fromUtf8("Успешн"))
|| lines[0].contains(QString::fromUtf8("Поступление"))
|| lines[0].contains(QString::fromUtf8("выполнен"))) {
detail.status = QString::fromUtf8("Успешный");
}
// lines[1] — "dd.MM.yyyy HH:mm:ss"
{
static const QRegularExpression reDt(
R"((\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2}))");
const auto m = reDt.match(lines[1]);
if (m.hasMatch()) {
detail.date = m.captured(1);
detail.time = m.captured(2);
}
}
// lines[2] — сумма со знаком: "-10" / "30" / "30.50" / "1 000,50".
detail.amount = parseDushanbeAmount(lines[2]);
// Дальше — пары label/value; значение может быть multi-line (Получатель).
// На сервер шлём первую строку получателя — таков сложившийся контракт.
static const QSet<QString> labels{
QString::fromUtf8("Карта"),
QString::fromUtf8("Получатель"),
QString::fromUtf8("Операция"),
QString::fromUtf8("Дата"),
QString::fromUtf8("Время"),
QString::fromUtf8("Закрыть"),
QString::fromUtf8("Назад"),
};
for (int i = 3; i < lines.size(); ++i) {
const QString lbl = lines[i].trimmed();
if (!labels.contains(lbl)) continue;
QStringList vals;
for (int j = i + 1; j < lines.size(); ++j) {
const QString cand = lines[j].trimmed();
if (labels.contains(cand)) break;
vals << cand;
}
if (vals.isEmpty()) continue;
if (lbl == QString::fromUtf8("Получатель")) {
detail.receiverAccount = vals.first();
} else if (lbl == QString::fromUtf8("Дата") && detail.date.isEmpty()) {
detail.date = vals.first();
} else if (lbl == QString::fromUtf8("Время") && detail.time.isEmpty()) {
detail.time = vals.first();
}
}
detailLoaded = true;
break;
}
// 2) Старый blob-формат (до 3.2.33): "Успешный платеж" / "Поступление" —
// одна нода с ключами и значениями через \n.
if (!detailLoaded) for (const Node &n : xmlScreenParser.findAllNodes()) {
if (!n.contentDesc.contains(QString::fromUtf8("Дата"))) continue;
if (!n.contentDesc.contains(QString::fromUtf8("Время"))) continue;
const QStringList lines = n.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
QString key = lines[i].trimmed();
if (key.endsWith(':')) key.chop(1); // нормализуем "Дата:" → "Дата"
if (i + 1 >= lines.size()) continue;
const QString val = lines[i + 1].trimmed();
if (key == QString::fromUtf8("Получатель")) {
detail.receiverAccount = val;
} else if (key == QString::fromUtf8("Сумма")) {
detail.amount = parseDushanbeAmount(val);
} else if (key == QString::fromUtf8("Дата")) {
detail.date = val;
} else if (key == QString::fromUtf8("Время")) {
detail.time = val;
}
}
detailLoaded = true;
break;
}
// 3) Split-формат: лейблы (с ':') и значения в отдельных View-нодах
// на одной Y-полосе. Промежуточная версия между blob и bottom-sheet.
if (!detailLoaded) {
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
const QString d = pairs.value(QString::fromUtf8("Дата"));
const QString t = pairs.value(QString::fromUtf8("Время"));
if (!d.isEmpty() && !t.isEmpty()) {
detail.date = d;
detail.time = t;
detail.receiverAccount = pairs.value(QString::fromUtf8("Получатель"));
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("Сумма")));
if (detail.amount == 0.0) {
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("К зачислению")));
}
detail.fee = parseDushanbeAmount(pairs.value(QString::fromUtf8("Комиссия")));
detail.operationId = pairs.value(QString::fromUtf8("Номер операции"));
detail.status = pairs.value(QString::fromUtf8("Статус"));
detailLoaded = true;
}
}
if (detailLoaded) break;
QThread::msleep(1500);
}
if (!detailLoaded) {
qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded "
"(tap missed or detail format unknown); staying on Выписка";
// Не вызываем goBack — после промаха тапа мы всё ещё на Выписке.
// goBack тут закрывает Выписку и ломает дальнейший скролл.
// Если же тап сработал и открылся НЕ-распознанный экран — следующая
// итерация parse обнаружит это, и мы обработаем штатно.
QThread::msleep(500);
continue;
}
qDebug() << "[Dushanbe::GetLastTransactions] Detail parsed:"
<< "date=" << detail.date << "time=" << detail.time
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
// Проверяем время ±10 минут
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
if (!txTime.isValid()) {
txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm");
}
if (txTime.isValid()) {
txTime.setTimeZone(tjTz);
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs > 600) {
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
}
// Нашли! Сохраняем
qDebug() << "[Dushanbe::GetLastTransactions] FOUND:"
<< "amount=" << detail.amount
<< "date=" << detail.date << detail.time
<< "opId=" << detail.operationId
<< "receiver=" << detail.receiverAccount
<< "status=" << detail.status;
// Формируем task_result напрямую из распарсенных данных и отдаём
// EventHandler через сигнал — без записи/чтения БД.
{
const double finalAmount = matchedX100 ? detail.amount * 100.0 : detail.amount;
const double finalFee = matchedX100 ? detail.fee * 100.0 : detail.fee;
QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
if (!txTime.isValid()) {
txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm");
}
if (txTime.isValid()) txTime.setTimeZone(tjTz);
QJsonObject txJson;
txJson["bank_transaction_id"] = detail.operationId.isEmpty()
? QString("dushanbe_") + detail.date + "_" + detail.time
: detail.operationId;
// Списание хранится с отрицательным знаком, пополнение — положительным.
// detail.amount всегда положителен (OCR), знак берём из списка Выписки.
const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount);
txJson["amount"] = static_cast<int>(signedAmount * 100);
txJson["currency"] = 972; // TJS
txJson["transaction_type"] = "bank";
txJson["bank_participator_id"] = detail.receiverAccount;
txJson["status"] = detail.status.contains(QString::fromUtf8("Успешн"))
? QStringLiteral("completed") : QStringLiteral("progress");
QJsonObject extraObj;
extraObj["fee"] = static_cast<int>(finalFee * 100);
txJson["extra"] = extraObj;
txJson["created_at"] = txTime.isValid()
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:"
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
emit transactionParsed(txJson);
}
// Скриншот экрана деталей транзакции — отправляем в мониторинг
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
// Назад к истории
Node nazadBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Назад"));
if (nazadBtn.x() > 0 && nazadBtn.y() > 0) {
AdbUtils::makeTap(m_deviceId, nazadBtn.x(), nazadBtn.y());
} else {
AdbUtils::goBack(m_deviceId);
}
QThread::msleep(1000);
emit finishedWithResult(m_error); // m_error пустой — успех
return;
}
// Скроллим вниз — свайп внутри области транзакций
if (scroll < maxScrolls) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
const int x = device.screenWidth / 2;
// Определяем область контента из реальных Y позиций items
int contentBottom = device.screenHeight * 2 / 3;
int contentTop = device.screenHeight / 3;
if (!items.isEmpty()) {
contentBottom = items.last().node.y();
contentTop = items.first().node.y();
}
const int fromY = qMin(contentBottom, device.screenHeight * 2 / 3);
const int toY = qMax(contentTop, device.screenHeight / 4);
qDebug() << "[Dushanbe::GetLastTransactions] Swipe from" << fromY << "to" << toY;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(2000);
}
}
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
+ " time=" + m_searchTime.toString(Qt::ISODate);
qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
// Возвращаемся на главную
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {
AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y());
} else {
AdbUtils::goBack(m_deviceId);
}
QThread::msleep(1000);
emit finishedWithResult(m_error);
}
} // namespace Dushanbe

View File

@ -0,0 +1,43 @@
#pragma once
#include "CommonScript.h"
#include <QDateTime>
#include <QJsonObject>
#include "db/DeviceInfo.h"
#include "db/BankProfileInfo.h"
namespace Dushanbe {
class GetLastTransactionsScript final : public CommonScript {
Q_OBJECT
public:
explicit GetLastTransactionsScript(
QString deviceId,
QString appCode,
double searchAmount = 0,
QDateTime searchTime = {},
QString materialId = {},
QObject *parent = nullptr
);
~GetLastTransactionsScript() override;
signals:
// Испускается, когда транзакция найдена и распарсена с экрана деталей.
// EventHandler использует эти данные напрямую для task_result вместо
// повторного чтения из БД.
void transactionParsed(const QJsonObject &tx);
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
const double m_searchAmount; // сумма в сомони (0 = все последние)
const QDateTime m_searchTime; // примерное время (UTC)
const QString m_materialId; // UUID задачи (для сохранения дампов)
QString m_error;
};
} // namespace Dushanbe

View File

@ -0,0 +1,156 @@
#include "GetProfileInfoScript.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h"
namespace Dushanbe {
GetProfileInfoScript::GetProfileInfoScript(
QString deviceId,
QString appCode,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)) {
}
GetProfileInfoScript::~GetProfileInfoScript() = default;
void GetProfileInfoScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::GetProfileInfo] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Всегда тапаем кнопку обновления — баланс на экране может быть закэширован
// и не отражать актуальное состояние счёта. Лоадера у refresh нет,
// поэтому ждём фиксированно.
Node refreshBtn = xmlScreenParser.findRefreshButtonOnHomeScreen();
if (refreshBtn.x() > 0) {
qDebug() << "[Dushanbe::GetProfileInfo] Tapping refresh to get up-to-date balance";
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(3000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
} else {
qWarning() << "[Dushanbe::GetProfileInfo] Refresh button not found, parsing as-is";
}
const double parsedBalance = xmlScreenParser.parseBalanceFromHomeScreen();
qDebug() << "[Dushanbe::GetProfileInfo] Parsed balance after refresh:" << parsedBalance;
if (parsedBalance <= 0.0) {
m_error = "Balance is zero after refresh: " + m_deviceId + " " + m_appCode;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
BankProfileDAO::updateAmount(app.id, parsedBalance);
// У Душанбе все карты сидят на одном счёте, а экран деталей карты
// отдаёт placeholder "0,00 TJS" — единственный источник правды
// для materials.amount это баланс home-screen.
MaterialDAO::setAmountByApp(device.id, m_appCode, parsedBalance);
// Тапаем по кнопке меню (гамбургер, левый верхний угол)
// Это первый clickable ImageView в верхней панели
Node menuButton;
for (const Node &node : xmlScreenParser.findAllNodes()) {
if (node.className == "android.widget.ImageView" && node.clickable && node.y() < 300) {
menuButton = node;
break;
}
}
if (menuButton.x() == 0 && menuButton.y() == 0) {
m_error = "Cannot find menu button: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
AdbUtils::makeTap(m_deviceId, menuButton.x(), menuButton.y());
QThread::msleep(2000);
// Ждём боковое меню
bool sideMenuFound = false;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isSideMenu()) {
sideMenuFound = true;
break;
}
QThread::msleep(1000);
}
if (!sideMenuFound) {
m_error = "Side menu not detected: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// Парсим ФИО и телефон из бокового меню
const QString fullName = xmlScreenParser.parseProfileFullName();
QString phone = xmlScreenParser.parseProfilePhone();
phone.remove(QRegularExpression("[^0-9+]"));
qDebug() << "[Dushanbe::GetProfileInfo] fullName:" << fullName << "phone:" << phone;
if (!fullName.isEmpty()) {
BankProfileDAO::updateProfileInfo(app.id, fullName, phone, "");
}
if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) {
m_error = err;
emit finishedWithResult(m_error);
return;
}
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
QList<QPair<MaterialInfo, Node>> accountsToPost;
for (const auto &acc : allAccounts) {
if (acc.materialId.isEmpty()) {
accountsToPost.append({acc, Node()});
}
}
if (!accountsToPost.isEmpty()) {
postAccountsData(accountsToPost);
}
emit finishedWithResult(m_error);
}
} // namespace Dushanbe

View File

@ -0,0 +1,27 @@
#pragma once
#include "CommonScript.h"
namespace Dushanbe {
class GetProfileInfoScript final : public CommonScript {
Q_OBJECT
public:
~GetProfileInfoScript() override;
explicit GetProfileInfoScript(
QString deviceId,
QString appCode,
QObject *parent = nullptr
);
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
QString m_error;
};
} // namespace Dushanbe

View File

@ -0,0 +1,80 @@
#include "LoginAndCheckAccountsScript.h"
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "AppLogger.h"
#include "GetProfileInfoScript.h"
namespace Dushanbe {
LoginAndCheckAccountsScript::LoginAndCheckAccountsScript(
QString deviceId,
QString appCode,
QObject *parent,
bool pinCheckOnly
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_pinCheckOnly(pinCheckOnly) {
}
LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
void LoginAndCheckAccountsScript::doStart() {
// Канонический ключ материала (device.id в БД) — для лукапов BankProfile;
// m_deviceId же перезаписываем ADB-серийником для команд.
const QString canonicalDeviceId = m_deviceId;
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
qDebug() << "[Dushanbe::Login] device.id=" << device.id << "adbSerial=" << device.adbSerial
<< "appCode=" << m_appCode << "app.id=" << app.id
<< "pinCode.length=" << app.pinCode.length() << "package=" << app.package;
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
m_error = "Cant open the home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
qWarning() << m_error;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
AppLogger::log("dushanbe/LoginAndCheck", m_deviceId, m_error, {}, "FETCH_PROFILE");
BankProfileDAO::updateComment(app.id,
"Не прошла проверка " + QDateTime::currentDateTime().toString("dd.MM.yyyy HH:mm:ss"));
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// Скриншот домашнего экрана — отправляем в мониторинг
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
if (m_pinCheckOnly) {
emit finishedWithResult(m_error);
return;
}
// В GetProfileInfoScript отдаём КАНОНИЧЕСКИЙ ключ материала, а не ADB-серийник,
// чтобы BankProfileDAO::getApplication нашла правильную запись с bank_profile_id.
GetProfileInfoScript profileScript(canonicalDeviceId, m_appCode);
profileScript.start();
emit finishedWithResult(m_error);
}
} // namespace Dushanbe

View File

@ -0,0 +1,29 @@
#pragma once
#include "CommonScript.h"
namespace Dushanbe {
class LoginAndCheckAccountsScript final : public CommonScript {
Q_OBJECT
public:
~LoginAndCheckAccountsScript() override;
explicit LoginAndCheckAccountsScript(
QString deviceId,
QString appCode,
QObject *parent = nullptr,
bool pinCheckOnly = false
);
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
const bool m_pinCheckOnly;
QString m_error;
};
} // namespace Dushanbe

View File

@ -0,0 +1,618 @@
#include "PayByCardScript.h"
#include <QRegularExpression>
#include <QThread>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "ocr/OcrUtils.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "AppLogger.h"
#include <QCoreApplication>
// Точечный OCR: находит строку "операции" и кропает правую часть для digit-only OCR
static QString recognizeOperationId(const QByteArray &pngData) {
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) return {};
const int w = pixGetWidth(pix);
const int h = pixGetHeight(pix);
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
pixDestroy(&pix); delete api;
return {};
}
api->SetImage(pix);
api->Recognize(nullptr);
int opLineY = -1, opLineH = 0;
bool prevWasNomer = false;
tesseract::ResultIterator *ri = api->GetIterator();
if (ri) {
do {
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
if (word) {
const QString w_str = QString::fromUtf8(word);
delete[] word;
// Ищем "Номер" (или OCR-артефакты: "Homep", "Номep")
if (w_str.contains(QString::fromUtf8("омер")) ||
w_str.contains(QString::fromUtf8("omep"))) {
prevWasNomer = true;
continue;
}
// После "Номер" ищем "операции"
if (prevWasNomer &&
(w_str.contains(QString::fromUtf8("операции")) ||
w_str.contains(QString::fromUtf8("onepaции")))) {
int x1, y1, x2, y2;
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
opLineY = y1;
opLineH = y2 - y1;
break;
}
prevWasNomer = false;
}
} while (ri->Next(tesseract::RIL_WORD));
}
api->End();
delete api;
if (opLineY < 0) { pixDestroy(&pix); return {}; }
const int cropY = qMax(0, opLineY - opLineH / 2);
const int cropH = opLineH * 2;
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
Pix *cropped = pixClipRectangle(pix, box, nullptr);
boxDestroy(&box);
pixDestroy(&pix);
if (!cropped) return {};
auto *api2 = new tesseract::TessBaseAPI();
if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) {
pixDestroy(&cropped); delete api2;
return {};
}
api2->SetVariable("tessedit_char_whitelist", "0123456789");
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
api2->SetImage(cropped);
char *text = api2->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&cropped);
api2->End();
delete api2;
result.remove(QRegularExpression("[^0-9]"));
if (result.length() >= 8) {
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
return result;
}
return {};
}
namespace Dushanbe {
PayByCardScript::PayByCardScript(
MaterialInfo account,
QString cardNumber,
double amount,
QObject *parent
) : CommonScript(parent),
m_account(std::move(account)),
m_cardNumber(std::move(cardNumber)),
m_amount(amount) {
}
PayByCardScript::~PayByCardScript() = default;
void PayByCardScript::doStart() {
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_account.deviceId;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_CARD", device.adbSerial, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::PayByCard] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(device.adbSerial, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
qWarning() << m_error;
AppLogger::log("dushanbe/PayByCard", device.id, m_error, {}, "CREATE_TRANSACTION");
AdbUtils::tryToKillApplication(device.adbSerial, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
}
makePayment(device.adbSerial, app.pinCode, app.phone, device.screenWidth, device.screenHeight);
emit finishedWithResult(m_error);
}
void PayByCardScript::makePayment(
const QString &deviceId,
const QString &pinCode,
const QString &bankProfilePhone,
const int width,
const int height
) {
// 1. Тапаем "Переводы" в нижней навигации
Node transfersTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Переводы"));
if (transfersTab.isEmpty()) {
m_error = "Tab 'Переводы' not found on home screen";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Tapping 'Переводы' tab";
AdbUtils::makeTap(deviceId, transfersTab.x(), transfersTab.y());
QThread::msleep(2000);
// 2. Ждём экран переводов — ищем "DC (по номеру карты)"
bool transfersLoaded = false;
Node cardTransferBtn;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
cardTransferBtn = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("DC (по номеру карты)"), true);
if (!cardTransferBtn.isEmpty()) {
transfersLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for transfers screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!transfersLoaded) {
m_error = "Transfers screen not loaded or 'DC (по номеру карты)' not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 3. Тапаем "DC (по номеру карты)"
qDebug() << "[Dushanbe::PayByCard] Tapping 'DC (по номеру карты)'";
AdbUtils::makeTap(deviceId, cardTransferBtn.x(), cardTransferBtn.y());
QThread::msleep(2000);
// 4. Ждём экран ввода — ищем EditText с hint "Номер карты получателя"
bool inputScreenLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
Node editText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер карты получателя"));
if (editText.isEmpty()) {
editText = xmlScreenParser.findFirstEditText();
}
if (!editText.isEmpty()) {
inputScreenLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for card input screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!inputScreenLoaded) {
m_error = "Card input screen not loaded";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 5. Вводим номер карты в поле "Номер карты получателя"
Node cardEditText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер карты получателя"));
if (cardEditText.isEmpty()) {
cardEditText = xmlScreenParser.findFirstEditText();
}
AdbUtils::makeTap(deviceId, cardEditText.x(), cardEditText.y());
QThread::msleep(500);
AdbUtils::inputText(deviceId, m_cardNumber);
QThread::msleep(1000);
// Скрываем клавиатуру чтобы поле суммы стало видимым
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
// 6. Вводим сумму в поле "Сумма" (hint содержит "Сумма")
Node sumEditText;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма"));
if (sumEditText.isEmpty()) sumEditText = xmlScreenParser.findSumEditText();
if (!sumEditText.isEmpty()) break;
qDebug() << "[Dushanbe::PayByCard] Waiting for sum input, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
if (sumEditText.isEmpty()) {
m_error = "Sum input field not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y());
QThread::msleep(500);
// Сумма приходит с копейками (например 100.50), форматируем как целое или с точкой
const QString amountStr = (m_amount == static_cast<int>(m_amount))
? QString::number(static_cast<int>(m_amount))
: QString::number(m_amount, 'f', 2);
AdbUtils::inputText(deviceId, amountStr);
QThread::msleep(1000);
// 6a. Скрываем клавиатуру и выбираем карту-источник (под полем "Комментарий")
AdbUtils::goBack(deviceId);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
{
const QString cardMask = "****" + m_account.lastNumbers;
Node cardNode;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (!n.clickable) continue;
if (n.contentDesc.contains(cardMask)) { cardNode = n; break; }
}
if (cardNode.isEmpty()) {
m_error = "Source card not found: " + cardMask;
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Selecting source card:" << cardMask;
AdbUtils::makeTap(deviceId, cardNode.x(), cardNode.y());
QThread::msleep(500);
}
// 7. Нажимаем "Далее"
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node daleeBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Далее"));
if (daleeBtn.isEmpty()) {
daleeBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Далее"), false);
}
if (daleeBtn.isEmpty()) {
m_error = "Button 'Далее' not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Tapping 'Далее'";
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
QThread::msleep(2000);
// 8. После "Далее" возможны три исхода:
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
// c) экран ошибки.
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
bool oplatitFound = false;
bool earlySuccess = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
qDebug() << "[Dushanbe::PayByCard] Payment completed immediately after 'Далее'";
oplatitFound = true;
earlySuccess = true;
break;
}
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected after 'Далее'";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (oplatitBtn.isEmpty()) {
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!oplatitBtn.isEmpty()) {
qDebug() << "[Dushanbe::PayByCard] Tapping 'Оплатить'";
AdbUtils::makeTap(deviceId, oplatitBtn.x(), oplatitBtn.y());
oplatitFound = true;
QThread::msleep(3000);
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for 'Оплатить', attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!oplatitFound) {
m_error = "Button 'Оплатить' not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
// content-desc содержит "Подтверждение платежа...Подтверждаю"
// Кнопка "Подтверждаю" визуально внизу bottom sheet
bool confirmed = earlySuccess;
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
// Пин-код может появиться
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByCard] PIN code screen, entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
// Ищем элемент с "Подтверждение платежа" и "Подтверждаю" в content-desc
Node confirmNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Подтверждение платежа"), true);
if (!confirmNode.isEmpty() &&
confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
// Compose flatten-ит весь bottom sheet в одну accessibility-кнопку,
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
// нижней четверти sheet, выше системного nav-бара).
// Compose отдаёт sheet как одну ноду на весь экран ([0,0][720,1640]),
// поэтому confirmNode.y1/y2 не сужают поиск — OCR сканирует весь
// скриншот и ловит ложные совпадения. Ограничиваем нижней половиной.
const int ocrYMin = (confirmNode.y1() + confirmNode.y2()) / 2;
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
ocrYMin, confirmNode.y2());
if (tap.x() < 0) {
const int tapY = confirmNode.y2() - qMax(150, height / 14);
tap = QPoint(confirmNode.x(), tapY);
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
}
qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap;
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
confirmed = true;
QThread::msleep(4000);
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for confirmation sheet, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!confirmed) {
m_error = "Confirmation sheet not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 10. Ждём результат — "Платеж выполнен" или ошибку.
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
// Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал,
// bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий
// sheet → пробуем другую Y. Без лимита получали бесконечный
// "Оплатить ↔ Подтверждаю" со случайными попаданиями в системные экраны
// (security/launcher).
bool success = earlySuccess;
int confirmRetries = 0;
int oplatitRetries = 0;
const int maxConfirmRetries = 2;
const int maxOplatitRetries = 2;
for (int attempt = 0; !earlySuccess && attempt < 60; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
// Пин-код
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByCard] PIN code screen (retry), entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
// Успех: content-desc начинается с "Платеж выполнен"
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
success = true;
break;
}
// Ошибка
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// Промежуточный статус — банк ещё обрабатывает перевод
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Выполняется"), true).isEmpty()) {
qDebug() << "[Dushanbe::PayByCard] Status 'Выполняется', waiting for completion, attempt"
<< attempt + 1;
QThread::msleep(2000);
continue;
}
// Если bottom sheet "Подтверждение платежа" всё ещё виден — первый тап
// не сработал (мимо OCR-координат), повторяем его той же логикой.
Node retryConfirm = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Подтверждение платежа"), true);
if (!retryConfirm.isEmpty() &&
retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
if (confirmRetries >= maxConfirmRetries) {
m_error = "Confirm tap missed " + QString::number(confirmRetries)
+ " times (sheet still visible)";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++confirmRetries;
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap"
<< confirmRetries << "/" << maxConfirmRetries;
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
ocrYMin, retryConfirm.y2());
if (tap.x() < 0) {
// Варьируем Y: первый retry — выше fallback на 100px,
// второй — ещё на 100px выше. Compose-кнопка действия
// обычно на y2 - 150..350; перебираем диапазон.
const int extra = 100 * confirmRetries;
const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra);
tap = QPoint(retryConfirm.x(), tapY);
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
}
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
QThread::msleep(4000);
continue;
}
// Sheet "Подтверждение платежа" пропал, но снова виден input-экран
// (кнопка "Оплатить" вернулась) — значит тап по "Подтверждаю" промахнулся
// и Compose дисмиснул bottom sheet (clip на scrim/Закрыть/X). Перезапускаем
// цикл: тапаем "Оплатить" → следующая итерация увидит sheet и retryConfirm
// выше повторно тапнет "Подтверждаю".
Node retryOplatit = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (retryOplatit.isEmpty()) {
retryOplatit = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!retryOplatit.isEmpty() && retryOplatit.clickable) {
if (oplatitRetries >= maxOplatitRetries) {
m_error = "Sheet dismissed and re-tap exhausted ("
+ QString::number(oplatitRetries) + " retries)";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++oplatitRetries;
qDebug() << "[Dushanbe::PayByCard] Sheet dismissed, re-tap 'Оплатить'"
<< oplatitRetries << "/" << maxOplatitRetries;
AdbUtils::makeTap(deviceId, retryOplatit.x(), retryOplatit.y());
QThread::msleep(3000);
continue;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for result, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!success) {
m_error = "Payment result not received within timeout";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Payment successful!";
m_error.clear();
// Скриншот чека — сохраняем как m_resultScreenshot и отправляем в мониторинг
m_resultScreenshot = AdbUtils::captureScreenshotBytes(deviceId);
const QByteArray &receiptImage = m_resultScreenshot;
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
// Парсим детали из экрана "Платеж выполнен".
// До 3.2.33 все поля шли одним content-desc с ключами "Дата:" / "Время:" / "Комиссия:".
// С 3.2.33 лейблы и значения — отдельные View-ноды в двух колонках.
QString bankTime;
double fee = 0;
Node resultNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true);
if (!resultNode.isEmpty()) {
const QStringList lines = resultNode.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QString line = lines[i].trimmed();
if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
fee = lines[i + 1].trimmed().toDouble();
} else if (line.startsWith(QString::fromUtf8("Дата:")) && i + 1 < lines.size()) {
bankTime = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Время:")) && i + 1 < lines.size()) {
bankTime += " " + lines[i + 1].trimmed();
}
}
}
if (bankTime.isEmpty()) {
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
const QString d = pairs.value(QString::fromUtf8("Дата"));
const QString t = pairs.value(QString::fromUtf8("Время"));
if (!d.isEmpty() || !t.isEmpty()) {
bankTime = (d + " " + t).trimmed();
}
if (fee == 0) {
fee = pairs.value(QString::fromUtf8("Комиссия")).toDouble();
}
}
qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee;
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
// Уникальность за счёт <bankProfilePhone, секунда>.
const QString operationId = QString("dushanbe_") + bankProfilePhone + "_"
+ QString::number(QDateTime::currentSecsSinceEpoch());
qDebug() << "[Dushanbe::PayByCard] Generated operationId:" << operationId;
// Сохраняем транзакцию
TransactionInfo tx;
tx.accountId = m_account.id;
tx.amount = -m_amount;
tx.fee = fee;
tx.status = TransactionStatus::Complete;
tx.type = TransactionType::Card;
tx.phone = m_cardNumber;
tx.bankName = "dushanbe_city_bank";
tx.bankTime = bankTime;
tx.bankTrExternalId = operationId;
tx.timestamp = QDateTime::currentDateTimeUtc();
tx.completeTime = QDateTime::currentDateTimeUtc();
tx.receiptImage = receiptImage;
const int txId = TransactionDAO::insertTransaction(tx);
if (txId != -1) {
qDebug() << "[Dushanbe::PayByCard] Transaction saved, id:" << txId
<< "operationId:" << operationId;
if (!receiptImage.isEmpty()) {
TransactionDAO::updateReceiptImage(txId, receiptImage);
}
} else {
qWarning() << "[Dushanbe::PayByCard] Failed to save transaction";
}
}
} // namespace Dushanbe

View File

@ -0,0 +1,37 @@
#pragma once
#include "CommonScript.h"
namespace Dushanbe {
class PayByCardScript final : public CommonScript {
Q_OBJECT
public:
PayByCardScript(
MaterialInfo account,
QString cardNumber,
double amount,
QObject *parent = nullptr
);
~PayByCardScript() override;
protected:
void doStart() override;
private:
const MaterialInfo m_account;
const QString m_cardNumber;
const double m_amount;
QString m_error;
void makePayment(
const QString &deviceId,
const QString &pinCode,
const QString &bankProfilePhone,
int width,
int height
);
};
} // namespace Dushanbe

View File

@ -0,0 +1,610 @@
#include "PayByPhoneScript.h"
#include <QRegularExpression>
#include <QThread>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "ocr/OcrUtils.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "AppLogger.h"
#include <QCoreApplication>
static QString recognizeOperationId(const QByteArray &pngData) {
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) return {};
const int w = pixGetWidth(pix);
const int h = pixGetHeight(pix);
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
pixDestroy(&pix); delete api;
return {};
}
api->SetImage(pix);
api->Recognize(nullptr);
int opLineY = -1, opLineH = 0;
bool prevWasNomer = false;
tesseract::ResultIterator *ri = api->GetIterator();
if (ri) {
do {
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
if (word) {
const QString w_str = QString::fromUtf8(word);
delete[] word;
if (w_str.contains(QString::fromUtf8("омер")) ||
w_str.contains(QString::fromUtf8("omep"))) {
prevWasNomer = true;
continue;
}
if (prevWasNomer &&
(w_str.contains(QString::fromUtf8("операции")) ||
w_str.contains(QString::fromUtf8("onepaции")))) {
int x1, y1, x2, y2;
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
opLineY = y1;
opLineH = y2 - y1;
break;
}
prevWasNomer = false;
}
} while (ri->Next(tesseract::RIL_WORD));
}
api->End();
delete api;
if (opLineY < 0) { pixDestroy(&pix); return {}; }
const int cropY = qMax(0, opLineY - opLineH / 2);
const int cropH = opLineH * 2;
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
Pix *cropped = pixClipRectangle(pix, box, nullptr);
boxDestroy(&box);
pixDestroy(&pix);
if (!cropped) return {};
auto *api2 = new tesseract::TessBaseAPI();
if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) {
pixDestroy(&cropped); delete api2;
return {};
}
api2->SetVariable("tessedit_char_whitelist", "0123456789");
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
api2->SetImage(cropped);
char *text = api2->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&cropped);
api2->End();
delete api2;
result.remove(QRegularExpression("[^0-9]"));
if (result.length() >= 8) {
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
return result;
}
return {};
}
namespace Dushanbe {
PayByPhoneScript::PayByPhoneScript(
MaterialInfo account,
QString phone,
double amount,
QObject *parent
) : CommonScript(parent),
m_account(std::move(account)),
m_phone(std::move(phone)),
m_amount(amount) {
}
PayByPhoneScript::~PayByPhoneScript() = default;
void PayByPhoneScript::doStart() {
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_account.deviceId;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_PHONE", device.adbSerial, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::PayByPhone] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(device.adbSerial, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
qWarning() << m_error;
AppLogger::log("dushanbe/PayByPhone", device.id, m_error, {}, "CREATE_TRANSACTION");
AdbUtils::tryToKillApplication(device.adbSerial, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
}
makePayment(device.adbSerial, app.pinCode, app.phone, device.screenWidth, device.screenHeight);
emit finishedWithResult(m_error);
}
void PayByPhoneScript::makePayment(
const QString &deviceId,
const QString &pinCode,
const QString &bankProfilePhone,
const int width,
const int height
) {
// 1. Тапаем "Переводы" в нижней навигации
Node transfersTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Переводы"));
if (transfersTab.isEmpty()) {
m_error = "Tab 'Переводы' not found on home screen";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Переводы' tab";
AdbUtils::makeTap(deviceId, transfersTab.x(), transfersTab.y());
QThread::msleep(2000);
// 2. Ждём экран переводов — ищем "DC (по номеру телефона)"
bool transfersLoaded = false;
Node phoneTransferBtn;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
phoneTransferBtn = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("DC (по номеру телефона)"), true);
if (!phoneTransferBtn.isEmpty()) {
transfersLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for transfers screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!transfersLoaded) {
m_error = "Transfers screen not loaded or 'DC (по номеру телефона)' not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 3. Тапаем "DC (по номеру телефона)"
qDebug() << "[Dushanbe::PayByPhone] Tapping 'DC (по номеру телефона)'";
AdbUtils::makeTap(deviceId, phoneTransferBtn.x(), phoneTransferBtn.y());
QThread::msleep(2000);
// 4. Ждём экран ввода — ищем EditText с hint "Номер телефона получателя"
bool inputScreenLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
Node editText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер телефона получателя"));
if (editText.isEmpty()) {
editText = xmlScreenParser.findFirstEditText();
}
if (!editText.isEmpty()) {
inputScreenLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for phone input screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!inputScreenLoaded) {
m_error = "Phone input screen not loaded";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 5. Вводим номер телефона
Node phoneEditText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер телефона получателя"));
if (phoneEditText.isEmpty()) {
phoneEditText = xmlScreenParser.findFirstEditText();
}
AdbUtils::makeTap(deviceId, phoneEditText.x(), phoneEditText.y());
QThread::msleep(500);
// Убираем "+" из номера если есть — вводим только цифры
QString phoneDigits = m_phone;
phoneDigits.remove('+');
AdbUtils::inputText(deviceId, phoneDigits);
QThread::msleep(1000);
// Скрываем клавиатуру чтобы поле суммы стало видимым
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
// 6. Вводим сумму в поле "Сумма"
Node sumEditText;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма"));
if (sumEditText.isEmpty()) sumEditText = xmlScreenParser.findSumEditText();
if (!sumEditText.isEmpty()) break;
qDebug() << "[Dushanbe::PayByPhone] Waiting for sum input, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
if (sumEditText.isEmpty()) {
m_error = "Sum input field not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y());
QThread::msleep(500);
const QString amountStr = (m_amount == static_cast<int>(m_amount))
? QString::number(static_cast<int>(m_amount))
: QString::number(m_amount, 'f', 2);
AdbUtils::inputText(deviceId, amountStr);
QThread::msleep(1000);
// 6a. Скрываем клавиатуру и выбираем карту-источник (под полем "Комментарий")
AdbUtils::goBack(deviceId);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
{
const QString cardMask = "****" + m_account.lastNumbers;
Node cardNode;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (!n.clickable) continue;
if (n.contentDesc.contains(cardMask)) { cardNode = n; break; }
}
if (cardNode.isEmpty()) {
m_error = "Source card not found: " + cardMask;
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByPhone] Selecting source card:" << cardMask;
AdbUtils::makeTap(deviceId, cardNode.x(), cardNode.y());
QThread::msleep(500);
}
// 7. Нажимаем "Далее"
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node daleeBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Далее"));
if (daleeBtn.isEmpty()) {
daleeBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Далее"), false);
}
if (daleeBtn.isEmpty()) {
m_error = "Button 'Далее' not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Далее'";
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
QThread::msleep(2000);
// 8. После "Далее" возможны три исхода:
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
// c) экран ошибки.
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
bool oplatitFound = false;
bool earlySuccess = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
qDebug() << "[Dushanbe::PayByPhone] Payment completed immediately after 'Далее'";
oplatitFound = true;
earlySuccess = true;
break;
}
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected after 'Далее'";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (oplatitBtn.isEmpty()) {
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!oplatitBtn.isEmpty()) {
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Оплатить'";
AdbUtils::makeTap(deviceId, oplatitBtn.x(), oplatitBtn.y());
oplatitFound = true;
QThread::msleep(3000);
break;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for 'Оплатить', attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!oplatitFound) {
m_error = "Button 'Оплатить' not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
bool confirmed = earlySuccess;
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByPhone] PIN code screen, entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
Node confirmNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Подтверждение платежа"), true);
if (!confirmNode.isEmpty() &&
confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
// Compose flatten-ит весь bottom sheet в одну accessibility-кнопку,
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
// нижней четверти sheet, выше системного nav-бара).
// Compose отдаёт sheet как одну ноду на весь экран ([0,0][720,1640]),
// поэтому confirmNode.y1/y2 не сужают поиск — OCR сканирует весь
// скриншот и ловит ложные совпадения (буква "в" в шапке).
// Ограничиваем нижней половиной — действие-кнопка всегда внизу sheet'a.
const int ocrYMin = (confirmNode.y1() + confirmNode.y2()) / 2;
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
ocrYMin, confirmNode.y2());
if (tap.x() < 0) {
const int tapY = confirmNode.y2() - qMax(150, height / 14);
tap = QPoint(confirmNode.x(), tapY);
qDebug() << "[Dushanbe::PayByPhone] OCR miss, fallback tap at" << tap;
}
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Подтверждаю' at" << tap;
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
confirmed = true;
QThread::msleep(4000);
break;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for confirmation sheet, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!confirmed) {
m_error = "Confirmation sheet not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 10. Ждём результат — "Платеж выполнен" или ошибку.
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
// Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал,
// bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий
// sheet → пробуем другую Y. Без лимита получали бесконечный
// "Оплатить ↔ Подтверждаю" со случайными попаданиями в системные экраны
// (security/launcher).
bool success = earlySuccess;
int confirmRetries = 0;
int oplatitRetries = 0;
const int maxConfirmRetries = 2;
const int maxOplatitRetries = 2;
for (int attempt = 0; !earlySuccess && attempt < 60; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByPhone] PIN code screen (retry), entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
success = true;
break;
}
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Выполняется"), true).isEmpty()) {
qDebug() << "[Dushanbe::PayByPhone] Status 'Выполняется', waiting for completion, attempt"
<< attempt + 1;
QThread::msleep(2000);
continue;
}
// Если bottom sheet "Подтверждение платежа" всё ещё виден — первый тап
// не сработал (мимо OCR-координат), повторяем его той же логикой.
Node retryConfirm = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Подтверждение платежа"), true);
if (!retryConfirm.isEmpty() &&
retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
if (confirmRetries >= maxConfirmRetries) {
m_error = "Confirm tap missed " + QString::number(confirmRetries)
+ " times (sheet still visible)";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++confirmRetries;
qDebug() << "[Dushanbe::PayByPhone] Confirm sheet still visible, re-tap"
<< confirmRetries << "/" << maxConfirmRetries;
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
ocrYMin, retryConfirm.y2());
if (tap.x() < 0) {
// Варьируем Y: первый retry — выше fallback на 100px,
// второй — ещё на 100px выше. Compose-кнопка действия
// обычно на y2 - 150..350; перебираем диапазон.
const int extra = 100 * confirmRetries;
const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra);
tap = QPoint(retryConfirm.x(), tapY);
qDebug() << "[Dushanbe::PayByPhone] OCR miss, fallback tap at" << tap;
}
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
QThread::msleep(4000);
continue;
}
// Sheet "Подтверждение платежа" пропал, но снова виден input-экран
// (кнопка "Оплатить" вернулась) — значит тап по "Подтверждаю" промахнулся
// и Compose дисмиснул bottom sheet (clip на scrim/Закрыть/X). Перезапускаем
// цикл: тапаем "Оплатить" → следующая итерация увидит sheet и retryConfirm
// выше повторно тапнет "Подтверждаю".
Node retryOplatit = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (retryOplatit.isEmpty()) {
retryOplatit = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!retryOplatit.isEmpty() && retryOplatit.clickable) {
if (oplatitRetries >= maxOplatitRetries) {
m_error = "Sheet dismissed and re-tap exhausted ("
+ QString::number(oplatitRetries) + " retries)";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++oplatitRetries;
qDebug() << "[Dushanbe::PayByPhone] Sheet dismissed, re-tap 'Оплатить'"
<< oplatitRetries << "/" << maxOplatitRetries;
AdbUtils::makeTap(deviceId, retryOplatit.x(), retryOplatit.y());
QThread::msleep(3000);
continue;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for result, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!success) {
m_error = "Payment result not received within timeout";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByPhone] Payment successful!";
m_error.clear();
// Скриншот чека — сохраняем как m_resultScreenshot и отправляем в мониторинг
m_resultScreenshot = AdbUtils::captureScreenshotBytes(deviceId);
const QByteArray &receiptImage = m_resultScreenshot;
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
// Парсим детали из экрана "Платеж выполнен". С 3.2.33 поля разнесены по
// отдельным View-узлам — пробуем сначала старый blob-формат, потом fallback.
QString bankTime;
double fee = 0;
Node resultNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true);
if (!resultNode.isEmpty()) {
const QStringList lines = resultNode.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QString line = lines[i].trimmed();
if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
fee = lines[i + 1].trimmed().toDouble();
} else if (line.startsWith(QString::fromUtf8("Дата:")) && i + 1 < lines.size()) {
bankTime = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Время:")) && i + 1 < lines.size()) {
bankTime += " " + lines[i + 1].trimmed();
}
}
}
if (bankTime.isEmpty()) {
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
const QString d = pairs.value(QString::fromUtf8("Дата"));
const QString t = pairs.value(QString::fromUtf8("Время"));
if (!d.isEmpty() || !t.isEmpty()) {
bankTime = (d + " " + t).trimmed();
}
if (fee == 0) {
fee = pairs.value(QString::fromUtf8("Комиссия")).toDouble();
}
}
qDebug() << "[Dushanbe::PayByPhone] Result: date=" << bankTime << "fee=" << fee;
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
// Уникальность за счёт <bankProfilePhone, секунда>.
const QString operationId = QString("dushanbe_") + bankProfilePhone + "_"
+ QString::number(QDateTime::currentSecsSinceEpoch());
qDebug() << "[Dushanbe::PayByPhone] Generated operationId:" << operationId;
// Сохраняем транзакцию
TransactionInfo tx;
tx.accountId = m_account.id;
tx.amount = -m_amount;
tx.fee = fee;
tx.status = TransactionStatus::Complete;
tx.type = TransactionType::Phone;
tx.phone = m_phone;
tx.bankName = "dushanbe_city_bank";
tx.bankTime = bankTime;
tx.bankTrExternalId = operationId;
tx.timestamp = QDateTime::currentDateTimeUtc();
tx.completeTime = QDateTime::currentDateTimeUtc();
tx.receiptImage = receiptImage;
const int txId = TransactionDAO::insertTransaction(tx);
if (txId != -1) {
qDebug() << "[Dushanbe::PayByPhone] Transaction saved, id:" << txId
<< "operationId:" << operationId;
if (!receiptImage.isEmpty()) {
TransactionDAO::updateReceiptImage(txId, receiptImage);
}
} else {
qWarning() << "[Dushanbe::PayByPhone] Failed to save transaction";
}
}
} // namespace Dushanbe

View File

@ -0,0 +1,37 @@
#pragma once
#include "CommonScript.h"
namespace Dushanbe {
class PayByPhoneScript final : public CommonScript {
Q_OBJECT
public:
PayByPhoneScript(
MaterialInfo account,
QString phone,
double amount,
QObject *parent = nullptr
);
~PayByPhoneScript() override;
protected:
void doStart() override;
private:
const MaterialInfo m_account;
const QString m_phone;
const double m_amount;
QString m_error;
void makePayment(
const QString &deviceId,
const QString &pinCode,
const QString &bankProfilePhone,
int width,
int height
);
};
} // namespace Dushanbe

View File

@ -0,0 +1,702 @@
#include "ScreenXmlParser.h"
#include <QRegularExpression>
#include <QXmlStreamReader>
#include <QDomDocument>
#include "android/xml/Node.h"
namespace Dushanbe {
static const QRegularExpression boundsRegex(R"(\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\])");
ScreenXmlParser::ScreenXmlParser(QObject *parent) : QObject(parent) {
}
ScreenXmlParser::~ScreenXmlParser() = default;
void ScreenXmlParser::parseAndSaveXml(const QString &xml) {
QDomDocument doc;
doc.setContent(xml);
m_xml = doc.documentElement();
m_nodes.clear();
QXmlStreamReader reader(xml);
while (!reader.atEnd()) {
reader.readNext();
if (reader.isStartElement() && reader.name() == u"node") {
Node node;
if (reader.attributes().hasAttribute("bounds")) {
QString bounds = reader.attributes().value("bounds").toString();
if (QRegularExpressionMatch match = boundsRegex.match(bounds); match.hasMatch()) {
node.setBounds(
match.captured(1).toInt(),
match.captured(2).toInt(),
match.captured(3).toInt(),
match.captured(4).toInt()
);
}
}
if (reader.attributes().hasAttribute("text")) {
node.text = reader.attributes().value("text").toString();
}
if (reader.attributes().hasAttribute("content-desc")) {
node.contentDesc = reader.attributes().value("content-desc").toString();
}
if (reader.attributes().hasAttribute("class")) {
node.className = reader.attributes().value("class").toString();
}
if (reader.attributes().hasAttribute("clickable")) {
node.clickable = reader.attributes().value("clickable").toString() == "true";
}
if (reader.attributes().hasAttribute("focusable")) {
node.focusable = reader.attributes().value("focusable").toString() == "true";
}
if (reader.attributes().hasAttribute("resource-id")) {
node.resourceId = reader.attributes().value("resource-id").toString();
}
if (reader.attributes().hasAttribute("hint")) {
node.hint = reader.attributes().value("hint").toString();
}
if (reader.attributes().hasAttribute("password")) {
node.password = reader.attributes().value("password").toString() == "true";
}
m_nodes.append(node);
}
}
}
const QList<Node> &ScreenXmlParser::findAllNodes() const {
return m_nodes;
}
Node ScreenXmlParser::findTextNode(const QString &text) {
for (const Node &node : m_nodes) {
if (node.text.trimmed() == text) {
return node;
}
}
return {};
}
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) {
for (const Node &node : m_nodes) {
if (node.contentDesc == contentDesc) {
return node;
}
}
return {};
}
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc, bool contains) {
for (const Node &node : m_nodes) {
if (contains) {
if (node.contentDesc.contains(contentDesc)) {
return node;
}
} else {
if (node.contentDesc == contentDesc) {
return node;
}
}
}
return {};
}
Node ScreenXmlParser::findButtonNode(const QString &text, bool contains) {
for (const Node &node : m_nodes) {
if (!node.clickable) continue;
if (contains) {
if (node.text.contains(text) || node.contentDesc.contains(text)) {
return node;
}
} else {
if (node.text.trimmed() == text || node.contentDesc == text) {
return node;
}
}
}
return {};
}
Node ScreenXmlParser::findFirstEditText() {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText") {
return node;
}
}
return {};
}
bool ScreenXmlParser::isPinCodeScreen() {
// 1. Прямое совпадение по тексту заголовка
for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("Введите код доступа")) ||
node.contentDesc.contains(QString::fromUtf8("Введите ПИН")) ||
node.contentDesc.contains(QString::fromUtf8("код доступа")) ||
node.text.contains(QString::fromUtf8("код доступа"))) {
return true;
}
}
// 2. Fallback: если на экране есть полная цифровая клавиатура 0-9
// (content-desc каждой кнопки = одна цифра) — это PIN-экран.
int digitButtons = 0;
bool seen[10] = {false};
for (const Node &node : m_nodes) {
if (node.contentDesc.size() != 1) continue;
const QChar c = node.contentDesc.at(0);
if (!c.isDigit()) continue;
const int d = c.digitValue();
if (d < 0 || d > 9 || seen[d]) continue;
seen[d] = true;
++digitButtons;
}
return digitButtons >= 10;
}
Node ScreenXmlParser::findPinCodeEditText() {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText" && node.password) {
return node;
}
}
return {};
}
bool ScreenXmlParser::isHomeScreen() {
bool hasGlavnaya = false;
bool hasBottomNav = false;
for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("Главная"))) {
hasGlavnaya = true;
}
// Нижняя навигация — любая из соседних вкладок указывает, что мы на
// основной странице приложения, а не на splash/PIN/диалоге.
if (node.contentDesc == QString::fromUtf8("Переводы") ||
node.contentDesc == QString::fromUtf8("Сервисы") ||
node.contentDesc == QString::fromUtf8("MiniApps") ||
node.contentDesc == QString::fromUtf8("История")) {
hasBottomNav = true;
}
if (hasGlavnaya && hasBottomNav) {
return true;
}
}
return false;
}
double ScreenXmlParser::parseBalanceFromHomeScreen() {
// На домашнем экране: parent node content-desc="TJS\n03.04.26 - 21:46:15"
// внутри него child node content-desc="0.00" (или "10.00")
// Стратегия: находим индекс parent, затем child сразу после него с числовым content-desc
for (int i = 0; i < m_nodes.size(); ++i) {
if (!m_nodes[i].contentDesc.startsWith("TJS")) continue;
// Проверяем что это блок с датой (не просто "TJS" текст)
if (!m_nodes[i].contentDesc.contains('\n')) continue;
// Child node с балансом идёт сразу после parent или рядом
for (int j = i + 1; j < qMin(i + 5, m_nodes.size()); ++j) {
QString raw = m_nodes[j].contentDesc.trimmed();
if (raw.isEmpty()) continue;
raw.replace(',', '.');
raw.remove(' ');
bool ok = false;
double val = raw.toDouble(&ok);
if (ok) return val;
}
}
return 0.0;
}
QString ScreenXmlParser::parseUserNameFromHomeScreen() {
// TODO: реализовать парсинг имени
return {};
}
Node ScreenXmlParser::findRefreshButtonOnHomeScreen() {
// В блоке content-desc="TJS\n<дата>" три clickable дочерних элемента:
// View с балансом, Button (глазик) и ImageView (рефреш). Тапать нужно
// именно ImageView — он правее глазика и единственный ImageView внутри
// bounds parent'а.
Node parent;
for (const Node &node : m_nodes) {
if (node.contentDesc.startsWith("TJS") && node.contentDesc.contains('\n')) {
parent = node;
break;
}
}
if (parent.x1() < 0) return {};
Node best;
for (const Node &node : m_nodes) {
if (!node.clickable) continue;
if (node.className != "android.widget.ImageView") continue;
if (node.x1() < parent.x1() || node.x2() > parent.x2()) continue;
if (node.y1() < parent.y1() || node.y2() > parent.y2()) continue;
if (best.x1() < 0 || node.x1() > best.x1()) {
best = node;
}
}
return best;
}
bool ScreenXmlParser::isSideMenu() {
bool hasVyhod = false;
bool hasIdentified = false;
for (const Node &node : m_nodes) {
// До 3.2.33 кнопка была "Выход", с 3.2.33 — "Выйти".
if (node.contentDesc == QString::fromUtf8("Выход") ||
node.contentDesc == QString::fromUtf8("Выйти")) {
hasVyhod = true;
}
// До 3.2.33: "Идентифицирован\nУспешно" — отдельная нода (startsWith).
// С 3.2.33: "Идентифицирован" вшито в блок профиля
// ("IN\nIsroil Nazarov\n+992 ...\nИдентифицирован\nУспешно") — нужен contains.
if (node.contentDesc.contains(QString::fromUtf8("Идентифицирован"))) {
hasIdentified = true;
}
if (hasVyhod && hasIdentified) {
return true;
}
}
return false;
}
QString ScreenXmlParser::parseProfileFullName() {
// До 3.2.33 одна нода содержит:
// "Имя Фамилия\n+992 (18) 555-5519\nВерсия: 3.2.5\nСборка: 390:3443"
// С 3.2.33 "Версия"/"Сборка" вынесены в отдельные ноды, а профиль:
// "IN\nIsroil Nazarov\n+992 (17) 682-7668\nИдентифицирован\nУспешно"
// Находим ноду по наличию "Идентифицирован" ИЛИ "Версия:+Сборка:", затем
// выбираем строку, идущую непосредственно перед строкой с телефоном "+...".
static const QRegularExpression phoneRe(R"(^\+\d)");
for (const Node &node : m_nodes) {
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован"));
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
node.contentDesc.contains(QString::fromUtf8("Сборка:"));
if (!isNewProfileBlock && !isOldProfileBlock) continue;
const QStringList lines = node.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
if (phoneRe.match(lines[i].trimmed()).hasMatch() && i > 0) {
return lines[i - 1].trimmed();
}
}
}
return {};
}
QString ScreenXmlParser::parseProfilePhone() {
static const QRegularExpression phoneRe(R"(^\+\d)");
for (const Node &node : m_nodes) {
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован"));
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
node.contentDesc.contains(QString::fromUtf8("Сборка:"));
if (!isNewProfileBlock && !isOldProfileBlock) continue;
for (const QString &line : node.contentDesc.split('\n')) {
const QString trimmed = line.trimmed();
if (phoneRe.match(trimmed).hasMatch()) {
return trimmed;
}
}
}
return {};
}
QMap<QString, QString> ScreenXmlParser::parseLabelValuePairs() {
// Применяется к экранам "Платеж выполнен" и подобным, где с 3.2.33
// лейблы и значения сидят в отдельных View-нодах: лейбл слева
// (content-desc оканчивается ':'), значение — справа на той же Y-полосе.
QMap<QString, QString> result;
for (int i = 0; i < m_nodes.size(); ++i) {
const QString rawLabel = m_nodes[i].contentDesc.trimmed();
if (rawLabel.isEmpty() || !rawLabel.endsWith(':')) continue;
const QString label = rawLabel.left(rawLabel.size() - 1).trimmed();
if (label.isEmpty() || result.contains(label)) continue;
const Node &lbl = m_nodes[i];
int bestIdx = -1;
for (int j = 0; j < m_nodes.size(); ++j) {
if (j == i) continue;
const QString candidate = m_nodes[j].contentDesc.trimmed();
if (candidate.isEmpty()) continue;
if (candidate.endsWith(':')) continue;
if (m_nodes[j].x1() < lbl.x2()) continue;
if (qAbs(m_nodes[j].y1() - lbl.y1()) > 8) continue;
if (bestIdx < 0 || m_nodes[j].x1() < m_nodes[bestIdx].x1()) {
bestIdx = j;
}
}
if (bestIdx >= 0) {
result[label] = m_nodes[bestIdx].contentDesc.trimmed();
}
}
return result;
}
bool ScreenXmlParser::isCardsScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Мои карты")) {
return true;
}
}
return false;
}
QList<ScreenXmlParser::CardInfo> ScreenXmlParser::parseCards() {
// Карты — ImageView с content-desc формата:
// "Обновлено: 03.04.26 - 22:43:57\n0.00 TJS\n**** 3258\n до \n11/35\nIMOMKULZODA MEKHRUBON"
// или "-\n**** \n до \n12/29\n \nКредитная карта"
QList<CardInfo> result;
for (const Node &node : m_nodes) {
if (node.className != "android.widget.ImageView") continue;
if (!node.clickable) continue;
if (!node.contentDesc.contains("****")) continue;
if (node.contentDesc.contains(QString::fromUtf8("Кредитная карта"))) continue;
QStringList lines;
for (const QString &line : node.contentDesc.split('\n')) {
QString trimmed = line.trimmed();
if (!trimmed.isEmpty()) {
lines.append(trimmed);
}
}
CardInfo card;
for (const QString &line : lines) {
// Баланс: "0.00 TJS"
if (line.contains("TJS")) {
QStringList parts = line.split(' ');
if (parts.size() >= 2) {
card.amount = parts[0].toDouble();
card.currency = "TJS";
}
continue;
}
// Последние цифры: "**** 3258"
if (line.startsWith("****")) {
QString nums = line.mid(4).trimmed();
if (!nums.isEmpty() && nums[0].isDigit()) {
card.lastNumbers = nums;
}
continue;
}
// Срок: "11/35" или "12/29"
static const QRegularExpression expiryRe(R"(^\d{2}/\d{2}$)");
if (expiryRe.match(line).hasMatch()) {
card.expiry = line;
continue;
}
// Пропускаем "до", "Обновлено:...", "-"
if (line == QString::fromUtf8("до") || line.startsWith(QString::fromUtf8("Обновлено")) || line == "-") {
continue;
}
// Имя держателя (CAPS) или описание
if (line == line.toUpper() && line.length() > 3) {
card.holderName = line;
} else {
card.description = line;
}
}
result.append(card);
}
return result;
}
bool ScreenXmlParser::isCardDetailScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Карта")) {
return true;
}
}
return false;
}
QString ScreenXmlParser::parseFullCardNumber() {
// Полный номер карты: "9762 0002 0711 3258" — clickable View
static const QRegularExpression cardNumberRe(R"(^\d{4}\s\d{4}\s\d{4}\s\d{4}$)");
for (const Node &node : m_nodes) {
if (cardNumberRe.match(node.contentDesc).hasMatch()) {
return node.contentDesc;
}
}
return {};
}
double ScreenXmlParser::parseCardDetailBalance() {
// "0,00 TJS" или "- TJS" — содержит TJS, но не "сомони"
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("TJS") && !node.contentDesc.contains(QString::fromUtf8("сомони"))) {
QString raw = node.contentDesc.trimmed();
raw.remove("TJS");
raw = raw.trimmed();
if (raw == "-" || raw.isEmpty()) return 0.0;
raw.replace(',', '.');
raw.remove(' ');
bool ok = false;
double val = raw.toDouble(&ok);
if (ok) return val;
}
}
return 0.0;
}
QString ScreenXmlParser::parseCardDetailHolder() {
// Имя держателя — текст в CAPS, не содержит цифр, длина > 3
static const QRegularExpression cardNumberRe(R"(\d{4}\s\d{4})");
for (const Node &node : m_nodes) {
const QString &desc = node.contentDesc;
if (desc.isEmpty() || desc.length() < 4) continue;
if (desc == desc.toUpper() && !desc.contains(QRegularExpression(R"(\d)")) &&
desc != QString::fromUtf8("TJS")) {
return desc.trimmed();
}
}
return {};
}
Node ScreenXmlParser::findCardSettingsButton() {
for (const Node &node : m_nodes) {
if (node.clickable && node.contentDesc == QString::fromUtf8("Настройки карты")) {
return node;
}
}
return {};
}
bool ScreenXmlParser::isCardSettingsBottomSheet() {
bool hasRequisites = false;
bool hasBlock = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Реквизиты карты")) hasRequisites = true;
if (node.contentDesc == QString::fromUtf8("Заблокировать карту")) hasBlock = true;
if (hasRequisites && hasBlock) return true;
}
return false;
}
Node ScreenXmlParser::findCardRequisitesMenuItem() {
// На bottom sheet это ImageView; на экране "Реквизиты и информация"
// одноимённый таб — Button. Берём именно ImageView, чтобы не перепутать.
for (const Node &node : m_nodes) {
if (node.clickable
&& node.className == "android.widget.ImageView"
&& node.contentDesc == QString::fromUtf8("Реквизиты карты")) {
return node;
}
}
return {};
}
bool ScreenXmlParser::isCardRequisitesScreen() {
bool hasCardTab = false;
bool hasAccountTab = false;
for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button") continue;
if (node.contentDesc == QString::fromUtf8("Реквизиты карты")) hasCardTab = true;
if (node.contentDesc == QString::fromUtf8("Реквизиты счета")) hasAccountTab = true;
if (hasCardTab && hasAccountTab) return true;
}
return false;
}
Node ScreenXmlParser::findAccountRequisitesTab() {
for (const Node &node : m_nodes) {
if (node.clickable
&& node.className == "android.widget.Button"
&& node.contentDesc == QString::fromUtf8("Реквизиты счета")) {
return node;
}
}
return {};
}
QString ScreenXmlParser::parseAccountNumber() {
// Берём ноду "Номер счета" (label) и ищем ближайший узел снизу
// с чисто цифровым content-desc — это и есть номер счёта.
Node label;
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Номер счета")) {
label = node;
break;
}
}
if (label.y2() < 0) return {};
static const QRegularExpression digitsOnly(R"(^\d{10,}$)");
Node best;
for (const Node &node : m_nodes) {
if (node.y1() < label.y2()) continue;
if (!digitsOnly.match(node.contentDesc).hasMatch()) continue;
if (best.y1() < 0 || node.y1() < best.y1()) {
best = node;
}
}
return best.contentDesc;
}
bool ScreenXmlParser::isHistoryScreen() {
bool hasOperacii = false;
bool hasVypiska = false;
for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Операции")) {
hasOperacii = true;
}
if (node.contentDesc == QString::fromUtf8("Выписка")) {
hasVypiska = true;
}
if (hasOperacii && hasVypiska) {
return true;
}
}
return false;
}
QList<ScreenXmlParser::TransactionItem> ScreenXmlParser::parseTransactionItems() {
// Транзакции — ImageView с content-desc формата:
// "9762***3258\n992882770011\n493.00 \nDC (по номеру телефона)\n 18:57:10"
QList<TransactionItem> result;
for (const Node &node : m_nodes) {
if (node.className != "android.widget.ImageView") continue;
if (!node.contentDesc.contains("***")) continue;
QStringList lines;
for (const QString &line : node.contentDesc.split('\n')) {
QString trimmed = line.trimmed();
if (!trimmed.isEmpty()) {
lines.append(trimmed);
}
}
if (lines.size() < 4) continue;
TransactionItem tx;
tx.cardMask = lines[0]; // "9762***3258"
tx.phone = lines[1]; // "992882770011"
// Сумма: "493.00" (может быть с пробелом в конце)
bool ok = false;
tx.amount = lines[2].toDouble(&ok);
if (!ok) tx.amount = 0.0;
tx.description = lines[3]; // "DC (по номеру телефона)" или "Tcell"
// Время — последний элемент
if (lines.size() >= 5) {
tx.time = lines[4]; // "18:57:10"
}
result.append(tx);
}
return result;
}
bool ScreenXmlParser::isTransactionDetailScreen() {
for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("Дата операции:")) &&
node.contentDesc.contains(QString::fromUtf8("Номер операции:"))) {
return true;
}
}
return false;
}
ScreenXmlParser::TransactionDetail ScreenXmlParser::parseTransactionDetail() {
// Все данные в одном content-desc, формат "ключ: \nзначение"
TransactionDetail detail;
for (const Node &node : m_nodes) {
if (!node.contentDesc.contains(QString::fromUtf8("Дата операции:"))) continue;
// Разбиваем по \n, парсим пары "ключ:" + следующая строка = значение
QStringList lines = node.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QString line = lines[i].trimmed();
if (line.startsWith(QString::fromUtf8("Дата операции:")) && i + 1 < lines.size()) {
detail.date = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Время операции:")) && i + 1 < lines.size()) {
detail.time = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Номер операции:")) && i + 1 < lines.size()) {
detail.operationId = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Поставщик:")) && i + 1 < lines.size()) {
detail.provider = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Счет отправителя:")) && i + 1 < lines.size()) {
detail.senderAccount = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Счет получателя:")) && i + 1 < lines.size()) {
detail.receiverAccount = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Сумма операции:")) && i + 1 < lines.size()) {
detail.amount = lines[i + 1].trimmed().toDouble();
} else if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
detail.fee = lines[i + 1].trimmed().toDouble();
} else if (line.startsWith(QString::fromUtf8("Статус:")) && i + 1 < lines.size()) {
detail.status = lines[i + 1].trimmed();
}
}
break;
}
return detail;
}
Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText" && node.hint.contains(hint)) {
return node;
}
}
return {};
}
Node ScreenXmlParser::findSumEditText() {
// На экране перевода по карте порядок EditText'ов сверху вниз:
// [0] номер карты, [1] сумма, [2] комментарий.
QList<Node> editTexts;
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText") editTexts.append(node);
}
std::sort(editTexts.begin(), editTexts.end(),
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
return editTexts.size() >= 2 ? editTexts[1] : Node{};
}
Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() {
for (const Node &node : m_nodes) {
if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button"
&& node.clickable) {
return node;
}
}
for (const Node &node : m_nodes) {
if (node.clickable && node.text.trimmed() == QString::fromUtf8("ЗАПРЕТИТЬ")) {
return node;
}
}
return {};
}
Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int screenHeight) {
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) {
return node;
}
}
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("Доступно обновление")) {
Node tapNode;
const int cx = screenWidth / 2;
const int cy = screenHeight / 20;
tapNode.setBounds(cx, cy, cx + 1, cy + 1);
return tapNode;
}
}
return {};
}
} // namespace Dushanbe

View File

@ -0,0 +1,165 @@
#pragma once
#include <QMap>
#include <QObject>
#include <QDomElement>
#include "android/xml/Node.h"
#include "db/MaterialInfo.h"
namespace Dushanbe {
class ScreenXmlParser final : public QObject {
Q_OBJECT
public:
explicit ScreenXmlParser(QObject *parent = nullptr);
~ScreenXmlParser() override;
void parseAndSaveXml(const QString &xml);
const QList<Node> &findAllNodes() const;
Node findTextNode(const QString &text);
Node findNodeByContentDesc(const QString &contentDesc);
Node findNodeByContentDesc(const QString &contentDesc, bool contains);
Node findButtonNode(const QString &text, bool contains);
Node findFirstEditText();
// TODO: реализовать после снятия XML дампов экранов
bool isPinCodeScreen();
Node findPinCodeEditText();
bool isHomeScreen();
// Парсит баланс с домашнего экрана
double parseBalanceFromHomeScreen();
// Находит кнопку обновления данных в блоке "TJS\n<дата>" — правый clickable
// ImageView (рядом слева сидит глазик-Button, его не трогаем).
Node findRefreshButtonOnHomeScreen();
// Парсит имя пользователя с домашнего экрана
QString parseUserNameFromHomeScreen();
// Определяет боковое меню (профиль)
bool isSideMenu();
// Парсит данные профиля из бокового меню
// content-desc: "Имя Фамилия\n+992...\nВерсия:...\nСборка:..."
QString parseProfileFullName();
QString parseProfilePhone();
// Определяет экран "Мои карты"
bool isCardsScreen();
// Структура для карты из экрана "Мои карты"
struct CardInfo {
double amount = 0.0;
QString currency;
QString lastNumbers; // "3258"
QString expiry; // "11/35"
QString holderName; // "IMOMKULZODA MEKHRUBON"
QString description; // "Кредитная карта" и т.д.
};
// Парсит все карты с экрана "Мои карты" (lastNumbers только)
QList<CardInfo> parseCards();
// Определяет экран детали карты (заголовок "Карта")
bool isCardDetailScreen();
// Парсит полный номер карты с экрана детали ("9762 0002 0711 3258")
QString parseFullCardNumber();
// Парсит баланс с экрана детали ("0,00 TJS")
double parseCardDetailBalance();
// Парсит имя держателя с экрана детали
QString parseCardDetailHolder();
// Кнопка "Настройки карты" на экране детали карты
Node findCardSettingsButton();
// Bottom sheet с пунктами "Реквизиты карты" / "Заблокировать карту" и т.д.
bool isCardSettingsBottomSheet();
// Кнопка "Реквизиты карты" внутри bottom sheet "Настройки карты"
Node findCardRequisitesMenuItem();
// Экран "Реквизиты и информация" с табами "Реквизиты карты" / "Реквизиты счета"
bool isCardRequisitesScreen();
// Таб "Реквизиты счета" (правый) на экране "Реквизиты и информация"
Node findAccountRequisitesTab();
// Значение под лейблом "Номер счета" — длинная цифровая строка
QString parseAccountNumber();
// Определяет экран истории операций (табы "Операции" + "Выписка")
bool isHistoryScreen();
// Структура для транзакции из экрана истории
struct TransactionItem {
QString cardMask; // "9762***3258"
QString phone; // "992882770011"
double amount = 0.0; // 493.00
QString description; // "DC (по номеру телефона)"
QString time; // "18:57:10"
};
// Парсит транзакции с экрана истории
QList<TransactionItem> parseTransactionItems();
// Определяет экран детали транзакции (содержит "Дата операции:" и "Номер операции:")
bool isTransactionDetailScreen();
// Структура для деталей транзакции
struct TransactionDetail {
QString date; // "03.04.2026"
QString time; // "18:57:10"
QString operationId; // "1593369670"
QString provider; // "DC (по номеру телефона)"
QString senderAccount; // "9762***3258"
QString receiverAccount;// "992882770011"
double amount = 0.0; // 493.00
double fee = 0.0; // 0.00
QString status; // "Успешный"
};
// Парсит детали транзакции
TransactionDetail parseTransactionDetail();
// Находит EditText по hint
// Парсит пары "Лейбл: " → "Значение" для экранов, где поля разложены по
// отдельным View-узлам в две колонки (формат с 3.2.33). Метод сканирует
// все ноды: лейблом считается контент, оканчивающийся на ':', значением —
// ближайший справа узел на той же Y-полосе. Возвращает map без двоеточий.
QMap<QString, QString> parseLabelValuePairs();
Node findEditTextByHint(const QString &hint);
// Поле ввода суммы на экране перевода. У EditText'а нет hint/content-desc
// (NAF), поэтому ищем по якорю — View "Мин. сумма: ..." под полем —
// и берём ближайший EditText выше якоря.
Node findSumEditText();
// Находит кнопку закрытия Google Play in-app update bottom sheet
// ("com.android.vending" оверлей "Доступно обновление"), если он показан
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android
// (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден.
Node tryToFindSystemPermissionDenyButton();
private:
QList<Node> m_nodes;
QDomElement m_xml;
};
} // namespace Dushanbe

205
android/ocr/OcrUtils.cpp Normal file
View File

@ -0,0 +1,205 @@
#include "OcrUtils.h"
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
namespace {
// Кэш байт rus.traineddata. Грузим файл через Qt (Unicode-aware пути) и
// отдаём Tesseract в виде in-memory blob через Init(data, size, lang, ...).
// Это обходит проблему, что на Windows Tesseract открывает traineddata через
// `fopen`, который использует ANSI-кодировку (cp1251 на русской системе).
// Если путь к exe содержит кириллицу (например, OneDrive\Рабочий стол) —
// fopen не находит файл и Init возвращает -1. Memory-init не зависит от пути.
const QByteArray &tessdataBytes() {
static QByteArray bytes;
static bool tried = false;
if (tried) return bytes;
tried = true;
const QString appDir = QCoreApplication::applicationDirPath();
const QStringList candidates = {
appDir + "/tessdata/rus.traineddata", // канонический путь
appDir + "/rus.traineddata", // legacy: рядом с exe
};
for (const QString &path : candidates) {
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) continue;
bytes = f.readAll();
f.close();
if (!bytes.isEmpty()) {
qDebug() << "[OcrUtils] loaded rus.traineddata from" << path
<< "(" << bytes.size() << "bytes)";
return bytes;
}
}
qWarning() << "[OcrUtils] rus.traineddata NOT FOUND. Checked:" << candidates
<< "— OCR будет работать в fallback-режиме без поиска текста.";
return bytes;
}
// Расстояние Левенштейна на QString — нужен для нечёткого сравнения OCR-вывода
// с искомым словом. Для "Подтверждаю" допускаем 1-2 ошибки.
int editDistance(const QString &a, const QString &b) {
const int n = a.size();
const int m = b.size();
if (n == 0) return m;
if (m == 0) return n;
QVector<int> prev(m + 1), cur(m + 1);
for (int j = 0; j <= m; ++j) prev[j] = j;
for (int i = 1; i <= n; ++i) {
cur[0] = i;
for (int j = 1; j <= m; ++j) {
const int cost = a[i - 1] == b[j - 1] ? 0 : 1;
cur[j] = qMin(qMin(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
}
prev.swap(cur);
}
return prev[m];
}
bool fuzzyMatch(const QString &word, const QString &needle) {
if (word.isEmpty() || needle.isEmpty()) return false;
const QString w = word.toLower();
const QString n = needle.toLower();
// Substring-ветка работает только если word достаточно длинный.
// Иначе любая одиночная буква, входящая в needle (для «подтверждаю»:
// п/о/д/т/в/е/р/ж/а/ю), даст ложный матч на случайный текст экрана —
// ровно так бот тапал в (620,302) по букве «в» где-то в шапке sheet'a.
const int minLen = qMax(qMin(n.size(), 3), n.size() * 3 / 5);
if (w.size() >= minLen && (w.contains(n) || n.contains(w))) return true;
// Допускаем до 20% ошибок (но не меньше 1) — Tesseract на «Подтверждаю» иногда
// даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания.
const int tolerance = qMax(1, n.size() / 5);
return editDistance(w, n) <= tolerance;
}
} // namespace
namespace OcrUtils {
QPoint findTextCenter(const QByteArray &png,
const QString &needle,
int yMin,
int yMax) {
if (png.isEmpty() || needle.isEmpty()) return {-1, -1};
QElapsedTimer timer;
timer.start();
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(png.constData()),
static_cast<size_t>(png.size()));
if (!pix) {
qWarning() << "[OcrUtils] pixReadMem failed";
return {-1, -1};
}
const int fullW = pixGetWidth(pix);
const int fullH = pixGetHeight(pix);
int cropY = 0;
Pix *target = pix;
Pix *cropped = nullptr;
if (yMin > 0 || (yMax > 0 && yMax < fullH)) {
const int y0 = qMax(0, yMin);
const int y1 = (yMax > 0 && yMax <= fullH) ? yMax : fullH;
if (y1 > y0) {
BOX *box = boxCreate(0, y0, fullW, y1 - y0);
cropped = pixClipRectangle(pix, box, nullptr);
boxDestroy(&box);
if (cropped) {
target = cropped;
cropY = y0;
}
}
}
// Гоняем OCR двумя проходами: оригинал + инверсия. Текст бывает разный —
// тёмный на светлом (заголовки/поля) и светлый на цветном (sticky-action
// кнопка, оранжевая/синяя). Инверсия белого-на-оранжевом превращает её в
// тёмное-на-голубом, что Tesseract разбирает на порядок надёжнее.
auto runPass = [&](Pix *src, bool invert) -> QPoint {
Pix *gray = pixConvertTo8(src, 0);
if (!gray) return {-1, -1};
Pix *prepared = gray;
Pix *inverted = nullptr;
if (invert) {
inverted = pixInvert(nullptr, gray);
if (inverted) prepared = inverted;
}
const QByteArray &td = tessdataBytes();
if (td.isEmpty()) {
if (inverted) pixDestroy(&inverted);
pixDestroy(&gray);
return {-1, -1};
}
auto *api = new tesseract::TessBaseAPI();
// Init(data, size, lang, ...) — грузим traineddata из памяти, чтобы
// обойти fopen на путях с кириллицей (Windows ANSI-API).
if (api->Init(td.constData(), td.size(), "rus",
tesseract::OEM_DEFAULT,
nullptr, 0, nullptr, nullptr, false, nullptr)) {
qWarning() << "[OcrUtils] Tesseract Init(memory) failed —"
<< "traineddata повреждён или несовместимая версия";
delete api;
if (inverted) pixDestroy(&inverted);
pixDestroy(&gray);
return {-1, -1};
}
api->SetImage(prepared);
api->Recognize(nullptr);
QPoint hit(-1, -1);
tesseract::ResultIterator *ri = api->GetIterator();
if (ri) {
do {
const char *raw = ri->GetUTF8Text(tesseract::RIL_WORD);
if (!raw) continue;
const QString word = QString::fromUtf8(raw).trimmed();
delete[] raw;
if (!fuzzyMatch(word, needle)) continue;
int x1, y1, x2, y2;
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
const int cx = (x1 + x2) / 2;
const int cy = (y1 + y2) / 2 + cropY;
qDebug() << "[OcrUtils] found" << needle << "" << word
<< (invert ? "[inverted]" : "[normal]")
<< "at (" << cx << "," << cy << ")";
hit = QPoint(cx, cy);
break;
} while (ri->Next(tesseract::RIL_WORD));
}
api->End();
delete api;
if (inverted) pixDestroy(&inverted);
pixDestroy(&gray);
return hit;
};
QPoint result = runPass(target, /*invert=*/false);
if (result.x() < 0) {
result = runPass(target, /*invert=*/true);
}
if (cropped) pixDestroy(&cropped);
pixDestroy(&pix);
if (result.x() < 0) {
qDebug() << "[OcrUtils]" << needle << "not found in" << timer.elapsed() << "ms";
} else {
qDebug() << "[OcrUtils]" << needle << "located in" << timer.elapsed() << "ms";
}
return result;
}
} // namespace OcrUtils

22
android/ocr/OcrUtils.h Normal file
View File

@ -0,0 +1,22 @@
#pragma once
#include <QByteArray>
#include <QPoint>
#include <QString>
namespace OcrUtils {
// Находит надпись `needle` на скриншоте (PNG-байты) и возвращает координаты
// центра bounding box-а в системе координат экрана. Если текст не найден —
// возвращает QPoint(-1, -1). Совпадение нечувствительно к регистру и допускает
// одну-две OCR-ошибки в букве (для "Подтверждаю" на оранжевом фоне это важно).
//
// `yMin`/`yMax` — необязательное окно по Y в координатах экрана. Если задано,
// OCR гоняется только по этой полосе, что в 2-3 раза быстрее на 1080×2340.
// 0/0 — обрабатывать всё изображение.
QPoint findTextCenter(const QByteArray &png,
const QString &needle,
int yMin = 0,
int yMax = 0);
} // namespace OcrUtils

View File

@ -0,0 +1,503 @@
#include "CommonScript.h"
#include <QJsonArray>
#include <QJsonObject>
#include <QRandomGenerator>
#include <QRegularExpression>
#include <QSettings>
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/DeviceDAO.h"
#include "dao/BankProfileDAO.h"
#include "net/NetworkService.h"
namespace Ozon {
CommonScript::CommonScript(QObject *parent)
: QObject(parent) {
}
CommonScript::~CommonScript() = default;
void CommonScript::start() {
doStart();
emit finished();
}
Node CommonScript::swipeToButton(
const QString &deviceId,
const QString &text, int &counter, const int width, const int height
) {
for (int i = 0; i < 10; ++i) {
if (Node button = xmlScreenParser.findButtonNode(text, true); button.x() > 0 && button.y() > 0) {
return button;
}
const int x = width / 2;
const int offset = height / 4;
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
}
return {};
}
bool CommonScript::inputPinCode(const QString &deviceId, const QList<Node> &pincode) {
for (const Node &node: pincode) {
const bool completePincode = AdbUtils::makeTap(deviceId, node.x(), node.y());
if (!completePincode) {
AdbUtils::makeTap(deviceId, node.x(), node.y());
}
QThread::msleep(QRandomGenerator::global()->bounded(100, 901));
}
return true;
}
bool CommonScript::runAppAndGoToHomeScreen(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
const int width,
const int height
) {
// Разблокируем экран перед запуском
AdbUtils::unlockScreen(deviceId, width, height);
// Быстрый path: если приложение уже запущено — не перезапускаем сразу,
// а смотрим текущий экран. Home → готово. PIN → вводим и ждём Home.
// Только если не смогли — убиваем и рестартим.
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
qDebug() << "Process already running, pid: " << pid;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Ozon] Fast path: already on Home screen";
return true;
}
{
const QList<Node> fastPincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
if (fastPincode.size() == pinCode.length() && fastPincode.size() >= 4) {
qDebug() << "[Ozon] Fast path: PIN screen, entering PIN";
if (Node wake = xmlScreenParser.findTextNode("Введите ПИН"); !wake.isEmpty()) {
AdbUtils::makeTap(deviceId, wake.x(), wake.y());
}
inputPinCode(deviceId, fastPincode);
QThread::msleep(3000);
for (int i = 0; i < 10; ++i) {
xmlScreenParser.parseAndSaveXml(
AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
qDebug() << "[Ozon] Fast path: Home reached after PIN";
return true;
}
// Ошибка загрузки — тапаем "Обновить"
{
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[Ozon] Fast path: page error after PIN, tapping 'Обновить'";
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
}
}
QThread::msleep(1000);
}
}
}
qDebug() << "[Ozon] Fast path failed, restarting app";
AdbUtils::tryToKillApplication(deviceId, packageName);
}
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
AdbUtils::takeScreenshot(deviceId, "run_app_error");
qWarning() << "Process has not started";
return false;
}
// Даем минимальное время на запуск
QThread::msleep(4500);
int counter = 0;
bool findAndInputPincode = false;
while (true) {
counter++;
// Если более 10 раз мы пытаемся перейти на домашнюю
if (counter > 10) {
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
return true;
}
// Проверяем на банеры после пин-кодом
Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
if (closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
QThread::msleep(1000);
continue;
}
// Проверяем на ошибку загрузки WebView ("Обновить")
{
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[runAppAndGoToHomeScreen] Page load error, tapping 'Обновить'";
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
continue;
}
}
// Проверяем на банеры перед пин-кодом
Node closeBannerNode = xmlScreenParser.tryToFindSecurityBanner();
if (!closeBannerNode.isEmpty()) {
AdbUtils::makeTap(deviceId, closeBannerNode.x(), closeBannerNode.y());
QThread::msleep(1000);
continue;
}
// Проверяем на баннер Google Play "Доступно обновление"
if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height); !gpNode.isEmpty()) {
qDebug() << "[runAppAndGoToHomeScreen] Google Play update banner detected, closing...";
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
continue;
}
// Проверяем на рекламный bottom sheet (ипотека, открыть счёт и т.п.)
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
qDebug() << "[runAppAndGoToHomeScreen] Ad bottom sheet detected, closing...";
AdbUtils::makeTap(deviceId, adNode.x(), adNode.y());
QThread::msleep(1000);
// Если touch_outside не помог — жмём назад
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
qDebug() << "[runAppAndGoToHomeScreen] Ad still visible, pressing back...";
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
}
continue;
}
// Проверяем, что это экран ввода пин-кода
if (!findAndInputPincode) {
const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
if (pincode.size() == pinCode.length() && pincode.size() >= 4) {
// чтобы пробудить экран
if (Node textPinCodeNode = xmlScreenParser.findTextNode("Введите ПИН"); !textPinCodeNode.isEmpty()) {
AdbUtils::makeTap(deviceId, textPinCodeNode.x(), textPinCodeNode.y());
}
inputPinCode(deviceId, pincode);
// После ввода пин-код нужно сделать задержку, пока подгрузка идет, чтобы снова не спарсить
findAndInputPincode = true;
QThread::msleep(3000);
continue;
}
}
QThread::msleep(1000);
}
}
bool CommonScript::tryToCloseAllBannersOnHomePage(const QString &deviceId, const int width, const int height) {
bool closedAny = false;
for (int i = 0; i < 5; ++i) {
bool closedThis = false;
// Баннер Google Play "Доступно обновление" — появляется поверх Ozon при возврате на домашнюю
if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height); !gpNode.isEmpty()) {
qDebug() << "[tryToCloseAllBannersOnHomePage] Google Play update banner detected, closing...";
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
closedThis = true;
} else if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
// Рекламный bottom sheet (ипотека, бесплатная карта, "Копите звёзды" и т.п.)
qDebug() << "[tryToCloseAllBannersOnHomePage] Ad bottom sheet detected, closing...";
AdbUtils::makeTap(deviceId, adNode.x(), adNode.y());
QThread::msleep(1000);
// Если touch_outside не помог — жмём назад
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
qDebug() << "[tryToCloseAllBannersOnHomePage] Ad still visible, pressing back...";
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
}
closedAny = true;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
continue;
} else if (Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
QThread::msleep(500);
closedThis = true;
}
if (!closedThis) break;
closedAny = true;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
}
return closedAny;
}
bool CommonScript::closeGooglePlayBannerIfVisible(
const QString &deviceId, const int width, const int height
) {
// 1. Системный диалог запроса разрешения Android (permissioncontroller) — тапаем "ЗАПРЕТИТЬ"
if (Node denyBtn = xmlScreenParser.tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
qDebug() << "[Ozon] System permission dialog detected mid-task, tapping 'ЗАПРЕТИТЬ'...";
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
return true;
}
// 2. Google Play in-app update bottom sheet
Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height);
if (gpNode.isEmpty()) return false;
qDebug() << "[Ozon] Google Play update banner detected mid-task, closing...";
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
return true;
}
bool CommonScript::goToAllProducts(
const QString &deviceId,
const int width,
const int height
) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, m_counter));
if (tryToCloseAllBannersOnHomePage(deviceId, width, height)) {
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
}
// Ozon: домашний экран и есть "Финансы" — счета уже доступны
if (xmlScreenParser.isHomeScreen()) {
return true;
}
// Если по какой-то причине не на домашнем — пробуем тапнуть по вкладке "Финансы"
for (int i = 0; i < 3; ++i) {
if (Node financeTab = xmlScreenParser.findNodeByContentDesc("Финансы"); !financeTab.isEmpty()) {
AdbUtils::makeTap(deviceId, financeTab.x(), financeTab.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isHomeScreen()) {
return true;
}
}
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
}
qDebug() << "goToAllProducts: not home screen";
return false;
}
bool CommonScript::findAndTapOnAccount(const QString &deviceId, const QString &accountNumbers) {
QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();
for (auto &[account, node]: accounts) {
// FIXME будем считать что числа последние везде уникальные
if (accountNumbers == account.lastNumbers) {
qDebug() << "findAndTapOnAccount: " << accountNumbers;
AdbUtils::makeTap(deviceId, node.x(), node.y());
QThread::msleep(1000);
return true;
}
}
return false;
}
void CommonScript::postAccountsData(const QList<QPair<MaterialInfo, Node> > &accounts) {
if (accounts.isEmpty()) {
return;
}
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonArray list;
for (const QPair<MaterialInfo, Node> &pair: accounts) {
QJsonObject obj;
MaterialInfo account = pair.first;
obj["appName"] = account.appCode;
obj["lastNumbers"] = account.lastNumbers;
obj["amount"] = account.amount;
obj["description"] = account.description;
obj["currency"] = account.currency;
obj["status"] = materialStatusToString(account.status);
list.append(obj);
}
// FIXME доставить десктоп ид из настроеек
worker->setDeviceId(accounts.first().first.deviceId);
worker->setPayload(QJsonDocument(list).toJson());
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
connect(worker, &NetworkService::finished, thread, &QThread::quit);
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
void CommonScript::postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction) {
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonObject obj;
obj["id"] = transaction.id;
obj["amount"] = transaction.amount;
obj["name"] = transaction.name;
obj["fee"] = transaction.fee;
obj["status"] = transactionStatusToString(transaction.status);
obj["type"] = transactionTypeToString(transaction.type);
obj["phone"] = transaction.phone;
obj["description"] = transaction.description;
obj["updatedDescription"] = transaction.updatedDescription;
obj["bankName"] = transaction.bankName;
obj["bankTime"] = transaction.bankTime;
obj["bankTransactionId"] = transaction.bankTrExternalId;
// FIXME доставить десктоп ид из настроеек
worker->setDeviceId(account.deviceId);
worker->addExtra("lastNumbers", account.lastNumbers);
worker->addExtra("transactionId", transaction.id);
worker->setPayload(QJsonDocument(obj).toJson());
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &NetworkService::insertTransactionData);
connect(worker, &NetworkService::finished, thread, &QThread::quit);
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
void CommonScript::updateTransactionData(
const MaterialInfo &account,
const int transactionId,
const TransactionStatus status,
const QString &oldDesc,
const QString &newDesc
) {
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonObject obj;
obj["id"] = transactionId;
obj["status"] = transactionStatusToString(status);
obj["description"] = newDesc;
obj["updatedDescription"] = oldDesc;
// FIXME доставить десктоп ид из настроеек
worker->setDeviceId(account.deviceId);
worker->addExtra("lastNumbers", account.lastNumbers);
worker->setPayload(QJsonDocument(obj).toJson());
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &NetworkService::updateTransactionData);
connect(worker, &NetworkService::finished, thread, &QThread::quit);
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
qWarning() << "[Ozon] createBankProfileIfNeeded: skipping — fullName or phone is empty";
return {};
}
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
const int currencyCode = currencyCodes.value(app.currency.toUpper(), 643);
const QStringList parts = app.fullName.split(' ', Qt::SkipEmptyParts);
// Нормализуем телефон: оставляем только цифры, добавляем код страны и "+"
const QSettings settings("config.ini", QSettings::IniFormat);
const QString phoneCode = settings.value(appCode + "/phone_code").toString();
QString phone = app.phone;
phone.remove(QRegularExpression("[^0-9]"));
if (!phoneCode.isEmpty() && !phone.startsWith(phoneCode)) {
if (phone.startsWith('8') && phoneCode != "8") {
phone.replace(0, 1, phoneCode);
} else {
phone.prepend(phoneCode);
}
}
phone.prepend('+');
const DeviceInfo device = DeviceDAO::getDeviceById(deviceId);
QJsonObject profileBody;
profileBody["first_name"] = parts.value(0);
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : QString("");
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
profileBody["bank_name"] = appCode;
profileBody["phone_number"] = phone;
const double balance = (parsedBalance >= 0.0) ? parsedBalance : app.amount;
profileBody["balance"] = QString::number(qRound64(balance * 100));
profileBody["state"] = (app.status == "active") ? "enabled" : "disabled";
profileBody["device_id"] = device.apiId;
profileBody["currency"] = currencyCode;
NetworkService ns;
ns.setDeviceId(deviceId);
ns.setPayload(QJsonDocument(profileBody).toJson());
if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id);
ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) {
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
qWarning() << "[Ozon] createBankProfileIfNeeded:" << err;
return err;
}
qDebug() << "[Ozon] createBankProfileIfNeeded: postBankProfile done";
} else {
ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile();
qDebug() << "[Ozon] createBankProfileIfNeeded: patchBankProfile done";
}
return {};
}
bool CommonScript::isAppRunning(const QString &deviceId, const QString &packageName) {
return !AdbUtils::tryToGetRunningAppPid(deviceId, packageName).isEmpty();
}
bool CommonScript::FreezeDetector::isUsefulDump(const QString &xml) {
if (xml.isEmpty()) return false;
// Полезный дамп должен содержать хотя бы один Button с непустым text
// ИЛИ EditText. Иначе — это shell-only state (только нав-табы, у которых
// text пуст и есть только content-desc).
if (!xml.contains(QStringLiteral("class=\"android.widget.Button\""))) return false;
static const QRegularExpression usefulRe(
R"(class="android\.widget\.Button"[^/]*text="[^"]+"|text="[^"]+"[^/]*class="android\.widget\.Button"|class="android\.widget\.EditText")"
);
return usefulRe.match(xml).hasMatch();
}
bool CommonScript::FreezeDetector::noticeAndCheckFrozen(const QString &xml) {
if (!isUsefulDump(xml)) {
// Пустой или WebView-shell-only — не учитываем.
return false;
}
if (!m_prev.isEmpty() && xml == m_prev) {
++m_streak;
if (m_streak >= m_maxStreak) return true;
} else {
m_streak = 0;
}
m_prev = xml;
return false;
}
void CommonScript::stop() {
m_running = false;
}
} // namespace Ozon

122
android/ozon/CommonScript.h Normal file
View File

@ -0,0 +1,122 @@
#pragma once
#include <QObject>
#include "ScreenXmlParser.h"
namespace Ozon {
class CommonScript : public QObject {
Q_OBJECT
public:
explicit CommonScript(QObject *parent = nullptr);
~CommonScript() override;
protected:
virtual void doStart() = 0;
// static method
static bool inputPinCode(const QString &deviceId, const QList<Node> &pincode);
bool goToAllProducts(
const QString &deviceId,
int width,
int height
);
bool findAndTapOnAccount(const QString &deviceId, const QString &accountNumbers);
void postAccountsData(const QList<QPair<MaterialInfo, Node> > &accounts);
void postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction);
void updateTransactionData(
const MaterialInfo &account,
int transactionId,
TransactionStatus status,
const QString &oldDesc,
const QString &newDesc
);
Node swipeToButton(
const QString &deviceId,
const QString &text,
int &counter, int width, int height
);
bool runAppAndGoToHomeScreen(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
int width,
int height
);
bool tryToCloseAllBannersOnHomePage(
const QString &deviceId, int width, int height
);
// Если виден баннер Google Play "Доступно обновление" — закрывает его и перепарсивает XML.
// Безопасно вызывать на любом экране; возвращает true если баннер был закрыт.
bool closeGooglePlayBannerIfVisible(
const QString &deviceId, int width, int height
);
// Создаёт bank_profile на сервере если ещё не создан.
// Возвращает пустую строку при успехе, текст ошибки при провале.
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
// Проверяет что приложение ещё запущено на устройстве
static bool isAppRunning(const QString &deviceId, const QString &packageName);
// Детектор зафризившегося экрана: считает подряд идущие байт-идентичные
// дампы с реальным контентом. Бесполезные дампы (пустые из-за adb-сбоя
// или WebView-shell-only без отрисованного Svelte-контента) пропускаются —
// не обнуляют счётчик и не накручивают его.
//
// Использование в wait-loop:
// FreezeDetector freezeCheck;
// for (int attempt = 0; attempt < N; ++attempt) {
// QString xml = AdbUtils::getScreenDumpAsXml(...);
// xmlScreenParser.parseAndSaveXml(xml);
// if (freezeCheck.noticeAndCheckFrozen(xml)) {
// m_error = "Экран зафризился"; return;
// }
// ... existing logic
// }
class FreezeDetector {
public:
explicit FreezeDetector(int maxStreak = 3) : m_maxStreak(maxStreak) {}
// True если получили (m_maxStreak + 1) подряд байт-идентичных полезных дампов.
bool noticeAndCheckFrozen(const QString &xml);
private:
static bool isUsefulDump(const QString &xml);
QString m_prev;
int m_streak = 0;
int m_maxStreak;
};
bool m_running = true;
ScreenXmlParser xmlScreenParser;
int m_counter = 0;
QByteArray m_resultScreenshot;
signals:
void finished();
void finishedWithResult(const QString &result);
void screenshotReady(const QByteArray &screenshot);
public slots:
virtual void start() final;
virtual void stop() final;
private:
Q_DISABLE_COPY(CommonScript);
};
} // namespace Ozon

View File

@ -0,0 +1,578 @@
#include "GetCardInfoScript.h"
#include <QDateTime>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/SettingsDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h"
#include "AppLogger.h"
namespace Ozon {
GetCardInfoScript::GetCardInfoScript(
QString deviceId,
QString appCode,
QString lastNumbers,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_lastNumbers(std::move(lastNumbers)) {
}
GetCardInfoScript::~GetCardInfoScript() = default;
void GetCardInfoScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
// Читаем текущий экран
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Закрываем рекламный bottom sheet, если открыт
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
qDebug() << "[GetCardInfo] Ad banner detected, closing...";
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Свежий дамп перед проверкой домашнего экрана
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Если не на домашнем экране — перезапускаем приложение
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[GetCardInfo] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Закрываем баннеры на главной
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Переходим в профиль и парсим имя и телефон (только если ещё нет в БД)
const bool hasProfileInfo = !app.fullName.isEmpty() && !app.phone.isEmpty();
Node userNameNode;
if (!hasProfileInfo) {
userNameNode = xmlScreenParser.findUserNameOnHomeScreen(device.screenHeight);
}
if (!hasProfileInfo && !userNameNode.isEmpty()) {
qDebug() << "[GetCardInfo] Navigating to profile to parse name/phone...";
AdbUtils::makeTap(m_deviceId, userNameNode.x(), userNameNode.y());
QThread::msleep(2500);
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isProfileScreen()) break;
QThread::msleep(1000);
}
if (xmlScreenParser.isProfileScreen()) {
const QString fullName = xmlScreenParser.parseProfileFullName();
const QString phone = xmlScreenParser.parseProfilePhone();
qDebug() << "[GetCardInfo] Profile parsed - fullName:" << fullName << "phone:" << phone;
BankProfileDAO::updateProfileInfo(app.id, fullName, phone);
} else {
qWarning() << "[GetCardInfo] Profile screen not reached, skipping profile parse";
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
} else {
qDebug() << "[GetCardInfo] Profile info already in DB or username not found, skipping profile parse";
}
// Проверяем что на домашнем экране перед поиском карт
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[GetCardInfo] Not on home screen before card search, restarting...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen before card search: " + device.name + " (" + m_deviceId + ")";
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
QThread::msleep(500);
}
// Ждём пока все карты подгрузятся — до 5 попыток
QList<MaterialInfo> cards;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight)) {
// баннер закрыт, дамп уже обновлён внутри helper'а
}
cards = xmlScreenParser.parseCardsOnHomeScreen();
if (!cards.isEmpty()) break;
qDebug() << "[GetCardInfo] No cards found on home screen, attempt" << attempt + 1 << "/ 5";
QThread::msleep(1500);
}
if (cards.isEmpty()) {
m_error = "No cards found on home screen: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
qDebug() << "[GetCardInfo] Found" << cards.size() << "card(s) on home screen";
// Заполняем метаданные на найденных картах. Новые карты (которых ещё нет в БД)
// не создаём здесь — запись появится только после успешного OCR полного номера
// в success-ветке ниже. Это защищает от записей из ленты переводов и не оставляет
// в БД материал с пустым card_number.
//
// Для карт, которые уже в БД, обновляем только amount/update_time —
// прочие поля (cardNumber, materialId, status, ...) сохраняем как было.
for (MaterialInfo &card : cards) {
card.deviceId = device.id;
card.appCode = m_appCode;
card.updateTime = QDateTime::currentDateTimeUtc();
card.status = MaterialStatus::Off;
const MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (existing.id == -1) continue;
MaterialInfo refresh = existing;
refresh.amount = card.amount;
refresh.updateTime = card.updateTime;
if (!MaterialDAO::upsertAccount(refresh)) {
qWarning() << "[GetCardInfo] Failed to refresh amount for card" << card.lastNumbers;
} else {
qDebug() << "[GetCardInfo] Refreshed amount for card"
<< card.lastNumbers << "to" << card.amount;
}
}
// Сохраняем баланс домашнего экрана в application
const double totalAmount = cards.isEmpty() ? 0.0 : cards[0].amount;
if (totalAmount > 0.0) {
BankProfileDAO::updateAmount(app.id, totalAmount);
}
// Синхронная отправка на сервер (NetworkService блокируется через QEventLoop внутри)
NetworkService ns;
ns.setDeviceId(m_deviceId);
// 1. Создаём девайс если нет api_id
if (device.apiId.isEmpty()) {
QJsonObject deviceBody;
deviceBody["desktop_id"] = SettingsDAO::get("desktop_id");
deviceBody["status"] = "ONLINE";
deviceBody["name"] = device.name;
deviceBody["screen_width"] = device.screenWidth;
deviceBody["screen_height"]= device.screenHeight;
deviceBody["battery"] = device.battery;
ns.setPayload(QJsonDocument(deviceBody).toJson());
ns.createDevice();
qDebug() << "[GetCardInfo] createDevice done";
}
// 2. Отправляем банковский профиль (используем свежие данные из БД — имя/телефон уже спарсены)
{
const BankProfileInfo freshApp = BankProfileDAO::getApplication(device.id, m_appCode);
const QStringList parts = freshApp.fullName.split(' ', Qt::SkipEmptyParts);
// ISO 4217 numeric currency codes
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
const int currencyCode = currencyCodes.value(freshApp.currency.toUpper(), 643);
QJsonObject profileBody;
profileBody["first_name"] = parts.value(0);
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : "";
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
profileBody["bank_name"] = m_appCode;
// Телефон: убираем пробелы, оставляем "+"
const QString phone = freshApp.phone.simplified().remove(' ');
profileBody["phone_number"] = phone;
profileBody["balance"] = QString::number(qRound64(totalAmount * 100));
profileBody["state"] = (freshApp.status == "active") ? "enabled" : "disabled";
profileBody["device_id"] = device.apiId;
profileBody["currency"] = currencyCode;
ns.setPayload(QJsonDocument(profileBody).toJson());
if (freshApp.bankProfileId.isEmpty()) {
ns.addExtra("app_id", freshApp.id);
ns.postBankProfile();
// Проверяем что bankProfileId сохранился
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
if (afterPost.bankProfileId.isEmpty()) {
const QString err = "postBankProfile failed for " + m_deviceId;
qWarning() << "[GetCardInfo]" << err;
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId),
"FETCH_PROFILE");
m_error = err;
emit finishedWithResult(m_error);
return;
}
qDebug() << "[GetCardInfo] postBankProfile done";
} else {
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
ns.patchBankProfile();
qDebug() << "[GetCardInfo] patchBankProfile done";
}
}
// Для каждой карты переходим на её экран и получаем полный номер через OCR
for (int i = 0; i < cards.size(); ++i) {
const MaterialInfo &card = cards[i];
// Если передан конкретный lastNumbers — обрабатываем только его
if (!m_lastNumbers.isEmpty() && card.lastNumbers != m_lastNumbers) continue;
qDebug() << "[GetCardInfo] Getting card number for" << card.lastNumbers;
// Если номер карты уже есть в БД — сумма уже обновлена через upsert, навигация не нужна
MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (existing.id != -1 && !existing.cardNumber.isEmpty()) {
qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "already has number, skipping";
continue;
}
// Тапаем по кнопке конкретной карты — каждая открывает свой экран
Node cardBtn = xmlScreenParser.findCardButtonByLastNumbers(card.lastNumbers);
if (cardBtn.isEmpty()) {
qWarning() << "[GetCardInfo] Card button not found for" << card.lastNumbers;
continue;
}
AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
// Скроллим WebView вверх, пока "Показать" не окажется в верхней трети экрана.
// На устройствах с короткой высотой (например, SM-A165F, h=2340) кнопка "Показать"
// находится за bottom navigation, и тап уходит в нав-бар.
const int targetTopY = device.screenHeight / 3;
for (int scrollAttempt = 0; scrollAttempt < 6; ++scrollAttempt) {
QThread::msleep(scrollAttempt == 0 ? 2000 : 600);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
Node candidate = xmlScreenParser.findButtonNode("Показать", false);
if (candidate.isEmpty()) {
if (xmlScreenParser.isHomeScreen()) {
cardBtn = xmlScreenParser.findCardButtonByLastNumbers(card.lastNumbers);
if (!cardBtn.isEmpty()) {
qDebug() << "[GetCardInfo] Still on home before scroll, re-tapping card" << card.lastNumbers;
AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
}
}
continue;
}
if (candidate.y() <= targetTopY) {
qDebug() << "[GetCardInfo] 'Показать' already at top y=" << candidate.y();
break;
}
const int swipeFromY = candidate.y();
const int swipeToY = device.screenHeight / 6;
qDebug() << "[GetCardInfo] Scrolling 'Показать' up:" << swipeFromY << "->" << swipeToY;
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, swipeFromY,
device.screenWidth / 2, swipeToY);
}
// Ждём экран карты и стабильной геометрии блока реквизитов.
// Без этого тап по "Показать" во время reflow уезжает ниже — в "Защиту от мошенников".
// Стабильность: "Показать" совпадает по y1 с "Реквизиты карты" И координаты не меняются в двух подряд дампах.
Node showBtn;
int prevShowY = -1;
for (int attempt = 0; attempt < 10; ++attempt) {
QThread::msleep(attempt == 0 ? 2000 : 500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
Node candidate = xmlScreenParser.findButtonNode("Показать", false);
Node requisites = xmlScreenParser.findTextNode("Реквизиты карты");
if (candidate.isEmpty() || requisites.isEmpty()) {
// Если всё ещё на домашнем экране — тап по карточке не сработал, повторяем
if (xmlScreenParser.isHomeScreen()) {
cardBtn = xmlScreenParser.findCardButtonByLastNumbers(card.lastNumbers);
if (!cardBtn.isEmpty()) {
qDebug() << "[GetCardInfo] Still on home screen, re-tapping card" << card.lastNumbers;
AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
}
}
prevShowY = -1;
qDebug() << "[GetCardInfo] Requisites not ready, attempt" << attempt + 1 << "/ 10";
continue;
}
// "Показать" должен быть в одной строке с "Реквизиты карты"
if (qAbs(candidate.y1() - requisites.y1()) > 10) {
prevShowY = -1;
qDebug() << "[GetCardInfo] 'Показать' not aligned with 'Реквизиты карты', waiting reflow";
continue;
}
// Y должна совпасть в двух подряд дампах (layout устаканился)
if (prevShowY >= 0 && qAbs(candidate.y() - prevShowY) <= 5) {
showBtn = candidate;
break;
}
prevShowY = candidate.y();
qDebug() << "[GetCardInfo] 'Показать' seen at y=" << candidate.y() << ", waiting for stable frame";
}
if (showBtn.isEmpty()) {
qWarning() << "[GetCardInfo] 'Показать' not stable for card" << card.lastNumbers;
AdbUtils::goBack(m_deviceId);
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
continue;
}
qDebug() << "[GetCardInfo] 'Показать' at" << showBtn.x() << showBtn.y();
AdbUtils::makeTap(m_deviceId, showBtn.x(), showBtn.y());
// Ждём появления полного номера карты в XML дампе
QString cardNumber;
for (int attempt = 0; attempt < 10; ++attempt) {
QThread::msleep(2000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
cardNumber = xmlScreenParser.findFullCardNumber();
if (!cardNumber.isEmpty()) {
qDebug() << "[GetCardInfo] Card number found on attempt" << attempt + 1;
break;
}
// Закрываем баннер "Понятно" и повторно нажимаем "Показать"
Node bannerBtn = xmlScreenParser.findTextNode("Понятно");
if (!bannerBtn.isEmpty()) {
qDebug() << "[GetCardInfo] Banner 'Понятно' detected, closing...";
AdbUtils::makeTap(m_deviceId, bannerBtn.x(), bannerBtn.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
Node retryShowBtn = xmlScreenParser.findButtonNode("Показать", false);
if (!retryShowBtn.isEmpty()) {
qDebug() << "[GetCardInfo] Re-tapping 'Показать' after banner close";
AdbUtils::makeTap(m_deviceId, retryShowBtn.x(), retryShowBtn.y());
}
continue;
}
// "Подождите" — загрузка в процессе, просто ждём
if (!xmlScreenParser.findButtonNode("Подождите", false).isEmpty()) {
qDebug() << "[GetCardInfo] 'Подождите' visible, waiting... attempt" << attempt + 1 << "/ 10";
continue;
}
// "Скрыть" видна — номер должен быть раскрыт, но парсер не нашёл
if (!xmlScreenParser.findButtonNode("Скрыть", false).isEmpty()) {
qDebug() << "[GetCardInfo] 'Скрыть' visible but card number not parsed, attempt" << attempt + 1 << "/ 10";
continue;
}
// Повторно нажимаем "Показать" если тап не сработал
Node retryShowBtn = xmlScreenParser.findButtonNode("Показать", false);
if (!retryShowBtn.isEmpty()) {
qDebug() << "[GetCardInfo] Re-tapping 'Показать', attempt" << attempt + 1 << "/ 10";
AdbUtils::makeTap(m_deviceId, retryShowBtn.x(), retryShowBtn.y());
} else {
qDebug() << "[GetCardInfo] Card number not visible, attempt" << attempt + 1 << "/ 10";
}
}
if (!cardNumber.isEmpty()) {
qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "full number:" << cardNumber;
// Апсёртим запись только теперь — с полным номером карты.
MaterialInfo toSave = card;
toSave.cardNumber = cardNumber;
toSave.updateTime = QDateTime::currentDateTimeUtc();
if (!MaterialDAO::upsertAccount(toSave)) {
qWarning() << "[GetCardInfo] Failed to upsert account for card" << card.lastNumbers;
} else {
qDebug() << "[GetCardInfo] Upserted account:" << card.lastNumbers
<< card.description << card.amount;
}
} else {
const QString err = "Card number not found for " + card.lastNumbers;
qWarning() << "[GetCardInfo]" << err;
// Без полного номера материал в БД не создаём.
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId),
"FETCH_PROFILE");
}
// Возвращаемся на домашний экран для следующей карты
if (i < cards.size() - 1) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(2000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
}
}
// 2.5. Достаём номер счёта (общий для всех карт): Основной счёт → Посмотреть реквизиты
// ВРЕМЕННО ОТКЛЮЧЕНО
// QString accountNumber;
// {
// bool needFetch = false;
// for (const MaterialInfo &c : cards) {
// const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, c.lastNumbers);
// if (dbCard.id != -1 && dbCard.accountNumber.isEmpty()) { needFetch = true; break; }
// }
//
// if (needFetch) {
// // Возвращаемся на главный экран — критерий именно по наличию кнопки
// // "Основной счёт ... ₽". isHomeScreen() для Озона нестрогий
// // (Перевести/Пополнить присутствуют и на других экранах).
// Node mainAcc;
// for (int attempt = 0; attempt < 6; ++attempt) {
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// mainAcc = xmlScreenParser.findMainAccountButton();
// if (mainAcc.x() > 0) break;
// AdbUtils::goBack(m_deviceId);
// QThread::msleep(1500);
// }
//
// if (mainAcc.x() > 0) {
// AdbUtils::makeTap(m_deviceId, mainAcc.x(), mainAcc.y());
// QThread::msleep(2000);
//
// // Скроллим WebView, пока "Посмотреть реквизиты" не окажется в верхней половине,
// // чтобы тап не ушёл в системный нав-бар.
// Node showReq;
// const int targetMaxY = device.screenHeight / 2;
// for (int attempt = 0; attempt < 6; ++attempt) {
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Node candidate = xmlScreenParser.findShowRequisitesButton();
// if (candidate.x() > 0 && candidate.y() <= targetMaxY) {
// showReq = candidate;
// break;
// }
// AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
// device.screenHeight * 2 / 3,
// device.screenWidth / 2,
// device.screenHeight / 3);
// QThread::msleep(800);
// }
//
// if (showReq.x() > 0) {
// AdbUtils::makeTap(m_deviceId, showReq.x(), showReq.y());
// QThread::msleep(2000);
//
// bool reqScreen = false;
// for (int attempt = 0; attempt < 6; ++attempt) {
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// if (xmlScreenParser.isAccountRequisitesScreen()) {
// reqScreen = true;
// break;
// }
// QThread::msleep(1000);
// }
//
// if (reqScreen) {
// for (int attempt = 0; attempt < 5; ++attempt) {
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// accountNumber = xmlScreenParser.parseAccountNumberFromRequisites();
// if (!accountNumber.isEmpty()) break;
// QThread::msleep(800);
// }
// qDebug() << "[GetCardInfo] accountNumber:" << accountNumber;
// } else {
// qWarning() << "[GetCardInfo] Account requisites screen not detected";
// }
// } else {
// qWarning() << "[GetCardInfo] 'Посмотреть реквизиты' not reached";
// }
// } else {
// qWarning() << "[GetCardInfo] 'Основной счёт' button not found on home";
// }
//
// // Возврат на главный — критерий по кнопке "Основной счёт ... ₽"
// for (int attempt = 0; attempt < 5; ++attempt) {
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// if (xmlScreenParser.findMainAccountButton().x() > 0) break;
// AdbUtils::goBack(m_deviceId);
// QThread::msleep(1500);
// }
//
// // Записываем номер счёта во все материалы device/app
// if (!accountNumber.isEmpty()) {
// for (const MaterialInfo &c : cards) {
// MaterialInfo upd = MaterialDAO::findAppAccount(device.id, m_appCode, c.lastNumbers);
// if (upd.id == -1) continue;
// upd.accountNumber = accountNumber;
// upd.updateTime = QDateTime::currentDateTimeUtc();
// if (!MaterialDAO::upsertAccount(upd)) {
// qWarning() << "[GetCardInfo] Failed to save accountNumber for" << upd.lastNumbers;
// }
// }
// }
// }
// }
// 3. Отправляем материалы (карты) — после OCR, когда номера карт уже в БД
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
if (!updatedApp.bankProfileId.isEmpty()) {
for (const MaterialInfo &card : cards) {
const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (dbCard.cardNumber.isEmpty()) continue;
if (!dbCard.materialId.isEmpty()) {
qDebug() << "[GetCardInfo] material already exists for card" << dbCard.lastNumbers << ", skipping";
continue;
}
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
QJsonObject materialBody;
materialBody["bank_profile_id"] = updatedApp.bankProfileId;
materialBody["card_number"] = dbCard.cardNumber;
materialBody["account_number"] = dbCard.accountNumber;
materialBody["name"] = updatedApp.fullName;
materialBody["state"] = materialStatusToString(dbCard.status) == "active" ? "enabled" : "disabled";
materialBody["currency"] = currencyCodes.value(dbCard.currency.toUpper(), 643);
ns.setPayload(QJsonDocument(materialBody).toJson());
ns.addExtra("account_id", dbCard.id);
ns.postMaterial();
qDebug() << "[GetCardInfo] postMaterial done for card" << dbCard.lastNumbers;
}
} else {
qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials";
}
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
}
} // namespace Ozon

View File

@ -0,0 +1,32 @@
#pragma once
#include "CommonScript.h"
namespace Ozon {
class GetCardInfoScript final : public CommonScript {
Q_OBJECT
public:
// lastNumbers — последние 4 цифры карты (например "9865").
// Если пусто — берём первую найденную карту на домашнем экране.
explicit GetCardInfoScript(
QString deviceId,
QString appCode,
QString lastNumbers,
QObject *parent = nullptr
);
~GetCardInfoScript() override;
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
const QString m_lastNumbers;
QString m_error;
};
} // namespace Ozon

View File

@ -0,0 +1,963 @@
#include "GetLastTransactionsScript.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QRegularExpression>
#include <QThread>
#include <QTimeZone>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "time/DateUtils.h"
#include "AppLogger.h"
#include <QCoreApplication>
static QString recognizeTextFromImage(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
qWarning() << "[OCR] Failed to init Tesseract";
delete api;
return {};
}
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) {
qWarning() << "[OCR] Failed to read image";
api->End();
delete api;
return {};
}
Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f);
pixDestroy(&pix);
if (!gray) {
qWarning() << "[OCR] Failed to convert to grayscale";
api->End();
delete api;
return {};
}
api->SetImage(gray);
api->SetSourceResolution(300);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&gray);
api->End();
delete api;
return result;
}
static QString extractTransactionId(const QString &ocrText) {
// ID может быть на одной строке: "ID операции B606513042211200000120011700501"
// или с дефисами: "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
// или разбит на 2 строки: "ID ... po-3140eb01-002c-5000-8000-033396b\n ... 637e1"
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-z0-9\-]+(?:[^\S\n]+[A-Za-z0-9\-]+)?))");
if (auto m = reId.match(ocrText); m.hasMatch()) {
QString id = m.captured(1);
id.remove(' ');
id.remove(QChar(0x00A0));
return id;
}
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
return {};
}
namespace Ozon {
GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId,
QString appCode,
int maxCount,
double searchAmount,
QDateTime searchTime,
QString materialId,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_maxCount(maxCount),
m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)),
m_materialId(std::move(materialId)) {
}
GetLastTransactionsScript::~GetLastTransactionsScript() = default;
void GetLastTransactionsScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId);
if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId);
if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
if (!navigateToTransactionList(device, app)) {
emit finishedWithResult(m_error);
return;
}
// Находим accountId
int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
if (!accounts.isEmpty()) {
accountId = accounts.first().id;
} else {
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
}
// Выбор режима: поиск конкретной транзакции или сканирование последних
if (qAbs(m_searchAmount) > 0.01) {
searchTransaction(device, accountId);
} else {
scanRecentTransactions(device, accountId);
}
emit finishedWithResult(m_error);
}
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
// Хелпер: если на экране ошибка загрузки ("Обновить"), тапаем один раз
auto tryRefreshIfError = [&]() {
// Кнопка "Обновить" — Button с content-desc="Обновить" (text пустой)
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[GetLastTransactions] Page load error, tapping 'Обновить' at"
<< refreshBtn.x() << refreshBtn.y();
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
};
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
// Закрываем рекламный bottom sheet
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Если уже на экране операций — скроллим наверх и готово
if (xmlScreenParser.isTransactionListScreen()) {
qDebug() << "[GetLastTransactions] Already on transaction list screen, scrolling to top";
// Несколько быстрых свайпов вверх чтобы вернуться к началу списка
for (int i = 0; i < 5; ++i) {
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 200);
QThread::msleep(500);
}
QThread::msleep(1000);
return true;
}
// Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) {
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
qWarning() << m_error;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
AppLogger::log("ozon/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
AdbUtils::tryToKillApplication(m_deviceId, app.package);
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
}
// Закрываем баннеры на главной
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Скроллим до "Все" и тапаем (быстрый fling чтобы пропустить баннеры)
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
Node allBtn;
for (int i = 0; i < 10; ++i) {
if (QThread::currentThread()->isInterruptionRequested()) return false;
const int x = device.screenWidth / 2;
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - 200, x, 200, 100);
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
allBtn = xmlScreenParser.findButtonNode("Все", false);
if (!allBtn.isEmpty() && allBtn.y() > 0 && allBtn.y() < navBarTop) break;
allBtn = {};
}
if (allBtn.isEmpty()) {
m_error = "Button 'Все' not found: " + m_deviceId;
qWarning() << m_error;
return false;
}
AdbUtils::makeTap(m_deviceId, allBtn.x(), allBtn.y());
QThread::msleep(2000);
// Ждём экран "Операции"
for (int attempt = 0; attempt < 10; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) return false;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isTransactionListScreen()) return true;
QThread::msleep(1500);
}
m_error = "Transaction list screen not loaded: " + m_deviceId;
qWarning() << m_error;
return false;
}
void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Время в Ozon показывается в таймзоне устройства, а не в MSK
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone deviceTz(tzName.toUtf8());
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
const int maxScrolls = 30;
// Реальный y верхней границы нижнего нав-холдера. Если в XML есть
// ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder,
// используем его y1; иначе грубая оценка ~10% снизу.
auto detectNavBarTop = [&]() -> int {
for (const Node &n : xmlScreenParser.getNodes()) {
if (n.resourceId == "ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder"
&& n.height() > 0) {
return n.y1();
}
}
return device.screenHeight - (device.screenHeight / 10);
};
int navBarTop = detectNavBarTop();
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
const QDate searchDate = searchLocal.date();
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
<< "amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate)
<< "tz=" << tzName
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
// ── Шаг 1: скроллим до нужной даты ──────────────────────────────
static const QMap<QString, int> monthMap = {
{"января", 1}, {"февраля", 2}, {"марта", 3}, {"апреля", 4},
{"мая", 5}, {"июня", 6}, {"июля", 7}, {"августа", 8},
{"сентября", 9}, {"октября", 10}, {"ноября", 11}, {"декабря", 12}
};
static const QRegularExpression dateRx(R"((\d{1,2})\s+(\S+),\s+\S+)");
auto parseDateHeader = [&](const QString &t) -> QDate {
const QDate today = QDateTime::currentDateTimeUtc().toTimeZone(deviceTz).date();
if (t.compare("Сегодня", Qt::CaseInsensitive) == 0) return today;
if (t.compare("Вчера", Qt::CaseInsensitive) == 0) return today.addDays(-1);
const auto match = dateRx.match(t);
if (!match.hasMatch()) return {};
const int day = match.captured(1).toInt();
const int month = monthMap.value(match.captured(2), 0);
if (month == 0) return {};
return QDate(searchDate.year(), month, day);
};
// Ozon WebView сворачивает заголовки дат вне вьюпорта в один y (0 или общий
// верхний y). Несколько разных дат на одной y-позиции — pile-up скрытых
// заголовков, их надо считать невидимыми.
auto computeDateYCount = [&]() -> QMap<int, int> {
QMap<int, int> cnt;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
if (node.y() <= 0) continue;
if (parseDateHeader(node.text.trimmed()).isValid()) cnt[node.y()]++;
}
return cnt;
};
auto hasDateOnScreen = [&]() -> bool {
const QMap<int, int> yCount = computeDateYCount();
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
if (node.y() <= 0) continue;
if (yCount.value(node.y(), 0) != 1) continue;
if (parseDateHeader(node.text.trimmed()) == searchDate) return true;
}
return false;
};
auto hasOlderDateOnScreen = [&]() -> bool {
const QMap<int, int> yCount = computeDateYCount();
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
if (node.y() <= 0) continue;
if (yCount.value(node.y(), 0) != 1) continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (d.isValid() && d < searchDate) return true;
}
return false;
};
// Ждём пока WebView раскроет accessibility tree (иначе dump пустой)
for (int wait = 0; wait < 5; ++wait) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.findTransactionButtons(1).size() > 0) break;
if (hasDateOnScreen()) break;
qDebug() << "[FetchTransactions] Waiting for WebView content..." << (wait + 1);
QThread::msleep(2000);
}
// Скроллим к нужной дате, запоминаем количество скроллов
int scrollsToDate = 0;
bool dateFound = false;
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (hasDateOnScreen()) {
dateFound = true;
scrollsToDate = scroll;
qDebug() << "[FetchTransactions] Found target date on screen after" << scroll << "scrolls";
break;
}
if (hasOlderDateOnScreen()) {
qDebug() << "[FetchTransactions] Passed target date, not found in list";
break;
}
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 2 / 3, device.screenWidth / 2,
device.screenHeight / 3, 600);
QThread::msleep(1500);
}
if (!dateFound) {
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return;
}
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню.
// Возвращает специальные коды через dateY:
// dateY >= 0 : header виден на этом Y (нормальный случай)
// dateY == 0 : header выше viewport, есть более старая видимая дата
// dateY == -1 : header не виден / лежит в нижней sticky-пачке
// (скролл вниз, чтобы target выехал в видимую часть)
// dateY == -2 : header в верхней sticky-пачке (несколько дат свёрнуто
// над viewport) — скролл вверх, чтобы пачка распалась
auto findDateYRange = [&]() -> QPair<int, int> {
const QMap<int, int> yCount = computeDateYCount();
auto isAnchored = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; };
auto isPiled = [&](int y) { return y > 0 && yCount.value(y, 0) > 1; };
const int viewportMid = device.screenHeight / 2;
int dateY = -1;
int nextDateY = device.screenHeight;
int targetPileY = -1;
bool foundOlderDate = false;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (!d.isValid()) continue;
if (d == searchDate) {
if (isAnchored(node.y())) dateY = node.y();
else if (isPiled(node.y())) targetPileY = node.y();
} else if (d < searchDate) {
foundOlderDate = true;
if (isAnchored(node.y())
&& (dateY < 0 || node.y() > dateY)
&& node.y() < nextDateY) {
nextDateY = node.y();
}
}
}
// Target в sticky-пачке: пачка над viewport (малый Y) → секция уже
// прокручена выше, надо вернуться (dateY = -2). Пачка снизу viewport
// (большой Y) → target ещё не доехал до экрана, скролл вниз (dateY = -1).
if (dateY < 0 && targetPileY >= 0) {
dateY = (targetPileY < viewportMid) ? -2 : -1;
}
// Target-header не виден, но видна более старая дата → target ушёл вверх:
// всё выше nextDateY принадлежит нашему дню.
if (dateY < 0 && nextDateY < device.screenHeight && targetPileY < 0) {
dateY = 0;
}
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate
<< "targetPileY=" << targetPileY;
return {dateY, nextDateY};
};
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
QSet<QString> checkedDetails;
int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок)
for (int attempt = 0; attempt < 15; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// Логируем все заголовки дат на экране
{
QStringList datesOnScreen;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (d.isValid()) {
datesOnScreen << QString("%1@y=%2").arg(d.toString("dd.MM")).arg(node.y());
}
}
qDebug() << "[FetchTransactions] attempt=" << attempt << "dates on screen:" << datesOnScreen;
}
// Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
if (dateHeaderY == -2) {
// Target застрял в sticky-пачке (Ozon стекает заголовки выше viewport
// в одну Y-позицию). Ниже пачки есть anchored более старая дата →
// секцию target уже проскроллили мимо. Скроллим обратно вверх,
// чтобы target распался из пачки и занял реальный Y.
qDebug() << "[FetchTransactions] Target piled in sticky band — scrolling up to detach";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 600);
QThread::msleep(1500);
continue;
}
if (dateHeaderY < 0) {
// Target-header не виден и ни одной более старой даты не видно →
// target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх).
qDebug() << "[FetchTransactions] Target not visible, no older date — scrolling down";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 2 / 3,
device.screenWidth / 2,
device.screenHeight / 3, 500);
QThread::msleep(1500);
continue;
}
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
// Target-header ушёл вверх, видна только более старая дата.
// Кнопки выше nextDateHeaderY могут принадлежать промежуточным
// (пропущенным) датам — матчить их опасно. Скроллим обратно вверх.
qDebug() << "[FetchTransactions] Target scrolled above — scrolling up to recover";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 600);
QThread::msleep(1500);
continue;
}
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:"
<< dateHeaderY << "-" << nextDateHeaderY;
bool tapped = false;
bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой
int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты
for (int bi = 0; bi < txButtons.size(); ++bi) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
// Пропускаем кнопки вне Y-диапазона нужной даты.
// Используем y1 (верх кнопки), а не центр — длинные кнопки могут
// заходить под нав-бар, но всё равно tappable (тапнем по верхней части).
const int btnY1 = txButtons[bi].y1();
const int btnY = txButtons[bi].y();
if (btnY1 <= dateHeaderY || btnY1 >= nextDateHeaderY) continue;
if (btnY1 <= 0 || btnY1 >= navBarTop) continue;
++buttonsInRange;
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
<< "text=" << txButtons[bi].text.left(60);
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
const Node &visibleBtn = txButtons[bi];
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
// нав-бар, центр окажется на нав-баре и тап улетит мимо.
const int safeMaxY = navBarTop - 20;
int tapY = visibleBtn.y();
if (tapY > safeMaxY) {
tapY = qMax(visibleBtn.y1() + 30, safeMaxY);
}
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
<< "at" << visibleBtn.x() << tapY
<< "(btn y1=" << visibleBtn.y1() << "y2=" << visibleBtn.y2()
<< "navBarTop=" << navBarTop
<< "dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
// Тапаем и ждём деталей
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), tapY);
QThread::msleep(2000);
TransactionInfo detail;
for (int da = 0; da < 10; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
detail = xmlScreenParser.parseTransactionDetail();
if (!detail.bankTime.isEmpty()) break;
if (xmlScreenParser.isTransactionListScreen()) {
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
}
QThread::msleep(1500);
}
if (detail.bankTime.isEmpty()) {
qWarning() << "[FetchTransactions] Could not load detail, going back";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
// Парсим дату из деталей
QString dtStr = detail.bankTime;
for (int ci = 0; ci < dtStr.size(); ++ci) {
const QChar ch = dtStr.at(ci);
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
}
QDateTime txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
if (!txTime.isValid()) txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
if (txTime.isValid()) txTime.setTimeZone(deviceTz);
// Пропускаем если уже проверяли эту транзакцию
if (checkedDetails.contains(dtStr)) {
qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(500);
continue;
}
checkedDetails.insert(dtStr);
tapped = true;
hasNewButton = true;
// Проверяем что транзакция принадлежит нужной дате (защита от широкого Y-диапазона)
if (txTime.isValid()) {
const QDate txLocalDate = txTime.toTimeZone(deviceTz).date();
if (txLocalDate != searchDate) {
qDebug() << "[FetchTransactions] Wrong date in detail:" << dtStr
<< "expected date" << searchDate.toString("dd.MM.yyyy")
<< "- button belongs to different date, skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
// Проверяем время (±10 минут)
if (txTime.isValid() && m_searchTime.isValid()) {
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs > 600) {
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
<< "diff=" << diffSecs << "sec";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
// Нашли!
qDebug() << "[FetchTransactions] FOUND transaction: device=" << m_deviceId
<< "amount=" << parsed.amount << "time=" << dtStr
<< "name=" << detail.name;
// Скриншот экрана деталей транзакции — отдаём в EventHandler.
// Раньше дополнительно тапали "Документы" + OCR ради ID операции,
// но это медленно и нестабильно. Теперь полагаемся на дату+время+
// сумму как идентификатор.
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
QString externalId = detail.bankTrExternalId;
// Сохраняем
if (accountId != -1) {
TransactionInfo tx;
tx.accountId = accountId;
tx.amount = parsed.amount;
tx.bankName = m_appCode;
tx.bankTime = dtStr;
tx.bankTrExternalId = externalId.isEmpty()
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
: externalId;
tx.status = TransactionStatus::Complete;
tx.timestamp = DateUtils::getUtcNow();
if (txTime.isValid()) tx.completeTime = txTime;
if (!detail.name.isEmpty()) tx.name = detail.name;
if (!detail.description.isEmpty()) tx.description = detail.description;
if (!detail.phone.isEmpty()) {
QString cleanPhone;
for (const QChar &ch : detail.phone)
if (ch.isDigit()) cleanPhone.append(ch);
tx.phone = cleanPhone;
tx.type = TransactionType::Phone;
}
TransactionDAO::insertTransactionIfNotExists(tx);
}
// Отдаём результат в EventHandler через сигнал (как Dushanbe)
{
const QString finalExternalId = externalId.isEmpty()
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
: externalId;
QJsonObject txJson;
txJson["bank_transaction_id"] = finalExternalId;
txJson["amount"] = static_cast<int>(parsed.amount * 100);
txJson["currency"] = 643; // RUB
txJson["transaction_type"] = "bank";
QString participator = detail.phone;
if (participator.isEmpty()) participator = detail.name;
txJson["bank_participator_id"] = participator;
txJson["status"] = "completed";
QJsonObject extraObj;
extraObj["fee"] = 0;
txJson["extra"] = extraObj;
txJson["created_at"] = txTime.isValid()
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
qDebug() << "[FetchTransactions] Emitting parsed tx:"
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
emit transactionParsed(txJson);
}
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
return; // успех
}
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
// суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше,
// нужная кнопка может быть ниже.
if (!hasNewButton && tapped) {
++noProgressCount;
if (noProgressCount >= 5) {
qDebug() << "[FetchTransactions] No progress after" << noProgressCount
<< "attempts with matching amount — giving up";
break;
}
} else if (hasNewButton) {
noProgressCount = 0;
}
// Скроллим если не нашли нужную транзакцию
if (!tapped || !hasNewButton) {
// Признак того, что под фолдом ещё есть не раскрытые транзакции
// (Ozon хранит off-screen-row как android.view.View с height=0).
const bool moreBelow = xmlScreenParser.hasCollapsedTransactionsBelow(device.screenHeight);
qDebug() << "[FetchTransactions] Scrolling (tapped=" << tapped
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
<< "noProgress=" << noProgressCount
<< "dateHeaderY=" << dateHeaderY
<< "nextDateHeaderY=" << nextDateHeaderY
<< "moreBelow=" << moreBelow << ")";
const int cx = device.screenWidth / 2;
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
// Target-header ушёл вверх — свайпим вниз чтоб вернуть его.
AdbUtils::makeSwipe(m_deviceId, cx,
device.screenHeight * 2 / 5, cx,
device.screenHeight * 3 / 4, 600);
QThread::msleep(1500);
} else {
// Скролл вниз. Ozon WebView ведёт себя странно: ОДИН медленный
// свайп иногда игнорируется (видимо, расценивается как hold).
// Стратегия: серия из 3 коротких быстрых свайпов внутри
// безопасной зоны (выше нав-бара). Это надёжно триггерит
// нативный fling в WebView.
// safeBottom — верхняя граница нав-бара минус запас.
// Когда target-секция уже у нижнего края (dateHeaderY >= safeBottom),
// зона "внутри секции" схлопывается — свайпаем фиксированным
// диапазоном относительно safeBottom, чтобы вытащить контент
// секции из-под нав-бара.
const int safeBottom = navBarTop - 30;
int amplitude;
if (dateHeaderY > 0 && dateHeaderY < safeBottom - 100) {
// Header виден достаточно высоко — скроллим внутри его секции.
const int safeTop = qMax(dateHeaderY + 80, device.screenHeight / 4);
amplitude = qMin(safeBottom - safeTop, 700);
} else {
// Header у самого низа или ниже нав-бара — большой свайп.
amplitude = qMin(safeBottom - device.screenHeight / 4, 700);
}
amplitude = qMax(amplitude, 300); // минимум 300px чтобы было ощутимо
qDebug() << "[FetchTransactions] Triple-swipe scroll:"
<< "safeBottom=" << safeBottom << "amplitude=" << amplitude
<< "(dateHeaderY=" << dateHeaderY << " moreBelow=" << moreBelow << ")";
for (int i = 0; i < 3; ++i) {
AdbUtils::makeSwipe(m_deviceId, cx, safeBottom,
cx, safeBottom - amplitude, 250);
QThread::msleep(350);
}
QThread::msleep(800);
}
}
}
m_error = "Transaction not found: device=" + m_deviceId
+ " amount=" + QString::number(m_searchAmount, 'f', 2)
+ " date=" + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
}
void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) {
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone deviceTz(tzName.toUtf8());
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
// Ждём появления транзакций
QList<Node> txButtons;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
txButtons = xmlScreenParser.findTransactionButtons(8);
if (!txButtons.isEmpty()) break;
QThread::msleep(1500);
}
qDebug() << "[GetLastTransactions] Found" << txButtons.size() << "transaction buttons";
QList<TransactionInfo> transactions;
for (int i = 0; i < txButtons.size(); ++i) {
const Node &btn = txButtons[i];
// Скроллим до кнопки если за экраном
if (btn.y() <= 0 || btn.y() >= navBarTop) {
for (int s = 0; s < 5; ++s) {
const int x = device.screenWidth / 2;
const int offset = device.screenHeight / 4;
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
txButtons = xmlScreenParser.findTransactionButtons(8);
if (i < txButtons.size() && txButtons[i].y() > 0 && txButtons[i].y() < navBarTop) break;
}
if (i >= txButtons.size()) break;
}
const Node &visibleBtn = txButtons[i];
TransactionInfo tx = xmlScreenParser.parseTransactionButtonText(visibleBtn.text);
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
QThread::msleep(2000);
// Ждём загрузки деталей
TransactionInfo detail;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
detail = xmlScreenParser.parseTransactionDetail();
if (!detail.bankTime.isEmpty()) break;
if (xmlScreenParser.isTransactionListScreen()) {
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
}
QThread::msleep(1500);
}
tx.bankName = m_appCode;
tx.bankTime = detail.bankTime;
tx.bankTrExternalId = detail.bankTrExternalId;
// Если ID не найден в XML — пробуем через "Документы" + OCR
if (tx.bankTrExternalId.isEmpty()) {
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
if (!docBtn.isEmpty()) {
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
bool docLoaded = false;
for (int da = 0; da < 15; ++da) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
docLoaded = true;
break;
}
QThread::msleep(1500);
}
if (docLoaded) {
QThread::msleep(500);
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!screenshot.isEmpty()) {
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
tx.bankTrExternalId = extractTransactionId(ocrText);
qDebug() << "[GetLastTransactions] OCR extracted ID:" << tx.bankTrExternalId;
}
}
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
tx.status = TransactionStatus::Complete;
tx.timestamp = DateUtils::getUtcNow();
if (!detail.name.isEmpty()) tx.name = detail.name;
if (!detail.phone.isEmpty()) {
QString cleanPhone;
for (const QChar &ch : detail.phone)
if (ch.isDigit()) cleanPhone.append(ch);
tx.phone = cleanPhone;
tx.type = TransactionType::Phone;
}
// Парсим время
if (!tx.bankTime.isEmpty()) {
QString dtStr = tx.bankTime;
for (int ci = 0; ci < dtStr.size(); ++ci) {
const QChar ch = dtStr.at(ci);
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
}
tx.bankTime = dtStr;
QDateTime parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
if (!parsed.isValid()) parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
if (parsed.isValid()) {
parsed.setTimeZone(deviceTz);
tx.completeTime = parsed;
}
}
// Фильтр по времени (24ч) если нет maxCount
if (m_maxCount <= 0 && tx.completeTime.isValid()) {
const qint64 ageSecs = tx.completeTime.toUTC().secsTo(QDateTime::currentDateTimeUtc());
if (ageSecs > 24 * 3600) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
break;
}
}
// Сохраняем
if (accountId != -1) {
tx.accountId = accountId;
if (TransactionDAO::insertTransactionIfNotExists(tx)) {
// Скриншот чека
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
if (!docBtn.isEmpty()) {
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
bool docLoaded = false;
for (int da = 0; da < 20; ++da) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
docLoaded = true; break;
}
QThread::msleep(1500);
}
if (docLoaded) {
QThread::msleep(500);
QProcess screencapProc;
screencapProc.start("adb", QStringList() << "-s" << m_deviceId << "exec-out" << "screencap" << "-p");
screencapProc.waitForFinished(5000);
const QByteArray screenshot = screencapProc.readAllStandardOutput();
if (!screenshot.isEmpty()) {
TransactionInfo saved = TransactionDAO::findByBankTimeAndAmount(
accountId, tx.bankName, tx.bankTime, tx.amount);
if (saved.id != -1) {
TransactionDAO::updateReceiptImage(saved.id, screenshot);
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
const QString txId = extractTransactionId(ocrText);
if (!txId.isEmpty()) {
TransactionDAO::updateBankTrExternalId(saved.id, txId);
}
}
}
}
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
}
transactions.append(tx);
if (m_maxCount > 0 && transactions.size() >= m_maxCount) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
break;
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1500);
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isTransactionListScreen()) break;
QThread::msleep(1000);
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
txButtons = xmlScreenParser.findTransactionButtons(8);
}
}
} // namespace Ozon

View File

@ -0,0 +1,51 @@
#pragma once
#include "CommonScript.h"
#include <QDateTime>
#include <QJsonObject>
#include "db/DeviceInfo.h"
#include "db/BankProfileInfo.h"
namespace Ozon {
class GetLastTransactionsScript final : public CommonScript {
Q_OBJECT
signals:
void transactionParsed(const QJsonObject &tx);
public:
explicit GetLastTransactionsScript(
QString deviceId,
QString appCode,
int maxCount = 0,
double searchAmount = 0,
QDateTime searchTime = {},
QString materialId = {},
QObject *parent = nullptr
);
~GetLastTransactionsScript() override;
protected:
void doStart() override;
private:
// Навигация до экрана "Операции", возвращает true если успешно
bool navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app);
// Сканирование последних транзакций (для UI: 24ч или maxCount)
void scanRecentTransactions(const DeviceInfo &device, int accountId);
// Поиск конкретной транзакции по сумме и времени (для задач FETCH_TRANSACTIONS)
void searchTransaction(const DeviceInfo &device, int accountId);
QString m_deviceId;
const QString m_appCode;
const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних
const double m_searchAmount; // сумма в рублях (0 = не искать конкретную)
const QDateTime m_searchTime; // примерное время (UTC)
const QString m_materialId; // UUID задачи (для сохранения дампов)
QString m_error;
};
} // namespace Ozon

View File

@ -0,0 +1,207 @@
#include "GetProfileInfoScript.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QMap>
#include <QtGlobal>
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h"
#include "AppLogger.h"
namespace Ozon {
GetProfileInfoScript::GetProfileInfoScript(
QString deviceId,
QString appCode,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)) {
}
GetProfileInfoScript::~GetProfileInfoScript() = default;
void GetProfileInfoScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
// Читаем текущий экран
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Закрываем рекламный bottom sheet, если открыт (home_ad_1)
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
qDebug() << "[GetProfileInfo] Ad banner detected, closing...";
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Свежий дамп перед проверкой домашнего экрана
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Если мы не на домашнем экране — перезапускаем приложение и вводим пин-код
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[GetProfileInfo] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// После runAppAndGoToHomeScreen xml уже обновлён внутри метода,
// но делаем свежий дамп, чтобы убедиться
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Закрываем баннеры на главной, если есть
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Парсим баланс с домашнего экрана
const QList<MaterialInfo> parsedCards = xmlScreenParser.parseCardsOnHomeScreen();
double parsedBalance = parsedCards.isEmpty() ? 0.0 : parsedCards[0].amount;
qDebug() << "[GetProfileInfo] parseCardsOnHomeScreen returned"
<< parsedCards.size() << "cards, balance:" << parsedBalance;
// Fallback: если парсинг не нашёл баланс, берём из materials
if (parsedBalance <= 0.0) {
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
for (const auto &acc : accounts) {
if (acc.amount > 0.0) { parsedBalance = acc.amount; break; }
}
qDebug() << "[GetProfileInfo] Fallback balance from materials:" << parsedBalance;
}
if (parsedBalance > 0.0) {
BankProfileDAO::updateAmount(app.id, parsedBalance);
}
// Находим имя пользователя в верхней части домашнего экрана и тапаем по нему
Node userNameNode = xmlScreenParser.findUserNameOnHomeScreen(device.screenHeight);
if (userNameNode.isEmpty()) {
m_error = "Cannot find user name on home screen: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
qDebug() << "[GetProfileInfo] Tapping on user name:" << userNameNode.text
<< "at" << userNameNode.x() << userNameNode.y();
AdbUtils::makeTap(m_deviceId, userNameNode.x(), userNameNode.y());
// Ждём загрузки страницы профиля
QThread::msleep(2500);
// Читаем XML профиля и проверяем что мы действительно там
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isProfileScreen()) {
break;
}
QThread::msleep(1000);
}
if (!xmlScreenParser.isProfileScreen()) {
m_error = "Profile screen not detected after tap: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
// Извлекаем полное имя и телефон
const QString fullName = xmlScreenParser.parseProfileFullName();
QString phone = xmlScreenParser.parseProfilePhone();
phone.remove(' ');
phone.remove(QChar(0x00A0));
qDebug() << "[GetProfileInfo] fullName:" << fullName << "phone:" << phone;
if (!BankProfileDAO::updateProfileInfo(app.id, fullName, phone)) {
m_error = "Failed to save profile info to DB: " + m_deviceId;
qWarning() << m_error;
}
// Отправляем bank_profile на сервер — используем свежепарсенные данные
const BankProfileInfo freshApp = BankProfileDAO::getApplication(device.id, m_appCode);
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
const int currencyCode = currencyCodes.value(freshApp.currency.toUpper(), 643);
const QStringList parts = fullName.split(' ', Qt::SkipEmptyParts);
QJsonObject profileBody;
profileBody["first_name"] = parts.value(0);
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : "";
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
profileBody["bank_name"] = m_appCode;
profileBody["phone_number"] = phone;
profileBody["balance"] = QString::number(qRound64(parsedBalance * 100));
profileBody["state"] = (freshApp.status == "active") ? "enabled" : "disabled";
profileBody["device_id"] = device.apiId;
profileBody["currency"] = currencyCode;
NetworkService ns;
ns.setDeviceId(m_deviceId);
ns.setPayload(QJsonDocument(profileBody).toJson());
if (freshApp.bankProfileId.isEmpty()) {
ns.addExtra("app_id", freshApp.id);
ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
if (afterPost.bankProfileId.isEmpty()) {
const QString err = "postBankProfile failed for " + m_deviceId;
qWarning() << "[GetProfileInfo]" << err;
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId),
"FETCH_PROFILE");
m_error = err;
emit finishedWithResult(m_error);
return;
}
qDebug() << "[GetProfileInfo] postBankProfile done";
} else {
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
ns.patchBankProfile();
qDebug() << "[GetProfileInfo] patchBankProfile done";
}
// Отправляем данные аккаунтов на сервер если нет material_id
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
QList<QPair<MaterialInfo, Node>> accountsToPost;
for (const auto &acc : allAccounts) {
if (acc.materialId.isEmpty()) {
accountsToPost.append({acc, Node()});
}
}
if (!accountsToPost.isEmpty()) {
postAccountsData(accountsToPost);
}
// Возвращаемся на домашний экран
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
emit finishedWithResult(m_error);
}
} // namespace Ozon

View File

@ -0,0 +1,27 @@
#pragma once
#include "CommonScript.h"
namespace Ozon {
class GetProfileInfoScript final : public CommonScript {
Q_OBJECT
public:
~GetProfileInfoScript() override;
explicit GetProfileInfoScript(
QString deviceId,
QString appCode,
QObject *parent = nullptr
);
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
QString m_error;
};
} // namespace Ozon

View File

@ -0,0 +1,133 @@
#include "LoginAndCheckAccountsScript.h"
#include <QThread>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h"
#include "AppLogger.h"
#include "GetCardInfoScript.h"
namespace Ozon {
LoginAndCheckAccountsScript::LoginAndCheckAccountsScript(
QString deviceId,
QString appCode,
QObject *parent,
bool fetchProfileOnly,
bool pinCheckOnly
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_fetchProfileOnly(fetchProfileOnly),
m_pinCheckOnly(pinCheckOnly) {
}
LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
void LoginAndCheckAccountsScript::doStart() {
// m_deviceId = ADB serial, device.id = android_id (для БД)
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
m_error = "Cant open the home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
qWarning() << m_error;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
BankProfileDAO::updateComment(app.id,
"Не прошла проверка " + QDateTime::currentDateTime().toString("dd.MM.yyyy HH:mm:ss"));
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
} else {
if (m_pinCheckOnly) {
emit finishedWithResult(m_error);
return;
}
// Скриншот домашнего экрана
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
// Парсим баланс с домашнего экрана
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
const QList<MaterialInfo> parsedCards = xmlScreenParser.parseCardsOnHomeScreen();
double parsedBalance = parsedCards.isEmpty() ? 0.0 : parsedCards[0].amount;
qDebug() << "[LoginAndCheck] Parsed balance:" << parsedBalance << "cards:" << parsedCards.size();
if (parsedBalance > 0.0) {
BankProfileDAO::updateAmount(app.id, parsedBalance);
}
// Освежаем amount только для уже существующих материалов (cardNumber/materialId
// и прочие поля сохраняем). Новые карты здесь не создаём — запись появится
// только после успешного OCR полного номера в GetCardInfoScript.
// Если ниже goToAllProducts + parseAccountsInfo отработают — они уточнят
// per-card сумму поверх.
for (const MaterialInfo &card : parsedCards) {
const MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (existing.id == -1) continue;
MaterialInfo refresh = existing;
refresh.amount = card.amount;
refresh.updateTime = QDateTime::currentDateTimeUtc();
if (!MaterialDAO::upsertAccount(refresh)) {
qWarning() << "[LoginAndCheck] Failed to refresh amount for card" << card.lastNumbers;
} else {
qDebug() << "[LoginAndCheck] Refreshed amount for card"
<< card.lastNumbers << "to" << card.amount;
}
}
// Создаём/обновляем bank profile на сервере
if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) {
m_error = err;
emit finishedWithResult(m_error);
return;
}
if (goToAllProducts(m_deviceId, device.screenWidth, device.screenHeight)) {
QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();
for (auto &[account, node]: accounts) {
account.deviceId = device.id;
account.appCode = m_appCode;
if (!MaterialDAO::upsertAccount(account)) {
m_error = "Not updated: " + account.lastNumbers;
qCritical() << m_error;
}
}
if (!m_fetchProfileOnly) {
postAccountsData(accounts);
}
} else {
qWarning() << "Cant go to all products: " << m_deviceId << " " << m_appCode;
}
if (!m_fetchProfileOnly) {
// Запускаем сканирование баланса, данных карт и отправку на сервер (синхронно)
GetCardInfoScript cardScript(m_deviceId, m_appCode, "");
cardScript.start();
}
}
emit finishedWithResult(m_error);
}
} // namespace Ozon

View File

@ -0,0 +1,31 @@
#pragma once
#include "CommonScript.h"
namespace Ozon {
class LoginAndCheckAccountsScript final : public CommonScript {
Q_OBJECT
public:
~LoginAndCheckAccountsScript() override;
explicit LoginAndCheckAccountsScript(
QString deviceId,
QString appCode,
QObject *parent = nullptr,
bool fetchProfileOnly = false,
bool pinCheckOnly = false
);
protected:
void doStart() override;
private:
QString m_deviceId;
const QString m_appCode;
const bool m_fetchProfileOnly;
const bool m_pinCheckOnly;
QString m_error;
};
} // namespace Ozon

View File

@ -0,0 +1,486 @@
#include "PayByCardScript.h"
#include <QCoreApplication>
#include <QProcess>
#include <QRegularExpression>
#include <QThread>
#include <QTimeZone>
#include <utility>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "time/DateUtils.h"
static QString recognizeTextFromImage(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
qWarning() << "[OCR] Failed to init Tesseract";
delete api;
return {};
}
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) {
qWarning() << "[OCR] Failed to read image";
api->End();
delete api;
return {};
}
api->SetImage(pix);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&pix);
api->End();
delete api;
return result;
}
static QString extractTransactionId(const QString &ocrText) {
// "ID операции B606513042211200000120011700501"
// "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
// или разбит на 2 строки
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-яЁё0-9\-]+(?:[^\S\n]+[A-Za-zА-Яа-яЁё0-9\-]+)?))");
if (auto m = reId.match(ocrText); m.hasMatch()) {
QString id = m.captured(1);
id.remove(' ');
id.remove(QChar(0x00A0));
return id;
}
// "№ Ф-2026-25235663"
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
return {};
}
namespace Ozon {
PayByCardScript::PayByCardScript(
MaterialInfo account,
QString cardNumber,
const double amount,
QObject *parent
) : CommonScript(parent),
m_account(std::move(account)), m_cardNumber(std::move(cardNumber)),
m_amount(amount) {
}
PayByCardScript::~PayByCardScript() = default;
void PayByCardScript::doStart() {
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_account.deviceId;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const QString &adbId = device.adbSerial;
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_CARD", adbId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
// Закрываем рекламный bottom sheet
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
qDebug() << "[PayByCard] Ad banner detected, closing...";
AdbUtils::makeTap(adbId, adNode.x(), adNode.y());
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
}
// Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[PayByCard] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(adbId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
qWarning() << m_error;
AdbUtils::tryToKillApplication(adbId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
}
// Закрываем баннеры
tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
// Закрываем баннер Google Play "Доступно обновление"
if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight); !gpNode.isEmpty()) {
qDebug() << "[PayByCard] Google Play update banner detected, closing...";
AdbUtils::makeTap(adbId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
}
// Закрываем баннер "Заказать карту" — тапаем сверху экрана
if (Node orderCardNode = xmlScreenParser.findTextNode("Заказать карту"); !orderCardNode.isEmpty()) {
qDebug() << "[PayByCard] 'Заказать карту' banner detected, closing...";
AdbUtils::makeTap(adbId, device.screenWidth / 2, device.screenHeight / 20);
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
}
makePayment(device.adbSerial, app.package, app.pinCode, app.phone, device.screenWidth, device.screenHeight);
emit finishedWithResult(m_error);
}
void PayByCardScript::makePayment(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
const QString &bankProfilePhone,
const int width,
const int height
) {
auto checkApp = [&]() -> bool {
if (!isAppRunning(deviceId, packageName)) {
m_error = "Приложение было закрыто";
qWarning() << "[PayByCard]" << m_error;
return false;
}
return true;
};
// 1. Нажимаем "Перевести" на домашнем экране
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty()) {
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
qWarning() << "[PayByCard]" << m_error;
return;
}
qDebug() << "[PayByCard] Tapping 'Перевести'...";
AdbUtils::makeTap(deviceId, transferBtn.x(), transferBtn.y());
QThread::msleep(2000);
// 2. Ждём экран "Платежи и переводы" И прогрузки кнопки "По номеру карты"
Node cardBtn;
bool paymentsPageLoaded = false;
bool titleSeen = false;
{
FreezeDetector freezeCheck;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
xmlScreenParser.parseAndSaveXml(xml);
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (freezeCheck.noticeAndCheckFrozen(xml)) {
qWarning() << "[PayByCard] screen frozen waiting Платежи и переводы";
break;
}
Node title = xmlScreenParser.findTextNode("Платежи и переводы");
if (!title.isEmpty()) titleSeen = true;
cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false);
if (!title.isEmpty() && !cardBtn.isEmpty()) {
paymentsPageLoaded = true;
break;
}
qDebug() << "[PayByCard] Waiting for 'Платежи и переводы' + 'По номеру карты', attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
}
if (!paymentsPageLoaded) {
m_error = titleSeen
? "Кнопка 'По номеру карты' не найдена на экране платежей"
: "Экран 'Платежи и переводы' не загрузился";
qWarning() << "[PayByCard]" << m_error;
return;
}
// 3. Нажимаем "По номеру карты" в секции "ДРУГИЕ СПОСОБЫ"
qDebug() << "[PayByCard] Tapping 'По номеру карты'...";
AdbUtils::makeTap(deviceId, cardBtn.x(), cardBtn.y());
QThread::msleep(2000);
// 4. Ждём экран "Перевод по номеру карты"
bool cardScreenLoaded = false;
{
FreezeDetector freezeCheck;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
xmlScreenParser.parseAndSaveXml(xml);
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (freezeCheck.noticeAndCheckFrozen(xml)) {
qWarning() << "[PayByCard] screen frozen waiting 'Перевод по номеру карты'";
break;
}
Node title = xmlScreenParser.findTextNode("Перевод по номеру карты");
if (!title.isEmpty()) {
cardScreenLoaded = true;
break;
}
qDebug() << "[PayByCard] Waiting for 'Перевод по номеру карты', attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
}
if (!cardScreenLoaded) {
m_error = "Экран 'Перевод по номеру карты' не загрузился";
qWarning() << "[PayByCard]" << m_error;
return;
}
qDebug() << "[PayByCard] Card payment screen loaded successfully";
// 5. Вводим номер карты в поле "Номер карты получателя"
Node cardInput;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
cardInput = xmlScreenParser.findEditTextByHint("Номер карты получателя");
if (cardInput.isEmpty()) {
// На части сборок Ozon WebView hint у поля PAN пустой, а лейбла
// "Номер карты получателя" на экране нет (есть только "КУДА").
// Поле PAN всегда внутри элемента resource-id="movable-pan-iframe".
cardInput = xmlScreenParser.findPanCardNumberInput();
}
if (!cardInput.isEmpty()) break;
qDebug() << "[PayByCard] Waiting for 'Номер карты получателя' input, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
}
if (cardInput.isEmpty()) {
m_error = "Поле 'Номер карты получателя' не найдено";
qWarning() << "[PayByCard]" << m_error;
return;
}
AdbUtils::makeTap(deviceId, cardInput.x(), cardInput.y());
QThread::msleep(500);
AdbUtils::inputText(deviceId, m_cardNumber);
QThread::msleep(2000);
// Закрываем системную клавиатуру — она перекрывает поле суммы и кнопку
// "Перевести" внизу, а на TECNO ещё мешает Ozon WebView дорендерить AT.
AdbUtils::hideKeyboard(deviceId);
QThread::msleep(800);
// 6. Ждём распознавания карты и вводим сумму
Node sumInput;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
sumInput = xmlScreenParser.findEditTextByHint("Сумма от");
if (!sumInput.isEmpty()) break;
// Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO).
// На экране "Перевод по номеру карты" всегда 2 EditText в DOM-порядке:
// карта (сверху), сумма (снизу). Оба имеют resource-id="input___xxxx",
// поэтому отличать по rid нельзя — берём второй по порядку обхода XML.
QList<Node> editTexts;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.widget.EditText") continue;
if (n.height() <= 0 || n.height() > height * 4 / 5) continue;
editTexts.append(n);
}
if (editTexts.size() >= 2) {
sumInput = editTexts[1];
}
if (!sumInput.isEmpty()) break;
qDebug() << "[PayByCard] Waiting for 'Сумма от' input, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
if (sumInput.isEmpty()) {
m_error = "Поле ввода суммы не найдено";
qWarning() << "[PayByCard]" << m_error;
return;
}
AdbUtils::makeTap(deviceId, sumInput.x(), sumInput.y());
QThread::msleep(500);
AdbUtils::inputText(deviceId, QString::number(static_cast<int>(m_amount)));
QThread::msleep(1000);
qDebug() << "[PayByCard] Card number and amount entered";
// Закрываем системную софт-клавиатуру — иначе она перекрывает кнопку "Перевести"
// внизу экрана (bounds схлопываются до нулевой высоты).
AdbUtils::hideKeyboard(deviceId);
QThread::msleep(800);
// 7. Ждём расчёта комиссии (текст после "Комиссия" перестаёт быть "Рассчитаем...")
double fee = 0;
bool feeCalculated = false;
for (int attempt = 0; attempt < 15; ++attempt) {
if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
bool foundKomissiya = false;
for (const Node &node : xmlScreenParser.findAllNodes()) {
if (node.className == "android.widget.TextView" && node.text.trimmed() == "Комиссия") {
foundKomissiya = true;
continue;
}
if (foundKomissiya && node.className == "android.widget.TextView" && !node.text.isEmpty()) {
const QString feeText = node.text.trimmed();
if (feeText.startsWith("Рассчитаем")) {
foundKomissiya = false;
break; // ещё не рассчитана
}
// Парсим "45 ₽" → 45.0
QString digits = feeText;
digits.remove(QRegularExpression("[^0-9,\\.]"));
digits.replace(',', '.');
fee = digits.toDouble();
feeCalculated = true;
qDebug() << "[PayByCard] Fee parsed:" << fee << "from text:" << feeText;
break;
}
}
if (feeCalculated) break;
qDebug() << "[PayByCard] Waiting for fee calculation, attempt" << attempt + 1 << "/ 15";
QThread::msleep(1500);
}
if (!feeCalculated) {
qWarning() << "[PayByCard] Fee not calculated after timeout, proceeding with fee=0";
}
// 8. Нажимаем "Перевести" (или "Перевести X ₽")
// Ретраим, пока bounds кнопки не будут ненулевыми — клавиатура могла всплыть
// заново (напр. если расчёт комиссии сфокусировал поле суммы).
Node transferBtn2;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
transferBtn2 = xmlScreenParser.findButtonNode("Перевести", true);
if (!transferBtn2.isEmpty() && transferBtn2.height() > 0) break;
qDebug() << "[PayByCard] Waiting for tappable 'Перевести', attempt" << attempt + 1 << "/ 5"
<< "(bounds collapsed — клавиатура ещё видна?)";
AdbUtils::hideKeyboard(deviceId);
QThread::msleep(1000);
}
if (transferBtn2.isEmpty() || transferBtn2.height() == 0) {
m_error = "Кнопка 'Перевести' не найдена или перекрыта клавиатурой";
qWarning() << "[PayByCard]" << m_error;
return;
}
qDebug() << "[PayByCard] Tapping transfer button:" << transferBtn2.text;
AdbUtils::makeTap(deviceId, transferBtn2.x(), transferBtn2.y());
QThread::msleep(3000);
// 8.5. Проверяем экран ввода PIN-кода
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
!pinNodes.isEmpty()) {
qDebug() << "[PayByCard] PIN code screen detected after confirm, entering PIN...";
inputPinCode(deviceId, pinNodes);
QThread::msleep(3000);
}
// 9. Ждём результат: "Успешно" или "Не удалось"
FreezeDetector resultFreeze;
for (int attempt = 0; attempt < 15; ++attempt) {
if (!checkApp()) return;
const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
xmlScreenParser.parseAndSaveXml(xml);
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (resultFreeze.noticeAndCheckFrozen(xml)) {
qWarning() << "[PayByCard] screen frozen while waiting payment result";
break;
}
// Если PIN-код появился позже — вводим
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
!pinNodes.isEmpty()) {
qDebug() << "[PayByCard] PIN code screen detected (retry), entering PIN...";
inputPinCode(deviceId, pinNodes);
QThread::msleep(3000);
continue;
}
if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) {
qDebug() << "[PayByCard] Payment successful!";
m_error.clear();
// Сохраняем транзакцию в БД
TransactionInfo tx;
tx.accountId = m_account.id;
tx.amount = -m_amount;
tx.fee = fee;
tx.phone = m_cardNumber;
tx.bankName = "ozon";
tx.type = TransactionType::Card;
tx.status = TransactionStatus::Complete;
tx.timestamp = DateUtils::getUtcNow();
tx.completeTime = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow"));
tx.bankTime = tx.completeTime.toString("dd.MM.yyyy, HH:mm");
const int txId = TransactionDAO::insertTransaction(tx);
qDebug() << "[PayByCard] Saved transaction to DB, id:" << txId << "fee:" << fee;
// Скриншот экрана "Успешно" — отдаём как receipt-image и в EventHandler.
// Раньше дополнительно тапали "Документы" + ждали "Поделиться" + OCR
// ради bank_transaction_id, но это медленно (до 30 сек ожидания) и
// нестабильно. Теперь полагаемся на fallback-ID.
QString externalId;
if (txId != -1) {
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
if (!screenshot.isEmpty()) {
m_resultScreenshot = screenshot;
emit screenshotReady(m_resultScreenshot);
TransactionDAO::updateReceiptImage(txId, screenshot);
qDebug() << "[PayByCard] Receipt saved to DB, txId:" << txId;
}
}
if (externalId.isEmpty() && txId != -1) {
externalId = "ozon_" + bankProfilePhone + "_" + QString::number(QDateTime::currentSecsSinceEpoch());
qDebug() << "[PayByCard] Generated fallback transaction ID:" << externalId;
}
if (!externalId.isEmpty() && txId != -1) {
TransactionDAO::updateBankTrExternalId(txId, externalId);
qDebug() << "[PayByCard] Transaction ID:" << externalId;
}
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
return;
}
if (!xmlScreenParser.findTextNode("Не удалось").isEmpty()) {
m_error = "Перевод не удался (экран 'Не удалось')";
qWarning() << "[PayByCard]" << m_error;
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
return;
}
qDebug() << "[PayByCard] Waiting for payment result, attempt" << attempt + 1 << "/ 15";
QThread::msleep(2000);
}
m_error = "Не дождались результата перевода";
qWarning() << "[PayByCard]" << m_error;
}
} // namespace Ozon

View File

@ -0,0 +1,38 @@
#pragma once
#include "CommonScript.h"
namespace Ozon {
class PayByCardScript final : public CommonScript {
Q_OBJECT
public:
~PayByCardScript() override;
PayByCardScript(
MaterialInfo account,
QString cardNumber,
double amount,
QObject *parent = nullptr
);
protected:
void doStart() override;
private:
const MaterialInfo m_account;
const QString m_cardNumber;
const double m_amount;
QString m_error;
void makePayment(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
const QString &bankProfilePhone,
int width,
int height
);
};
} // namespace Ozon

View File

@ -0,0 +1,657 @@
#include "PayByPhoneScript.h"
#include <QProcess>
#include <QRegularExpression>
#include <QThread>
#include <QTimeZone>
#include <utility>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "time/DateUtils.h"
#include <QCoreApplication>
static QString recognizeTextFromImage(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
qWarning() << "[OCR] Failed to init Tesseract";
delete api;
return {};
}
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) {
qWarning() << "[OCR] Failed to read image";
api->End();
delete api;
return {};
}
api->SetImage(pix);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&pix);
api->End();
delete api;
return result;
}
static QString extractTransactionId(const QString &ocrText) {
// "ID операции B606513042211200000120011700501"
// "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
// или разбит на 2 строки
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-яЁё0-9\-]+(?:[^\S\n]+[A-Za-zА-Яа-яЁё0-9\-]+)?))");
if (auto m = reId.match(ocrText); m.hasMatch()) {
QString id = m.captured(1);
id.remove(' ');
id.remove(QChar(0x00A0));
return id;
}
// "№ Ф-2026-25235663"
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
return {};
}
namespace Ozon {
static QString sanitizeBankName(const QString &raw) {
// "Озон банк (Ozon)" -> "Озон банк"; убираем содержимое круглых скобок
// вместе с предшествующими пробелами и обрезаем края.
QString s = raw;
s.remove(QRegularExpression(R"(\s*\([^)]*\))"));
return s.trimmed();
}
PayByPhoneScript::PayByPhoneScript(
MaterialInfo account,
QString phone,
QString bankName,
const double amount,
QObject *parent
) : CommonScript(parent),
m_account(std::move(account)), m_phone(std::move(phone)),
m_bankName(sanitizeBankName(bankName)), m_amount(amount) {
}
PayByPhoneScript::~PayByPhoneScript() = default;
void PayByPhoneScript::doStart() {
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_account.deviceId;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const QString &adbId = device.adbSerial;
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_PHONE", adbId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
// Закрываем рекламный bottom sheet
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
qDebug() << "[PayByPhone] Ad banner detected, closing...";
AdbUtils::makeTap(adbId, adNode.x(), adNode.y());
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
}
// Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[PayByPhone] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(adbId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
qWarning() << m_error;
AdbUtils::tryToKillApplication(adbId, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
}
// Закрываем баннеры
tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
// Закрываем баннер Google Play "Доступно обновление"
if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight); !gpNode.isEmpty()) {
qDebug() << "[PayByPhone] Google Play update banner detected, closing...";
AdbUtils::makeTap(adbId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
}
// Закрываем баннер "Заказать карту" — тапаем сверху экрана
if (Node orderCardNode = xmlScreenParser.findTextNode("Заказать карту"); !orderCardNode.isEmpty()) {
qDebug() << "[PayByPhone] 'Заказать карту' banner detected, closing...";
AdbUtils::makeTap(adbId, device.screenWidth / 2, device.screenHeight / 20);
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
}
makePayment(device.adbSerial, app.package, app.pinCode, app.phone, device.screenWidth, device.screenHeight);
emit finishedWithResult(m_error);
}
void PayByPhoneScript::makePayment(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
const QString &bankProfilePhone,
const int width,
const int height
) {
// Макрос-проверка: приложение живо?
auto checkApp = [&]() -> bool {
if (!isAppRunning(deviceId, packageName)) {
m_error = "Приложение было закрыто";
qWarning() << "[PayByPhone]" << m_error;
return false;
}
return true;
};
// 1. Нажимаем "Перевести" на домашнем экране.
// Внимание: поверх правой части кнопки сидит referral-бейдж "badge icon за друга"
// (bounds типа [550,90][602,140]), который перекрывает центр кнопки. Тап в центр
// открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы
// гарантированно попасть мимо бейджа.
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty()) {
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
qWarning() << "[PayByPhone]" << m_error;
return;
}
// Тапаем в нижнюю часть кнопки (там, где визуально надпись "Перевести"),
// ниже бейджа "за друга" (он заканчивается на y≈140).
const int transferTapX = transferBtn.x();
const int transferTapY = transferBtn.y1() + transferBtn.height() * 4 / 5;
qDebug() << "[PayByPhone] Tapping 'Перевести' at" << transferTapX << "," << transferTapY
<< "(below 'за друга' badge)";
AdbUtils::makeTap(deviceId, transferTapX, transferTapY);
QThread::msleep(2000);
// 2. Ждём экран "Платежи и переводы" с полем "Телефон получателя"
bool paymentsPageLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
Node phoneField = xmlScreenParser.findFirstEditText();
if (!phoneField.isEmpty()) {
paymentsPageLoaded = true;
break;
}
qDebug() << "[PayByPhone] Waiting for payments page, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
if (!paymentsPageLoaded) {
m_error = "Экран 'Платежи и переводы' не загрузился";
qWarning() << "[PayByPhone]" << m_error;
return;
}
// 3. Тапаем на поле "Телефон получателя" чтобы перейти на экран "Кому перевести"
Node phoneFieldTap = xmlScreenParser.findFirstEditText();
qDebug() << "[PayByPhone] Tapping 'Телефон получателя'...";
AdbUtils::makeTap(deviceId, phoneFieldTap.x(), phoneFieldTap.y());
QThread::msleep(2000);
// 4. Ждём экран "Кому перевести"
bool phoneScreenLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
// Закрываем диалог "Разрешить доступ к контактам"
Node notNowBtn = xmlScreenParser.findButtonNode("Не сейчас", false);
if (!notNowBtn.isEmpty()) {
qDebug() << "[PayByPhone] Contacts permission dialog detected, tapping 'Не сейчас'...";
AdbUtils::makeTap(deviceId, notNowBtn.x(), notNowBtn.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
}
Node title = xmlScreenParser.findTextNode("Кому перевести");
if (!title.isEmpty()) {
phoneScreenLoaded = true;
break;
}
qDebug() << "[PayByPhone] Waiting for 'Кому перевести', attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
if (!phoneScreenLoaded) {
m_error = "Экран 'Кому перевести' не загрузился";
qWarning() << "[PayByPhone]" << m_error;
return;
}
// 5. Тапаем на EditText и вводим номер телефона
Node phoneInput;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
phoneInput = xmlScreenParser.findFirstEditText();
if (!phoneInput.isEmpty()) break;
qDebug() << "[PayByPhone] Waiting for phone input field, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
if (phoneInput.isEmpty()) {
m_error = "Поле ввода телефона не найдено";
qWarning() << "[PayByPhone]" << m_error;
return;
}
AdbUtils::makeTap(deviceId, phoneInput.x(), phoneInput.y());
QThread::msleep(500);
AdbUtils::inputText(deviceId, m_phone);
QThread::msleep(2000);
// 6. Ждём экран "По номеру телефона", обрабатывая промежуточные экраны
bool sumScreenLoaded = false;
bool bankSelected = false;
bool otherBankClicked = false;
FreezeDetector freezeCheck;
for (int attempt = 0; attempt < 20; ++attempt) {
if (!checkApp()) return;
const QString currentDumpXml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
xmlScreenParser.parseAndSaveXml(currentDumpXml);
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (freezeCheck.noticeAndCheckFrozen(currentDumpXml)) {
qWarning() << "[PayByPhone] 4 identical dumps in a row — screen frozen, aborting";
break;
}
// Проверяем: уже на экране суммы?
Node sumTitle = xmlScreenParser.findTextNode("По номеру телефона");
if (!sumTitle.isEmpty() && sumTitle.y() < height / 7) {
sumScreenLoaded = true;
break;
}
// Ищем кнопку с номером телефона (новый перевод / последние переводы)
QString phoneDigits = m_phone;
phoneDigits.remove(QRegularExpression("[^0-9]"));
bool foundPhoneBtn = false;
for (const Node &btn : xmlScreenParser.findAllButtons()) {
QString btnDigits = btn.text;
btnDigits.remove(QRegularExpression("[^0-9]"));
if (btnDigits.contains(phoneDigits) || phoneDigits.contains(btnDigits.right(10))) {
qDebug() << "[PayByPhone] Found button with phone number:" << btn.text;
AdbUtils::makeTap(deviceId, btn.x(), btn.y());
QThread::msleep(2000);
foundPhoneBtn = true;
break;
}
}
if (foundPhoneBtn) continue;
// Экран выбора банка: ищем "Другой банк" (текст) или EditText с hint "Название банка"
if (!bankSelected) {
// Вариант 1: кнопка "Другой банк" (content-desc="other-bank-item", text пустой).
// На устройствах с маленьким экраном кнопка лежит в конце виртуализированного
// списка и приходит с bounds=[0,0][0,0] — Node::isEmpty это не ловит, тап
// улетает в (0,0). Скроллим список вниз, пока bounds не станут ненулевыми
// (после первого скролла кнопка обычно материализуется у нижнего края, но
// тап по ней работает — это её настоящая позиция в layout).
auto findOtherBank = [&]() -> Node {
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.contentDesc.contains("other-bank-item")) return n;
}
return {};
};
bool foundOtherBank = false;
Node node = findOtherBank();
for (int scrollAttempt = 0;
!node.isEmpty() && (node.width() == 0 || node.height() == 0) && scrollAttempt < 6;
++scrollAttempt) {
qDebug() << "[PayByPhone] 'Другой банк' has collapsed bounds, scrolling list down, attempt"
<< scrollAttempt + 1 << "/ 6";
AdbUtils::makeSwipe(deviceId, width / 2, height * 3 / 4, width / 2, height / 4);
QThread::msleep(800);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
node = findOtherBank();
}
if (!node.isEmpty() && node.width() > 0 && node.height() > 0) {
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item) at"
<< node.x() << "," << node.y();
AdbUtils::makeTap(deviceId, node.x(), node.y());
QThread::msleep(2000);
foundOtherBank = true;
otherBankClicked = true;
}
if (foundOtherBank) continue;
// Вариант 2: уже на экране поиска банка (EditText) — только после клика на "Другой банк"
if (otherBankClicked) {
// После перехода на экран поиска банка поле ввода прячется под верхним
// тулбаром (рендерится Svelte WebView'ом). Свайпаем сверху вниз, чтобы
// подтянуть содержимое и сделать поле физически видимым/активным.
qDebug() << "[PayByPhone] Scrolling up to reveal hidden bank search input...";
AdbUtils::makeSwipe(deviceId, width / 2, height / 4, width / 2, height * 3 / 4);
QThread::msleep(800);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
Node bankInput = xmlScreenParser.findFirstEditText();
if (!bankInput.isEmpty()) {
qDebug() << "[PayByPhone] Bank search screen detected, entering bank name...";
// Переключаем IME ДО тапа по полю — иначе смена IME после тапа
// закроет системную клавиатуру и WebView резайзится, из-за чего
// сохранённые координаты bankInput уезжают и повторный тап мимо.
if (!AdbUtils::enableAdbIMEKeyboard(deviceId)) {
m_error = "Не удалось активировать ADB Keyboard (см. логи 'ime list -s' / default_input_method)";
qWarning() << "[PayByPhone]" << m_error;
return;
}
QThread::msleep(800);
// Перечитываем XML — layout с ADBKeyboard (без UI) отличается от layout
// с системной клавиатурой. Берём актуальные bounds поля.
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node freshBankInput = xmlScreenParser.findFirstEditText();
if (!freshBankInput.isEmpty()) bankInput = freshBankInput;
AdbUtils::makeTap(deviceId, bankInput.x(), bankInput.y());
QThread::msleep(500);
AdbUtils::inputAdbIMEText(deviceId, m_bankName);
QThread::msleep(2500);
// Верифицируем, что текст действительно попал в поле
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node verifyInput = xmlScreenParser.findFirstEditText();
if (verifyInput.text.isEmpty()) {
qWarning() << "[PayByPhone] Поле банка пустое после inputAdbIMEText — ретраим";
AdbUtils::makeTap(deviceId, bankInput.x(), bankInput.y());
QThread::msleep(500);
AdbUtils::inputAdbIMEText(deviceId, m_bankName);
QThread::msleep(2500);
}
AdbUtils::disableAdbIMEKeyboard(deviceId);
qDebug() << "[PayByPhone] Entered bank name via ADB IME:" << m_bankName;
QThread::msleep(2000);
// Ищем банк в списке и кликаем по нему (кнопка с числовым content-desc)
bool bankFound = false;
for (int ba = 0; ba < 10; ++ba) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
// Сначала проверяем, что поле поиска реально содержит введённый текст —
// иначе мы тапаем по первому попавшемуся банку из несфильтрованного списка.
Node searchField = xmlScreenParser.findFirstEditText();
if (searchField.text.trimmed().isEmpty()) {
qDebug() << "[PayByPhone] Search field still empty, waiting for filter, attempt" << ba + 1 << "/ 10";
QThread::msleep(1500);
continue;
}
for (const Node &node : xmlScreenParser.findAllNodes()) {
if (node.className == "android.widget.Button"
&& !node.contentDesc.isEmpty()
&& node.contentDesc != "base-go-back-button"
&& node.contentDesc != "native-toolbar-close-button"
&& node.contentDesc != "clear-input-icon") {
const int tapX = node.x1() + node.width() / 4;
qDebug() << "[PayByPhone] Found bank in list, tapping:" << node.contentDesc << "tapX:" << tapX << "y:" << node.y();
AdbUtils::makeTap(deviceId, tapX, node.y());
bankFound = true;
break;
}
}
if (bankFound) break;
qDebug() << "[PayByPhone] Waiting for bank list, attempt" << ba + 1 << "/ 10";
QThread::msleep(1500);
}
if (!bankFound) {
m_error = "Банк '" + m_bankName + "' не найден в списке";
qWarning() << "[PayByPhone]" << m_error;
return;
}
bankSelected = true;
QThread::msleep(2000);
continue;
}
} // otherBankClicked
}
qDebug() << "[PayByPhone] Waiting for 'По номеру телефона', attempt" << attempt + 1 << "/ 20";
QThread::msleep(1500);
}
if (!sumScreenLoaded) {
m_error = "Экран ввода суммы не загрузился";
qWarning() << "[PayByPhone]" << m_error;
return;
}
// 7. Вводим сумму
// На экране "По номеру телефона" обычно 2 EditText: сумма (сверху) и
// сообщение получателю (снизу). Оба имеют rid="input___xxxx" в Svelte UI.
// Берём верхний (минимальный y1) — это amount.
Node sumInput;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
QList<Node> candidateInputs;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.widget.EditText") continue;
if (n.height() <= 0 || n.height() > height * 4 / 5) continue;
if (!n.resourceId.startsWith("input___")) continue;
candidateInputs.append(n);
}
if (!candidateInputs.isEmpty()) {
std::sort(candidateInputs.begin(), candidateInputs.end(),
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
sumInput = candidateInputs.first(); // верхний = amount
break;
}
// Старая логика — если нет ни одного "input___": берём любой EditText.
sumInput = xmlScreenParser.findFirstEditText();
if (!sumInput.isEmpty()) break;
qDebug() << "[PayByPhone] Waiting for sum input field, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
}
if (sumInput.isEmpty()) {
m_error = "Поле ввода суммы не найдено";
qWarning() << "[PayByPhone]" << m_error;
return;
}
AdbUtils::makeTap(deviceId, sumInput.x(), sumInput.y());
QThread::msleep(500);
AdbUtils::inputText(deviceId, QString::number(static_cast<int>(m_amount)));
QThread::msleep(1000);
// Закрываем системную софт-клавиатуру, иначе она перекрывает "Продолжить"
// (кнопка уезжает за нижнюю границу web_view_holder и имеет нулевую высоту).
AdbUtils::hideKeyboard(deviceId);
QThread::msleep(800);
// 6. Нажимаем "Продолжить" (ретраим, пока bounds перестанут быть нулевыми)
Node continueBtn;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
continueBtn = xmlScreenParser.findButtonNode("Продолжить", false);
if (!continueBtn.isEmpty() && continueBtn.height() > 0) break;
qDebug() << "[PayByPhone] Waiting for tappable 'Продолжить', attempt" << attempt + 1 << "/ 5"
<< "(bounds collapsed — клавиатура ещё видна?)";
AdbUtils::hideKeyboard(deviceId);
QThread::msleep(1000);
}
if (continueBtn.isEmpty() || continueBtn.height() == 0) {
m_error = "Кнопка 'Продолжить' не найдена или перекрыта клавиатурой";
qWarning() << "[PayByPhone]" << m_error;
return;
}
AdbUtils::makeTap(deviceId, continueBtn.x(), continueBtn.y());
QThread::msleep(2000);
// 7. Ждём экран "Подтвердите перевод" с кнопкой "Перевести ..."
bool confirmScreenLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
Node confirmTitle = xmlScreenParser.findTextNode("Подтвердите перевод");
if (!confirmTitle.isEmpty()) {
confirmScreenLoaded = true;
break;
}
qDebug() << "[PayByPhone] Waiting for 'Подтвердите перевод', attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
if (!confirmScreenLoaded) {
m_error = "Экран подтверждения не загрузился";
qWarning() << "[PayByPhone]" << m_error;
return;
}
// 8. Нажимаем "Перевести ..." (ищем по первому слову)
Node transferConfirmBtn;
for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return;
transferConfirmBtn = xmlScreenParser.findButtonNode("Перевести", true);
if (!transferConfirmBtn.isEmpty()) break;
qDebug() << "[PayByPhone] Waiting for 'Перевести ...' button, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
}
if (transferConfirmBtn.isEmpty()) {
m_error = "Кнопка 'Перевести ...' не найдена на экране подтверждения";
qWarning() << "[PayByPhone]" << m_error;
return;
}
qDebug() << "[PayByPhone] Tapping confirm button:" << transferConfirmBtn.text;
AdbUtils::makeTap(deviceId, transferConfirmBtn.x(), transferConfirmBtn.y());
QThread::msleep(3000);
// 8.5. Проверяем экран ввода PIN-кода
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
!pinNodes.isEmpty()) {
qDebug() << "[PayByPhone] PIN code screen detected after confirm, entering PIN...";
inputPinCode(deviceId, pinNodes);
QThread::msleep(3000);
}
// 9. Ждём результат: "Успешно" или "Не удалось"
for (int attempt = 0; attempt < 15; ++attempt) {
if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
// Если PIN-код появился позже — вводим
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
!pinNodes.isEmpty()) {
qDebug() << "[PayByPhone] PIN code screen detected (retry), entering PIN...";
inputPinCode(deviceId, pinNodes);
QThread::msleep(3000);
continue;
}
if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) {
qDebug() << "[PayByPhone] Payment successful!";
m_error.clear();
// Сохраняем транзакцию в БД
TransactionInfo tx;
tx.accountId = m_account.id;
tx.amount = -m_amount;
tx.phone = m_phone;
tx.bankName = "ozon";
tx.type = TransactionType::Phone;
tx.status = TransactionStatus::Complete;
tx.timestamp = DateUtils::getUtcNow();
tx.completeTime = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow"));
tx.bankTime = tx.completeTime.toString("dd.MM.yyyy, HH:mm");
const int txId = TransactionDAO::insertTransaction(tx);
qDebug() << "[PayByPhone] Saved transaction to DB, id:" << txId;
// Скриншот экрана "Успешно" — отдаём как receipt-image и в EventHandler.
// Раньше дополнительно тапали "Документы" + ждали "Поделиться" + OCR
// ради bank_transaction_id, но это медленно (до 30 сек ожидания) и
// нестабильно. Теперь полагаемся на fallback-ID из phone+timestamp.
QString externalId;
if (txId != -1) {
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
if (!screenshot.isEmpty()) {
m_resultScreenshot = screenshot;
emit screenshotReady(m_resultScreenshot);
TransactionDAO::updateReceiptImage(txId, screenshot);
qDebug() << "[PayByPhone] Receipt saved to DB, txId:" << txId;
}
}
if (externalId.isEmpty() && txId != -1) {
externalId = "ozon_" + bankProfilePhone + "_" + QString::number(QDateTime::currentSecsSinceEpoch());
qDebug() << "[PayByPhone] Generated fallback transaction ID:" << externalId;
}
if (!externalId.isEmpty() && txId != -1) {
TransactionDAO::updateBankTrExternalId(txId, externalId);
qDebug() << "[PayByPhone] Transaction ID:" << externalId;
}
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
return;
}
if (!xmlScreenParser.findTextNode("Не удалось").isEmpty()) {
m_error = "Перевод не удался (экран 'Не удалось')";
qWarning() << "[PayByPhone]" << m_error;
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
return;
}
qDebug() << "[PayByPhone] Waiting for payment result, attempt" << attempt + 1 << "/ 15";
QThread::msleep(2000);
}
m_error = "Не дождались результата перевода";
qWarning() << "[PayByPhone]" << m_error;
}
} // namespace Ozon

View File

@ -0,0 +1,40 @@
#pragma once
#include "CommonScript.h"
namespace Ozon {
class PayByPhoneScript final : public CommonScript {
Q_OBJECT
public:
~PayByPhoneScript() override;
PayByPhoneScript(
MaterialInfo account,
QString phone,
QString bankName,
double amount,
QObject *parent = nullptr
);
protected:
void doStart() override;
private:
const MaterialInfo m_account;
const QString m_phone;
const QString m_bankName;
const double m_amount;
QString m_error;
void makePayment(
const QString &deviceId,
const QString &packageName,
const QString &pinCode,
const QString &bankProfilePhone,
int width,
int height
);
};
} // namespace Ozon

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,135 @@
#pragma once
#include <QObject>
#include <QDomElement>
#include "android/xml/Node.h"
#include "db/MaterialInfo.h"
#include "db/TransactionInfo.h"
namespace Ozon {
class ScreenXmlParser final : public QObject {
Q_OBJECT
public:
explicit ScreenXmlParser(QObject *parent = nullptr);
~ScreenXmlParser() override;
Node tryToFindBannerOrDialog(int width, int height);
Node tryToFindSecurityBanner();
Node tryToFindSelectBottomSheet();
Node findTextNode(const QString &text);
Node findTextNode(const QString &text, int x1, int y1, int x2, int y2);
Node findFirstEditText();
Node findEditTextByHint(const QString &hintPrefix);
// Возвращает EditText внутри Ozon "movable-pan-iframe" — поле номера карты
// получателя на экране "Перевод по номеру карты". Нужно как fallback, когда
// WebView не отдаёт hint и на экране нет TextView-лейбла "Номер карты
// получателя" (лейбл — просто "КУДА").
Node findPanCardNumberInput();
Node findButtonNode(const QString &text, bool contains);
QList<Node> findAllButtons();
const QList<Node> &findAllNodes() const;
Node findNodeByContentDesc(const QString &contentDesc);
bool isHomeScreen();
Node tryToFindAdBanner();
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android
// (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден.
Node tryToFindSystemPermissionDenyButton();
// Returns a Button node whose text is exactly 4 digits (card last numbers)
Node findCardButtonOnHomeScreen();
// Finds a card button whose text ends with the given last 4 digits
Node findCardButtonByLastNumbers(const QString &lastNumbers);
// Returns all cards visible on the home screen as MaterialInfo stubs
// (lastNumbers, description, amount filled; id=-1, cardNumber empty)
QList<MaterialInfo> parseCardsOnHomeScreen();
bool isProfileScreen();
Node findUserNameOnHomeScreen(int screenHeight);
QString parseProfileFullName();
QString parseProfilePhone();
QList<Node> tryToFindPinCode(const QString &pinCode, const QString &title);
QList<Node> getLast2DaysTransactions();
void parseAndSaveXml(const QString &xml);
TransactionInfo parseTransactionInfoHistory(int screenWidth, int screenHeight);
TransactionInfo parseTransactionInfo(int screenWidth, int screenHeight);
QList<TransactionInfo> parseTodayAndYesterdayReportHistory();
QList<TransactionInfo> parseTodayAndYesterdayHistory();
QList<QPair<MaterialInfo, Node> > parseAccountsInfo();
// Finds a TextView with full card number (format "XXXX XXXX XXXX XXXX")
QString findFullCardNumber();
// Кнопка "Основной счёт X ₽" на главном экране
Node findMainAccountButton();
// Кнопка/текст "Посмотреть реквизиты" на экране "Основной счёт"
Node findShowRequisitesButton();
// Экран "Реквизиты счёта" — заголовок text="Реквизиты счёта"
bool isAccountRequisitesScreen();
// Парсит "Номер счёта получателя <20 цифр>" из текстовой ноды
QString parseAccountNumberFromRequisites();
bool isTransactionListScreen();
// Возвращает кнопки транзакций на экране "Операции" (до maxCount штук)
QList<Node> findTransactionButtons(int maxCount);
// Ozon WebView рендерит tappable-Button только для видимых транзакций.
// Off-screen элементы остаются в accessibility-tree как android.view.View
// с "схлопнутыми" bounds (height == 0). Если такие View с ₽-знаком есть —
// значит в списке осталось что прокручивать. Используется для детекции
// того, что скролл не завершён.
bool hasCollapsedTransactionsBelow(int screenHeight);
// Парсит имя и сумму из текста кнопки: "NAME + 150 ₽ CATEGORY"
TransactionInfo parseTransactionButtonText(const QString &text);
// Парсит детали транзакции: дату, имя отправителя, телефон
TransactionInfo parseTransactionDetail();
void test();
[[nodiscard]] const QList<Node> &getNodes() const { return m_nodes; }
private:
QList<Node> m_nodes;
QDomElement m_xml;
QList<Node> findPinCodeNodes(const QString &pinCode);
};
} // namespace Ozon

BIN
arc.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
arc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
assets/ADBKeyboard.apk Normal file

Binary file not shown.

View File

@ -0,0 +1,80 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout"
package="com.google.android.permissioncontroller" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[18,532][702,1060]">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout"
package="com.google.android.permissioncontroller" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[18,532][702,1060]">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="com.google.android.permissioncontroller" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[18,532][702,1060]">
<node index="0" text="" resource-id="" class="android.widget.ScrollView"
package="com.google.android.permissioncontroller" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[18,532][702,1060]">
<node index="0" text="" resource-id="com.android.permissioncontroller:id/grant_singleton"
class="android.widget.LinearLayout" package="com.google.android.permissioncontroller"
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[18,532][702,1060]">
<node index="0" text="" resource-id="com.android.permissioncontroller:id/grant_dialog"
class="android.widget.LinearLayout" package="com.google.android.permissioncontroller"
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[18,532][702,1060]">
<node index="0" text="" resource-id="com.android.permissioncontroller:id/content_container"
class="android.widget.LinearLayout" package="com.google.android.permissioncontroller"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[48,562][672,816]">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout"
package="com.google.android.permissioncontroller" content-desc=""
checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[93,607][627,771]">
<node index="0" text=""
resource-id="com.android.permissioncontroller:id/permission_icon"
class="android.widget.ImageView"
package="com.google.android.permissioncontroller" content-desc=""
checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[337,607][382,652]"/>
<node index="1" text="Allow Альфа-Банк to access your contacts?"
resource-id="com.android.permissioncontroller:id/permission_message"
class="android.widget.TextView"
package="com.google.android.permissioncontroller" content-desc=""
checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[93,675][627,771]"/>
</node>
</node>
<node index="1" text="" resource-id="" class="android.widget.LinearLayout"
package="com.google.android.permissioncontroller" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[48,818][672,1030]">
<node index="0" text="ALLOW"
resource-id="com.android.permissioncontroller:id/permission_allow_button"
class="android.widget.Button" package="com.google.android.permissioncontroller"
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[48,818][672,923]"/>
<node index="1" text="DONT ALLOW"
resource-id="com.android.permissioncontroller:id/permission_deny_button"
class="android.widget.Button" package="com.google.android.permissioncontroller"
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[48,925][672,1030]"/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

BIN
assets/black/accounts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

123
assets/black/accounts.xml Normal file
View File

@ -0,0 +1,123 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,136][1080,2337]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[26,1256][1054,2337]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[89,1309][991,2059]"
drawing-order="0" hint="">
<node NAF="true" index="0" text="" resource-id=""
class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[928,1309][991,1372]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Datos de tu cuenta"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[89,1372][478,1437]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="CVU" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[89,1516][165,1571]"
drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="0000177501057000175004"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[89,1579][492,1626]" drawing-order="0" hint=""/>
<node NAF="true" index="4" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[938,1545][991,1598]" drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Alias"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[89,1653][173,1708]" drawing-order="0" hint=""/>
<node index="6" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="27335723320.black"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[89,1716][378,1763]" drawing-order="0" hint=""/>
<node NAF="true" index="7" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[938,1682][991,1734]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="Compartir datos"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[47,2143][1033,2316]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Cerrar" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

View File

@ -0,0 +1,248 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.ScrollView"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="true" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="¡Hola!&#10;veronica cintia heredia &#10;vc"
checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,158][1080,347]" drawing-order="0"
hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tu wallet"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[107,465][414,570]" drawing-order="0"
hint=""/>
<node index="2" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Saldo disponible"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[107,596][370,643]" drawing-order="0"
hint=""/>
<node NAF="true" index="3" text="" resource-id=""
class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[440,495][484,539]"
drawing-order="0" hint=""/>
<node index="4" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tu CVU"
checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[783,484][974,597]" drawing-order="0"
hint=""/>
<node index="5" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="$0,00"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[107,643][316,761]" drawing-order="0"
hint=""/>
<node index="6" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Accesos rápidos"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[53,1116][378,1179]"
drawing-order="0" hint=""/>
<node index="7" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="QR Personal" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[53,1218][315,1488]"
drawing-order="0" hint=""/>
<node index="8" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Enviar a&#10;CBU o alias" checkable="false"
checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[409,1218][671,1536]" drawing-order="0" hint=""/>
<node index="9" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Solicitar&#10;con link" checkable="false"
checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[765,1218][1028,1536]" drawing-order="0" hint=""/>
<node index="10" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tu actividad"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[53,1667][297,1730]"
drawing-order="0" hint=""/>
<node index="11" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Ver más"
checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[802,1667][1028,1730]"
drawing-order="0" hint=""/>
<node index="12" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickaEble="false" enabled="true"
focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,1756][1080,2229]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc=""
checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,1756][1080,2229]"
drawing-order="0" hint="">
<node index="0" text="" resource-id=""
class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia&#10;21 de febrero de 2026 - 09:39&#10;$-1.857,&#10;00"
checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[0,1756][1080,1880]" drawing-order="0"
hint=""/>
<node index="1" text="" resource-id=""
class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia&#10;21 de febrero de 2026 - 09:38&#10;$-123,&#10;45"
checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[0,1880][1080,2003]" drawing-order="0"
hint=""/>
<node index="2" text="" resource-id=""
class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia externa de NOELIA ISABEL LEZCANO&#10;21 de febrero de 2026 - 09:35&#10;$123,&#10;45"
checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[0,2003][1080,2179]" drawing-order="0"
hint=""/>
</node>
</node>
<node index="13" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tarjetas"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[53,2255][215,2318]"
drawing-order="0" hint=""/>
<node index="14" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Ver más"
checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[802,2255][1028,2318]"
drawing-order="0" hint=""/>
<node index="15" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="No hay tarjetas en tu billetera"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[302,2344][778,2391]"
drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
<node NAF="true" index="1" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[456,2106][624,2274]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="Inicio" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[88,2229][162,2350]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="Enviar" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[251,2229][336,2350]" drawing-order="0" hint=""/>
<node index="4" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="Recibir" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[729,2229][824,2350]" drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="Pagar" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[913,2229][992,2350]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -0,0 +1,122 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,283]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,283][1080,1937]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="Vas a transferir &#10;a JOEL ARCHENTI" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[280,322][800,501]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="$" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[378,815][406,883]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="500," checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[406,766][635,931]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="00" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[635,807][702,891]" drawing-order="0" hint=""/>
<node index="4" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="BANCO DE GALICIA Y BUENOS AIRES S.A.U." checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[161,1063][945,1118]" drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Alias:" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[293,1170][390,1225]" drawing-order="0" hint=""/>
<node index="6" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc=" GRUTA.MADRID.ATRIO"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[390,1170][813,1225]"
drawing-order="0" hint=""/>
<node index="7" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="CBU:" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[257,1278][344,1333]" drawing-order="0" hint=""/>
<node index="8" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc=" 0070359630004039066690"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[344,1278][849,1333]"
drawing-order="0" hint=""/>
<node index="9" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="$10.000,00 disponible"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[388,1674][692,1716]"
drawing-order="0" hint=""/>
<node index="10" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Transferir" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[63,1743][1017,1874]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -0,0 +1,134 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,283]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,283][1080,1918]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[63,322][1017,832]" drawing-order="0" hint="">
<node NAF="true" index="0" text="" resource-id=""
class="android.widget.EditText" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[63,401][1017,569]" drawing-order="0"
hint="Ingresar CBU, CVU o Alias"/>
<node index="1" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Continuar"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[63,648][1017,779]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Cuentas recientes"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[63,976][388,1031]"
drawing-order="0" hint=""/>
<node NAF="true" index="2" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[938,990][1017,1043]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[63,1057][1017,1750]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[63,1057][1017,1750]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="RM&#10;ROCIO MICAELA MONTES&#10;BANCO NO INFORMADO"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[63,1057][1017,1215]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="GA&#10;Gabriel Alejandro del Roio&#10;BANCO NO INFORMADO"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[63,1215][1017,1372]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="AL&#10;Alexander Leonardo Nahuel Soto&#10;BANCO NO INFORMADO"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[63,1372][1017,1530]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="MS&#10;MICAELA SOLEDAD VARGAS&#10;BANCO NO INFORMADO"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[63,1530][1017,1687]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@ -0,0 +1,100 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,283]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,283][1080,1447]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.ScrollView"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="true" long-clickable="false" password="false"
selected="false" bounds="[0,283][1080,1447]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="¿Cuánto vas a transferír &#10;a JOEL ARCHENTI?"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[191,322][889,501]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[63,661][1017,1447]"
drawing-order="0" hint="">
<node index="0" text="0,00" resource-id=""
class="android.widget.EditText" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="true" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[63,819][1017,945]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="$10.000,00 disponible"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[292,1050][788,1118]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Continuar"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[63,1328][1017,1447]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

View File

@ -0,0 +1,196 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2190]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2190]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2190]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,283]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Atrás"
checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,136][147,283]" drawing-order="0"
hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,1924]"
drawing-order="0" hint="">
<node index="0" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[66,315][255,504]"
drawing-order="0" hint=""/>
<node index="1" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[240,0][1080,548]"
drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Dinero enviado!"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[66,570][752,704]" drawing-order="0"
hint=""/>
<node index="3" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="Tu dinero fue transferido de manera exitosa. Recordá que puede tardar hasta 24 hs en impactar en la cuenta destino."
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[66,730][1014,911]" drawing-order="0"
hint=""/>
<node index="4" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="JOEL ARCHENTI"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[63,1087][534,1176]"
drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="$500,00"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[784,1087][1017,1176]"
drawing-order="0" hint=""/>
<node index="6" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Black Wallet"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[63,1202][305,1263]"
drawing-order="0" hint=""/>
<node index="7" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="12/03/26 04:01"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[719,1202][1017,1263]"
drawing-order="0" hint=""/>
<node index="8" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Id Coelsa"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[63,1368][240,1428]"
drawing-order="0" hint=""/>
<node index="9" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="1-30717458822-1773298911069rk-1"
checkable="false" checked="false" clickable="false"
enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[306,1368][1017,1428]"
drawing-order="0" hint=""/>
<node index="10" text="" resource-id=""
class="android.widget.Button" package="app.blackwallet.ar"
content-desc="Compartir " checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[116,1691][530,1835]" drawing-order="0" hint=""/>
<node index="11" text="" resource-id=""
class="android.widget.Button" package="app.blackwallet.ar"
content-desc="Descargar" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[551,1691][965,1835]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
<node NAF="true" index="1" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[456,2106][624,2274]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="Inicio" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[88,2229][162,2350]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="Enviar" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[251,2229][336,2350]" drawing-order="0" hint=""/>
<node index="4" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="Recibir" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[729,2229][824,2350]" drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="Pagar" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[913,2229][992,2350]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@ -0,0 +1,72 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,283]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""/>
</node>
</node>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Cerrar" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@ -0,0 +1,118 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,136][1080,2337]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,1301][1080,2337]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[63,1354][1017,1954]"
drawing-order="0" hint="">
<node NAF="true" index="0" text="" resource-id=""
class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[954,1354][1017,1417]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="JOEL ARCHENTI"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[355,1417][725,1490]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Alias: "
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[275,1551][385,1608]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="GRUTA.MADRID.ATRIO"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[385,1551][805,1608]" drawing-order="0" hint=""/>
<node index="4" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="CBU: "
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[244,1650][343,1708]" drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="0070359630004039066690"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[343,1650][836,1708]" drawing-order="0" hint=""/>
<node index="6" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="BANCO DE GALICIA Y BUENOS AIRES S.A.U."
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[143,1750][937,1808]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Transferir"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[87,2090][993,2264]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Cerrar" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

BIN
assets/black/pin_code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

97
assets/black/pin_code.xml Normal file
View File

@ -0,0 +1,97 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,283]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Registrate" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[42,173][315,246]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,283][1080,2018]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Ingresá tu&#10;código PIN&#10;Es un código de seguridad que te va a permitir operar con más seguridad&#10;Ingresá tu PIN"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,283][1080,1396]"
drawing-order="0" hint=""/>
<node NAF="true" index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="true" password="false"
selected="false" bounds="[263,1449][818,1514]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.widget.EditText"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="true" scrollable="false" long-clickable="false" password="true"
selected="false" bounds="[263,1449][818,1556]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="¿Olvidaste tu PIN?"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[357,1687][723,1750]"
drawing-order="0" hint=""/>
<node index="4" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="Tengo un código de verificación" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[218,1803][862,1866]" drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Cerrar sesión"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[435,1918][645,1966]"
drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@ -0,0 +1,184 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,283]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tu información"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[189,173][581,246]"
drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.widget.ScrollView"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="true" focused="false"
scrollable="true" long-clickable="false" password="false" selected="false"
bounds="[0,283][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="vc" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[496,494][584,597]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="veronica cintia heredia "
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[205,716][875,800]"
drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="12 de febrero 1989"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[348,826][732,882]"
drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[66,951][118,1043]" drawing-order="0" hint=""/>
<node index="4" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tu CVU" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[171,950][287,997]" drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="0000177501057000175004"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[171,997][606,1044]"
drawing-order="0" hint=""/>
<node NAF="true" index="6" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[962,934][1041,1060]" drawing-order="0" hint=""/>
<node index="7" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[66,1106][118,1198]" drawing-order="0" hint=""/>
<node index="8" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tu Alias" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[171,1105][294,1152]" drawing-order="0" hint=""/>
<node index="9" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="27335723320.black"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[171,1152][481,1199]"
drawing-order="0" hint=""/>
<node NAF="true" index="10" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[883,1089][962,1215]" drawing-order="0" hint=""/>
<node NAF="true" index="11" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[962,1089][1041,1215]" drawing-order="0" hint=""/>
<node index="12" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Editá tu información"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[79,1359][449,1414]"
drawing-order="0" hint=""/>
<node index="13" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[79,1467][1001,2400]" drawing-order="0" hint="">
<node index="0" text="veronica cintia " resource-id=""
class="android.view.View" package="app.blackwallet.ar" content-desc=""
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[79,1467][1001,1635]" drawing-order="0" hint="Nombre"/>
<node index="1" text="heredia " resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[79,1714][1001,1882]"
drawing-order="0" hint="Apellido"/>
<node index="2" text="Fiorinogriselda4@gmail.com" resource-id=""
class="android.view.View" package="app.blackwallet.ar" content-desc=""
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[79,1960][1001,2128]" drawing-order="0" hint="E-mail"/>
<node index="3" text="12/2/1989" resource-id=""
class="android.widget.EditText" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[79,2207][1001,2375]" drawing-order="0"
hint="Fecha de nacimiento"/>
<node index="4" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[875,2228][1001,2354]"
drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][0,0]"
drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@ -0,0 +1,142 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,283]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tu información"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[189,173][581,246]"
drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.widget.ScrollView"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="true" focused="false"
scrollable="true" long-clickable="false" password="false" selected="false"
bounds="[0,283][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Editá tu información"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[79,283][449,305]"
drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[79,358][1001,2295]" drawing-order="0" hint="">
<node index="0" text="veronica cintia " resource-id=""
class="android.view.View" package="app.blackwallet.ar" content-desc=""
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[79,358][1001,526]" drawing-order="0" hint="Nombre"/>
<node index="1" text="heredia " resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[79,605][1001,773]"
drawing-order="0" hint="Apellido"/>
<node index="2" text="Fiorinogriselda4@gmail.com" resource-id=""
class="android.view.View" package="app.blackwallet.ar" content-desc=""
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[79,851][1001,1019]" drawing-order="0" hint="E-mail"/>
<node index="3" text="12/2/1989" resource-id=""
class="android.widget.EditText" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[79,1098][1001,1266]" drawing-order="0"
hint="Fecha de nacimiento"/>
<node index="4" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[875,1119][1001,1245]"
drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Género&#10;Femenino"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[79,1345][1001,1513]" drawing-order="0" hint=""/>
<node index="6" text="3543554877" resource-id=""
class="android.widget.EditText" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[79,1592][1001,1760]" drawing-order="0"
hint="Teléfono celular"/>
<node index="7" text="********" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[79,1838][1001,2006]"
drawing-order="0" hint="Contraseña"/>
<node index="8" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[875,1856][1001,1989]"
drawing-order="0" hint=""/>
<node index="9" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Guardar cambios"
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[79,2111][1001,2243]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

BIN
assets/black/side_menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

169
assets/black/side_menu.xml Normal file
View File

@ -0,0 +1,169 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="vc&#10;veronica cintia heredia " checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,1856]"
drawing-order="0" hint="">
<node NAF="true" index="0" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[42,178][134,238]"
drawing-order="0" hint=""/>
<node index="1" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Tu Información" checkable="false"
checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[42,685][1038,763]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Notificaciones" checkable="false"
checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[42,845][1038,923]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Ajustes y preferencias" checkable="false"
checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[42,1005][1038,1084]" drawing-order="0" hint=""/>
<node index="4" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Ayuda" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[42,1165][1038,1244]" drawing-order="0" hint=""/>
<node index="5" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Invitar a Black Wallet" checkable="false"
checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[42,1325][1038,1404]" drawing-order="0" hint=""/>
<node index="6" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Cerrar sesión" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[42,1669][1038,1748]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,1856][1080,1882]"
drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,1882][1080,2223]"
drawing-order="0" hint="">
<node index="0" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Inicio" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[88,2024][162,2145]"
drawing-order="0" hint=""/>
<node index="1" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Enviar" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[251,2024][336,2145]" drawing-order="0" hint=""/>
<node NAF="true" index="2" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[456,1898][624,2066]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Recibir" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[729,2024][824,2145]" drawing-order="0" hint=""/>
<node index="4" text="" resource-id=""
class="android.widget.ImageView" package="app.blackwallet.ar"
content-desc="Pagar" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false"
bounds="[913,2024][992,2145]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

View File

@ -0,0 +1,93 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,283]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tu Actividad" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[189,173][510,246]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,1221]" drawing-order="0" hint="">
<node NAF="true" index="0" text="" resource-id=""
class="android.widget.EditText" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[53,394][1028,520]" drawing-order="0" hint="Buscar"/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,677][1080,1116]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,677][1080,1116]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar"
content-desc="Aún no tienes actividad&#10;Una vez que tengas actividad en tu cuenta la&#10; podrás ver reflejada acá."
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,677][1080,1116]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View File

@ -0,0 +1,163 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="true" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,283]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.Button"
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
checked="false" clickable="true" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="Tu Actividad" checkable="false"
checked="false" clickable="false" enabled="true" focusable="true"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[189,173][510,246]" drawing-order="0" hint=""/>
</node>
<node index="1" text="" resource-id="" class="android.widget.ScrollView"
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="true" focused="false"
scrollable="true" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
<node NAF="true" index="0" text="" resource-id=""
class="android.widget.EditText" package="app.blackwallet.ar"
content-desc="" checkable="false" checked="false" clickable="true"
enabled="true" focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[53,394][1028,520]" drawing-order="0" hint="Buscar"/>
<node index="1" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false" password="false"
selected="false" bounds="[0,625][1080,2400]" drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.view.View"
package="app.blackwallet.ar" content-desc="" checkable="false"
checked="false" clickable="false" enabled="true" focusable="false"
focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,625][1080,2400]"
drawing-order="0" hint="">
<node index="0" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia externa de JOEL ARCHENTI&#10;10 de marzo de 2026 - 16:23&#10;$10.000,&#10;00"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,625][1080,814]" drawing-order="0" hint=""/>
<node index="1" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia&#10;21 de febrero de 2026 - 09:39&#10;$-1.857,&#10;00"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,814][1080,950]" drawing-order="0" hint=""/>
<node index="2" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia&#10;21 de febrero de 2026 - 09:38&#10;$-123,&#10;45"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,950][1080,1087]" drawing-order="0" hint=""/>
<node index="3" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia externa de NOELIA ISABEL LEZCANO&#10;21 de febrero de 2026 - 09:35&#10;$123,&#10;45"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,1087][1080,1276]" drawing-order="0" hint=""/>
<node index="4" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia externa de NOELIA ISABEL LEZCANO&#10;21 de febrero de 2026 - 09:34&#10;$1.234,&#10;00"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,1276][1080,1465]" drawing-order="0" hint=""/>
<node index="5" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia externa de NOELIA ISABEL LEZCANO&#10;21 de febrero de 2026 - 09:34&#10;$123,&#10;00"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,1465][1080,1654]" drawing-order="0" hint=""/>
<node index="6" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia&#10;15 de febrero de 2026 - 07:07&#10;$-56.030,&#10;00"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,1654][1080,1790]" drawing-order="0" hint=""/>
<node index="7" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia&#10;14 de febrero de 2026 - 23:59&#10;$-161.000,&#10;00"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,1790][1080,1927]" drawing-order="0" hint=""/>
<node index="8" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia externa de Daniela Lorena Saucedo&#10;14 de febrero de 2026 - 21:30&#10;$10.000,&#10;00"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,1927][1080,2116]" drawing-order="0" hint=""/>
<node index="9" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia externa de Edgardo Sebastian Bravo&#10;14 de febrero de 2026 - 21:29&#10;$5.030,&#10;00"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,2116][1080,2305]" drawing-order="0" hint=""/>
<node index="10" text="" resource-id="" class="android.widget.ImageView"
package="app.blackwallet.ar"
content-desc="Transferencia externa de JOAQUIN GABRIEL,LEONARDO &#10;14 de febrero de 2026 - 21:28&#10;$16.000,&#10;00"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="true" focused="false" scrollable="false"
long-clickable="false" password="false" selected="false"
bounds="[0,2305][1080,2400]" drawing-order="0" hint=""/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

21
assets/capture.sh Executable file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# Проверка аргумента
if [ -z "$1" ]; then
echo "Usage: $0 <name>"
exit 1
fi
NAME="$1"
DEVICE_ID="6edc4a47"
# Снимок UI Automator
adb -s "$DEVICE_ID" shell 'uiautomator dump /sdcard/uidump.xml' && \
adb -s "$DEVICE_ID" pull /sdcard/uidump.xml "${NAME}.xml" && \
adb -s "$DEVICE_ID" shell 'rm /sdcard/uidump.xml'
# Скриншот экрана
adb -s "$DEVICE_ID" shell 'screencap -p /sdcard/screen.png' && \
adb -s "$DEVICE_ID" pull /sdcard/screen.png "${NAME}.png" && \
adb -s "$DEVICE_ID" shell 'rm /sdcard/screen.png'

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа&#10;1.00 TJS&#10;Поставщик:&#10;DC (по номеру карты) &#10;Номер/Счет&#10;9762000186263942&#10;Способ оплаты:&#10;9762 **** **** 8865&#10;Сумма зачисления:&#10;1.00 TJS&#10;Комиссия:&#10;0.00 TJS&#10;Сумма списания:&#10;1.00 TJS&#10;Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа&#10;1.00 TJS&#10;Поставщик:&#10;DC (по номеру карты) &#10;Номер/Счет&#10;9762000104225205&#10;Способ оплаты:&#10;9762 **** **** 8865&#10;Сумма зачисления:&#10;1.00 TJS&#10;Комиссия:&#10;0.00 TJS&#10;Сумма списания:&#10;1.00 TJS&#10;Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More