1
0
forked from BRT/arc

Compare commits

..

2 Commits

Author SHA1 Message Date
slava
f8c5a6656f Port Ozon card and payment scripts to JavaScript, mirroring C++ logic for card info fetching and phone-based payments. 2026-05-20 14:52:08 +05:00
slava
cae345cf61 Implement JS-based remote scripting framework for dynamic script updates:
- Add Proof of Concept (PoC) for executing JavaScript scripts fetched remotely.
- Integrate `Black::GetProfileInfoScript` logic into a JS version with fallback to C++ on errors.
- Extend `ScreenXmlParser` with additional JS-to-C++ bridge methods.
- Update `config.ini` for scripting configuration.
- Ensure scripts are securely fetched with SHA-256 validation and use an atomic caching mechanism.
- Add developer tools for debugging JS scripts and enable easier script updates without app re-deployment.
2026-05-13 15:42:10 +07:00
128 changed files with 6207 additions and 3127 deletions

7
.gitignore vendored
View File

@ -30,10 +30,3 @@ Makefile
/database/app_data.db /database/app_data.db
/build_x64/ /build_x64/
/tests/mock_server/scenarios/pool/ /tests/mock_server/scenarios/pool/
/.cache/
/.claude/
/dumps/
/ARC_autogen/
/build_test/
/build_x64/
/tests/mock_server/scenarios/

View File

@ -58,6 +58,7 @@ find_package(Qt6 REQUIRED COMPONENTS
Widgets Widgets
Sql Sql
Xml Xml
Qml
) )
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
@ -156,6 +157,7 @@ if (WIN32)
Qt6::Xml Qt6::Xml
Qt6::Sql Qt6::Sql
Qt6::Network Qt6::Network
Qt6::Qml
Tesseract::libtesseract Tesseract::libtesseract
) )
@ -171,6 +173,10 @@ if (WIN32)
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}") 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}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
# Bundled JS scripts fallback на случай, когда удалённый manifest недоступен
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/scripts" DESTINATION "${CMAKE_BINARY_DIR}")
endif ()
# adb копируется в post-build чтобы не падать при configure если adb.exe занят # adb копируется в post-build чтобы не падать при configure если adb.exe занят
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*") file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
foreach(_adb_file ${_adb_files}) foreach(_adb_file ${_adb_files})
@ -228,6 +234,14 @@ if (WIN32)
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk" "$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
) )
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
add_custom_command(TARGET ARC POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/scripts"
"$<TARGET_FILE_DIR:ARC>/scripts"
)
endif ()
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend) # Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
file(GLOB _openssl_dlls "${VCPKG_BIN_DIR}/libssl*.dll" file(GLOB _openssl_dlls "${VCPKG_BIN_DIR}/libssl*.dll"
"${VCPKG_BIN_DIR}/libcrypto*.dll") "${VCPKG_BIN_DIR}/libcrypto*.dll")
@ -276,6 +290,7 @@ elseif (APPLE)
Qt6::Sql Qt6::Sql
Qt6::Xml Qt6::Xml
Qt6::Network Qt6::Network
Qt6::Qml
tesseract tesseract
leptonica leptonica
) )
@ -291,6 +306,9 @@ elseif (APPLE)
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}") 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}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/scripts" DESTINATION "${CMAKE_BINARY_DIR}")
endif ()
endif () endif ()
target_include_directories(ARC PRIVATE target_include_directories(ARC PRIVATE

136
README.md
View File

@ -1,136 +0,0 @@
# 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`)

View File

@ -152,71 +152,7 @@ QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &
return pid.replace("\n", ""); 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) { 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; QProcess process;
process.setWorkingDirectory(adbDir()); process.setWorkingDirectory(adbDir());
process.start(adbPath(), { process.start(adbPath(), {
@ -231,47 +167,16 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
return false; return false;
} }
process.waitForFinished(); process.waitForFinished();
const QString stdOut = QString::fromUtf8(process.readAllStandardOutput()); const QString output = 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`) терялся. // Если нет, то: "** No activities found to run, monkey aborted."
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey exitStatus=" if (output.contains("Events injected: 1")) {
<< exitStatus << "exitCode=" << exitCode;
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
if (stdOut.contains("Events injected: 1")) {
return true; return true;
} }
// Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code, // Тут надо в логи писать, почему не стартует
// в отличие от monkey, который при пустом resolve просто молча печатает "No activities found". qDebug() << "Start app output:" << output;
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; return false;
} }
@ -574,23 +479,16 @@ bool AdbUtils::enableAdbIMEKeyboard(const QString &deviceId) {
bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) { bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) {
QProcess process; QProcess process;
process.setWorkingDirectory(adbDir()); 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(), { process.start(adbPath(), {
"-s", deviceId, "-s", deviceId,
"shell", "am broadcast -a ADB_INPUT_B64 --es msg " + b64 "shell", "am broadcast -a ADB_INPUT_TEXT --es msg '" + text + "'"
}); });
if (!process.waitForStarted(5000)) { if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString();
return false; return false;
} }
process.waitForFinished(); process.waitForFinished();
qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode() qDebug() << "[AdbUtils] inputAdbIMEText result:" << process.exitCode()
<< "text:" << text
<< "output:" << process.readAllStandardOutput().trimmed() << "output:" << process.readAllStandardOutput().trimmed()
<< "error:" << process.readAllStandardError().trimmed(); << "error:" << process.readAllStandardError().trimmed();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
@ -740,21 +638,6 @@ QString AdbUtils::getDeviceTimezone(const QString &deviceId) {
return out.trimmed(); 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) { void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) {
const QString adb = adbPath(); const QString adb = adbPath();

View File

@ -56,9 +56,6 @@ public:
// Возвращает таймзону устройства (напр. "Asia/Bangkok"), либо пустую строку при ошибке // Возвращает таймзону устройства (напр. "Asia/Bangkok"), либо пустую строку при ошибке
static QString getDeviceTimezone(const QString &deviceId); static QString getDeviceTimezone(const QString &deviceId);
// Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии
static QString getAppVersion(const QString &deviceId, const QString &packageName);
private: private:
static QString adbDir(); static QString adbDir();
static bool startProcess(const QString &program, const QStringList &args, static bool startProcess(const QString &program, const QStringList &args,

View File

@ -200,12 +200,12 @@ void CommonScript::updateTransactionData(
thread->start(); thread->start();
} }
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) { void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode); const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) { if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
qWarning() << "[Black] createBankProfileIfNeeded: skipping — fullName or phone is empty"; qWarning() << "[Black] createBankProfileIfNeeded: skipping — fullName or phone is empty";
return {}; return;
} }
const QMap<QString, int> currencyCodes = { const QMap<QString, int> currencyCodes = {
@ -249,45 +249,16 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
if (app.bankProfileId.isEmpty()) { if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id); ns.addExtra("app_id", app.id);
ns.postBankProfile(); 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"; qDebug() << "[Black] createBankProfileIfNeeded: postBankProfile done";
} else { } else {
ns.addExtra("bank_profile_id", app.bankProfileId); ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile(); ns.patchBankProfile();
qDebug() << "[Black] createBankProfileIfNeeded: patchBankProfile done"; qDebug() << "[Black] createBankProfileIfNeeded: patchBankProfile done";
} }
return {};
} }
void CommonScript::stop() { void CommonScript::stop() {
m_running = false; 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 } // namespace Black

View File

@ -46,16 +46,8 @@ protected:
const QString &newDesc const QString &newDesc
); );
// Создаёт bank_profile на сервере если ещё не создан. // Создаёт bank_profile на сервере если ещё не создан
// Возвращает пустую строку при успехе, текст ошибки при провале. void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
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; bool m_running = true;
ScreenXmlParser xmlScreenParser; ScreenXmlParser xmlScreenParser;

View File

@ -41,7 +41,6 @@ void GetCardInfoScript::doStart() {
// Читаем текущий экран // Читаем текущий экран
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// Если не на домашнем экране — перезапускаем // Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {
@ -55,7 +54,6 @@ void GetCardInfoScript::doStart() {
return; return;
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
} }
// 1. Парсим баланс с домашнего экрана // 1. Парсим баланс с домашнего экрана
@ -80,7 +78,6 @@ void GetCardInfoScript::doStart() {
bool cvuScreenFound = false; bool cvuScreenFound = false;
for (int attempt = 0; attempt < 5; ++attempt) { for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isCVUScreen()) { if (xmlScreenParser.isCVUScreen()) {
cvuScreenFound = true; cvuScreenFound = true;
break; break;
@ -119,7 +116,7 @@ void GetCardInfoScript::doStart() {
account.amount = balance; account.amount = balance;
account.currency = "ARS"; account.currency = "ARS";
account.description = "CVU"; account.description = "CVU";
account.status = MaterialStatus::Off; account.status = MaterialStatus::Active;
account.updateTime = QDateTime::currentDateTimeUtc(); account.updateTime = QDateTime::currentDateTimeUtc();
if (!MaterialDAO::upsertAccount(account)) { if (!MaterialDAO::upsertAccount(account)) {

View File

@ -3,6 +3,7 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QThread> #include <QThread>
#include <QVariantMap>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
@ -10,6 +11,8 @@
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
namespace Black { namespace Black {
@ -25,6 +28,26 @@ GetProfileInfoScript::GetProfileInfoScript(
GetProfileInfoScript::~GetProfileInfoScript() = default; GetProfileInfoScript::~GetProfileInfoScript() = default;
void GetProfileInfoScript::doStart() { void GetProfileInfoScript::doStart() {
// PoC удалённых JS-скриптов: пробуем JS-версию, при engine-ошибке делаем
// прозрачный fallback на C++ ниже. См. docs/remote_scripts.md.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("black/get_profile_info")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
};
const auto status = Scripting::ScriptRuntime::run(
"black", "get_profile_info", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Black::GetProfileInfo] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; 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); const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
@ -164,11 +187,7 @@ void GetProfileInfoScript::doStart() {
} }
// 7. Создаём bank profile если нет // 7. Создаём bank profile если нет
if (const QString err = createBankProfileIfNeeded(m_deviceId, m_appCode); !err.isEmpty()) { createBankProfileIfNeeded(m_deviceId, m_appCode);
m_error = err;
emit finishedWithResult(m_error);
return;
}
// 8. Отправляем данные аккаунтов на сервер если нет material_id // 8. Отправляем данные аккаунтов на сервер если нет material_id
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode); const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);

View File

@ -542,37 +542,4 @@ Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
return {}; 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 } // namespace Black

View File

@ -110,13 +110,6 @@ public:
// Находит EditText по hint // Находит EditText по hint
Node findEditTextByHint(const QString &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: private:
QList<Node> m_nodes; QList<Node> m_nodes;
QDomElement m_xml; QDomElement m_xml;

View File

@ -15,9 +15,7 @@
#include <qDebug> #include <qDebug>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "dao/SettingsDAO.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "LogArchiveSender.h"
namespace { namespace {
@ -166,28 +164,14 @@ TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
} }
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const { QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
const QString apiToken = SettingsDAO::get("api_key"); QString s = QString("Bank: %1 | Task: %2 | Device: %3")
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate); .arg(m_bank, m_taskKind, m_adbDeviceIdRaw);
if (!m_meta.materialId.isEmpty()) s += QString("\nMaterial ID: %1").arg(m_meta.materialId);
QStringList head{QStringLiteral("[TASK_ERROR]")}; if (m_meta.amount != 0) s += QString("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
if (!apiToken.isEmpty()) if (!m_meta.phone.isEmpty()) s += QString("\nPhone: %1").arg(m_meta.phone);
head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped()); if (!m_meta.card.isEmpty()) s += QString("\nCard: %1").arg(m_meta.card);
head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped()); s += QString("\nError: %1").arg(errorMessage.left(800));
head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped()); return s;
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) { void TaskDumpRecorder::saveXml(int counter, const QString &xml) {
@ -279,7 +263,11 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
QFile::remove(archivePath); QFile::remove(archivePath);
NetworkService *svc = networkServiceRef().data(); NetworkService *svc = networkServiceRef().data();
if (svc) { if (!svc) {
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
<< "(setNetworkService was not called at app startup?)";
return;
}
qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService" qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService"
<< "archive.size=" << archiveBytes.size() << "archive.size=" << archiveBytes.size()
<< "image.size=" << finalScreenshot.size() << "image.size=" << finalScreenshot.size()
@ -293,15 +281,6 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — " qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
"slot not found or wrong signature for" << folder; "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); QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
bg->start(); bg->start();

View File

@ -46,34 +46,6 @@ bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode)
qWarning() << "[Dushanbe::inputPinCode] pinCode is EMPTY — nothing to enter!"; qWarning() << "[Dushanbe::inputPinCode] pinCode is EMPTY — nothing to enter!";
return false; 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"; qDebug() << "[Dushanbe::inputPinCode] entering" << pinCode.length() << "digits";
// DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9") // DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9")
for (const QChar &digit : pinCode) { for (const QChar &digit : pinCode) {
@ -152,14 +124,10 @@ bool CommonScript::runAppAndGoToHomeScreen(
if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) { if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe] PIN screen detected on attempt" << counter << "entering PIN"; qDebug() << "[Dushanbe] PIN screen detected on attempt" << counter << "entering PIN";
if (inputPinCode(deviceId, pinCode)) { if (!inputPinCode(deviceId, pinCode)) {
findAndInputPincode = true; qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter;
} else {
// Клавиатура могла не дорисоваться или PIN ввёлся частично —
// оставляем флаг false, следующая итерация повторит ввод,
// когда кнопки появятся.
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter << "— will retry";
} }
findAndInputPincode = true;
QThread::msleep(3000); QThread::msleep(3000);
continue; continue;
} }
@ -267,12 +235,12 @@ void CommonScript::updateTransactionData(
thread->start(); thread->start();
} }
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) { void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode); const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) { if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
qWarning() << "[Dushanbe] createBankProfileIfNeeded: skipping — fullName or phone is empty"; qWarning() << "[Dushanbe] createBankProfileIfNeeded: skipping — fullName or phone is empty";
return {}; return;
} }
const QMap<QString, int> currencyCodes = { const QMap<QString, int> currencyCodes = {
@ -315,19 +283,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
if (app.bankProfileId.isEmpty()) { if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id); ns.addExtra("app_id", app.id);
ns.postBankProfile(); 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"; qDebug() << "[Dushanbe] createBankProfileIfNeeded: postBankProfile done";
} else { } else {
ns.addExtra("bank_profile_id", app.bankProfileId); ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile(); ns.patchBankProfile();
qDebug() << "[Dushanbe] createBankProfileIfNeeded: patchBankProfile done"; qDebug() << "[Dushanbe] createBankProfileIfNeeded: patchBankProfile done";
} }
return {};
} }
void CommonScript::stop() { void CommonScript::stop() {

View File

@ -45,7 +45,7 @@ protected:
const QString &newDesc const QString &newDesc
); );
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0); void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
// Если виден баннер Google Play "Доступно обновление" — закрывает его и перепарсивает XML. // Если виден баннер Google Play "Доступно обновление" — закрывает его и перепарсивает XML.
// Безопасно вызывать на любом экране; возвращает true если баннер был закрыт. // Безопасно вызывать на любом экране; возвращает true если баннер был закрыт.

View File

@ -39,7 +39,6 @@ void GetCardInfoScript::doStart() {
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::GetCardInfo] Not on home screen, restarting app..."; qDebug() << "[Dushanbe::GetCardInfo] Not on home screen, restarting app...";
@ -52,7 +51,6 @@ void GetCardInfoScript::doStart() {
return; return;
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
} }
// Парсим баланс с домашнего экрана // Парсим баланс с домашнего экрана
@ -79,7 +77,6 @@ void GetCardInfoScript::doStart() {
bool cardsScreenFound = false; bool cardsScreenFound = false;
for (int attempt = 0; attempt < 5; ++attempt) { for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isCardsScreen()) { if (xmlScreenParser.isCardsScreen()) {
cardsScreenFound = true; cardsScreenFound = true;
break; break;
@ -130,7 +127,6 @@ void GetCardInfoScript::doStart() {
bool detailFound = false; bool detailFound = false;
for (int attempt = 0; attempt < 5; ++attempt) { for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isCardDetailScreen()) { if (xmlScreenParser.isCardDetailScreen()) {
detailFound = true; detailFound = true;
break; break;
@ -146,32 +142,25 @@ void GetCardInfoScript::doStart() {
// Ждём загрузки баланса (может быть 0 пока данные подтягиваются) // Ждём загрузки баланса (может быть 0 пока данные подтягиваются)
QThread::msleep(1500); QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
QString fullNumber = xmlScreenParser.parseFullCardNumber(); QString fullNumber = xmlScreenParser.parseFullCardNumber();
// Баланс берём с домашнего экрана (parseBalanceFromHomeScreen выше): const double cardBalance = xmlScreenParser.parseCardDetailBalance();
// в Душанбе все карты привязаны к одному счёту, и на экране детали
// карты баланс часто отображается как placeholder "0,00 TJS" пока
// данные подгружаются с сервера.
const double cardBalance = balance;
const QString holder = xmlScreenParser.parseCardDetailHolder(); const QString holder = xmlScreenParser.parseCardDetailHolder();
const QString fullNumberClean = QString(fullNumber).remove(' '); const QString fullNumberClean = QString(fullNumber).remove(' ');
const QString lastNumbers = (fullNumberClean.length() >= 4)
? fullNumberClean.right(4)
: (cards[i].lastNumbers.isEmpty() ? QString() : cards[i].lastNumbers);
qDebug() << "[Dushanbe::GetCardInfo] Card" << i << ":" << fullNumber qDebug() << "[Dushanbe::GetCardInfo] Card" << i << ":" << fullNumber
<< "balance:" << cardBalance << "holder:" << holder; << "balance:" << cardBalance << "holder:" << holder;
// Без полного номера карты материал не создаём и на сервер не отправляем — if (lastNumbers.isEmpty()) {
// защита от записей с пустым card_number (см. Ozon::GetCardInfo). qWarning() << "[Dushanbe::GetCardInfo] No card number for card" << i;
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); AdbUtils::goBack(m_deviceId);
QThread::msleep(1500); QThread::msleep(1500);
continue; continue;
} }
const QString lastNumbers = fullNumberClean.right(4);
// Достаём номер счёта: Настройки карты → Реквизиты карты → таб "Реквизиты счета". // Достаём номер счёта: Настройки карты → Реквизиты карты → таб "Реквизиты счета".
// ВРЕМЕННО ОТКЛЮЧЕНО // ВРЕМЕННО ОТКЛЮЧЕНО
QString accountNumber; QString accountNumber;
@ -254,7 +243,7 @@ void GetCardInfoScript::doStart() {
account.amount = cardBalance; account.amount = cardBalance;
account.currency = "TJS"; account.currency = "TJS";
account.description = cards[i].description.isEmpty() ? holder : cards[i].description; account.description = cards[i].description.isEmpty() ? holder : cards[i].description;
account.status = MaterialStatus::Off; account.status = MaterialStatus::Active;
account.updateTime = QDateTime::currentDateTimeUtc(); account.updateTime = QDateTime::currentDateTimeUtc();
qDebug() << "[Dushanbe::GetCardInfo] Saving card:" << "lastNumbers=" << lastNumbers qDebug() << "[Dushanbe::GetCardInfo] Saving card:" << "lastNumbers=" << lastNumbers
@ -294,7 +283,6 @@ void GetCardInfoScript::doStart() {
// Ждём возврата на экран "Мои карты" // Ждём возврата на экран "Мои карты"
for (int attempt = 0; attempt < 5; ++attempt) { for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isCardsScreen()) break; if (xmlScreenParser.isCardsScreen()) break;
QThread::msleep(1000); QThread::msleep(1000);
} }

View File

@ -2,7 +2,6 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSet>
#include <QThread> #include <QThread>
#include <QTimeZone> #include <QTimeZone>
@ -17,24 +16,6 @@
#include "AppLogger.h" #include "AppLogger.h"
#include <QCoreApplication> #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) { static QString recognizeTextFromImage(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI(); auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) { if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
@ -472,17 +453,13 @@ void GetLastTransactionsScript::doStart() {
} }
// 2. Собираем View-транзакции (формат Выписки) // 2. Собираем View-транзакции (формат Выписки)
// Известные форматы content-desc (всё через '\n'): // Два формата content-desc:
// a) DCWallet (старый, 5 строк): DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS // a) DCWallet/*** — старый: DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
// b) DCWallet (новый, 6 строк): DCWallet*WDC...\nphone-card-\ncard_owner\namount\nописание\n HH:MM:SS // b) P2P — новый: Сегодня|dd.MM.yyyy\ncard\ncard\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 reTime(R"(\d{2}:\d{2}:\d{2})");
static const QRegularExpression reDateOrToday( static const QRegularExpression reDateOrToday(
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$") QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера"))); .arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)");
QList<VypiskaItem> items; QList<VypiskaItem> items;
for (const Node &n : xmlScreenParser.findAllNodes()) { for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.view.View") continue; if (n.className != "android.view.View") continue;
@ -498,14 +475,10 @@ void GetLastTransactionsScript::doStart() {
VypiskaItem it; VypiskaItem it;
bool ok = false; bool ok = false;
// Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному // В P2P формате amount на позиции [3] (после даты, двух карт), в DCWallet — [2]
// DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые const int amtIdx = isP2P ? 3 : 2;
// поля не подпадают под паттерн. if (amtIdx >= lines.size()) continue;
for (const QString &line : lines) { it.amount = lines[amtIdx].trimmed().toDouble(&ok);
if (!reAmountLine.match(line.trimmed()).hasMatch()) continue;
it.amount = line.trimmed().toDouble(&ok);
if (ok) break;
}
if (!ok) continue; if (!ok) continue;
it.time = last; it.time = last;
it.node = n; it.node = n;
@ -625,7 +598,7 @@ void GetLastTransactionsScript::doStart() {
} }
} }
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи) // Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи)
if (m_searchTime.isValid() && !tx.time.isEmpty()) { if (m_searchTime.isValid() && !tx.time.isEmpty()) {
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss"); const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time(); const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
@ -668,113 +641,30 @@ void GetLastTransactionsScript::doStart() {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; } if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); 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()) { for (const Node &n : xmlScreenParser.findAllNodes()) {
const QString cd = n.contentDesc; if (!n.contentDesc.contains(QString::fromUtf8("Успешный платеж"))) continue;
if (!cd.contains(QString::fromUtf8("Карта"))) continue; if (!n.contentDesc.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'); const QStringList lines = n.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) { for (int i = 0; i < lines.size(); ++i) {
QString key = lines[i].trimmed(); const QString key = lines[i].trimmed();
if (key.endsWith(':')) key.chop(1); // нормализуем "Дата:" → "Дата"
if (i + 1 >= lines.size()) continue; if (i + 1 >= lines.size()) continue;
const QString val = lines[i + 1].trimmed(); const QString val = lines[i + 1].trimmed();
if (key == QString::fromUtf8("Получатель")) { if (key == QString::fromUtf8("Получатель:")) {
detail.receiverAccount = val; detail.receiverAccount = val;
} else if (key == QString::fromUtf8("Сумма")) { } else if (key == QString::fromUtf8("Сумма:")) {
detail.amount = parseDushanbeAmount(val); detail.amount = val.toDouble();
} else if (key == QString::fromUtf8("Дата")) { } else if (key == QString::fromUtf8("Дата:")) {
detail.date = val; detail.date = val;
} else if (key == QString::fromUtf8("Время")) { } else if (key == QString::fromUtf8("Время:")) {
detail.time = val; detail.time = val;
} }
} }
if (n.contentDesc.contains(QString::fromUtf8("Успешный"))) {
detail.status = QString::fromUtf8("Успешный");
}
detailLoaded = true; detailLoaded = true;
break; 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; if (detailLoaded) break;
QThread::msleep(1500); QThread::msleep(1500);
} }
@ -793,7 +683,7 @@ void GetLastTransactionsScript::doStart() {
<< "date=" << detail.date << "time=" << detail.time << "date=" << detail.date << "time=" << detail.time
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount; << "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
// Проверяем время ±10 минут // Проверяем время ±1 час
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) { if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
QDateTime txTime = QDateTime::fromString( QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");

View File

@ -77,10 +77,6 @@ void GetProfileInfoScript::doStart() {
return; return;
} }
BankProfileDAO::updateAmount(app.id, parsedBalance); BankProfileDAO::updateAmount(app.id, parsedBalance);
// У Душанбе все карты сидят на одном счёте, а экран деталей карты
// отдаёт placeholder "0,00 TJS" — единственный источник правды
// для materials.amount это баланс home-screen.
MaterialDAO::setAmountByApp(device.id, m_appCode, parsedBalance);
// Тапаем по кнопке меню (гамбургер, левый верхний угол) // Тапаем по кнопке меню (гамбургер, левый верхний угол)
// Это первый clickable ImageView в верхней панели // Это первый clickable ImageView в верхней панели
@ -133,11 +129,7 @@ void GetProfileInfoScript::doStart() {
BankProfileDAO::updateProfileInfo(app.id, fullName, phone, ""); BankProfileDAO::updateProfileInfo(app.id, fullName, phone, "");
} }
if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) { createBankProfileIfNeeded(device.id, m_appCode, parsedBalance);
m_error = err;
emit finishedWithResult(m_error);
return;
}
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode); const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
QList<QPair<MaterialInfo, Node>> accountsToPost; QList<QPair<MaterialInfo, Node>> accountsToPost;

View File

@ -307,33 +307,11 @@ void PayByCardScript::makePayment(
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y()); AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
QThread::msleep(2000); QThread::msleep(2000);
// 8. После "Далее" возможны три исхода: // 8. После "Далее" кнопка меняется на "Оплатить" — ждём и нажимаем
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
// c) экран ошибки.
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
bool oplatitFound = false; bool oplatitFound = false;
bool earlySuccess = false;
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); 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("Оплатить")); Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (oplatitBtn.isEmpty()) { if (oplatitBtn.isEmpty()) {
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false); oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
@ -360,8 +338,8 @@ void PayByCardScript::makePayment(
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю" // 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
// content-desc содержит "Подтверждение платежа...Подтверждаю" // content-desc содержит "Подтверждение платежа...Подтверждаю"
// Кнопка "Подтверждаю" визуально внизу bottom sheet // Кнопка "Подтверждаю" визуально внизу bottom sheet
bool confirmed = earlySuccess; bool confirmed = false;
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -382,14 +360,10 @@ void PayByCardScript::makePayment(
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом // отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр // по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
// нижней четверти sheet, выше системного nav-бара). // нижней четверти 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); const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen, QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"), QString::fromUtf8("Подтверждаю"),
ocrYMin, confirmNode.y2()); confirmNode.y1(), confirmNode.y2());
if (tap.x() < 0) { if (tap.x() < 0) {
const int tapY = confirmNode.y2() - qMax(150, height / 14); const int tapY = confirmNode.y2() - qMax(150, height / 14);
tap = QPoint(confirmNode.x(), tapY); tap = QPoint(confirmNode.x(), tapY);
@ -417,17 +391,8 @@ void PayByCardScript::makePayment(
// 10. Ждём результат — "Платеж выполнен" или ошибку. // 10. Ждём результат — "Платеж выполнен" или ошибку.
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк // Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин. // подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
// Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал, bool success = false;
// bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий for (int attempt = 0; attempt < 60; ++attempt) {
// 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)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -471,62 +436,20 @@ void PayByCardScript::makePayment(
QString::fromUtf8("Подтверждение платежа"), true); QString::fromUtf8("Подтверждение платежа"), true);
if (!retryConfirm.isEmpty() && if (!retryConfirm.isEmpty() &&
retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) { retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
if (confirmRetries >= maxConfirmRetries) { qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tapping 'Подтверждаю'";
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); const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen, QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"), QString::fromUtf8("Подтверждаю"),
ocrYMin, retryConfirm.y2()); retryConfirm.y1(), retryConfirm.y2());
if (tap.x() < 0) { if (tap.x() < 0) {
// Варьируем Y: первый retry — выше fallback на 100px, const int tapY = retryConfirm.y2() - qMax(150, height / 14);
// второй — ещё на 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); tap = QPoint(retryConfirm.x(), tapY);
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
} }
AdbUtils::makeTap(deviceId, tap.x(), tap.y()); AdbUtils::makeTap(deviceId, tap.x(), tap.y());
QThread::msleep(4000); QThread::msleep(4000);
continue; 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; qDebug() << "[Dushanbe::PayByCard] Waiting for result, attempt" << attempt + 1;
QThread::msleep(2000); QThread::msleep(2000);
} }
@ -548,9 +471,8 @@ void PayByCardScript::makePayment(
if (!m_resultScreenshot.isEmpty()) if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot); emit screenshotReady(m_resultScreenshot);
// Парсим детали из экрана "Платеж выполнен". // Парсим детали из content-desc "Платеж выполнен"
// До 3.2.33 все поля шли одним content-desc с ключами "Дата:" / "Время:" / "Комиссия:". // Формат: "Платеж выполнен\nПоставщик:\n...\nКомиссия:\n0.00\nДата:\n05.04.26\nВремя:\n20:19:56\nНа главную"
// С 3.2.33 лейблы и значения — отдельные View-ноды в двух колонках.
QString bankTime; QString bankTime;
double fee = 0; double fee = 0;
@ -568,19 +490,8 @@ void PayByCardScript::makePayment(
bankTime += " " + lines[i + 1].trimmed(); 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; qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee;
}
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю). // bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
// Уникальность за счёт <bankProfilePhone, секунда>. // Уникальность за счёт <bankProfilePhone, секунда>.

View File

@ -307,33 +307,11 @@ void PayByPhoneScript::makePayment(
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y()); AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
QThread::msleep(2000); QThread::msleep(2000);
// 8. После "Далее" возможны три исхода: // 8. После "Далее" кнопка меняется на "Оплатить"
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
// c) экран ошибки.
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
bool oplatitFound = false; bool oplatitFound = false;
bool earlySuccess = false;
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); 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("Оплатить")); Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (oplatitBtn.isEmpty()) { if (oplatitBtn.isEmpty()) {
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false); oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
@ -358,8 +336,8 @@ void PayByPhoneScript::makePayment(
} }
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю" // 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
bool confirmed = earlySuccess; bool confirmed = false;
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -378,15 +356,10 @@ void PayByPhoneScript::makePayment(
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом // отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр // по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
// нижней четверти sheet, выше системного nav-бара). // нижней четверти 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); const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen, QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"), QString::fromUtf8("Подтверждаю"),
ocrYMin, confirmNode.y2()); confirmNode.y1(), confirmNode.y2());
if (tap.x() < 0) { if (tap.x() < 0) {
const int tapY = confirmNode.y2() - qMax(150, height / 14); const int tapY = confirmNode.y2() - qMax(150, height / 14);
tap = QPoint(confirmNode.x(), tapY); tap = QPoint(confirmNode.x(), tapY);
@ -414,17 +387,8 @@ void PayByPhoneScript::makePayment(
// 10. Ждём результат — "Платеж выполнен" или ошибку. // 10. Ждём результат — "Платеж выполнен" или ошибку.
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк // Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин. // подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
// Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал, bool success = false;
// bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий for (int attempt = 0; attempt < 60; ++attempt) {
// 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)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -464,62 +428,20 @@ void PayByPhoneScript::makePayment(
QString::fromUtf8("Подтверждение платежа"), true); QString::fromUtf8("Подтверждение платежа"), true);
if (!retryConfirm.isEmpty() && if (!retryConfirm.isEmpty() &&
retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) { retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
if (confirmRetries >= maxConfirmRetries) { qDebug() << "[Dushanbe::PayByPhone] Confirm sheet still visible, re-tapping 'Подтверждаю'";
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); const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen, QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"), QString::fromUtf8("Подтверждаю"),
ocrYMin, retryConfirm.y2()); retryConfirm.y1(), retryConfirm.y2());
if (tap.x() < 0) { if (tap.x() < 0) {
// Варьируем Y: первый retry — выше fallback на 100px, const int tapY = retryConfirm.y2() - qMax(150, height / 14);
// второй — ещё на 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); tap = QPoint(retryConfirm.x(), tapY);
qDebug() << "[Dushanbe::PayByPhone] OCR miss, fallback tap at" << tap;
} }
AdbUtils::makeTap(deviceId, tap.x(), tap.y()); AdbUtils::makeTap(deviceId, tap.x(), tap.y());
QThread::msleep(4000); QThread::msleep(4000);
continue; 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; qDebug() << "[Dushanbe::PayByPhone] Waiting for result, attempt" << attempt + 1;
QThread::msleep(2000); QThread::msleep(2000);
} }
@ -541,8 +463,7 @@ void PayByPhoneScript::makePayment(
if (!m_resultScreenshot.isEmpty()) if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot); emit screenshotReady(m_resultScreenshot);
// Парсим детали из экрана "Платеж выполнен". С 3.2.33 поля разнесены по // Парсим детали из content-desc "Платеж выполнен"
// отдельным View-узлам — пробуем сначала старый blob-формат, потом fallback.
QString bankTime; QString bankTime;
double fee = 0; double fee = 0;
@ -560,19 +481,8 @@ void PayByPhoneScript::makePayment(
bankTime += " " + lines[i + 1].trimmed(); 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; qDebug() << "[Dushanbe::PayByPhone] Result: date=" << bankTime << "fee=" << fee;
}
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю). // bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
// Уникальность за счёт <bankProfilePhone, секунда>. // Уникальность за счёт <bankProfilePhone, секунда>.

View File

@ -174,7 +174,6 @@ bool ScreenXmlParser::isHomeScreen() {
// основной странице приложения, а не на splash/PIN/диалоге. // основной странице приложения, а не на splash/PIN/диалоге.
if (node.contentDesc == QString::fromUtf8("Переводы") || if (node.contentDesc == QString::fromUtf8("Переводы") ||
node.contentDesc == QString::fromUtf8("Сервисы") || node.contentDesc == QString::fromUtf8("Сервисы") ||
node.contentDesc == QString::fromUtf8("MiniApps") ||
node.contentDesc == QString::fromUtf8("История")) { node.contentDesc == QString::fromUtf8("История")) {
hasBottomNav = true; hasBottomNav = true;
} }
@ -244,15 +243,12 @@ bool ScreenXmlParser::isSideMenu() {
bool hasVyhod = false; bool hasVyhod = false;
bool hasIdentified = false; bool hasIdentified = false;
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
// До 3.2.33 кнопка была "Выход", с 3.2.33 — "Выйти". if (node.contentDesc == QString::fromUtf8("Выход")) {
if (node.contentDesc == QString::fromUtf8("Выход") ||
node.contentDesc == QString::fromUtf8("Выйти")) {
hasVyhod = true; hasVyhod = true;
} }
// До 3.2.33: "Идентифицирован\nУспешно" — отдельная нода (startsWith). // На реальном экране content-desc = "Идентифицирован\nУспешно",
// С 3.2.33: "Идентифицирован" вшито в блок профиля // поэтому строгое равенство не проходит — используем startsWith.
// ("IN\nIsroil Nazarov\n+992 ...\nИдентифицирован\nУспешно") — нужен contains. if (node.contentDesc.startsWith(QString::fromUtf8("Идентифицирован"))) {
if (node.contentDesc.contains(QString::fromUtf8("Идентифицирован"))) {
hasIdentified = true; hasIdentified = true;
} }
if (hasVyhod && hasIdentified) { if (hasVyhod && hasIdentified) {
@ -263,22 +259,13 @@ bool ScreenXmlParser::isSideMenu() {
} }
QString ScreenXmlParser::parseProfileFullName() { QString ScreenXmlParser::parseProfileFullName() {
// До 3.2.33 одна нода содержит: // content-desc: "Мехрубон Имомкулзода\n+992 (18) 555-5519\nВерсия: 3.2.5\nСборка: 390:3443"
// "Имя Фамилия\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) { for (const Node &node : m_nodes) {
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован")); if (node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) && node.contentDesc.contains(QString::fromUtf8("Сборка:"))) {
node.contentDesc.contains(QString::fromUtf8("Сборка:")); QStringList lines = node.contentDesc.split('\n');
if (!isNewProfileBlock && !isOldProfileBlock) continue; if (!lines.isEmpty()) {
const QStringList lines = node.contentDesc.split('\n'); return lines[0].trimmed();
for (int i = 0; i < lines.size(); ++i) {
if (phoneRe.match(lines[i].trimmed()).hasMatch() && i > 0) {
return lines[i - 1].trimmed();
} }
} }
} }
@ -286,53 +273,18 @@ QString ScreenXmlParser::parseProfileFullName() {
} }
QString ScreenXmlParser::parseProfilePhone() { QString ScreenXmlParser::parseProfilePhone() {
static const QRegularExpression phoneRe(R"(^\+\d)");
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован")); if (node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) && node.contentDesc.contains(QString::fromUtf8("Сборка:"))) {
node.contentDesc.contains(QString::fromUtf8("Сборка:")); QStringList lines = node.contentDesc.split('\n');
if (!isNewProfileBlock && !isOldProfileBlock) continue; if (lines.size() >= 2) {
for (const QString &line : node.contentDesc.split('\n')) { return lines[1].trimmed();
const QString trimmed = line.trimmed();
if (phoneRe.match(trimmed).hasMatch()) {
return trimmed;
} }
} }
} }
return {}; 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() { bool ScreenXmlParser::isCardsScreen() {
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.contentDesc == QString::fromUtf8("Мои карты")) { if (node.contentDesc == QString::fromUtf8("Мои карты")) {

View File

@ -1,6 +1,5 @@
#pragma once #pragma once
#include <QMap>
#include <QObject> #include <QObject>
#include <QDomElement> #include <QDomElement>
#include "android/xml/Node.h" #include "android/xml/Node.h"
@ -136,12 +135,6 @@ public:
TransactionDetail parseTransactionDetail(); TransactionDetail parseTransactionDetail();
// Находит EditText по hint // Находит EditText по hint
// Парсит пары "Лейбл: " → "Значение" для экранов, где поля разложены по
// отдельным View-узлам в две колонки (формат с 3.2.33). Метод сканирует
// все ноды: лейблом считается контент, оканчивающийся на ':', значением —
// ближайший справа узел на той же Y-полосе. Возвращает map без двоеточий.
QMap<QString, QString> parseLabelValuePairs();
Node findEditTextByHint(const QString &hint); Node findEditTextByHint(const QString &hint);
// Поле ввода суммы на экране перевода. У EditText'а нет hint/content-desc // Поле ввода суммы на экране перевода. У EditText'а нет hint/content-desc

View File

@ -5,6 +5,7 @@
#include <QDir> #include <QDir>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QFile> #include <QFile>
#include <QRegularExpression>
#include <tesseract/baseapi.h> #include <tesseract/baseapi.h>
#include <leptonica/allheaders.h> #include <leptonica/allheaders.h>
@ -70,12 +71,7 @@ bool fuzzyMatch(const QString &word, const QString &needle) {
if (word.isEmpty() || needle.isEmpty()) return false; if (word.isEmpty() || needle.isEmpty()) return false;
const QString w = word.toLower(); const QString w = word.toLower();
const QString n = needle.toLower(); const QString n = needle.toLower();
// Substring-ветка работает только если word достаточно длинный. if (w.contains(n) || n.contains(w)) return true;
// Иначе любая одиночная буква, входящая в 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 на «Подтверждаю» иногда // Допускаем до 20% ошибок (но не меньше 1) — Tesseract на «Подтверждаю» иногда
// даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания. // даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания.
const int tolerance = qMax(1, n.size() / 5); const int tolerance = qMax(1, n.size() / 5);
@ -202,4 +198,63 @@ QPoint findTextCenter(const QByteArray &png,
return result; return result;
} }
QString recognizeText(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
const QByteArray &td = tessdataBytes();
int initResult;
if (td.isEmpty()) {
const QByteArray appDirBytes = QCoreApplication::applicationDirPath().toUtf8();
initResult = api->Init(appDirBytes.constData(), "rus+eng");
} else {
initResult = api->Init(td.constData(), td.size(), "rus+eng", tesseract::OEM_DEFAULT,
nullptr, 0, nullptr, nullptr, false, nullptr);
}
if (initResult) {
qWarning() << "[OcrUtils::recognizeText] 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() << "[OcrUtils::recognizeText] Failed to read image";
api->End();
delete api;
return {};
}
Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f);
pixDestroy(&pix);
if (!gray) {
qWarning() << "[OcrUtils::recognizeText] 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;
}
QString extractTransactionId(const QString &ocrText) {
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 OcrUtils } // namespace OcrUtils

View File

@ -19,4 +19,15 @@ QPoint findTextCenter(const QByteArray &png,
int yMin = 0, int yMin = 0,
int yMax = 0); int yMax = 0);
// Полнотекстовый OCR с грейскейл + 300 dpi (для парсинга реквизитов
// транзакции). rus+eng по умолчанию. Пустая строка при ошибке.
QString recognizeText(const QByteArray &pngData);
// Извлекает ID транзакции из распознанного текста.
// Поддерживает два формата Ozon:
// "ID операции B6065130422112..."
// "ID операции po-3140eb01-..."
// "№ Ф-2026-25235663"
QString extractTransactionId(const QString &ocrText);
} // namespace OcrUtils } // namespace OcrUtils

View File

@ -401,12 +401,12 @@ void CommonScript::updateTransactionData(
thread->start(); thread->start();
} }
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) { void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode); const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) { if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
qWarning() << "[Ozon] createBankProfileIfNeeded: skipping — fullName or phone is empty"; qWarning() << "[Ozon] createBankProfileIfNeeded: skipping — fullName or phone is empty";
return {}; return;
} }
const QMap<QString, int> currencyCodes = { const QMap<QString, int> currencyCodes = {
@ -450,19 +450,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
if (app.bankProfileId.isEmpty()) { if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id); ns.addExtra("app_id", app.id);
ns.postBankProfile(); 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"; qDebug() << "[Ozon] createBankProfileIfNeeded: postBankProfile done";
} else { } else {
ns.addExtra("bank_profile_id", app.bankProfileId); ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile(); ns.patchBankProfile();
qDebug() << "[Ozon] createBankProfileIfNeeded: patchBankProfile done"; qDebug() << "[Ozon] createBankProfileIfNeeded: patchBankProfile done";
} }
return {};
} }
bool CommonScript::isAppRunning(const QString &deviceId, const QString &packageName) { bool CommonScript::isAppRunning(const QString &deviceId, const QString &packageName) {

View File

@ -63,9 +63,8 @@ protected:
const QString &deviceId, int width, int height const QString &deviceId, int width, int height
); );
// Создаёт bank_profile на сервере если ещё не создан. // Создаёт bank_profile на сервере если ещё не создан
// Возвращает пустую строку при успехе, текст ошибки при провале. void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
// Проверяет что приложение ещё запущено на устройстве // Проверяет что приложение ещё запущено на устройстве
static bool isAppRunning(const QString &deviceId, const QString &packageName); static bool isAppRunning(const QString &deviceId, const QString &packageName);

View File

@ -13,6 +13,8 @@
#include "dao/SettingsDAO.h" #include "dao/SettingsDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "AppLogger.h" #include "AppLogger.h"
namespace Ozon { namespace Ozon {
@ -31,6 +33,27 @@ GetCardInfoScript::GetCardInfoScript(
GetCardInfoScript::~GetCardInfoScript() = default; GetCardInfoScript::~GetCardInfoScript() = default;
void GetCardInfoScript::doStart() { void GetCardInfoScript::doStart() {
// Удалённый JS-скрипт: пробуем JS-версию, при engine-ошибке делаем
// прозрачный fallback на C++.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/get_card_info")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
{"lastNumbers", m_lastNumbers},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "get_card_info", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::GetCardInfo] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; 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); const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
@ -90,7 +113,6 @@ void GetCardInfoScript::doStart() {
for (int attempt = 0; attempt < 5; ++attempt) { for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isProfileScreen()) break; if (xmlScreenParser.isProfileScreen()) break;
QThread::msleep(1000); QThread::msleep(1000);
} }
@ -107,14 +129,12 @@ void GetCardInfoScript::doStart() {
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);
QThread::msleep(1500); QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
} else { } else {
qDebug() << "[GetCardInfo] Profile info already in DB or username not found, skipping profile parse"; qDebug() << "[GetCardInfo] Profile info already in DB or username not found, skipping profile parse";
} }
// Проверяем что на домашнем экране перед поиском карт // Проверяем что на домашнем экране перед поиском карт
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[GetCardInfo] Not on home screen before card search, restarting..."; qDebug() << "[GetCardInfo] Not on home screen before card search, restarting...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
@ -133,9 +153,6 @@ void GetCardInfoScript::doStart() {
QList<MaterialInfo> cards; QList<MaterialInfo> cards;
for (int attempt = 0; attempt < 5; ++attempt) { for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight)) {
// баннер закрыт, дамп уже обновлён внутри helper'а
}
cards = xmlScreenParser.parseCardsOnHomeScreen(); cards = xmlScreenParser.parseCardsOnHomeScreen();
if (!cards.isEmpty()) break; if (!cards.isEmpty()) break;
qDebug() << "[GetCardInfo] No cards found on home screen, attempt" << attempt + 1 << "/ 5"; qDebug() << "[GetCardInfo] No cards found on home screen, attempt" << attempt + 1 << "/ 5";
@ -152,30 +169,17 @@ void GetCardInfoScript::doStart() {
qDebug() << "[GetCardInfo] Found" << cards.size() << "card(s) on home screen"; qDebug() << "[GetCardInfo] Found" << cards.size() << "card(s) on home screen";
// Заполняем метаданные на найденных картах. Новые карты (которых ещё нет в БД) // Апсёртим аккаунты для всех найденных карт
// не создаём здесь — запись появится только после успешного OCR полного номера
// в success-ветке ниже. Это защищает от записей из ленты переводов и не оставляет
// в БД материал с пустым card_number.
//
// Для карт, которые уже в БД, обновляем только amount/update_time —
// прочие поля (cardNumber, materialId, status, ...) сохраняем как было.
for (MaterialInfo &card : cards) { for (MaterialInfo &card : cards) {
card.deviceId = device.id; card.deviceId = device.id;
card.appCode = m_appCode; card.appCode = m_appCode;
card.updateTime = QDateTime::currentDateTimeUtc(); card.updateTime = QDateTime::currentDateTimeUtc();
card.status = MaterialStatus::Off; card.status = MaterialStatus::Active;
if (!MaterialDAO::upsertAccount(card)) {
const MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers); qWarning() << "[GetCardInfo] Failed to upsert account for card" << 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 { } else {
qDebug() << "[GetCardInfo] Refreshed amount for card" qDebug() << "[GetCardInfo] Upserted account:" << card.lastNumbers
<< card.lastNumbers << "to" << card.amount; << card.description << card.amount;
} }
} }
@ -239,11 +243,9 @@ void GetCardInfoScript::doStart() {
AppLogger::log("ozon/GetCardInfo", m_deviceId, err, AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId), AdbUtils::captureScreenshotBytes(m_deviceId),
"FETCH_PROFILE"); "FETCH_PROFILE");
m_error = err; } else {
emit finishedWithResult(m_error);
return;
}
qDebug() << "[GetCardInfo] postBankProfile done"; qDebug() << "[GetCardInfo] postBankProfile done";
}
} else { } else {
ns.addExtra("bank_profile_id", freshApp.bankProfileId); ns.addExtra("bank_profile_id", freshApp.bankProfileId);
ns.patchBankProfile(); ns.patchBankProfile();
@ -283,7 +285,6 @@ void GetCardInfoScript::doStart() {
for (int scrollAttempt = 0; scrollAttempt < 6; ++scrollAttempt) { for (int scrollAttempt = 0; scrollAttempt < 6; ++scrollAttempt) {
QThread::msleep(scrollAttempt == 0 ? 2000 : 600); QThread::msleep(scrollAttempt == 0 ? 2000 : 600);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
Node candidate = xmlScreenParser.findButtonNode("Показать", false); Node candidate = xmlScreenParser.findButtonNode("Показать", false);
if (candidate.isEmpty()) { if (candidate.isEmpty()) {
@ -315,7 +316,6 @@ void GetCardInfoScript::doStart() {
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
QThread::msleep(attempt == 0 ? 2000 : 500); QThread::msleep(attempt == 0 ? 2000 : 500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
Node candidate = xmlScreenParser.findButtonNode("Показать", false); Node candidate = xmlScreenParser.findButtonNode("Показать", false);
Node requisites = xmlScreenParser.findTextNode("Реквизиты карты"); Node requisites = xmlScreenParser.findTextNode("Реквизиты карты");
@ -355,7 +355,6 @@ void GetCardInfoScript::doStart() {
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);
QThread::msleep(1500); QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
continue; continue;
} }
@ -367,7 +366,6 @@ void GetCardInfoScript::doStart() {
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
QThread::msleep(2000); QThread::msleep(2000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
cardNumber = xmlScreenParser.findFullCardNumber(); cardNumber = xmlScreenParser.findFullCardNumber();
if (!cardNumber.isEmpty()) { if (!cardNumber.isEmpty()) {
qDebug() << "[GetCardInfo] Card number found on attempt" << attempt + 1; qDebug() << "[GetCardInfo] Card number found on attempt" << attempt + 1;
@ -413,20 +411,19 @@ void GetCardInfoScript::doStart() {
if (!cardNumber.isEmpty()) { if (!cardNumber.isEmpty()) {
qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "full number:" << cardNumber; qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "full number:" << cardNumber;
// Апсёртим запись только теперь — с полным номером карты. // Получаем id аккаунта из БД и сохраняем номер карты
MaterialInfo toSave = card; MaterialInfo saved = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
toSave.cardNumber = cardNumber; if (saved.id != -1) {
toSave.updateTime = QDateTime::currentDateTimeUtc(); MaterialDAO::updateCardNumber(saved.id, cardNumber);
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 { } else {
const QString err = "Card number not found for " + card.lastNumbers; const QString err = "Card number not found for " + card.lastNumbers;
qWarning() << "[GetCardInfo]" << err; qWarning() << "[GetCardInfo]" << err;
// Без полного номера материал в БД не создаём. MaterialInfo saved = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (saved.id != -1) {
MaterialDAO::updateAccountById(saved.id, materialStatusToString(MaterialStatus::Off),
saved.description, saved.currency);
}
AppLogger::log("ozon/GetCardInfo", m_deviceId, err, AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId), AdbUtils::captureScreenshotBytes(m_deviceId),
"FETCH_PROFILE"); "FETCH_PROFILE");
@ -437,7 +434,6 @@ void GetCardInfoScript::doStart() {
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);
QThread::msleep(2000); QThread::msleep(2000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
} }
} }

View File

@ -16,6 +16,8 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "time/DateUtils.h" #include "time/DateUtils.h"
#include "AppLogger.h" #include "AppLogger.h"
@ -98,6 +100,29 @@ GetLastTransactionsScript::GetLastTransactionsScript(
GetLastTransactionsScript::~GetLastTransactionsScript() = default; GetLastTransactionsScript::~GetLastTransactionsScript() = default;
void GetLastTransactionsScript::doStart() { void GetLastTransactionsScript::doStart() {
// Удалённый JS-скрипт. signalSink=this позволяет JS эмитить transactionParsed.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/get_last_transactions")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
{"maxCount", m_maxCount},
{"searchAmount", m_searchAmount},
{"searchTime", m_searchTime.isValid() ? m_searchTime.toString(Qt::ISODate) : ""},
{"materialId", m_materialId},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "get_last_transactions", params, &jsOut, this);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::GetLastTransactions] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId);
if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId);
if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
@ -379,53 +404,37 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране. // Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню. // Кнопки транзакций между этими Y принадлежат нужному дню.
// Возвращает специальные коды через dateY: // Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё
// dateY >= 0 : header виден на этом Y (нормальный случай) // видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY
// dateY == 0 : header выше viewport, есть более старая видимая дата // принадлежат нашему дню.
// dateY == -1 : header не виден / лежит в нижней sticky-пачке
// (скролл вниз, чтобы target выехал в видимую часть)
// dateY == -2 : header в верхней sticky-пачке (несколько дат свёрнуто
// над viewport) — скролл вверх, чтобы пачка распалась
auto findDateYRange = [&]() -> QPair<int, int> { auto findDateYRange = [&]() -> QPair<int, int> {
const QMap<int, int> yCount = computeDateYCount(); const QMap<int, int> yCount = computeDateYCount();
auto isAnchored = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; }; auto isVisible = [&](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 dateY = -1;
int nextDateY = device.screenHeight; int nextDateY = device.screenHeight;
int targetPileY = -1;
bool foundOlderDate = false; bool foundOlderDate = false;
for (const Node &node : xmlScreenParser.getNodes()) { for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue; if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed()); const QDate d = parseDateHeader(node.text.trimmed());
if (!d.isValid()) continue; if (!d.isValid()) continue;
if (d == searchDate) { if (d == searchDate) {
if (isAnchored(node.y())) dateY = node.y(); if (isVisible(node.y())) dateY = node.y();
else if (isPiled(node.y())) targetPileY = node.y();
} else if (d < searchDate) { } else if (d < searchDate) {
foundOlderDate = true; foundOlderDate = true;
if (isAnchored(node.y()) if (isVisible(node.y()) && (dateY < 0 || node.y() > dateY)
&& (dateY < 0 || node.y() > dateY)
&& node.y() < nextDateY) { && node.y() < nextDateY) {
nextDateY = node.y(); 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 ушёл вверх: // Target-header не виден, но видна более старая дата → target ушёл вверх:
// всё выше nextDateY принадлежит нашему дню. // всё выше nextDateY принадлежит нашему дню.
if (dateY < 0 && nextDateY < device.screenHeight && targetPileY < 0) { if (dateY < 0 && nextDateY < device.screenHeight) {
dateY = 0; dateY = 0;
} }
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate << "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate;
<< "targetPileY=" << targetPileY;
return {dateY, nextDateY}; return {dateY, nextDateY};
}; };
@ -456,19 +465,6 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
// Находим Y-диапазон заголовка нужной даты // Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange(); 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) { if (dateHeaderY < 0) {
// Target-header не виден и ни одной более старой даты не видно → // Target-header не виден и ни одной более старой даты не видно →
// target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх). // target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх).

View File

@ -12,6 +12,8 @@
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "AppLogger.h" #include "AppLogger.h"
namespace Ozon { namespace Ozon {
@ -27,6 +29,26 @@ GetProfileInfoScript::GetProfileInfoScript(
GetProfileInfoScript::~GetProfileInfoScript() = default; GetProfileInfoScript::~GetProfileInfoScript() = default;
void GetProfileInfoScript::doStart() { void GetProfileInfoScript::doStart() {
// Удалённый JS-скрипт: пробуем JS-версию, при engine-ошибке делаем
// прозрачный fallback на C++ ниже. См. docs/remote_scripts.md.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/get_profile_info")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "get_profile_info", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::GetProfileInfo] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; 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); const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
@ -174,11 +196,9 @@ void GetProfileInfoScript::doStart() {
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err, AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId), AdbUtils::captureScreenshotBytes(m_deviceId),
"FETCH_PROFILE"); "FETCH_PROFILE");
m_error = err; } else {
emit finishedWithResult(m_error);
return;
}
qDebug() << "[GetProfileInfo] postBankProfile done"; qDebug() << "[GetProfileInfo] postBankProfile done";
}
} else { } else {
ns.addExtra("bank_profile_id", freshApp.bankProfileId); ns.addExtra("bank_profile_id", freshApp.bankProfileId);
ns.patchBankProfile(); ns.patchBankProfile();

View File

@ -7,6 +7,8 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "AppLogger.h" #include "AppLogger.h"
#include "GetCardInfoScript.h" #include "GetCardInfoScript.h"
@ -29,6 +31,36 @@ LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
void LoginAndCheckAccountsScript::doStart() { void LoginAndCheckAccountsScript::doStart() {
// Удалённый JS-скрипт: пробуем JS-версию. JS отвечает ТОЛЬКО за свою часть
// (логин + сбор/постинг аккаунтов). Финальный вызов GetCardInfoScript
// оставлен в C++ снаружи блока — когда get_card тоже уедет на JS,
// его собственный doStart() сам подхватит JS-путь.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/login_and_check_accounts")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
{"fetchProfileOnly", m_fetchProfileOnly},
{"pinCheckOnly", m_pinCheckOnly},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "login_and_check_accounts", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
// pinCheckOnly: вышли сразу после успешного логина — карты не сканировались.
// fetchProfileOnly: профиль сохранён, карты обновлять не надо.
if (m_error.isEmpty() && !m_fetchProfileOnly && !m_pinCheckOnly) {
GetCardInfoScript cardScript(m_deviceId, m_appCode, "");
cardScript.start();
}
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::LoginAndCheck] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
// m_deviceId = ADB serial, device.id = android_id (для БД) // 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; 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); const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
@ -76,31 +108,8 @@ void LoginAndCheckAccountsScript::doStart() {
BankProfileDAO::updateAmount(app.id, parsedBalance); 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 на сервере // Создаём/обновляем bank profile на сервере
if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) { createBankProfileIfNeeded(device.id, m_appCode, parsedBalance);
m_error = err;
emit finishedWithResult(m_error);
return;
}
if (goToAllProducts(m_deviceId, device.screenWidth, device.screenHeight)) { if (goToAllProducts(m_deviceId, device.screenWidth, device.screenHeight)) {
QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo(); QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();

View File

@ -16,6 +16,8 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "time/DateUtils.h" #include "time/DateUtils.h"
static QString recognizeTextFromImage(const QByteArray &pngData) { static QString recognizeTextFromImage(const QByteArray &pngData) {
@ -80,6 +82,34 @@ PayByCardScript::PayByCardScript(
PayByCardScript::~PayByCardScript() = default; PayByCardScript::~PayByCardScript() = default;
void PayByCardScript::doStart() { void PayByCardScript::doStart() {
// Удалённый JS-скрипт. При engine-ошибке — fallback на C++.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/pay_by_card")) {
QString jsOut;
QVariantMap accountMap;
accountMap["id"] = m_account.id;
accountMap["materialId"] = m_account.materialId;
accountMap["appCode"] = m_account.appCode;
accountMap["deviceId"] = m_account.deviceId;
accountMap["lastNumbers"] = m_account.lastNumbers;
accountMap["cardNumber"] = m_account.cardNumber;
accountMap["currency"] = m_account.currency;
const QVariantMap params{
{"account", accountMap},
{"cardNumber", m_cardNumber},
{"amount", m_amount},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "pay_by_card", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::PayByCard] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId); const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode); const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
@ -287,17 +317,30 @@ void PayByCardScript::makePayment(
if (!sumInput.isEmpty()) break; if (!sumInput.isEmpty()) break;
// Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO). // Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO).
// На экране "Перевод по номеру карты" всегда 2 EditText в DOM-порядке: // На экране "Перевод по номеру карты" всегда 2 EditText: карта (сверху)
// карта (сверху), сумма (снизу). Оба имеют resource-id="input___xxxx", // и сумма (снизу). Стратегии по приоритету:
// поэтому отличать по rid нельзя — берём второй по порядку обхода XML. // 1) Svelte генерирует resource-id="input___xxxx" ТОЛЬКО для суммы —
QList<Node> editTexts; // берём этот EditText.
// 2) Если rid нет (старая версия Ozon UI) — берём второй EditText
// по y-порядку (карта y1=меньше, сумма y1=больше).
QList<Node> candidateInputs;
for (const Node &n : xmlScreenParser.findAllNodes()) { for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.widget.EditText") continue; if (n.className != "android.widget.EditText") continue;
if (n.height() <= 0 || n.height() > height * 4 / 5) continue; if (n.height() <= 0 || n.height() > height * 4 / 5) continue;
editTexts.append(n); candidateInputs.append(n);
} }
if (editTexts.size() >= 2) { // Strategy 1: rid="input___"
sumInput = editTexts[1]; for (const Node &n : candidateInputs) {
if (n.resourceId.startsWith("input___")) {
sumInput = n;
break;
}
}
// Strategy 2: bottom-most по y1 (если их 2 — берём второй)
if (sumInput.isEmpty() && candidateInputs.size() >= 2) {
std::sort(candidateInputs.begin(), candidateInputs.end(),
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
sumInput = candidateInputs.last();
} }
if (!sumInput.isEmpty()) break; if (!sumInput.isEmpty()) break;

View File

@ -15,6 +15,8 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "time/DateUtils.h" #include "time/DateUtils.h"
#include <QCoreApplication> #include <QCoreApplication>
@ -67,14 +69,6 @@ static QString extractTransactionId(const QString &ocrText) {
namespace Ozon { namespace Ozon {
static QString sanitizeBankName(const QString &raw) {
// "Озон банк (Ozon)" -> "Озон банк"; убираем содержимое круглых скобок
// вместе с предшествующими пробелами и обрезаем края.
QString s = raw;
s.remove(QRegularExpression(R"(\s*\([^)]*\))"));
return s.trimmed();
}
PayByPhoneScript::PayByPhoneScript( PayByPhoneScript::PayByPhoneScript(
MaterialInfo account, MaterialInfo account,
QString phone, QString phone,
@ -83,12 +77,40 @@ PayByPhoneScript::PayByPhoneScript(
QObject *parent QObject *parent
) : CommonScript(parent), ) : CommonScript(parent),
m_account(std::move(account)), m_phone(std::move(phone)), m_account(std::move(account)), m_phone(std::move(phone)),
m_bankName(sanitizeBankName(bankName)), m_amount(amount) { m_bankName(std::move(bankName)), m_amount(amount) {
} }
PayByPhoneScript::~PayByPhoneScript() = default; PayByPhoneScript::~PayByPhoneScript() = default;
void PayByPhoneScript::doStart() { void PayByPhoneScript::doStart() {
// Удалённый JS-скрипт. При engine-ошибке — fallback на C++.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/pay_by_phone")) {
QString jsOut;
QVariantMap accountMap;
accountMap["id"] = m_account.id;
accountMap["materialId"] = m_account.materialId;
accountMap["appCode"] = m_account.appCode;
accountMap["deviceId"] = m_account.deviceId;
accountMap["lastNumbers"] = m_account.lastNumbers;
accountMap["currency"] = m_account.currency;
const QVariantMap params{
{"account", accountMap},
{"phone", m_phone},
{"bankName", m_bankName},
{"amount", m_amount},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "pay_by_phone", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::PayByPhone] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId); const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode); const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
@ -170,11 +192,7 @@ void PayByPhoneScript::makePayment(
} }
return true; return true;
}; };
// 1. Нажимаем "Перевести" на домашнем экране. // 1. Нажимаем "Перевести" на домашнем экране
// Внимание: поверх правой части кнопки сидит referral-бейдж "badge icon за друга"
// (bounds типа [550,90][602,140]), который перекрывает центр кнопки. Тап в центр
// открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы
// гарантированно попасть мимо бейджа.
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false); Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty()) { if (transferBtn.isEmpty()) {
m_error = "Кнопка 'Перевести' не найдена на домашнем экране"; m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
@ -182,13 +200,8 @@ void PayByPhoneScript::makePayment(
return; return;
} }
// Тапаем в нижнюю часть кнопки (там, где визуально надпись "Перевести"), qDebug() << "[PayByPhone] Tapping 'Перевести'...";
// ниже бейджа "за друга" (он заканчивается на y≈140). AdbUtils::makeTap(deviceId, transferBtn.x(), transferBtn.y());
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); QThread::msleep(2000);
// 2. Ждём экран "Платежи и переводы" с полем "Телефон получателя" // 2. Ждём экран "Платежи и переводы" с полем "Телефон получателя"
@ -313,54 +326,28 @@ void PayByPhoneScript::makePayment(
// Экран выбора банка: ищем "Другой банк" (текст) или EditText с hint "Название банка" // Экран выбора банка: ищем "Другой банк" (текст) или EditText с hint "Название банка"
if (!bankSelected) { if (!bankSelected) {
// Вариант 1: кнопка "Другой банк" (content-desc="other-bank-item", text пустой). // Вариант 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; bool foundOtherBank = false;
Node node = findOtherBank(); {
for (int scrollAttempt = 0; Node node;
!node.isEmpty() && (node.width() == 0 || node.height() == 0) && scrollAttempt < 6; for (const Node &n : xmlScreenParser.findAllNodes()) {
++scrollAttempt) { if (n.contentDesc.contains("other-bank-item")) {
qDebug() << "[PayByPhone] 'Другой банк' has collapsed bounds, scrolling list down, attempt" node = n;
<< scrollAttempt + 1 << "/ 6"; break;
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) { if (!node.isEmpty()) {
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item) at" qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item)...";
<< node.x() << "," << node.y();
AdbUtils::makeTap(deviceId, node.x(), node.y()); AdbUtils::makeTap(deviceId, node.x(), node.y());
QThread::msleep(2000); QThread::msleep(2000);
foundOtherBank = true; foundOtherBank = true;
otherBankClicked = true; otherBankClicked = true;
} }
}
if (foundOtherBank) continue; if (foundOtherBank) continue;
// Вариант 2: уже на экране поиска банка (EditText) — только после клика на "Другой банк" // Вариант 2: уже на экране поиска банка (EditText) — только после клика на "Другой банк"
if (otherBankClicked) { 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(); Node bankInput = xmlScreenParser.findFirstEditText();
if (!bankInput.isEmpty()) { if (!bankInput.isEmpty()) {
qDebug() << "[PayByPhone] Bank search screen detected, entering bank name..."; qDebug() << "[PayByPhone] Bank search screen detected, entering bank name...";

View File

@ -426,20 +426,12 @@ Node ScreenXmlParser::findCardButtonOnHomeScreen() {
Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) { Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) {
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button" || !node.clickable) continue; if (node.className != "android.widget.Button" || !node.clickable) continue;
const QString t = node.text.trimmed(); if (node.text.trimmed().endsWith(lastNumbers)) {
if (!t.endsWith(lastNumbers)) continue;
// Отсекаем строки из ленты переводов (см. parseCardsOnHomeScreen)
if (t.contains(QString::fromUtf8("Переводы"))
|| t.contains(QStringLiteral("**"))
|| t.contains(QChar(0x2212)) || t.contains(QChar('+'))) {
qDebug() << "[findCardBtn] Skipping transfer-feed row for lastNumbers"
<< lastNumbers << "text=" << node.text;
continue;
}
qDebug() << "[findCardBtn] Found by lastNumbers" << lastNumbers qDebug() << "[findCardBtn] Found by lastNumbers" << lastNumbers
<< "text=" << node.text << "at" << node.x() << node.y(); << "text=" << node.text << "at" << node.x() << node.y();
return node; return node;
} }
}
qWarning() << "[findCardBtn] Not found for lastNumbers=" << lastNumbers qWarning() << "[findCardBtn] Not found for lastNumbers=" << lastNumbers
<< ". Clickable buttons:"; << ". Clickable buttons:";
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
@ -522,26 +514,13 @@ QList<MaterialInfo> ScreenXmlParser::parseCardsOnHomeScreen() {
if (commonAmount == 0.0) if (commonAmount == 0.0)
qWarning() << "[parseCards] Balance button not found or amount=0"; qWarning() << "[parseCards] Balance button not found or amount=0";
// 2. Собираем все кнопки карт (текст заканчивается на 4 цифры). // 2. Собираем все кнопки карт (текст заканчивается на 4 цифры)
// Записи из ленты переводов выглядят как кнопки и тоже заканчиваются на 4 цифры
// ("Евгений Дмитриевич Р. 100 ₽ Переводы • Карта **1092") — отсеиваем по маркерам.
QList<MaterialInfo> cards; QList<MaterialInfo> cards;
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button" || !node.clickable) continue; if (node.className != "android.widget.Button" || !node.clickable) continue;
QString t = node.text.trimmed(); QString t = node.text.trimmed();
if (!endsWithFourDigits.match(t).hasMatch()) continue; if (!endsWithFourDigits.match(t).hasMatch()) continue;
// Маркеры записи из ленты переводов:
// - слово "Переводы"
// - маска "**" перед цифрами
// - знак суммы транзакции (минус U+2212 или плюс)
if (t.contains(QString::fromUtf8("Переводы"))
|| t.contains(QStringLiteral("**"))
|| t.contains(QChar(0x2212)) || t.contains(QChar('+'))) {
qDebug() << "[parseCards] Skipping transfer-feed row:" << t;
continue;
}
MaterialInfo acc; MaterialInfo acc;
acc.lastNumbers = t.right(4); acc.lastNumbers = t.right(4);
acc.amount = commonAmount; acc.amount = commonAmount;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

View File

@ -1 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 86 KiB

View File

@ -1 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

View File

@ -1 +0,0 @@
<?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;559411001&#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.

Before

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

View File

@ -1 +0,0 @@
<?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="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.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.ImageView" package="tj.dc.next1" content-desc="Успешный платёж&#10;21.05.2026 16:58:32&#10;-10&#10;Карта&#10;9762 0002 2091 6042&#10;Получатель&#10;DCWallet*WDC0000**TAJIKISTAN&#10;992176827668-9762000191282739-&#10;Операция&#10;Дебетная часть P2P/операции&#10;Дата&#10;21.05.2026&#10;Время&#10;16:58:32&#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>

View File

@ -10,7 +10,7 @@ set CONFIG_FILE=%PROJECT_DIR%config.ini
set TG_BOT_TOKEN=8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8 set TG_BOT_TOKEN=8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8
set TG_CHAT_ID=-5177086220 set TG_CHAT_ID=-5177086220
for /f "tokens=2 delims==" %%V in ('findstr /b /i "version=" "%CONFIG_FILE%"') do set OLD_VERSION=%%V for /f "tokens=2 delims==" %%V in ('findstr /i "version=" "%CONFIG_FILE%"') do set OLD_VERSION=%%V
set CURRENT_VERSION=%OLD_VERSION% set CURRENT_VERSION=%OLD_VERSION%
if "%~1"=="-up-version" ( if "%~1"=="-up-version" (
@ -21,7 +21,7 @@ if "%~1"=="-up-version" (
set /a PATCH=%%C+1 set /a PATCH=%%C+1
) )
set CURRENT_VERSION=!MAJOR!.!MINOR!.!PATCH! set CURRENT_VERSION=!MAJOR!.!MINOR!.!PATCH!
powershell -NoProfile -Command "(Get-Content '!CONFIG_FILE!') -replace '^version=!OLD_VERSION!$', 'version=!CURRENT_VERSION!' | Set-Content '!CONFIG_FILE!'" powershell -NoProfile -Command "(Get-Content '!CONFIG_FILE!') -replace 'version=!OLD_VERSION!', 'version=!CURRENT_VERSION!' | Set-Content '!CONFIG_FILE!'"
echo Version: !OLD_VERSION! -^> !CURRENT_VERSION! echo Version: !OLD_VERSION! -^> !CURRENT_VERSION!
) else ( ) else (
echo Version: !CURRENT_VERSION! echo Version: !CURRENT_VERSION!

View File

@ -3,7 +3,7 @@ list=ozon, black, dushanbe_city_bank
[common] [common]
currencies=USD, ARS, RUB, TJS currencies=USD, ARS, RUB, TJS
version=1.0.64 version=0.0.9
[db] [db]
dbPath=./database/app_data.db dbPath=./database/app_data.db
@ -18,11 +18,8 @@ pathDesktopCreate=/api/v1/desktop/
pathDevice=/api/v1/device/ pathDevice=/api/v1/device/
pathDeviceScreenshot=/api/v1/device/add_screenshot/ pathDeviceScreenshot=/api/v1/device/add_screenshot/
pathMaterial=/api/v1/profile/material pathMaterial=/api/v1/profile/material
pathProfilePing=/api/v1/profile/ping
pathMonitoringLog=/monitoring/log pathMonitoringLog=/monitoring/log
pathMonitoringDump=/monitoring/dump pathMonitoringDump=/monitoring/dump
monitoringBase=http://142.93.102.40:8080
monitoringToken=509b91592749959b9ff56f9ed9189e5b3e91e1260003f63c477255e1c187298a
[black] [black]
android_package_name=app.blackwallet.ar android_package_name=app.blackwallet.ar
@ -30,7 +27,6 @@ currency=ARS
country=argentina country=argentina
name=Black name=Black
phone_code=54 phone_code=54
app_version=0
[ozon] [ozon]
android_package_name=ru.ozon.fintech.finance android_package_name=ru.ozon.fintech.finance
@ -38,7 +34,6 @@ currency=RUB
country=russia country=russia
name=Ozon name=Ozon
phone_code=7 phone_code=7
app_version=19.17.0
[dushanbe_city_bank] [dushanbe_city_bank]
android_package_name=tj.dc.next1 android_package_name=tj.dc.next1
@ -46,4 +41,17 @@ currency=TJS
country=tajikistan country=tajikistan
name=Dushanbe City name=Dushanbe City
phone_code=992 phone_code=992
app_version=3.2.34
[scripting]
# Глобальный выключатель JS-скриптов. При false весь scripting-слой выключен.
enabled=false
# Список <bank>/<script>, для которых пробуем JS-версию (через запятую).
# При engine-ошибке runtime прозрачно откатывается на C++.
use_js_for=black/get_profile_info, ozon/get_profile_info, ozon/login_and_check_accounts, ozon/get_card_info, ozon/pay_by_card, ozon/pay_by_phone, ozon/get_last_transactions
# URL JSON-манифеста удалённых обновлений. Пустой → без удалённых обновлений,
# работает только bundled+cache (см. docs/remote_scripts.md).
manifest_url=
# Каталог локального кэша. Если пусто — берётся <AppDataLocation>/scripts/.
cache_dir=
# Каталог bundled-скриптов. Если пусто — рядом с бинарником: ./scripts/
bundled_dir=

View File

@ -119,16 +119,10 @@ void DatabaseManager::initSchema() {
bank_profile_id TEXT DEFAULT '', bank_profile_id TEXT DEFAULT '',
currency TEXT DEFAULT '', currency TEXT DEFAULT '',
amount REAL DEFAULT 0, amount REAL DEFAULT 0,
app_version TEXT DEFAULT '',
app_version_checked_at DATETIME,
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE, FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE,
UNIQUE (device_id, code) UNIQUE (device_id, code)
))"); ))");
// Миграция: добавляем колонки app_version и app_version_checked_at если их нет (ошибка = уже существует)
q.exec("ALTER TABLE bank_profiles ADD COLUMN app_version TEXT DEFAULT ''");
q.exec("ALTER TABLE bank_profiles ADD COLUMN app_version_checked_at DATETIME");
q.exec(R"(CREATE TABLE IF NOT EXISTS materials ( q.exec(R"(CREATE TABLE IF NOT EXISTS materials (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT, device_id TEXT,
@ -176,7 +170,6 @@ void DatabaseManager::initSchema() {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER, account_id INTEGER,
external_id TEXT, external_id TEXT,
transaction_id TEXT,
device_id TEXT, device_id TEXT,
amount REAL, amount REAL,
phone TEXT, phone TEXT,
@ -193,7 +186,6 @@ void DatabaseManager::initSchema() {
FOREIGN KEY (account_id) REFERENCES materials (id) ON DELETE CASCADE, FOREIGN KEY (account_id) REFERENCES materials (id) ON DELETE CASCADE,
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE
))"); ))");
q.exec("ALTER TABLE events ADD COLUMN transaction_id TEXT");
q.exec(R"(CREATE TABLE IF NOT EXISTS banks ( q.exec(R"(CREATE TABLE IF NOT EXISTS banks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -0,0 +1,97 @@
#include "AppLogDAO.h"
#include <QSqlQuery>
#include <QSqlError>
#include <QDebug>
#include "DatabaseManager.h"
#include "time/DateUtils.h"
bool AppLogDAO::insert(const AppLogEntry &entry) {
QSqlQuery q(DatabaseManager::instance().database());
q.prepare(R"(
INSERT INTO app_logs (source, device_id, message, screenshot, timestamp)
VALUES (:source, :device_id, :message, :screenshot, :timestamp)
)");
q.bindValue(":source", entry.source);
q.bindValue(":device_id", entry.deviceId);
q.bindValue(":message", entry.message);
q.bindValue(":screenshot", entry.screenshot.isEmpty()
? QVariant(QMetaType(QMetaType::QByteArray))
: QVariant(entry.screenshot));
q.bindValue(":timestamp", DateUtils::toUtcString(entry.timestamp));
if (!q.exec()) {
qWarning() << "[AppLogDAO] insert failed:" << q.lastError().text();
return false;
}
return true;
}
QList<AppLogEntry> AppLogDAO::getLogs(int page, int pageSize) {
QList<AppLogEntry> result;
QSqlQuery q(DatabaseManager::instance().database());
q.prepare(R"(
SELECT id, source, device_id, message, timestamp
FROM app_logs
ORDER BY id DESC
LIMIT :limit OFFSET :offset
)");
q.bindValue(":limit", pageSize);
q.bindValue(":offset", page * pageSize);
if (!q.exec()) {
qWarning() << "[AppLogDAO] getLogs failed:" << q.lastError().text();
return result;
}
while (q.next()) {
AppLogEntry e;
e.id = q.value("id").toInt();
e.source = q.value("source").toString();
e.deviceId = q.value("device_id").toString();
e.message = q.value("message").toString();
e.timestamp = DateUtils::fromUtcString(q.value("timestamp").toString());
result.append(e);
}
return result;
}
int AppLogDAO::getTotalCount() {
QSqlQuery q(DatabaseManager::instance().database());
q.exec("SELECT COUNT(*) FROM app_logs");
if (q.next()) return q.value(0).toInt();
return 0;
}
QByteArray AppLogDAO::getScreenshot(int id) {
QSqlQuery q(DatabaseManager::instance().database());
q.prepare("SELECT screenshot FROM app_logs WHERE id = :id");
q.bindValue(":id", id);
if (!q.exec() || !q.next()) return {};
return q.value(0).toByteArray();
}
QList<AppLogEntry> AppLogDAO::getLogsWithScreenshots(int limit) {
QList<AppLogEntry> result;
QSqlQuery q(DatabaseManager::instance().database());
q.prepare(R"(
SELECT id, source, device_id, message, screenshot, timestamp
FROM app_logs
ORDER BY id DESC
LIMIT :limit
)");
q.bindValue(":limit", limit);
if (!q.exec()) {
qWarning() << "[AppLogDAO] getLogsWithScreenshots failed:" << q.lastError().text();
return result;
}
while (q.next()) {
AppLogEntry e;
e.id = q.value("id").toInt();
e.source = q.value("source").toString();
e.deviceId = q.value("device_id").toString();
e.message = q.value("message").toString();
e.screenshot = q.value("screenshot").toByteArray();
e.timestamp = DateUtils::fromUtcString(q.value("timestamp").toString());
result.append(e);
}
return result;
}

20
database/dao/AppLogDAO.h Normal file
View File

@ -0,0 +1,20 @@
#pragma once
#include <QByteArray>
#include <QList>
#include "db/AppLogEntry.h"
class AppLogDAO {
public:
[[nodiscard]] static bool insert(const AppLogEntry &entry);
// Возвращает записи без BLOB-скриншота (лёгкая загрузка для таблицы)
[[nodiscard]] static QList<AppLogEntry> getLogs(int page, int pageSize);
[[nodiscard]] static int getTotalCount();
// Загружает скриншот отдельно по id (по клику в UI)
[[nodiscard]] static QByteArray getScreenshot(int id);
// Все записи со скриншотами (для экспорта)
[[nodiscard]] static QList<AppLogEntry> getLogsWithScreenshots(int limit = 100);
};

View File

@ -44,42 +44,25 @@ BankInfo BankDAO::findByAlias(const QString &alias) {
void BankDAO::replaceAll(const QList<BankInfo> &banks) { void BankDAO::replaceAll(const QList<BankInfo> &banks) {
QSqlDatabase db = DatabaseManager::instance().database(); QSqlDatabase db = DatabaseManager::instance().database();
if (!db.transaction()) {
qWarning() << "[BankDAO] replaceAll: cannot begin transaction:" << db.lastError().text();
return;
}
QSqlQuery query(db); QSqlQuery query(db);
if (!query.exec("DELETE FROM banks")) {
qWarning() << "[BankDAO] replaceAll: DELETE failed:" << query.lastError().text();
db.rollback();
return;
}
if (!query.prepare(R"( db.transaction();
query.exec("DELETE FROM banks");
query.prepare(R"(
INSERT INTO banks (alias, name, nspk_name) INSERT INTO banks (alias, name, nspk_name)
VALUES (:alias, :name, :nspk_name) VALUES (:alias, :name, :nspk_name)
)")) { )");
qWarning() << "[BankDAO] replaceAll: prepare INSERT failed:" << query.lastError().text();
db.rollback();
return;
}
for (const BankInfo &bank : banks) { for (const BankInfo &bank : banks) {
query.bindValue(":alias", bank.alias); query.bindValue(":alias", bank.alias);
query.bindValue(":name", bank.name); query.bindValue(":name", bank.name);
query.bindValue(":nspk_name", bank.nspkName); query.bindValue(":nspk_name", bank.nspkName);
if (!query.exec()) { if (!query.exec()) {
qWarning() << "[BankDAO] replaceAll: INSERT failed for alias" << bank.alias qWarning() << "Ошибка при вставке bank:" << query.lastError().text();
<< ":" << query.lastError().text() << "— rolling back";
db.rollback();
return;
} }
} }
if (!db.commit()) { db.commit();
qWarning() << "[BankDAO] replaceAll: commit failed:" << db.lastError().text();
db.rollback();
}
} }

View File

@ -151,8 +151,6 @@ BankProfileInfo parseApplication(const QSqlQuery &query) {
app.bankProfileId = query.value("bank_profile_id").toString(); app.bankProfileId = query.value("bank_profile_id").toString();
app.currency = query.value("currency").toString(); app.currency = query.value("currency").toString();
app.amount = query.value("amount").toDouble(); app.amount = query.value("amount").toDouble();
app.appVersion = query.value("app_version").toString();
app.appVersionCheckedAt = query.value("app_version_checked_at").toDateTime();
return app; return app;
} }
@ -160,7 +158,7 @@ BankProfileInfo BankProfileDAO::getApplication(const QString &deviceId, const QS
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"( query.prepare(R"(
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount, app_version, app_version_checked_at SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount
FROM bank_profiles FROM bank_profiles
WHERE device_id = :device_id AND code = :code WHERE device_id = :device_id AND code = :code
LIMIT 1 LIMIT 1
@ -185,7 +183,7 @@ QList<BankProfileInfo> BankProfileDAO::getApplicationsByDeviceId(const QString &
QList<BankProfileInfo> list; QList<BankProfileInfo> list;
query.prepare(R"( query.prepare(R"(
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount, app_version, app_version_checked_at SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount
FROM bank_profiles FROM bank_profiles
WHERE device_id = :device_id AND install = 'yes' WHERE device_id = :device_id AND install = 'yes'
)"); )");
@ -208,7 +206,7 @@ QList<BankProfileInfo> BankProfileDAO::getAllApplicationsByDeviceId(const QStrin
QList<BankProfileInfo> list; QList<BankProfileInfo> list;
query.prepare(R"( query.prepare(R"(
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount, app_version, app_version_checked_at SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount
FROM bank_profiles FROM bank_profiles
WHERE device_id = :device_id WHERE device_id = :device_id
)"); )");
@ -264,35 +262,6 @@ bool BankProfileDAO::updateAmount(const int appId, const double amount) {
return true; return true;
} }
bool BankProfileDAO::adjustAmount(const int appId, const double delta) {
QSqlQuery query(DatabaseManager::instance().database());
query.prepare("UPDATE bank_profiles SET amount = amount - :delta WHERE id = :id");
query.bindValue(":delta", delta);
query.bindValue(":id", appId);
if (!query.exec()) {
qWarning() << "[BankProfileDAO] adjustAmount failed:" << query.lastError().text();
return false;
}
return true;
}
bool BankProfileDAO::updateAppVersion(const int appId, const QString &appVersion) {
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
UPDATE bank_profiles
SET app_version = :app_version, app_version_checked_at = :checked_at
WHERE id = :id
)");
query.bindValue(":app_version", appVersion);
query.bindValue(":checked_at", QDateTime::currentDateTimeUtc());
query.bindValue(":id", appId);
if (!query.exec()) {
qWarning() << "Ошибка при обновлении app_version:" << query.lastError().text();
return false;
}
return true;
}
bool BankProfileDAO::updateBankProfileId(const int appId, const QString &bankProfileId) { bool BankProfileDAO::updateBankProfileId(const int appId, const QString &bankProfileId) {
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare("UPDATE bank_profiles SET bank_profile_id = :bank_profile_id WHERE id = :id"); query.prepare("UPDATE bank_profiles SET bank_profile_id = :bank_profile_id WHERE id = :id");
@ -309,7 +278,7 @@ QList<BankProfileInfo> BankProfileDAO::findAllByBankProfileId(const QString &ban
QList<BankProfileInfo> list; QList<BankProfileInfo> list;
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"( query.prepare(R"(
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount, app_version, app_version_checked_at SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount
FROM bank_profiles FROM bank_profiles
WHERE bank_profile_id = :bank_profile_id WHERE bank_profile_id = :bank_profile_id
)"); )");

View File

@ -36,12 +36,6 @@ public:
[[nodiscard]] static bool updateAmount(int appId, double amount); [[nodiscard]] static bool updateAmount(int appId, double amount);
// Атомарно вычитает delta из bank_profiles.amount.
// delta > 0 — расход, delta < 0 — пополнение.
[[nodiscard]] static bool adjustAmount(int appId, double delta);
[[nodiscard]] static bool updateAppVersion(int appId, const QString &appVersion);
[[nodiscard]] static BankProfileInfo getApplication(const QString &deviceId, const QString &appCode); [[nodiscard]] static BankProfileInfo getApplication(const QString &deviceId, const QString &appCode);
[[nodiscard]] static QList<BankProfileInfo> getApplicationsByDeviceId(const QString &deviceId); [[nodiscard]] static QList<BankProfileInfo> getApplicationsByDeviceId(const QString &deviceId);

View File

@ -13,7 +13,6 @@ EventInfo parseEvent(const QSqlQuery &query) {
event.status = eventStatusFromString(query.value("status").toString()); event.status = eventStatusFromString(query.value("status").toString());
event.type = eventTypeFromString(query.value("type").toString()); event.type = eventTypeFromString(query.value("type").toString());
event.externalId = query.value("external_id").toString(); event.externalId = query.value("external_id").toString();
event.transactionId = query.value("transaction_id").toString();
event.accountId = query.value("account_id").toInt(); event.accountId = query.value("account_id").toInt();
event.deviceId = query.value("device_id").toString(); event.deviceId = query.value("device_id").toString();
event.amount = query.value("amount").toDouble(); event.amount = query.value("amount").toDouble();
@ -37,16 +36,15 @@ int EventDAO::insertEvent(const EventInfo &event) {
query.prepare(R"( query.prepare(R"(
INSERT INTO events ( INSERT INTO events (
status, external_id, transaction_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type status, external_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type
) VALUES ( ) VALUES (
:status, :external_id, :transaction_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type :status, :external_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type
) )
)"); )");
query.bindValue(":status", eventStatusToString(event.status)); query.bindValue(":status", eventStatusToString(event.status));
query.bindValue(":type", eventTypeToString(event.type)); query.bindValue(":type", eventTypeToString(event.type));
query.bindValue(":external_id", event.externalId); query.bindValue(":external_id", event.externalId);
query.bindValue(":transaction_id", event.transactionId);
query.bindValue(":account_id", event.accountId); query.bindValue(":account_id", event.accountId);
query.bindValue(":device_id", event.deviceId); query.bindValue(":device_id", event.deviceId);
query.bindValue(":amount", event.amount); query.bindValue(":amount", event.amount);
@ -141,7 +139,7 @@ QList<EventInfo> EventDAO::getAllEvents(const EventStatus &status) {
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"( query.prepare(R"(
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events FROM events
WHERE status = :status WHERE status = :status
@ -163,7 +161,7 @@ QList<EventInfo> EventDAO::getAllEvents(const EventStatus &status) {
EventInfo EventDAO::getFirstWaitEventByDeviceId(const QString &key) { EventInfo EventDAO::getFirstWaitEventByDeviceId(const QString &key) {
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"( query.prepare(R"(
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events FROM events
WHERE status = :status AND device_id = :device_id WHERE status = :status AND device_id = :device_id
@ -195,7 +193,7 @@ QList<EventInfo> EventDAO::getEvents(int page, int pageSize) {
QList<EventInfo> events; QList<EventInfo> events;
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"( query.prepare(R"(
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp, status, type, comment, json, sent_to_server, update_time, complete_time, timestamp,
(LENGTH(screenshot) > 0) as has_screenshot (LENGTH(screenshot) > 0) as has_screenshot
FROM events FROM events
@ -278,7 +276,7 @@ QList<EventInfo> EventDAO::cancelWaitingByMaterial(const QString &materialId, Ev
QList<EventInfo> cancelled; QList<EventInfo> cancelled;
QSqlQuery selectQuery(DatabaseManager::instance().database()); QSqlQuery selectQuery(DatabaseManager::instance().database());
selectQuery.prepare(R"( selectQuery.prepare(R"(
SELECT id, external_id, transaction_id, bank_name, type FROM events SELECT id, external_id, bank_name, type FROM events
WHERE external_id = :material_id AND type = :type AND status = :wait WHERE external_id = :material_id AND type = :type AND status = :wait
)"); )");
selectQuery.bindValue(":material_id", materialId); selectQuery.bindValue(":material_id", materialId);
@ -290,9 +288,8 @@ QList<EventInfo> EventDAO::cancelWaitingByMaterial(const QString &materialId, Ev
EventInfo e; EventInfo e;
e.id = selectQuery.value(0).toInt(); e.id = selectQuery.value(0).toInt();
e.externalId = selectQuery.value(1).toString(); e.externalId = selectQuery.value(1).toString();
e.transactionId = selectQuery.value(2).toString(); e.bankName = selectQuery.value(2).toString();
e.bankName = selectQuery.value(3).toString(); e.type = eventTypeFromString(selectQuery.value(3).toString());
e.type = eventTypeFromString(selectQuery.value(4).toString());
cancelled.append(e); cancelled.append(e);
} }
} }
@ -318,12 +315,12 @@ QList<EventInfo> EventDAO::cancelWaitingByType(const QString &deviceId, EventTyp
QSqlQuery selectQuery(DatabaseManager::instance().database()); QSqlQuery selectQuery(DatabaseManager::instance().database());
if (bankName.isEmpty()) { if (bankName.isEmpty()) {
selectQuery.prepare(R"( selectQuery.prepare(R"(
SELECT id, external_id, transaction_id, bank_name, type FROM events SELECT id, external_id, bank_name, type FROM events
WHERE device_id = :device_id AND type = :type AND status = :wait WHERE device_id = :device_id AND type = :type AND status = :wait
)"); )");
} else { } else {
selectQuery.prepare(R"( selectQuery.prepare(R"(
SELECT id, external_id, transaction_id, bank_name, type FROM events SELECT id, external_id, bank_name, type FROM events
WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name
)"); )");
selectQuery.bindValue(":bank_name", bankName); selectQuery.bindValue(":bank_name", bankName);
@ -337,9 +334,8 @@ QList<EventInfo> EventDAO::cancelWaitingByType(const QString &deviceId, EventTyp
EventInfo e; EventInfo e;
e.id = selectQuery.value(0).toInt(); e.id = selectQuery.value(0).toInt();
e.externalId = selectQuery.value(1).toString(); e.externalId = selectQuery.value(1).toString();
e.transactionId = selectQuery.value(2).toString(); e.bankName = selectQuery.value(2).toString();
e.bankName = selectQuery.value(3).toString(); e.type = eventTypeFromString(selectQuery.value(3).toString());
e.type = eventTypeFromString(selectQuery.value(4).toString());
cancelled.append(e); cancelled.append(e);
} }
} }
@ -373,7 +369,7 @@ QList<EventInfo> EventDAO::cancelWaitingByBank(const QString &deviceId, const QS
QList<EventInfo> cancelled; QList<EventInfo> cancelled;
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"( query.prepare(R"(
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events FROM events
WHERE device_id = :device_id AND bank_name = :bank_name AND status = :wait WHERE device_id = :device_id AND bank_name = :bank_name AND status = :wait
@ -429,7 +425,7 @@ QList<EventInfo> EventDAO::getRecentEvents(int hours) {
QList<EventInfo> events; QList<EventInfo> events;
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"( query.prepare(R"(
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events FROM events
WHERE timestamp >= datetime('now', :hours) WHERE timestamp >= datetime('now', :hours)

View File

@ -128,50 +128,6 @@ bool MaterialDAO::updateMaterialId(const int accountId, const QString &materialI
return true; return true;
} }
bool MaterialDAO::adjustAmount(const int id, const double delta) {
QSqlQuery query(DatabaseManager::instance().database());
query.prepare("UPDATE materials SET amount = amount - :delta WHERE id = :id");
query.bindValue(":delta", delta);
query.bindValue(":id", id);
if (!query.exec()) {
qWarning() << "[MaterialDAO] adjustAmount failed:" << query.lastError().text();
return false;
}
return true;
}
bool MaterialDAO::adjustAmountByApp(const QString &deviceId, const QString &appCode, const double delta) {
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
UPDATE materials SET amount = amount - :delta
WHERE device_id = :device_id AND app_code = :app_code
)");
query.bindValue(":delta", delta);
query.bindValue(":device_id", deviceId);
query.bindValue(":app_code", appCode);
if (!query.exec()) {
qWarning() << "[MaterialDAO] adjustAmountByApp failed:" << query.lastError().text();
return false;
}
return true;
}
bool MaterialDAO::setAmountByApp(const QString &deviceId, const QString &appCode, const double amount) {
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
UPDATE materials SET amount = :amount
WHERE device_id = :device_id AND app_code = :app_code
)");
query.bindValue(":amount", amount);
query.bindValue(":device_id", deviceId);
query.bindValue(":app_code", appCode);
if (!query.exec()) {
qWarning() << "[MaterialDAO] setAmountByApp failed:" << query.lastError().text();
return false;
}
return true;
}
MaterialInfo parseAccount(const QSqlQuery &query) { MaterialInfo parseAccount(const QSqlQuery &query) {
MaterialInfo acc; MaterialInfo acc;
acc.id = query.value("id").toInt(); acc.id = query.value("id").toInt();
@ -249,7 +205,6 @@ QList<MaterialInfo> MaterialDAO::getAccounts(const QString &deviceId, const QStr
SELECT id, device_id, app_code, last_numbers, card_number, account_number, material_id, amount, update_time, status, description, currency SELECT id, device_id, app_code, last_numbers, card_number, account_number, material_id, amount, update_time, status, description, currency
FROM materials FROM materials
WHERE device_id = :device_id AND app_code = :app_code WHERE device_id = :device_id AND app_code = :app_code
ORDER BY id
)"); )");
query.bindValue(":device_id", deviceId); query.bindValue(":device_id", deviceId);

View File

@ -26,18 +26,6 @@ public:
[[nodiscard]] static bool updateMaterialId(int accountId, const QString &materialId); [[nodiscard]] static bool updateMaterialId(int accountId, const QString &materialId);
// Атомарно вычитает delta из materials.amount для одного материала.
// delta > 0 — расход, delta < 0 — пополнение.
[[nodiscard]] static bool adjustAmount(int id, double delta);
// Атомарно вычитает delta из materials.amount для всех материалов
// в (deviceId, appCode) — для банков с одним счётом на все карты (Dushanbe).
[[nodiscard]] static bool adjustAmountByApp(const QString &deviceId, const QString &appCode, double delta);
// Перезаписывает materials.amount абсолютным значением для всех материалов
// в (deviceId, appCode). Используется после re-parse экрана.
[[nodiscard]] static bool setAmountByApp(const QString &deviceId, const QString &appCode, double amount);
[[nodiscard]] static QList<MaterialInfo> getAccounts(const QString &deviceId, const QString &appName); [[nodiscard]] static QList<MaterialInfo> getAccounts(const QString &deviceId, const QString &appName);
[[nodiscard]] static MaterialInfo getAccountById(int accountId); [[nodiscard]] static MaterialInfo getAccountById(int accountId);

View File

@ -102,54 +102,3 @@ pathTaskResult = /api/v1/task/task_result
- **Bearer**`Authorization: Bearer <access_token>` из `config.ini [common/access_token]` - **Bearer**`Authorization: Bearer <access_token>` из `config.ini [common/access_token]`
- **Legacy** — поле `token` в multipart-теле (из `config.ini [common/token]`) - **Legacy** — поле `token` в multipart-теле (из `config.ini [common/token]`)
---
## Таймауты и ретраи
Каждый синхронный запрос крутится в локальном `QEventLoop` под охраной `QTimer`. Если таймер сработал раньше, чем `reply->finished` — это **client timeout**: запрос аборитится и повторяется заново.
### Параметры
| Параметр | Значение | Где |
|----------|----------|-----|
| Лимит ожидания | **30 секунд** | `kDefaultTimeoutMs` — все запросы |
| Лимит ожидания (dump) | **120 секунд** | `kDumpTimeoutMs` — только `postMonitoringDump` (большой zip-архив) |
| Макс. попыток | **3** | `kMaxRequestAttempts` — 1 основная + 2 повтора |
| Пауза между попытками | **0** | Ретрай идёт сразу после abort |
Константы и helper `awaitReply()` — в анонимном namespace в `services/net/NetworkService.cpp`.
### Когда ретраим
Ретрай срабатывает **только** при client timeout (наш `QTimer` сработал, reply не успел завершиться). На любых других исходах ретрая нет:
| Исход | Ретрай? | Почему |
|-------|---------|--------|
| Client timeout (наш QTimer) | ✅ да | сеть моргнула — стоит попробовать |
| HTTP 4xx (401/404/409/…) | ❌ нет | детерминированная ошибка, повтор не поможет |
| HTTP 5xx | ❌ нет | сервер ответил — это бизнес-сбой, не транспортный |
| `ConnectionRefused` / DNS / SSL | ❌ нет | повтор не починит инфраструктурную проблему |
| `success: false` в JSON | ❌ нет | прикладная ошибка |
После каждой неудачной попытки в лог пишется `Network timeout (POST) attempt N/3: <details>`; если все 3 попытки таймаутнулись — финальная строка `Timeout (POST) after 3 attempts: <details>` плюс запись в `GeneralLogDAO` (для `postJson`/`patchJson`).
### Покрытие методов
| Метод | Ретрай | Примечание |
|-------|--------|------------|
| `postJson` | ✅ | |
| `patchJson` | ✅ | |
| `getJson` | ✅ | |
| `deleteJson` | ✅ | |
| `get(path, params)` | ✅ | используется `getPayments()` (legacy) |
| `syncBanksIfEmpty()` инлайн-GET | ✅ | |
| `postDeviceScreenshot()` | ✅ | multipart пересобирается на каждой попытке |
| `postMonitoringLog()` | ✅ | multipart пересобирается на каждой попытке |
| `postMonitoringDump()` | ✅ | timeout 120s; multipart пересобирается |
| `post(QHttpMultiPart*, path)` | ❌ | multipart передаётся снаружи и удаляется вместе с `reply`; колл-сайты (`postEventStatus`/`insertTransactionData`/…) — legacy |
| `getTasks()` | ❌ | асинхронный (без `QEventLoop`); таймаут просто аборитит reply |
### Идемпотентность
⚠️ POST-запросы, создающие ресурсы (`/desktop/`, `/device/`, `/profile/bank_profile`, `/profile/material`), при ретрае могут создать дубль, если первая попытка дошла до сервера, но ответ потерялся. Сейчас защиты от этого нет — полагаемся на то, что 3 timeout-а подряд это редкая ситуация. Если станет проблемой — добавлять идемпотентные ключи на стороне бэкенда.

View File

@ -1,120 +0,0 @@
# Мастер установки банка (`BankSetupWizard`)
Файлы: `views/bank/BankSetupWizard.{h,cpp}`
## Назначение
Диалоговое окно, проводящее пользователя через настройку нового банковского профиля на конкретном устройстве: ввод пин-кода → автоматический вход в приложение → парсинг профиля → парсинг карт → выбор активной карты. Все данные одновременно сохраняются в локальную БД и регистрируются на бэкенде (`localhost:9999/api/v1/`).
Поддерживаемые банки: **Ozon**, **Black** (Birbank), **Dushanbe City Bank** (`appCode == "ozon" | "black" | "dushanbe_city_bank"`). Остальные банки в мастере не поддерживаются и сразу падают с ошибкой (`BankSetupWizard.cpp:443, 494, 549`).
## Где открывается
Два входа, оба создают `BankSetupWizard` как обычный `QDialog` (немодальный):
- `views/device/DeviceSettingsWindow.cpp:176` — из карточки настроек устройства.
- `views/device/DeviceWidget.cpp:278` — из плитки устройства в главном окне.
В обоих местах используется свежая запись из БД, если профиль уже есть; иначе — переданный `BankProfileInfo` шаблон.
## Структура UI: `QStackedWidget` из 5 страниц
Индексы страниц: `0` Intro, `1` PIN, `2` Progress, `3` Success, `4` Error (`BankSetupWizard.cpp:32-38`).
| Шаг | Что показывает | Кнопки |
|-----|----------------|--------|
| 1 Intro (`createStep1`) | Иконка + название банка + список из 3 шагов | **Далее** → шаг 2 |
| 2 PIN (`createStep2`) | Поле ввода пин-кода (max 10 символов) | **Назад**, **Далее** → сохраняет `m_app.pinCode`, переходит на шаг 3 и запускает `startVerification()` |
| 3 Progress (`createStep3`) | `QProgressBar` + текстовая метка статуса | **Назад**`stopWorkerThread()`, возврат на шаг 2 |
| 4a Success (`createStep4Success`) | ФИО / Телефон / Email + таблица карт с radio-выбором | **Завершить**`applyActiveCardSelection()`, `accept()` |
| 4b Error (`createStep4Error`) | Человекочитаемый текст ошибки | **Назад** → шаг 2, **Начать заново** → очищает БД-запись (если профиль ещё не отправлен) и возвращает на шаг 1 |
## Жизненный цикл и пауза других профилей
В **конструкторе** (`BankSetupWizard.cpp:41`) сразу вызывается `pauseActiveProfiles()`:
- Берёт все профили устройства через `BankProfileDAO::getApplicationsByDeviceId`.
- Все `status == "active"` переводятся в `"off"` локально и **выключаются на сервере** через `NetworkService::toggleBankProfile(enable=false)` (`BankSetupWizard.cpp:297-321`).
- Коды паузованных профилей запоминаются в `m_pausedProfiles`.
Это сделано, чтобы фоновый `EventHandler` / `DeviceScreener` не дёргали телефон параллельно во время работы мастера.
В **деструкторе** (`BankSetupWizard.cpp:44-47`): `stopWorkerThread()` + `resumePausedProfiles()` — возвращает паузованные профили в `"active"` и шлёт `toggleBankProfile(enable=true)` на сервер.
## Цепочка верификации (шаги 3 → 4)
`startVerification()``runPinCheck()``runProfileCheck()``runCardCheck()``onVerificationSuccess()`.
Каждый шаг запускает соответствующий банк-скрипт в **отдельном `QThread`** (`m_workerThread`), результат принимает лямбдой через `Qt::QueuedConnection`. Если шаг вернул непустую строку — `onVerificationError()`, иначе — следующий шаг.
### 1. `runPinCheck()` — проверка пин-кода (`BankSetupWizard.cpp:384`)
Сначала `startVerification()` делает базовую проверку, что устройство онлайн (`device.status == "device"`), затем upsert'ит профиль в БД с `status="off"`, `install=true`, чтобы получить `m_app.id` (`BankSetupWizard.cpp:362-382`).
Дальше создаётся `LoginAndCheckAccountsScript` с флагом **`pinCheckOnly=true`** (3-й или 4-й аргумент в зависимости от банка). Этот флаг говорит скрипту: только дойти до главного экрана, чтобы убедиться что пин валидный, и выйти. Никаких материалов не создаётся.
При успехе: `BankProfileDAO::updatePinCodeStatus(appId, "yes", "")`, прогресс → 33%, переход к `runProfileCheck()`.
### 2. `runProfileCheck()` — данные профиля (`BankSetupWizard.cpp:450`)
Запускает `GetProfileInfoScript`. Скрипт парсит ФИО/телефон/email с главного экрана, обновляет `bank_profiles` в БД и **отправляет на сервер** `POST/PATCH` через `NetworkService` — на этом этапе бэкенд возвращает `bank_profile_id`.
После завершения обработчик `onProfileResult` дочитывает запись из БД (`BankProfileDAO::getApplication`) и проверяет, что `bankProfileId` непустой (`BankSetupWizard.cpp:463`). Если бэкенд не вернул id — ошибка "Не удалось зарегистрировать банковский профиль на сервере. Возможно, банк ещё не поддерживается сервером." Прогресс → 66%, переход к `runCardCheck()`.
### 3. `runCardCheck()` — карты (`BankSetupWizard.cpp:501`)
Запускает `GetCardInfoScript`. Скрипт обходит все карты в приложении, парсит полный номер карты (для Ozon/Dushanbe — через OCR/XML), складывает в БД и отправляет `POST material` на сервер.
После завершения `onCardResult` читает `materials = MaterialDAO::getAccounts(...)`, и если хотя бы одна карта есть, но **ни у одной нет `materialId`** (т.е. ничего не синхронизировалось с сервером) — ошибка "Не удалось отправить данные карт на сервер" (`BankSetupWizard.cpp:518`). Прогресс → 100%, вызов `onVerificationSuccess()`.
### 4. `onVerificationSuccess()` (`BankSetupWizard.cpp:556`)
- Перечитывает профиль из БД, формирует HTML с ФИО/телефоном/email.
- Загружает карты, и **если хоть у одной `cardNumber.isEmpty()` — уходит в Error** ("Не удалось получить полный номер карты, распознаны только последние 4 цифры") (`BankSetupWizard.cpp:574-580`). Это страховка поверх защиты в самих скриптах.
- Заполняет таблицу: 4 колонки (Номер / Сумма / Валюта / Активна-radio).
- Логика автовыбора:
- **0 карт** → строка "Карты не найдены", кнопка Завершить активна.
- **1 карта** → автовыбор, колонка radio скрыта.
- **≥2 карт** → первая карта выбрана по умолчанию, можно перевыбрать.
### 5. `applyActiveCardSelection()` (`BankSetupWizard.cpp:631`) — при нажатии Завершить
- `MaterialDAO::activateExclusive(deviceId, appCode, selectedMaterialId)` — делает выбранную карту единственной активной в этом профиле; возвращает список деактивированных.
- Для выбранной карты шлёт `NetworkService::toggleMaterial(enable=true)`, для каждой деактивированной — `toggleMaterial(enable=false)`. Каждый запрос — в своём `QThread`, fire-and-forget.
## Обработка ошибок
`onVerificationError()` (`BankSetupWizard.cpp:690`) проганяет текст через `humanReadableError()` (`BankSetupWizard.cpp:659`), который:
1. Определяет **этап** по подстрокам ("пин-кода" / "профиля" / "карт").
2. Распознаёт типовые сообщения скриптов (`"Cant open the home screen"`, `"No cards found"`, `"Cannot find user name"`, и т.д.) и подставляет понятный русский текст с подсказкой что делать.
3. Всё незнакомое → "Произошла непредвиденная ошибка. Попробуйте повторить позже."
Кнопка **«Начать заново»** на странице Error дополнительно чистит БД, но **только если профиль ещё не зарегистрирован на сервере** (`current.bankProfileId.isEmpty()`, `BankSetupWizard.cpp:277-283`): удаляет материалы (`MaterialDAO::deleteAccountsByApp`) и сам профиль (`BankProfileDAO::deleteProfile`), сбрасывает `m_app.id = -1`. Это предотвращает дубликаты при повторной попытке.
## Управление воркер-потоком
`stopWorkerThread()` (`BankSetupWizard.cpp:342`) — критичный кусок:
1. `requestInterruption()` + `quit()`, ждёт до 3 секунд `wait(3000)`.
2. Если поток **залип** (обычно — внутри `QProcess::waitForFinished` в `AdbUtils::startProcess`): **отсоединяет** `QThread` от родителя (`setParent(nullptr)`) и вешает `deleteLater` на сигнал `finished`. Без этого `~QWidget` удалит работающий `QThread``qFatal`.
Каждый `run*Check()` сначала зовёт `stopWorkerThread()`, поэтому переход на следующий шаг безопасен даже при гонках.
## Краткая последовательность данных
```
Пользователь вводит PIN
BankProfileDAO::upsertApplication (status=off, install=true)
LoginAndCheckAccountsScript (pinCheckOnly=true) ─→ только верифицирует вход
BankProfileDAO::updatePinCodeStatus("yes", "")
GetProfileInfoScript ─→ парсит профиль → POST/PATCH bank_profile → bankProfileId
GetCardInfoScript ─→ парсит карты → POST material для каждой → materialId
Таблица карт + выбор активной
MaterialDAO::activateExclusive + toggleMaterial на сервере
accept() → resumePausedProfiles() в деструкторе возвращает остальные профили в active
```

View File

@ -11,6 +11,7 @@
| Раздел | Описание | | Раздел | Описание |
|--------|----------| |--------|----------|
| [События и задачи](events.md) | Очередь задач: получение из API, хранение в БД, выполнение скриптов | | [События и задачи](events.md) | Очередь задач: получение из API, хранение в БД, выполнение скриптов |
| [Удалённые JS-скрипты](remote_scripts.md) | Scripting-слой: обновляемые JS-версии банковских скриптов через `QJSEngine` |
### API Backend (`Adb Core Backend v1.0.0`) ### API Backend (`Adb Core Backend v1.0.0`)

480
docs/remote_scripts.md Normal file
View File

@ -0,0 +1,480 @@
# Удалённые JS-скрипты задач
Документ описывает scripting-слой, который позволяет обновлять логику банковских
скриптов **без пересборки и переустановки приложения** — JS-файлы тянутся с сервера
и исполняются внутри `QJSEngine`.
> **Статус.** Фреймворк и удалённое обновление полностью реализованы. На JS
> перенесены: `Black::GetProfileInfo` (PoC); **все 7 скриптов Ozon**
> `get_profile_info`, `login_and_check_accounts`, `get_card_info`, `pay_by_card`,
> `pay_by_phone`, `get_last_transactions`. Остальные банки (Dushanbe, оставшийся
> Black) — на C++, мигрируются.
---
## 1. Зачем это нужно
Банковские UI меняются часто: переезжают кнопки, появляются новые экраны
подтверждения, шрифты ломают OCR. Раньше любая такая правка требовала:
1. Обновить C++-код скрипта.
2. Пересобрать релиз под Windows/macOS.
3. Раскатить новый exe на все десктопы.
При живом потоке задач каждый раз это «окно простоя» в несколько часов. Удалённые
JS-скрипты позволяют деплоить правку парсинга/последовательности тапов за минуты,
без релизного цикла.
C++-скрипты при этом **не убираются**: при любой ошибке загрузки/исполнения JS
runtime прозрачно откатывается на текущую C++-реализацию. Это страхует от
ситуации «сервер отдал битый JS — все устройства встали».
---
## 2. Архитектура
```
┌──────────────────────────────────────────────────────────────────┐
│ config.ini [scripting] │
│ enabled, use_js_for, manifest_url, cache_dir, bundled_dir │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ main.cpp → startScriptUpdater() │
│ └─ Scripting::ScriptUpdater │
│ └─ GET manifest_url → JSON {scripts:[{bank,script, │
│ version,url,sha256}]} │
│ └─ для каждой записи: GET url → SHA-256 check → │
│ ScriptCache::installFromBytes │
└──────────────────────────────────────────────────────────────────┘
▼ (атомарно через QSaveFile)
<cache_dir>/<bank>/<script>.js + <script>.version
┌──────────────────────────────────────────────────────────────────┐
│ Worker-поток EventHandler'а
│ Black::GetProfileInfoScript::doStart() │
│ ├─ ScriptingConfig::isJsEnabledFor("black/get_profile_info")│
│ ├─ ScriptRuntime::run("black","get_profile_info",params) │
│ │ ┌──────────────────────────────────────────────┐ │
│ │ │ QJSEngine │ │
│ │ │ host = {log, timer, adb, dao, dump, │ │
│ │ │ parser (BlackParserBridge), │ │
│ │ │ helpers (BlackHelpersBridge), │ │
│ │ │ params} │ │
│ │ │ eval(<bank>/<script>.js) run(host)
│ │ └──────────────────────────────────────────────┘ │
│ ├─ Completed → emit finishedWithResult(jsResult) │
│ └─ EngineError → лог + fallback на C++-ветку │
└──────────────────────────────────────────────────────────────────┘
```
**Ключевые свойства:**
- `QJSEngine` пересоздаётся на каждый запуск — состояние от прошлой задачи не
утечёт в следующую.
- JS исполняется в **том же** worker-потоке, что и C++-скрипт, поэтому
`host.timer.sleep()` блокирующий, `host.dao.*` напрямую читают SQLite,
всё остальное работает как раньше.
- `host.timer.sleep()` дробит большой интервал на 100-мс куски и проверяет
`QThread::isInterruptionRequested()` — watchdog EventHandler'а (5 мин)
по-прежнему может остановить зависший JS-поток.
- Резолв скрипта: сначала `cache_dir/<bank>/<script>.js`, потом
`bundled_dir/<bank>/<script>.js` (рядом с exe). Если ничего нет — `Disabled`,
fallback на C++.
---
## 3. Конфигурация (`config.ini`)
```ini
[scripting]
enabled=false
use_js_for=black/get_profile_info
manifest_url=
cache_dir=
bundled_dir=
```
| Ключ | Тип | Default | Назначение |
|---------------|--------|---------|--------------------------------------------------------------------|
| `enabled` | bool | `false` | Глобальный выключатель. При `false` весь слой бездействует. |
| `use_js_for` | csv | пусто | Список `<bank>/<script>`, для которых пробуем JS-версию. |
| `manifest_url`| string | пусто | URL JSON-манифеста. Пусто → удалённого обновления нет. |
| `cache_dir` | path | пусто | Кастомный каталог кэша. Пусто → `<AppDataLocation>/scripts/`. |
| `bundled_dir` | path | пусто | Каталог bundled-скриптов. Пусто → `<applicationDirPath>/scripts/`. |
**Включение JS для PoC-скрипта:**
```ini
[scripting]
enabled=true
use_js_for=black/get_profile_info
manifest_url=https://your-host/api/v1/scripts/manifest
```
При `enabled=false` (текущий default) приложение ведёт себя ровно как до PoC — JS
никогда не запускается, никакой нагрузки.
---
## 4. Формат манифеста
`GET <manifest_url>` должен возвращать `application/json`:
```json
{
"scripts": [
{
"bank": "black",
"script": "get_profile_info",
"version": "1.2.3",
"url": "https://cdn.example.com/scripts/black/get_profile_info-1.2.3.js",
"sha256": "9af15e8c1b1d4a2a3c9f7c5b...64-hex-chars..."
}
]
}
```
Поля:
| Поле | Обязательно | Назначение |
|-----------|-------------|---------------------------------------------------------------------|
| `bank` | да | Идентификатор банка (например `black`, `ozon`). |
| `script` | да | Имя скрипта без расширения (`get_profile_info`). |
| `version` | да | Произвольная строка. Сохраняется рядом со скриптом как `*.version`. |
| `url` | да | Прямая ссылка на `.js`-файл. HTTPS обязателен. |
| `sha256` | да | SHA-256 содержимого `.js` в lowercase hex. |
**Алгоритм апдейтера:**
1. `GET manifest_url`. Сетевые ошибки → лог `[ScriptUpdater] manifest fetch failed`, скрипт остаётся прежним.
2. Для каждой записи: если `ScriptCache::installedVersion(bank, script) == version` — пропускаем.
3. Иначе `GET url`, считаем SHA-256, сравниваем.
4. При совпадении — `QSaveFile` (атомарно: запись в `.tmp``commit`/rename). При несовпадении — лог и пропуск.
5. Старый `.js` остаётся до успешного апдейта. **На устройстве всегда либо валидный новый, либо валидный старый.**
Версии используются как ключ кэша, поэтому **версию обязательно нужно увеличивать** при каждой публикации — иначе обновление пропустится.
---
## 5. JS-API (что доступно из скрипта)
Каждый запуск получает глобальный объект `host`. Полный набор:
### `host.log`
```javascript
host.log.debug(msg)
host.log.info(msg)
host.log.warn(msg)
host.log.error(msg)
```
Уходит в `qDebug/qInfo/qWarning/qCritical` с тегом `[js:<bank>/<script>]`.
### `host.timer`
```javascript
host.timer.sleep(ms) // блокирующий sleep с проверкой interrupt каждые 100мс
host.timer.isInterrupted() // true, если watchdog запросил прерывание потока
```
### `host.adb` — обёртка над `AdbUtils`
```javascript
host.adb.getScreenDumpAsXml(deviceId, idx) → string
host.adb.getRawScreenDumpAsXml(deviceId) → string
host.adb.makeTap(deviceId, x, y) → bool
host.adb.makeSwipe(deviceId, x1, y1, x2, y2) → bool
host.adb.makeSwipeWithDuration(deviceId, x1, y1, x2, y2, ms) → bool
host.adb.tryToStartApplication(deviceId, package) → bool
host.adb.tryToKillApplication(deviceId, package) → bool
host.adb.tryToGetRunningAppPid(deviceId, package) → string
host.adb.takeScreenshot(deviceId, name) → bool
host.adb.inputText(deviceId, text) → bool
host.adb.inputAdbIMEText(deviceId, text) → bool
host.adb.enableAdbIMEKeyboard(deviceId) → bool
host.adb.disableAdbIMEKeyboard(deviceId) → bool
host.adb.hideKeyboard(deviceId) → bool
host.adb.goBack(deviceId) → bool
host.adb.unlockScreen(deviceId, w, h) → void
host.adb.getDeviceTimezone(deviceId) → string
host.adb.captureScreenshotBase64(deviceId) → string // PNG → base64
```
### `host.dao` — чтение/запись в SQLite
```javascript
host.dao.deviceGetById(id) → DeviceObj | {}
host.dao.deviceFindByAdbSerial(serial) → DeviceObj | {}
host.dao.bankProfileGet(deviceId, appCode) → ProfileObj | {}
host.dao.bankProfileUpdateAmount(id, amount) → bool
host.dao.bankProfileUpdateProfileInfo(id, fullName, phone, email) → bool
host.dao.bankProfileUpdateComment(id, comment) → bool
host.dao.materialGetAccounts(deviceId, appCode) → Array<MaterialObj>
```
`DeviceObj`: `{ id, adbSerial, name, screenWidth, screenHeight, apiId }`.
Если устройство не найдено — пустой объект `{}` (без поля `id`).
`ProfileObj`: `{ id, appCode, name, package, pinCode, status, amount, fullName, phone, email, currency, bankProfileId }`.
`MaterialObj`: `{ id, materialId, appCode, deviceId, lastNumbers, amount, currency, description, status }`.
### `host.script` (опционально)
```javascript
host.script.emitTransactionParsed(jsonString)
```
Эмитит Qt-сигнал `transactionParsed(QJsonObject)` на C++ скрипте, который
передан в `ScriptRuntime::run(... , this)`. Используется
`get_last_transactions.js` для трансляции спарсенных транзакций в EventHandler.
Без `signalSink` метод — no-op.
### `host.ocr`
```javascript
host.ocr.recognizeFromScreen(deviceId) → string // grayscale + 300dpi, rus+eng
host.ocr.extractTransactionId(ocrText) → string // "ID операции..." / "№ Ф-..."
```
Реализация в `android/ocr/OcrUtils`. Используется `get_last_transactions.js`
для извлечения `bank_transaction_id` с экрана "Документы" Ozon.
### `host.parser` (Black и Ozon)
Обёртка над `Black::ScreenXmlParser`. Состояние (распаршенный XML) живёт ровно
один прогон скрипта.
```javascript
host.parser.parseAndSaveXml(xml)
host.parser.isHomeScreen() → bool
host.parser.isSideMenu() → bool
host.parser.isPinCodeScreen() → bool
host.parser.isProfilePage() → bool
// ... все is*Screen() методы
host.parser.findHolaButton() → NodeObj
host.parser.findNodeByContentDesc(text) → NodeObj
host.parser.findNodeByContentDescContains(text) → NodeObj
host.parser.findButtonNode(text, contains) → NodeObj
host.parser.findFirstEditText() → NodeObj
host.parser.findPinCodeEditText() → NodeObj
host.parser.findEditTextByHint(hint) → NodeObj
host.parser.parseUserNameFromHomeScreen() → string
host.parser.parseBalanceFromHomeScreen() → number
host.parser.parseProfileNombre()/Apellido/Email/Phone → string
host.parser.parseCVUNumber() → string
// ... остальные методы Black::ScreenXmlParser
```
`NodeObj`: `{ x, y, x1, y1, x2, y2, width, height, text, contentDesc, resourceId, className, hint, clickable, focusable, password, isEmpty }`.
`x`/`y` — центр элемента (как в C++ `Node::x()` / `Node::y()`).
### `host.helpers` (только для `black`)
Высокоуровневые операции, повторяющие `Black::CommonScript`:
```javascript
host.helpers.runAppAndGoToHomeScreen(deviceId, package, pin, w, h) → bool
host.helpers.postAccountsData(arrayOfMaterialObj) → void
host.helpers.createBankProfileIfNeeded(deviceId, appCode, balance) → void
```
Парсер у `helpers` и `host.parser` **один и тот же** — состояние XML
синхронизировано.
### `host.dump` — скоуп `TaskDumpRecorder`
```javascript
host.dump.beginScope(bank, taskKind, deviceId, metaObj)
host.dump.endScope(errorMessage) // "" = success
```
`metaObj`: `{ materialId, amount, phone, card }` (все поля опциональны).
Если `endScope` не вызвать, деструктор `DumpBridge` закроет скоуп без ошибки.
### `host.params`
То, что передал C++-вызыватель. Для PoC:
```javascript
host.params.deviceId
host.params.appCode
```
---
## 6. Контракт `run(host)`
JS-файл **обязан** определить функцию `run(host)` в глобальной области:
```javascript
function run(host) {
// ...
return ""; // success
// return "human-readable error"; // бизнес-ошибка
// throw new Error("..."); // engine error → fallback на C++
}
```
Возвращаемое значение трактуется так:
| Возврат | Result | Что увидит EventHandler |
|--------------------------------------|---------------|--------------------------------------------------|
| `""`, `null`, `undefined` | `Completed` | `m_error` пустой → success |
| любая непустая строка | `Completed` | `m_error = "<строка>"` → ошибка задачи |
| `throw` | `EngineError` | Лог + fallback на C++ doStart() |
| eval/parse не прошёл, нет `run(host)`| `EngineError` | То же |
**Главное различие:** возврат строки — это «скрипт отработал, но обнаружил
бизнес-проблему» (например, не нашёл нужный экран). Это **не** триггерит
fallback. Throw — это «скрипт сломался» и **триггерит** fallback.
---
## 7. Версионирование, кэш и обновление
- Источник истины: `<cache_dir>/<bank>/<script>.js`. Рядом лежит `<script>.version`
с произвольной строкой версии.
- При старте приложения `ScriptUpdater::start()` сравнивает версии с манифестом
и докачивает только то, что устарело.
- `cache_dir` по умолчанию:
- macOS: `~/Library/Application Support/<orgName>/ARC/scripts/`
- Windows: `%APPDATA%/<orgName>/ARC/scripts/`
- `bundled_dir` — каталог `scripts/` рядом с exe, кладётся CMake'ом из
`<source>/scripts/`. Это **запасной парашют**: если кэш пуст или повреждён —
работает bundled.
- Атомарность установки гарантируется `QSaveFile` (запись в `.tmp`
`commit` = rename).
**Сценарии:**
| Состояние | Что произойдёт |
|----------------------------------------|-------------------------------------------------------------|
| Кэш пуст, bundled есть | Используется bundled |
| Кэш заполнен, обновлений нет | Используется кэш |
| Манифест недоступен | Лог warning, используется то, что было |
| SHA-256 не сошлась | Лог warning, файл **не** перезаписывается |
| Обновление прошло | Новый `.js` + новый `.version`. Следующий запуск увидит его |
---
## 8. Безопасность
- **SHA-256 обязателен** — без него `ScriptUpdater` отклоняет запись манифеста.
- Хеш проверяется ПОСЛЕ скачивания, ПЕРЕД заменой кэша. При несовпадении старый
скрипт остаётся.
- Транспорт — HTTPS (HTTP/CDN тоже сработает, но не используйте в проде).
- **Из коробки нет подписи ключом сервера.** Если manifest_url подменят
(DNS hijack, MITM при кривом TLS), злоумышленник может подсунуть свой JS с
его же sha256. Если такая модель угроз релевантна, нужно добавить Ed25519
подпись манифеста (см. раздел «Дальнейшие шаги»).
- JS работает в `QJSEngine` без доступа к файловой системе/сети напрямую — только
через bridge-объекты. То есть фактически он может только делать то, что уже
умеет делать C++ Black-скрипт. Это **не** sandbox для враждебного кода, но
ограничение поверхности.
---
## 9. Отладка и поведение при ошибке
### Локальная разработка
1. Поставить `enabled=true`, `use_js_for=black/get_profile_info` в `config.ini`.
2. Положить отредактированный `get_profile_info.js` в **`bundled_dir`** (например `<build>/scripts/black/`) — кэш не используется при отсутствии manifest_url.
3. Запустить приложение. В логах `application.log` искать строки `[js:black/get_profile_info]`.
### Falback на C++
Любая из этих ситуаций триггерит fallback:
- В `config.ini` `enabled=false` или скрипта нет в `use_js_for`.
- Файл не найден ни в кэше, ни в bundled.
- `engine.evaluate()` вернул ошибку (синтаксис).
- В JS-коде нет `function run(host)`.
- Из `run(host)` бросилось исключение.
В каждом случае пишется warning вида:
```
[Black::GetProfileInfo] JS path failed ( "<диагностика>" ) — fallback to C++ implementation
```
После этого `Black::GetProfileInfoScript::doStart()` отрабатывает по своей
исторической C++-логике, и пользователь/сервер ничего не замечает.
### Дамп задачи
JS вызывает `host.dump.beginScope(...)`/`endScope(error)` ровно так же, как C++
дёргает `TaskDumpRecorder::Scope` — папка дампа, скриншоты, error-caption работают
без изменений. Если JS не успел вызвать `endScope`, деструктор `DumpBridge` закроет
скоуп с пустой ошибкой.
---
## 10. Как добавить новый скрипт
На PoC поддержан один скрипт. Чтобы добавить ещё один (например `black/pay_by_card`):
1. **C++-сторона.** В `android/black/PayByCardScript.cpp::doStart()` в начало
функции вставить ту же ветку, что уже есть в `GetProfileInfoScript.cpp`:
```cpp
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("black/pay_by_card")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
{"cardNumber", m_cardNumber},
{"amount", m_amount},
};
const auto status = Scripting::ScriptRuntime::run(
"black", "pay_by_card", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Black::PayByCard] JS failed (" << jsOut << ") fallback C++";
}
```
2. **Bridges.** Если скрипту нужны новые методы парсера или хелперов — добавить
`Q_INVOKABLE` в `BlackParserBridge` / `BlackHelpersBridge` в
`services/scripting/ScriptBridges.h/.cpp`.
3. **JS.** Положить `scripts/black/pay_by_card.js` с `function run(host)`.
Использовать `host.params.cardNumber`, `host.params.amount`.
4. **Конфиг.** В `config.ini` добавить:
```ini
[scripting]
enabled=true
use_js_for=black/get_profile_info, black/pay_by_card
```
5. **Манифест.** Опубликовать новую запись в `<manifest_url>` с новым `sha256` и
`version`.
Если нужен скрипт для другого банка — добавьте `*ParserBridge` и `*HelpersBridge`
по образцу Black, и в `ScriptRuntime::run` развилку `if (bank == "ozon") ...`.
---
## 11. Известные ограничения
- Парсер/хелперы реализованы для `black` (минимум для get_profile_info) и `ozon`
(полный набор). Для Dushanbe и оставшихся Black-скриптов bridges нужно
добавить аналогично — см. секцию 10.
- Нет hot-reload: обновление подхватится при следующем запуске задачи (новый
`QJSEngine` создаётся на каждый запуск, но файл читается уже из обновлённого
кэша).
- Нет подписи манифеста — целостность только sha256.
- Нет аутентификации при `GET <manifest_url>` — сервер должен сам ограничивать
доступ (IP-allowlist, отдельный токен в URL, basic-auth на ревёрсе и т.п.).
- `host.timer.sleep` блокирующий. Это плюс (читаемый процедурный код), но
любое await/promise-стиль писать **нельзя** — нет event loop внутри `QJSEngine`.
---
## 12. Дальнейшие шаги (после PoC)
1. **Подпись манифеста (Ed25519).** Сервер подписывает JSON приватным ключом,
клиент проверяет публичным ключом, хардкоднутым в бинарник (или
зашифрованным конфигом).
2. **Аутентификация манифеста.** `Bearer access_token` из `SettingsDAO` уже есть
в `NetworkService` — переиспользовать.
3. **Hot-reload по push.** Сервер шлёт нотификацию «обновись», апдейтер тянет
манифест немедленно, а не только при старте.
4. **Telemetry.** Отправлять на `/monitoring/log` событие при каждом успешном/
неуспешном fallback'е — поможет понять, какие скрипты часто падают.
5. **Расширение покрытия.** Перенести остальные `*Script` Black-банка, затем
Ozon и Dushanbe.
6. **Тесты.** Завести golden-tests: для каждого `<bank>/<script>` записанный
набор XML-дампов + ожидаемые ADB-операции (моки), прогоняется на JS-версии.

View File

@ -1,105 +0,0 @@
# Синхронизация статусов bank_profile и material
## Определения
### Локальные статусы (SQLite)
| Таблица | Поле | Значения |
|---|---|---|
| `bank_profiles` | `status` | `"active"` \| `"off"` |
| `materials` | `status` | `"active"` \| `"off"` (enum `MaterialStatus::Active` / `MaterialStatus::Off`) |
### Серверные статусы (REST API)
| Сущность | Поле | Значения |
|---|---|---|
| bank_profile | `state` | `"enabled"` \| `"disabled"` |
| material | `state` | `"enabled"` \| `"disabled"` |
### Маппинг
| Локально | Сервер |
|---|---|
| `active` | `enabled` |
| `off` | `disabled` |
---
## Инвариант
**Локальное состояние — единственный источник истины.** Сервер всегда отражает локаль. При любых расхождениях побеждает локальная БД, и сервер дотягивается до неё (а не наоборот).
Pull с сервера (`syncBankProfilesFromServer`) используется только для **создания новых записей** (новые bank_profile / новые материалы) и привязки серверных UUID. Статусы существующих записей с сервера в локаль никогда не пишутся.
---
## Основные правила
### 1. Смена онлайн-статуса устройства
- **Device → OFFLINE:** bank_profile этого устройства выключается **и локально, и на сервере** (`PATCH bank_profile/{id}/false`). Материалы локально не трогаем — они остаются в том состоянии, в котором были (см. правило 4-А ниже).
- **Device → ONLINE:** локальный статус **не меняется**. На сервер пушится текущее локальное состояние bank_profile и всех его материалов, если оно расходится с серверным.
### 2. Периодическая синхронизация
- Каждые **60 секунд** запускается фоновая задача, которая сравнивает локальные статусы bank_profile и материалов с серверными и отправляет `PATCH` только для расходящихся.
- Локаль не трогается. Цель — компенсировать сетевые сбои, перезапуски сервера и т.п.
### 3. Обработка ошибок при включении
- Когда пользователь включает bank_profile или material и `PATCH .../true` падает (network error / HTTP не-2xx / `success:false`):
- локально оставляем `off`;
- показываем пользователю сообщение об ошибке (см. правило 6 про UI).
- При неудачном **выключении** локально оставляем `off`; рассинхрон с сервером устранит минутный таймер из правила 2.
### 4. Реконсиляция материалов после включения профиля
- Срабатывает **только при включении** профиля (`PATCH bank_profile/{id}/true`), при выключении — нет (см. правило 4-B про гард ниже).
- Отдельной реконсиляции после PATCH не делаем — расхождения материалов подхватит 60s-таймер из правила 2 (в худшем случае — через минуту).
- Допустима оптимизация: «дёрнуть» периодический sync сразу после успешного включения профиля, чтобы не ждать.
---
## Дополнительные правила и инварианты
### 4-A. Материалы при device → OFFLINE
- При переходе device → OFFLINE правило 1 выключает только профиль. Материалы локально не трогаем — после повторного включения профиля они вернутся к работе без лишних кликов пользователя.
- В UI материалы при `bank_profile.status == "off"` отображаются **приглушённо** или с пометкой «профиль выключен», но их `status` в БД остаётся как есть.
### 4-B. Гард на пуш материала при выключенном профиле
- На сервере действует инвариант: `material.state ≤ bank_profile.state`. Если профиль `disabled`, сервер принудительно держит материалы в `disabled`.
- Чтобы не получить бесконечный diff-цикл, **при любом пуше статуса материала** (toggle в UI, периодический sync, follow-up после toggle профиля) проверяем локальный `bank_profile.status`. Если профиль `off``PATCH` материала **не отправляем**, локальный статус не меняем.
- Этот гард уже частично реализован в `AccountSettingsWindow.cpp:556`; нужно поднять его на уровень `NetworkService`, чтобы покрыть все пути пуша.
### 5. Дебаунс ADB-флапа
- ADB может мерцать (USB-хаб, спящий экран, Doze). Без дебаунса каждое мерцание сработает как device → OFFLINE.
- **Online — мгновенно** (0 секунд). Как только устройство видно — сразу триггерим логику device → ONLINE.
- **Offline — с задержкой 30 секунд.** Устройство считается офлайн только после того, как ADB **не** показывает его как `device` непрерывно в течение 30 секунд. Если за этот период оно вернулось — считаем что флапа не было.
### 6. Стартовый порядок (избегание гонки)
- `DeviceScreener` эмитит сигналы `deviceWentOnline(deviceId)` / `deviceWentOffline(deviceId)` **только при смене** статуса. На «никогда не видел в этой сессии» никаких событий нет.
- `NetworkService` стартует 60s-таймер только **после первого успешного прохода** `DeviceScreener` (сигнал `firstPassDone`). До этого никаких offline-эвентов не обрабатывает.
- Это исключает ситуацию «на старте все устройства выглядят офлайн → массово выключаем профили».
### 7. Сериализация PATCH-ов
- Mutex на пару `(deviceId, bankProfileId)`: внутри одного устройства+профиля PATCH-операции последовательны, между разными — могут идти параллельно.
- Защищает от гонки: одновременно прилетели device-event, юзер-toggle и периодический sync для одного профиля → они выстраиваются в очередь.
### 8. UI-обработка ошибок (overlay-pattern)
- При любом PATCH статуса (профиля или материала) UI показывает оверлей «Сохранение…» поверх тоггла.
- Тоггл **двигается только после ответа сервера**:
- успех → переходит в новое положение, оверлей скрывается;
- ошибка → остаётся в исходном положении, оверлей сменяется сообщением «Ошибка: …», локальный статус не меняется.
- Сейчас частично реализовано через `hideOverlay` в `AccountSettingsWindow.cpp:559`. Применить тот же pattern в `DeviceWidget`, `BankSetupWizard` и всех других местах с toggle.
### 9. Индикация неработающей синхронизации
- В `NetworkService` ведём счётчик `m_consecutiveSyncFailures`, инкрементируем при каждом проваленном тике (правило 2) или провале `GET /current_profile`. Сбрасываем при первом успехе.
- При превышении порога (например, **3 тика подряд**) эмитим сигнал в `MainWindow`, который показывает индикатор «Нет связи с сервером, последняя успешная синхронизация: HH:MM:SS».
- При следующем успехе индикатор скрывается.
### 10. Параллелизм и ограничения нагрузки
- В одном тике периодического sync может образоваться большой объём расхождений (5 устройств × 6 профилей × 4 материала = до 120 PATCH).
- Параллелизм с **лимитом 4-8 одновременных PATCH**; mutex из правила 7 соблюдается.
- Флаг `m_syncInProgress`: если предыдущий тик ещё не завершился, новый тик **пропускается**. Это предотвращает накопление очереди.
### 11. Поведение при провале `GET /current_profile`
- Если периодический `GET /current_profile` упал (таймаут/сеть/5xx) — тик **пропускается тихо**, локаль не трогаем, никакие PATCH не шлём.
- Сам факт неудачи учитывается в счётчике из правила 9.
---
## Связанные существующие правила (контекст)
- Toggle материала, когда `bank_profile.status == "off"`, **не идёт на сервер** — сохраняется только локально (см. правило 4-B). Серверная синхронизация произойдёт после включения профиля и следующего 60s-тика.
- После `BankSetupWizard` `bank_profile` остаётся `off` по дизайну — пользователь должен включить его вручную.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -55,6 +55,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) {
#include "dao/SettingsDAO.h" #include "dao/SettingsDAO.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "scripting/ScriptUpdater.h"
#include "black/PayByCardScript.h" #include "black/PayByCardScript.h"
#include "ozon/PayByPhoneScript.h" #include "ozon/PayByPhoneScript.h"
#include "widget/loader/ErrorWindow.h" #include "widget/loader/ErrorWindow.h"
@ -65,7 +66,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) {
#include <QPushButton> #include <QPushButton>
#include <QFileDialog> #include <QFileDialog>
void startDeviceScreener(const QCoreApplication &app, NetworkService *networkService = nullptr) { void startDeviceScreener(const QCoreApplication &app) {
auto *thread = new QThread(); auto *thread = new QThread();
auto *deviceScreenerWorker = new DeviceScreener; auto *deviceScreenerWorker = new DeviceScreener;
@ -76,20 +77,6 @@ void startDeviceScreener(const QCoreApplication &app, NetworkService *networkSer
// Когда поток запустится, автоматически вызовется worker->start() внутри фонового потока. // Когда поток запустится, автоматически вызовется worker->start() внутри фонового потока.
QObject::connect(thread, &QThread::started, deviceScreenerWorker, &DeviceScreener::start); QObject::connect(thread, &QThread::started, deviceScreenerWorker, &DeviceScreener::start);
// Связь с NetworkService: первый проход → старт периодической синхронизации;
// переходы online/offline → реакция в NetworkService (правило #1, #6).
if (networkService) {
QObject::connect(deviceScreenerWorker, &DeviceScreener::firstPassDone,
networkService, &NetworkService::startPeriodicSync,
Qt::QueuedConnection);
QObject::connect(deviceScreenerWorker, &DeviceScreener::deviceWentOnline,
networkService, &NetworkService::onDeviceWentOnline,
Qt::QueuedConnection);
QObject::connect(deviceScreenerWorker, &DeviceScreener::deviceWentOffline,
networkService, &NetworkService::onDeviceWentOffline,
Qt::QueuedConnection);
}
// 🔁 Эти 3 строки делают автоматическую зачистку: // 🔁 Эти 3 строки делают автоматическую зачистку:
// Когда worker закончит работу (emit finished()), мы: // Когда worker закончит работу (emit finished()), мы:
// - останавливаем поток (thread->quit()) // - останавливаем поток (thread->quit())
@ -179,7 +166,17 @@ void startBlackPayByCardTest() {
thread->start(); thread->start();
} }
NetworkService* startNetworkService(const QCoreApplication &app) { void startScriptUpdater() {
// Опрашивает manifest и подтягивает обновлённые JS-скрипты, если в
// config.ini включена секция [scripting]. При выключенной секции
// start() сразу эмитит finished — никакой нагрузки.
auto *updater = new Scripting::ScriptUpdater;
QObject::connect(updater, &Scripting::ScriptUpdater::finished,
updater, &QObject::deleteLater);
QMetaObject::invokeMethod(updater, "start", Qt::QueuedConnection);
}
void startNetworkService(const QCoreApplication &app) {
auto *paymentThread = new QThread; auto *paymentThread = new QThread;
auto *networkService = new NetworkService; auto *networkService = new NetworkService;
AppLogger::setNetworkService(networkService); AppLogger::setNetworkService(networkService);
@ -223,7 +220,6 @@ NetworkService* startNetworkService(const QCoreApplication &app) {
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
paymentThread->start(); paymentThread->start();
return networkService;
} }
static QFile logFile; static QFile logFile;
@ -372,12 +368,10 @@ int main(int argc, char *argv[]) {
auto *mainWindow = new MainWindow(); auto *mainWindow = new MainWindow();
mainWindow->show(); mainWindow->show();
registrationWindow->close(); registrationWindow->close();
NetworkService *net = startNetworkService(app); startNetworkService(app);
QObject::connect(net, &NetworkService::syncHealthChanged, startDeviceScreener(app);
mainWindow, &MainWindow::setSyncHealth,
Qt::QueuedConnection);
startDeviceScreener(app, net);
startEventHandler(app); startEventHandler(app);
startScriptUpdater();
// startBlackPayByCardTest(); // startBlackPayByCardTest();
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
}; };
@ -406,11 +400,8 @@ int main(int argc, char *argv[]) {
if (r == 1) { if (r == 1) {
auto *mainWindow = new MainWindow(); auto *mainWindow = new MainWindow();
mainWindow->show(); mainWindow->show();
NetworkService *net = startNetworkService(app); startNetworkService(app);
QObject::connect(net, &NetworkService::syncHealthChanged, startDeviceScreener(app);
mainWindow, &MainWindow::setSyncHealth,
Qt::QueuedConnection);
startDeviceScreener(app, net);
startEventHandler(app); startEventHandler(app);
// startBlackPayByCardTest(); // startBlackPayByCardTest();
} else if (r == 0) { } else if (r == 0) {
@ -430,12 +421,10 @@ int main(int argc, char *argv[]) {
} else if (result == 1) { } else if (result == 1) {
auto *mainWindow = new MainWindow(); auto *mainWindow = new MainWindow();
mainWindow->show(); mainWindow->show();
NetworkService *net = startNetworkService(app); startNetworkService(app);
QObject::connect(net, &NetworkService::syncHealthChanged, startDeviceScreener(app);
mainWindow, &MainWindow::setSyncHealth,
Qt::QueuedConnection);
startDeviceScreener(app, net);
startEventHandler(app); startEventHandler(app);
startScriptUpdater();
// startBlackPayByCardTest(); // startBlackPayByCardTest();
} else if (result == 0) { } else if (result == 0) {
SettingsDAO::remove("api_key"); SettingsDAO::remove("api_key");

View File

@ -36,8 +36,6 @@ CREATE TABLE IF NOT EXISTS bank_profiles
bank_profile_id TEXT DEFAULT '', bank_profile_id TEXT DEFAULT '',
currency TEXT DEFAULT '', currency TEXT DEFAULT '',
amount REAL DEFAULT 0, amount REAL DEFAULT 0,
app_version TEXT DEFAULT '',
app_version_checked_at DATETIME,
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE, FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE,
UNIQUE (device_id, code) UNIQUE (device_id, code)
); );
@ -91,7 +89,6 @@ CREATE TABLE IF NOT EXISTS events
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER, account_id INTEGER,
external_id TEXT, external_id TEXT,
transaction_id TEXT,
device_id TEXT, device_id TEXT,
amount REAL, amount REAL,
phone TEXT, phone TEXT,

View File

@ -13,7 +13,7 @@ public:
m_y2 = y2; m_y2 = y2;
} }
[[nodiscard]] bool isEmpty() const { bool isEmpty() {
return m_x1 < 0 && m_y1 < 0 && m_x2 < 0 && m_y2 < 0 && text.isEmpty() && className.isEmpty(); return m_x1 < 0 && m_y1 < 0 && m_x2 < 0 && m_y2 < 0 && text.isEmpty() && className.isEmpty();
} }

13
models/db/AppLogEntry.h Normal file
View File

@ -0,0 +1,13 @@
#pragma once
#include <QString>
#include <QByteArray>
#include <QDateTime>
struct AppLogEntry {
int id = -1;
QString source; // "ozon/LoginAndCheck", "ozon/GetLastTransactions", etc.
QString deviceId; // ADB device id, может быть пустым
QString message; // текст ошибки
QByteArray screenshot; // PNG-байты скриншота (может быть пустым)
QDateTime timestamp;
};

View File

@ -21,7 +21,5 @@ struct BankProfileInfo {
QString bankProfileId; QString bankProfileId;
QString currency; QString currency;
double amount = 0.0; double amount = 0.0;
QString appVersion;
QDateTime appVersionCheckedAt;
QDateTime updateTime; QDateTime updateTime;
}; };

View File

@ -56,7 +56,6 @@ inline EventType eventTypeFromString(const QString &str) {
struct EventInfo { struct EventInfo {
int id = -1; int id = -1;
QString externalId; QString externalId;
QString transactionId;
int accountId = -1; int accountId = -1;
QString deviceId; QString deviceId;

View File

@ -41,5 +41,5 @@ struct MaterialInfo {
QString description; QString description;
double amount = 0.0; double amount = 0.0;
QDateTime updateTime; QDateTime updateTime;
MaterialStatus status = MaterialStatus::Off; MaterialStatus status = MaterialStatus::Active;
}; };

View File

@ -0,0 +1,173 @@
// black/get_profile_info.js
//
// JS-порт Black::GetProfileInfoScript. Логика 1:1 с C++ (см.
// android/black/GetProfileInfoScript.cpp), обращения к ADB/DAO/парсеру
// идут через host.*-мост.
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "black"
// Возвращает:
// "" / null / undefined → success
// строку → бизнес-ошибка (попадёт в EventInfo.error)
// При выбросе исключения runtime сделает fallback на C++.
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var counter = 0;
// 1. Резолвим device (id может быть как android_id, так и adb serial).
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) {
device = host.dao.deviceFindByAdbSerial(deviceId);
}
if (device && device.adbSerial) {
deviceId = device.adbSerial;
}
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("black", "FETCH_PROFILE_INFO", deviceId, {});
try {
// 2. Читаем текущий экран
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 3. Если не на домашнем экране — перезапускаем приложение
if (!host.parser.isHomeScreen()) {
host.log.debug("Not on home screen, restarting app...");
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + deviceId + ") " + appCode;
host.log.warn(err);
host.adb.tryToKillApplication(deviceId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 4. Тапаем "¡Hola!"
var holaNode = host.parser.findHolaButton();
if (holaNode.isEmpty) {
var err2 = "Cannot find Hola button on home screen: " + deviceId;
host.log.warn(err2);
host.adb.tryToKillApplication(deviceId, app.package);
return err2;
}
// 5. Парсим имя и баланс с домашнего экрана
var fullName = host.parser.parseUserNameFromHomeScreen();
var balance = host.parser.parseBalanceFromHomeScreen();
host.log.debug("fullName from home: " + fullName + " balance: " + balance);
// Сохраняем баланс
host.dao.bankProfileUpdateAmount(app.id, balance);
host.adb.makeTap(deviceId, holaNode.x, holaNode.y);
host.timer.sleep(2000);
// 6. Ждём боковое меню
var sideMenuFound = false;
for (var i = 0; i < 5; ++i) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (host.parser.isSideMenu()) {
sideMenuFound = true;
break;
}
host.timer.sleep(1000);
}
if (!sideMenuFound) {
var err3 = "Side menu not detected: " + deviceId;
host.log.warn(err3);
host.adb.tryToKillApplication(deviceId, app.package);
return err3;
}
// 7. Тапаем "Tu Información"
var tuInfoNode = host.parser.findNodeByContentDesc("Tu Información");
if (tuInfoNode.isEmpty) {
var err4 = "Cannot find Tu Información button: " + deviceId;
host.log.warn(err4);
host.adb.tryToKillApplication(deviceId, app.package);
return err4;
}
host.adb.makeTap(deviceId, tuInfoNode.x, tuInfoNode.y);
host.timer.sleep(2500);
// 8. Ждём страницу профиля
var profileFound = false;
for (var j = 0; j < 5; ++j) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (host.parser.isProfilePage()) {
profileFound = true;
break;
}
host.timer.sleep(1000);
}
if (!profileFound) {
var err5 = "Profile page not detected: " + deviceId;
host.log.warn(err5);
host.adb.tryToKillApplication(deviceId, app.package);
return err5;
}
// 9. Парсим данные профиля
var nombre = host.parser.parseProfileNombre();
var apellido = host.parser.parseProfileApellido();
var email = host.parser.parseProfileEmail();
// 10. Скроллим вниз чтобы увидеть телефон
var x = Math.floor(device.screenWidth / 2);
var fromY = Math.floor(device.screenHeight * 3 / 4);
var toY = Math.floor(device.screenHeight / 4);
host.adb.makeSwipe(deviceId, x, fromY, x, toY);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var phone = host.parser.parseProfilePhone();
// Собираем полное имя
var parsedFullName;
if (nombre && apellido) {
parsedFullName = nombre + " " + apellido;
} else if (nombre) {
parsedFullName = nombre;
} else {
parsedFullName = fullName;
}
host.log.debug("nombre: " + nombre + " apellido: " + apellido
+ " email: " + email + " phone: " + phone
+ " fullName: " + parsedFullName);
// 11. Сохраняем профиль
if (!host.dao.bankProfileUpdateProfileInfo(app.id, parsedFullName, phone, email)) {
host.log.warn("Failed to save profile info to DB");
}
// 12. Создаём bank profile на сервере если нет
host.helpers.createBankProfileIfNeeded(deviceId, appCode, -1);
// 13. Отправляем аккаунты без material_id на сервер
var allAccounts = host.dao.materialGetAccounts(device.id, appCode);
var accountsToPost = [];
for (var k = 0; k < allAccounts.length; ++k) {
if (!allAccounts[k].materialId) accountsToPost.push(allAccounts[k]);
}
if (accountsToPost.length > 0) {
host.helpers.postAccountsData(accountsToPost);
}
host.dump.endScope("");
return ""; // success
} catch (e) {
// Любая JS-ошибка → engine error для runtime → fallback на C++.
host.dump.endScope(String(e));
throw e;
}
}

View File

@ -0,0 +1,358 @@
// ozon/get_card_info.js
//
// JS-порт Ozon::GetCardInfoScript. Логика 1:1 с C++ (см.
// android/ozon/GetCardInfoScript.cpp). Самый длинный из Ozon-скриптов:
// сканирует все карты с home screen, для каждой раскрывает полный номер
// через "Показать" с retry-логикой и постит на сервер.
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "ozon"
// params.lastNumbers — если задан, обрабатывается только одна карта
// Возвращает:
// "" / null / undefined → success
// строка → бизнес-ошибка
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var lastNumbers = host.params.lastNumbers || "";
var counter = 0;
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) {
device = host.dao.deviceFindByAdbSerial(deviceId);
}
if (device && device.adbSerial) {
deviceId = device.adbSerial;
}
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("ozon", "FETCH_CARDS", deviceId, {});
try {
// 1. Читаем текущий экран
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 2. Закрываем рекламный bottom sheet, если открыт
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.log.debug("Ad banner detected, closing...");
host.adb.makeTap(deviceId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 3. Свежий дамп перед проверкой домашнего экрана
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 4. Если не на домашнем — перезапускаем приложение
if (!host.parser.isHomeScreen()) {
host.log.debug("Not on home screen, restarting app...");
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + deviceId + ")";
host.log.warn(err);
host.adb.tryToKillApplication(deviceId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 5. Закрываем баннеры
host.helpers.tryToCloseAllBannersOnHomePage(deviceId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 6. Парсим имя/телефон, если в БД ещё нет
var hasProfileInfo = !!(app.fullName) && !!(app.phone);
if (!hasProfileInfo) {
var userNameNode = host.parser.findUserNameOnHomeScreen(device.screenHeight);
if (!userNameNode.isEmpty) {
host.log.debug("Navigating to profile to parse name/phone...");
host.adb.makeTap(deviceId, userNameNode.x, userNameNode.y);
host.timer.sleep(2500);
for (var a = 0; a < 5; ++a) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (host.parser.isProfileScreen()) break;
host.timer.sleep(1000);
}
if (host.parser.isProfileScreen()) {
var fullName = host.parser.parseProfileFullName();
var phone = host.parser.parseProfilePhone();
host.log.debug("Profile parsed - fullName: " + fullName + " phone: " + phone);
host.dao.bankProfileUpdateProfileInfo(app.id, fullName, phone, "");
} else {
host.log.warn("Profile screen not reached, skipping profile parse");
}
host.adb.goBack(deviceId);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
} else {
host.log.debug("Username not found on home, skipping profile parse");
}
} else {
host.log.debug("Profile info already in DB, skipping profile parse");
}
// 7. Проверяем что снова на домашнем экране
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (!host.parser.isHomeScreen()) {
host.log.debug("Not on home screen before card search, restarting...");
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err2 = "Cannot reach home screen before card search: " + device.name + " (" + deviceId + ")";
host.log.warn(err2);
host.adb.tryToKillApplication(deviceId, app.package);
return err2;
}
host.helpers.tryToCloseAllBannersOnHomePage(deviceId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
}
// 8. Ждём пока карты подгрузятся
var cards = [];
for (var attempt = 0; attempt < 5; ++attempt) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
cards = host.parser.parseCardsOnHomeScreen();
if (cards.length > 0) break;
host.log.debug("No cards found on home screen, attempt " + (attempt + 1) + "/5");
host.timer.sleep(1500);
}
if (cards.length === 0) {
var noCardsErr = "No cards found on home screen: " + deviceId;
host.log.warn(noCardsErr);
host.adb.tryToKillApplication(deviceId, app.package);
return noCardsErr;
}
host.log.debug("Found " + cards.length + " card(s) on home screen");
// 9. Апсёртим аккаунты
for (var ci = 0; ci < cards.length; ++ci) {
cards[ci].deviceId = device.id;
cards[ci].appCode = appCode;
cards[ci].status = "active";
if (!host.dao.materialUpsert(cards[ci])) {
host.log.warn("Failed to upsert account for card " + cards[ci].lastNumbers);
} else {
host.log.debug("Upserted account: " + cards[ci].lastNumbers
+ " " + cards[ci].description + " " + cards[ci].amount);
}
}
// 10. Баланс
var totalAmount = cards[0].amount;
if (totalAmount > 0.0) {
host.dao.bankProfileUpdateAmount(app.id, totalAmount);
}
// 11. createDevice если нет apiId
host.helpers.createDeviceIfNeeded(device);
// 12. Регистрируем/обновляем bank profile на сервере
host.helpers.createBankProfileIfNeeded(deviceId, appCode, totalAmount);
// 13. Для каждой карты раскрываем полный номер через "Показать"
for (var i = 0; i < cards.length; ++i) {
var card = cards[i];
if (lastNumbers && card.lastNumbers !== lastNumbers) continue;
host.log.debug("Getting card number for " + card.lastNumbers);
var existing = host.dao.materialFindAppAccount(device.id, appCode, card.lastNumbers);
if (existing && existing.id >= 0 && existing.cardNumber) {
host.log.debug("Card " + card.lastNumbers + " already has number, skipping");
continue;
}
var cardBtn = host.parser.findCardButtonByLastNumbers(card.lastNumbers);
if (cardBtn.isEmpty) {
host.log.warn("Card button not found for " + card.lastNumbers);
continue;
}
host.adb.makeTap(deviceId, cardBtn.x, cardBtn.y);
// 13a. Скроллим WebView вверх, пока "Показать" не окажется в верхней трети экрана
var targetTopY = Math.floor(device.screenHeight / 3);
for (var s = 0; s < 6; ++s) {
host.timer.sleep(s === 0 ? 2000 : 600);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var cand = host.parser.findButtonNode("Показать", false);
if (cand.isEmpty) {
if (host.parser.isHomeScreen()) {
var rb = host.parser.findCardButtonByLastNumbers(card.lastNumbers);
if (!rb.isEmpty) {
host.log.debug("Still on home before scroll, re-tapping card " + card.lastNumbers);
host.adb.makeTap(deviceId, rb.x, rb.y);
}
}
continue;
}
if (cand.y <= targetTopY) {
host.log.debug("'Показать' already at top y=" + cand.y);
break;
}
var swipeFromY = cand.y;
var swipeToY = Math.floor(device.screenHeight / 6);
host.log.debug("Scrolling 'Показать' up: " + swipeFromY + " -> " + swipeToY);
host.adb.makeSwipe(deviceId, Math.floor(device.screenWidth / 2), swipeFromY,
Math.floor(device.screenWidth / 2), swipeToY);
}
// 13b. Ждём стабильной геометрии: "Показать" в одной строке с "Реквизиты карты"
// И координаты не меняются в двух подряд дампах.
var showBtn = null;
var prevShowY = -1;
for (var w = 0; w < 10; ++w) {
host.timer.sleep(w === 0 ? 2000 : 500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var c = host.parser.findButtonNode("Показать", false);
var req = host.parser.findTextNode("Реквизиты карты");
if (c.isEmpty || req.isEmpty) {
if (host.parser.isHomeScreen()) {
var rb2 = host.parser.findCardButtonByLastNumbers(card.lastNumbers);
if (!rb2.isEmpty) {
host.log.debug("Still on home screen, re-tapping card " + card.lastNumbers);
host.adb.makeTap(deviceId, rb2.x, rb2.y);
}
}
prevShowY = -1;
host.log.debug("Requisites not ready, attempt " + (w + 1) + "/10");
continue;
}
if (Math.abs(c.y1 - req.y1) > 10) {
prevShowY = -1;
host.log.debug("'Показать' not aligned with 'Реквизиты карты', waiting reflow");
continue;
}
if (prevShowY >= 0 && Math.abs(c.y - prevShowY) <= 5) {
showBtn = c;
break;
}
prevShowY = c.y;
host.log.debug("'Показать' seen at y=" + c.y + ", waiting for stable frame");
}
if (!showBtn) {
host.log.warn("'Показать' not stable for card " + card.lastNumbers);
host.adb.goBack(deviceId);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
continue;
}
host.log.debug("'Показать' at " + showBtn.x + " " + showBtn.y);
host.adb.makeTap(deviceId, showBtn.x, showBtn.y);
// 13c. Ждём появления полного номера карты
var cardNumber = "";
for (var t = 0; t < 10; ++t) {
host.timer.sleep(2000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
cardNumber = host.parser.findFullCardNumber();
if (cardNumber) {
host.log.debug("Card number found on attempt " + (t + 1));
break;
}
// Баннер "Понятно" — закрываем и повторно тапаем "Показать"
var banner = host.parser.findTextNode("Понятно");
if (!banner.isEmpty) {
host.log.debug("Banner 'Понятно' detected, closing...");
host.adb.makeTap(deviceId, banner.x, banner.y);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var retry = host.parser.findButtonNode("Показать", false);
if (!retry.isEmpty) {
host.log.debug("Re-tapping 'Показать' after banner close");
host.adb.makeTap(deviceId, retry.x, retry.y);
}
continue;
}
// "Подождите" — загрузка идёт
if (!host.parser.findButtonNode("Подождите", false).isEmpty) {
host.log.debug("'Подождите' visible, waiting... attempt " + (t + 1) + "/10");
continue;
}
// "Скрыть" видна — номер должен быть, но парсер не нашёл
if (!host.parser.findButtonNode("Скрыть", false).isEmpty) {
host.log.debug("'Скрыть' visible but card number not parsed, attempt " + (t + 1) + "/10");
continue;
}
// Повторный тап "Показать"
var retry2 = host.parser.findButtonNode("Показать", false);
if (!retry2.isEmpty) {
host.log.debug("Re-tapping 'Показать', attempt " + (t + 1) + "/10");
host.adb.makeTap(deviceId, retry2.x, retry2.y);
} else {
host.log.debug("Card number not visible, attempt " + (t + 1) + "/10");
}
}
if (cardNumber) {
host.log.debug("Card " + card.lastNumbers + " full number: " + cardNumber);
var saved = host.dao.materialFindAppAccount(device.id, appCode, card.lastNumbers);
if (saved && saved.id >= 0) {
host.dao.materialUpdateCardNumber(saved.id, cardNumber);
}
} else {
var noNumErr = "Card number not found for " + card.lastNumbers;
host.log.warn(noNumErr);
var s2 = host.dao.materialFindAppAccount(device.id, appCode, card.lastNumbers);
if (s2 && s2.id >= 0) {
host.dao.materialUpdateAccountById(s2.id, "off", s2.description, s2.currency);
}
}
// Возвращаемся на домашний для следующей карты
if (i < cards.length - 1) {
host.adb.goBack(deviceId);
host.timer.sleep(2000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
}
// 14. Отправляем материалы на сервер — после того как все номера в БД
var updatedApp = host.dao.bankProfileGet(device.id, appCode);
if (updatedApp && updatedApp.bankProfileId) {
for (var k = 0; k < cards.length; ++k) {
var dbCard = host.dao.materialFindAppAccount(device.id, appCode, cards[k].lastNumbers);
if (!dbCard || dbCard.id < 0) continue;
if (!dbCard.cardNumber) continue;
if (dbCard.materialId) {
host.log.debug("material already exists for card " + dbCard.lastNumbers + ", skipping");
continue;
}
host.helpers.postMaterial(dbCard, updatedApp.bankProfileId, updatedApp.fullName);
host.log.debug("postMaterial done for card " + dbCard.lastNumbers);
}
} else {
host.log.warn("bankProfileId empty, skipping materials");
}
host.adb.tryToKillApplication(deviceId, app.package);
host.dump.endScope("");
return ""; // success
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}

View File

@ -0,0 +1,665 @@
// ozon/get_last_transactions.js
//
// JS-порт Ozon::GetLastTransactionsScript. Логика 1:1 с C++ (см.
// android/ozon/GetLastTransactionsScript.cpp). Самый длинный из Ozon-скриптов:
// навигация до "Операции" → либо поиск конкретной транзакции по сумме+времени,
// либо сканирование последних N транзакций.
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "ozon"
// params.maxCount — 0 = фильтр по 24ч, >0 = N последних
// params.searchAmount — сумма в рублях для поиска (0 = scan-mode)
// params.searchTime — ISO-строка времени (UTC, "" если scan-mode)
// params.materialId — UUID задачи (для дампа)
//
// Особенности порта:
// - таймзону устройства игнорируем, используем MSK (+3ч) — Ozon только в РФ.
// В C++ берётся getDeviceTimezone(), но в 99% случаев это всё равно МСК.
// - host.script.emitTransactionParsed(jsonStr) шлёт результат в EventHandler.
// - OCR для bank_transaction_id (когда XML не отдал) — через host.ocr.
var MSK_OFFSET_MIN = 180; // +03:00
var MONTH_MAP = {
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12
};
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var maxCount = host.params.maxCount | 0;
var searchAmount = +host.params.searchAmount || 0;
var searchTimeIso = host.params.searchTime || "";
var materialId = host.params.materialId || "";
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) device = host.dao.deviceFindByAdbSerial(deviceId);
if (device && device.adbSerial) deviceId = device.adbSerial;
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("ozon", "FETCH_TRANSACTIONS", deviceId, {
materialId: materialId, amount: searchAmount,
});
var counter = {n: 0};
try {
if (!navigateToTransactionList(host, device, app, deviceId, counter)) {
var navErr = "Transaction list screen not loaded: " + deviceId;
host.dump.endScope(navErr);
return navErr;
}
var accountId = -1;
var accounts = host.dao.materialGetAccounts(device.id, appCode);
if (accounts.length > 0) accountId = accounts[0].id;
else host.log.warn("No account found for " + device.id + " " + appCode);
var result;
if (Math.abs(searchAmount) > 0.01) {
result = searchTransaction(host, device, deviceId, appCode, accountId,
searchAmount, searchTimeIso, counter);
} else {
result = scanRecentTransactions(host, device, deviceId, appCode, accountId,
maxCount, counter);
}
host.dump.endScope(result || "");
return result;
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}
// ─── navigateToTransactionList ─────────────────────────────────────────────
function navigateToTransactionList(host, device, app, deviceId, counter) {
function tryRefreshIfError() {
var refreshBtn = host.parser.findNodeByContentDesc
? host.parser.findNodeByContentDesc("Обновить")
: findNodeByDescFallback(host, "Обновить");
if (refreshBtn && refreshBtn.x > 0 && refreshBtn.y > 0) {
host.log.debug("Page load error, tapping 'Обновить'");
host.adb.makeTap(deviceId, refreshBtn.x, refreshBtn.y);
host.timer.sleep(5000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
}
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
tryRefreshIfError();
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.adb.makeTap(deviceId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
var stillAd = host.parser.tryToFindAdBanner();
if (!stillAd.isEmpty) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
}
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
// Уже на экране операций — скроллим к началу
if (host.parser.isTransactionListScreen()) {
host.log.debug("Already on tx list, scrolling to top");
for (var i = 0; i < 5; ++i) {
host.adb.makeSwipeWithDuration(deviceId,
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight / 3),
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight * 2 / 3),
200);
host.timer.sleep(500);
}
host.timer.sleep(1000);
return true;
}
// Не на домашнем → перезапуск
if (!host.parser.isHomeScreen()) {
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
host.log.warn("Cannot reach home screen");
host.adb.tryToKillApplication(deviceId, app.package);
return false;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
tryRefreshIfError();
}
host.helpers.tryToCloseAllBannersOnHomePage(deviceId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
// Скроллим до "Все" и тапаем
var navBarTop = device.screenHeight - Math.floor(device.screenHeight / 10);
var allBtn = {isEmpty: true};
for (var j = 0; j < 10; ++j) {
if (host.timer.isInterrupted()) return false;
var x = Math.floor(device.screenWidth / 2);
host.adb.makeSwipeWithDuration(deviceId, x, device.screenHeight - 200, x, 200, 100);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
allBtn = host.parser.findButtonNode("Все", false);
if (!allBtn.isEmpty && allBtn.y > 0 && allBtn.y < navBarTop) break;
allBtn = {isEmpty: true};
}
if (allBtn.isEmpty) return false;
host.adb.makeTap(deviceId, allBtn.x, allBtn.y);
host.timer.sleep(2000);
// Ждём экран "Операции"
for (var a = 0; a < 10; ++a) {
if (host.timer.isInterrupted()) return false;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (host.parser.isTransactionListScreen()) return true;
host.timer.sleep(1500);
}
return false;
}
// Fallback: ищем по content-desc через findAllNodes если bridge не имеет findNodeByContentDesc.
function findNodeByDescFallback(host, desc) {
var all = host.parser.findAllNodes();
for (var i = 0; i < all.length; ++i) {
if (all[i].contentDesc === desc) return all[i];
}
return {isEmpty: true, x: -1, y: -1};
}
// ─── Дата-хелперы ──────────────────────────────────────────────────────────
// Парсит заголовок "Сегодня"/"Вчера"/"DD месяца, день_недели" в Date (00:00 MSK).
function parseDateHeader(text, searchYear) {
var t = (text || "").trim();
var nowMsk = nowMskDate();
if (t.toLowerCase() === "сегодня") return atMidnightMsk(nowMsk);
if (t.toLowerCase() === "вчера") return atMidnightMsk(addDays(nowMsk, -1));
var m = t.match(/(\d{1,2})\s+(\S+),\s+\S+/);
if (!m) return null;
var day = parseInt(m[1], 10);
var monName = m[2].toLowerCase();
var mon = MONTH_MAP[monName] || 0;
if (mon === 0) return null;
return new Date(Date.UTC(searchYear, mon - 1, day) - MSK_OFFSET_MIN * 60 * 1000);
}
function nowMskDate() {
return new Date(Date.now() + MSK_OFFSET_MIN * 60 * 1000); // shift to MSK
}
function atMidnightMsk(d) {
// d уже в MSK-shifted виде
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
- MSK_OFFSET_MIN * 60 * 1000);
}
function addDays(d, n) {
return new Date(d.getTime() + n * 86400000);
}
function sameDate(a, b) {
if (!a || !b) return false;
return a.getTime() === b.getTime();
}
function isBefore(a, b) {
return a && b && a.getTime() < b.getTime();
}
function formatDateMsk(d) {
var msk = new Date(d.getTime() + MSK_OFFSET_MIN * 60 * 1000);
function pad(n) { return n < 10 ? '0' + n : '' + n; }
return pad(msk.getUTCDate()) + "." + pad(msk.getUTCMonth() + 1) + "." + msk.getUTCFullYear();
}
// ─── searchTransaction ─────────────────────────────────────────────────────
function searchTransaction(host, device, deviceId, appCode, accountId,
searchAmount, searchTimeIso, counter) {
var maxScrolls = 30;
var searchTime = searchTimeIso ? new Date(searchTimeIso) : null;
if (!searchTime || isNaN(searchTime.getTime())) return "Invalid searchTime";
var searchDate = atMidnightMsk(new Date(searchTime.getTime() + MSK_OFFSET_MIN * 60 * 1000));
var searchYear = new Date(searchTime.getTime() + MSK_OFFSET_MIN * 60 * 1000).getUTCFullYear();
function detectNavBarTop() {
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.resourceId === "ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder"
&& n.height > 0) return n.y1;
}
return device.screenHeight - Math.floor(device.screenHeight / 10);
}
var navBarTop = detectNavBarTop();
host.log.debug("Searching: amount=" + searchAmount + " date=" + formatDateMsk(searchDate));
// Считаем сколько дат на каждой y-координате (для детекции схлопнутых)
function computeDateYCount() {
var cnt = {};
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.className !== "android.widget.TextView") continue;
if (n.y <= 0) continue;
if (parseDateHeader(n.text, searchYear)) {
cnt[n.y] = (cnt[n.y] || 0) + 1;
}
}
return cnt;
}
function hasDateOnScreen() {
var yc = computeDateYCount();
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.className !== "android.widget.TextView") continue;
if (n.y <= 0) continue;
if ((yc[n.y] || 0) !== 1) continue;
var d = parseDateHeader(n.text, searchYear);
if (d && sameDate(d, searchDate)) return true;
}
return false;
}
function hasOlderDateOnScreen() {
var yc = computeDateYCount();
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.className !== "android.widget.TextView") continue;
if (n.y <= 0) continue;
if ((yc[n.y] || 0) !== 1) continue;
var d = parseDateHeader(n.text, searchYear);
if (d && isBefore(d, searchDate)) return true;
}
return false;
}
// Ждём WebView accessibility tree
for (var w = 0; w < 5; ++w) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (host.parser.findTransactionButtons(1).length > 0) break;
if (hasDateOnScreen()) break;
host.timer.sleep(2000);
}
// Скроллим до нужной даты
var dateFound = false;
for (var s = 0; s < maxScrolls; ++s) {
if (host.timer.isInterrupted()) return "Interrupted";
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (hasDateOnScreen()) { dateFound = true; break; }
if (hasOlderDateOnScreen()) { host.log.debug("Passed target date"); break; }
host.adb.makeSwipeWithDuration(deviceId,
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight * 2 / 3),
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight / 3),
600);
host.timer.sleep(1500);
}
if (!dateFound) {
return "Date not found in transaction list: " + formatDateMsk(searchDate);
}
function findDateYRange() {
var yc = computeDateYCount();
function isVisible(y) { return y > 0 && (yc[y] || 0) === 1; }
var dateY = -1;
var nextDateY = device.screenHeight;
var foundOlder = false;
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.className !== "android.widget.TextView") continue;
var d = parseDateHeader(n.text, searchYear);
if (!d) continue;
if (sameDate(d, searchDate)) {
if (isVisible(n.y)) dateY = n.y;
} else if (isBefore(d, searchDate)) {
foundOlder = true;
if (isVisible(n.y) && (dateY < 0 || n.y > dateY) && n.y < nextDateY) {
nextDateY = n.y;
}
}
}
if (dateY < 0 && nextDateY < device.screenHeight) dateY = 0;
return [dateY, nextDateY];
}
var checked = {};
var noProgress = 0;
for (var attempt = 0; attempt < 15; ++attempt) {
if (host.timer.isInterrupted()) return "Interrupted";
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
var range = findDateYRange();
var dateHeaderY = range[0], nextDateHeaderY = range[1];
if (dateHeaderY < 0) {
host.adb.makeSwipeWithDuration(deviceId,
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight * 2 / 3),
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight / 3), 500);
host.timer.sleep(1500);
continue;
}
if (dateHeaderY === 0 && nextDateHeaderY < device.screenHeight) {
host.adb.makeSwipeWithDuration(deviceId,
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight / 3),
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight * 2 / 3), 600);
host.timer.sleep(1500);
continue;
}
var txButtons = host.parser.findTransactionButtons(20);
var tapped = false, hasNewButton = false, buttonsInRange = 0;
for (var bi = 0; bi < txButtons.length; ++bi) {
if (host.timer.isInterrupted()) return "Interrupted";
var btn = txButtons[bi];
if (btn.y1 <= dateHeaderY || btn.y1 >= nextDateHeaderY) continue;
if (btn.y1 <= 0 || btn.y1 >= navBarTop) continue;
++buttonsInRange;
var parsed = host.parser.parseTransactionButtonText(btn.text);
if (Math.abs(parsed.amount - searchAmount) > 0.01) continue;
var safeMaxY = navBarTop - 20;
var tapY = btn.y;
if (tapY > safeMaxY) tapY = Math.max(btn.y1 + 30, safeMaxY);
host.adb.makeTap(deviceId, btn.x, tapY);
host.timer.sleep(2000);
var detail = {bankTime: ""};
for (var da = 0; da < 10; ++da) {
if (host.timer.isInterrupted()) return "Interrupted";
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
detail = host.parser.parseTransactionDetail();
if (detail.bankTime) break;
if (host.parser.isTransactionListScreen()) {
host.adb.makeTap(deviceId, btn.x, btn.y);
}
host.timer.sleep(1500);
}
if (!detail.bankTime) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
continue;
}
var dtStr = detail.bankTime.replace(/[ ]/g, ' ');
var txTime = parseBankTime(dtStr);
if (checked[dtStr]) {
host.adb.goBack(deviceId);
host.timer.sleep(500);
continue;
}
checked[dtStr] = true;
tapped = true;
hasNewButton = true;
// Дата принадлежит нужной?
if (txTime) {
var txMsk = new Date(txTime.getTime() + MSK_OFFSET_MIN * 60 * 1000);
var txDate = new Date(Date.UTC(txMsk.getUTCFullYear(), txMsk.getUTCMonth(),
txMsk.getUTCDate()) - MSK_OFFSET_MIN * 60 * 1000);
if (!sameDate(txDate, searchDate)) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
continue;
}
// ±10 минут
if (searchTime) {
var diffSec = Math.abs((txTime.getTime() - searchTime.getTime()) / 1000);
if (diffSec > 600) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
continue;
}
}
}
host.log.debug("FOUND transaction: amount=" + parsed.amount + " time=" + dtStr);
// Скриншот деталей для receipt
host.adb.captureScreenshotBase64(deviceId);
var externalId = detail.bankTrExternalId || "";
if (accountId !== -1) {
var fallbackId = externalId || ("ozon_" + dtStr.replace(/,/g, '').trim().replace(/ /g, '_'));
var tx = {
accountId: accountId,
amount: parsed.amount,
bankName: appCode,
bankTime: dtStr,
bankTrExternalId: fallbackId,
status: "complete",
timestamp: new Date().toISOString(),
};
if (txTime) tx.completeTime = txTime.toISOString();
if (detail.name) tx.name = detail.name;
if (detail.description) tx.description = detail.description;
if (detail.phone) {
tx.phone = (detail.phone || "").replace(/[^0-9]/g, '');
tx.type = "phone";
}
host.dao.transactionInsert(tx);
}
// Шлём в EventHandler через signalSink
var finalId = externalId || ("ozon_" + dtStr.replace(/,/g, '').trim().replace(/ /g, '_'));
var emitObj = {
bank_transaction_id: finalId,
amount: Math.round(parsed.amount * 100),
currency: 643,
transaction_type: "bank",
bank_participator_id: detail.phone || detail.name || "",
status: "completed",
extra: {fee: 0},
created_at: txTime ? txTime.toISOString() : null,
};
host.script.emitTransactionParsed(JSON.stringify(emitObj));
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return ""; // success
}
if (!hasNewButton && tapped) {
if (++noProgress >= 5) break;
} else if (hasNewButton) noProgress = 0;
if (!tapped || !hasNewButton) {
var cx = Math.floor(device.screenWidth / 2);
if (dateHeaderY === 0 && nextDateHeaderY < device.screenHeight) {
host.adb.makeSwipeWithDuration(deviceId, cx,
Math.floor(device.screenHeight * 2 / 5), cx,
Math.floor(device.screenHeight * 3 / 4), 600);
host.timer.sleep(1500);
} else {
var safeBottom = navBarTop - 30;
var amplitude;
if (dateHeaderY > 0 && dateHeaderY < safeBottom - 100) {
var safeTop = Math.max(dateHeaderY + 80, Math.floor(device.screenHeight / 4));
amplitude = Math.min(safeBottom - safeTop, 700);
} else {
amplitude = Math.min(safeBottom - Math.floor(device.screenHeight / 4), 700);
}
amplitude = Math.max(amplitude, 300);
for (var sw = 0; sw < 3; ++sw) {
host.adb.makeSwipeWithDuration(deviceId, cx, safeBottom,
cx, safeBottom - amplitude, 250);
host.timer.sleep(350);
}
host.timer.sleep(800);
}
}
}
return "Transaction not found: amount=" + searchAmount.toFixed(2)
+ " date=" + formatDateMsk(searchDate);
}
// Парсит "dd.MM.yyyy, HH:mm" или "dd.MM.yyyy,HH:mm" → UTC Date (MSK-localized).
function parseBankTime(s) {
var m = (s || "").match(/(\d{2})\.(\d{2})\.(\d{4}),?\s*(\d{2}):(\d{2})/);
if (!m) return null;
var d = parseInt(m[1], 10), mo = parseInt(m[2], 10), y = parseInt(m[3], 10);
var hh = parseInt(m[4], 10), mm = parseInt(m[5], 10);
return new Date(Date.UTC(y, mo - 1, d, hh, mm) - MSK_OFFSET_MIN * 60 * 1000);
}
// ─── scanRecentTransactions ────────────────────────────────────────────────
function scanRecentTransactions(host, device, deviceId, appCode, accountId, maxCount, counter) {
var navBarTop = device.screenHeight - Math.floor(device.screenHeight / 10);
// Ждём появления транзакций
var txButtons = [];
for (var i = 0; i < 5; ++i) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
txButtons = host.parser.findTransactionButtons(8);
if (txButtons.length > 0) break;
host.timer.sleep(1500);
}
var transactions = [];
for (var i2 = 0; i2 < txButtons.length; ++i2) {
var btn = txButtons[i2];
if (btn.y <= 0 || btn.y >= navBarTop) {
for (var s = 0; s < 5; ++s) {
var x = Math.floor(device.screenWidth / 2);
var offset = Math.floor(device.screenHeight / 4);
host.adb.makeSwipe(deviceId, x, device.screenHeight - offset, x, offset);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
txButtons = host.parser.findTransactionButtons(8);
if (i2 < txButtons.length && txButtons[i2].y > 0 && txButtons[i2].y < navBarTop) break;
}
if (i2 >= txButtons.length) break;
}
var visibleBtn = txButtons[i2];
var tx = host.parser.parseTransactionButtonText(visibleBtn.text);
host.adb.makeTap(deviceId, visibleBtn.x, visibleBtn.y);
host.timer.sleep(2000);
var detail = {bankTime: ""};
for (var da = 0; da < 10; ++da) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
detail = host.parser.parseTransactionDetail();
if (detail.bankTime) break;
if (host.parser.isTransactionListScreen()) {
host.adb.makeTap(deviceId, visibleBtn.x, visibleBtn.y);
}
host.timer.sleep(1500);
}
tx.bankName = appCode;
tx.bankTime = detail.bankTime;
tx.bankTrExternalId = detail.bankTrExternalId || "";
// Если ID не найден — пробуем через "Документы" + OCR
if (!tx.bankTrExternalId) {
var docBtn = host.parser.findButtonNode("Документы", false);
if (!docBtn.isEmpty) {
host.adb.makeTap(deviceId, docBtn.x, docBtn.y);
host.timer.sleep(2000);
var docLoaded = false;
for (var di = 0; di < 15; ++di) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (!findNodeByDescFallback(host, "Поделиться").isEmpty) {
docLoaded = true; break;
}
host.timer.sleep(1500);
}
if (docLoaded) {
host.timer.sleep(500);
var ocrText = host.ocr.recognizeFromScreen(deviceId);
if (ocrText) {
tx.bankTrExternalId = host.ocr.extractTransactionId(ocrText) || "";
}
}
host.adb.goBack(deviceId);
host.timer.sleep(1000);
}
}
tx.status = "complete";
tx.timestamp = new Date().toISOString();
if (detail.name) tx.name = detail.name;
if (detail.phone) {
tx.phone = (detail.phone || "").replace(/[^0-9]/g, '');
tx.type = "phone";
}
if (tx.bankTime) {
var dtStr = tx.bankTime.replace(/[ ]/g, ' ');
tx.bankTime = dtStr;
var parsed = parseBankTime(dtStr);
if (parsed) tx.completeTime = parsed.toISOString();
}
// Фильтр по времени (24ч)
if (maxCount <= 0 && tx.completeTime) {
var ageSec = (Date.now() - new Date(tx.completeTime).getTime()) / 1000;
if (ageSec > 86400) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
break;
}
}
if (accountId !== -1) {
tx.accountId = accountId;
host.dao.transactionInsert(tx);
// Receipt + OCR-id через "Документы" — упрощённо, без двойного перехода.
// Если уже извлекли ID выше — не повторяем.
}
transactions.push(tx);
if (maxCount > 0 && transactions.length >= maxCount) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
break;
}
host.adb.goBack(deviceId);
host.timer.sleep(1500);
for (var wa = 0; wa < 5; ++wa) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (host.parser.isTransactionListScreen()) break;
host.timer.sleep(1000);
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
txButtons = host.parser.findTransactionButtons(8);
}
return ""; // success
}

View File

@ -0,0 +1,166 @@
// ozon/get_profile_info.js
//
// JS-порт Ozon::GetProfileInfoScript. Логика 1:1 с C++ (см.
// android/ozon/GetProfileInfoScript.cpp). Все ADB/DAO/парсер-вызовы идут
// через host.*-мост, состояние парсера общее с host.helpers.
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "ozon"
// Возвращает:
// "" / null / undefined → success
// строка → бизнес-ошибка (попадёт в EventInfo.error
// и в BankSetupWizard.humanReadableError)
// При throw runtime сделает fallback на C++.
//
// Сообщения об ошибках в этом файле ДОЛЖНЫ совпадать с подстроками из
// views/bank/BankSetupWizard.cpp:659 (humanReadableError), иначе мастер
// покажет "Произошла непредвиденная ошибка" вместо конкретной диагностики.
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var counter = 0;
// 1. Резолвим device (id может быть как android_id, так и adb serial).
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) {
device = host.dao.deviceFindByAdbSerial(deviceId);
}
if (device && device.adbSerial) {
deviceId = device.adbSerial;
}
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("ozon", "FETCH_PROFILE_INFO", deviceId, {});
try {
// 2. Читаем текущий экран
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 3. Закрываем рекламный bottom sheet, если открыт (home_ad_1)
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.log.debug("Ad banner detected, closing...");
host.adb.makeTap(deviceId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 4. Свежий дамп перед проверкой домашнего экрана
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 5. Если не на домашнем экране — перезапускаем приложение и вводим пин-код
if (!host.parser.isHomeScreen()) {
host.log.debug("Not on home screen, restarting app...");
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + deviceId + ") " + appCode;
host.log.warn(err);
host.adb.tryToKillApplication(deviceId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 6. Закрываем баннеры на главной, если есть
host.helpers.tryToCloseAllBannersOnHomePage(deviceId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 7. Парсим баланс с домашнего экрана
var parsedCards = host.parser.parseCardsOnHomeScreen();
var parsedBalance = parsedCards.length > 0 ? parsedCards[0].amount : 0.0;
host.log.debug("parseCardsOnHomeScreen returned " + parsedCards.length
+ " cards, balance: " + parsedBalance);
// 8. Fallback: если парсинг не нашёл баланс, берём из materials
if (parsedBalance <= 0.0) {
var accounts = host.dao.materialGetAccounts(device.id, appCode);
for (var i = 0; i < accounts.length; ++i) {
if (accounts[i].amount > 0.0) {
parsedBalance = accounts[i].amount;
break;
}
}
host.log.debug("Fallback balance from materials: " + parsedBalance);
}
if (parsedBalance > 0.0) {
host.dao.bankProfileUpdateAmount(app.id, parsedBalance);
}
// 9. Находим имя пользователя в верхней части и тапаем по нему
var userNameNode = host.parser.findUserNameOnHomeScreen(device.screenHeight);
if (userNameNode.isEmpty) {
var err2 = "Cannot find user name on home screen: " + deviceId;
host.log.warn(err2);
host.adb.tryToKillApplication(deviceId, app.package);
return err2;
}
host.log.debug("Tapping on user name: " + userNameNode.text
+ " at " + userNameNode.x + " " + userNameNode.y);
host.adb.makeTap(deviceId, userNameNode.x, userNameNode.y);
// 10. Ждём загрузки страницы профиля
host.timer.sleep(2500);
// 11. Проверяем что мы действительно на экране профиля (с retry)
var profileFound = false;
for (var j = 0; j < 5; ++j) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (host.parser.isProfileScreen()) {
profileFound = true;
break;
}
host.timer.sleep(1000);
}
if (!profileFound) {
var err3 = "Profile screen not detected after tap: " + deviceId;
host.log.warn(err3);
host.adb.tryToKillApplication(deviceId, app.package);
return err3;
}
// 12. Извлекаем полное имя и телефон. Telephone из Ozon XML может
// содержать неразрывные пробелы (U+00A0) и обычные — чистим оба.
var fullName = host.parser.parseProfileFullName();
var phone = host.parser.parseProfilePhone().replace(/[\s ]/g, '');
host.log.debug("fullName: " + fullName + " phone: " + phone);
// 13. Сохраняем профиль (email Ozon не отдаёт — передаём пустую строку)
if (!host.dao.bankProfileUpdateProfileInfo(app.id, fullName, phone, "")) {
host.log.warn("Failed to save profile info to DB: " + deviceId);
}
// 14. Регистрируем/обновляем bank profile на сервере. Хелпер сам
// перечитывает свежий профиль из БД и шлёт POST/PATCH по наличию bankProfileId.
host.helpers.createBankProfileIfNeeded(deviceId, appCode, parsedBalance);
// 15. Отправляем аккаунты без material_id на сервер
var allAccounts = host.dao.materialGetAccounts(device.id, appCode);
var accountsToPost = [];
for (var k = 0; k < allAccounts.length; ++k) {
if (!allAccounts[k].materialId) accountsToPost.push(allAccounts[k]);
}
if (accountsToPost.length > 0) {
host.helpers.postAccountsData(accountsToPost);
}
// 16. Возвращаемся на домашний экран
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.dump.endScope("");
return ""; // success
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}

View File

@ -0,0 +1,118 @@
// ozon/login_and_check_accounts.js
//
// JS-порт Ozon::LoginAndCheckAccountsScript. Логика 1:1 с C++ (см.
// android/ozon/LoginAndCheckAccountsScript.cpp).
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "ozon"
// params.fetchProfileOnly — true, если карты не нужно постить (используется в payment flow)
// params.pinCheckOnly — true, если только проверить пин (используется в мастере)
// Возвращает:
// "" / null / undefined → success
// строка → бизнес-ошибка
//
// Финальный вызов GetCardInfoScript (если !fetchProfileOnly && !pinCheckOnly)
// делается в C++ снаружи JS-блока — см. LoginAndCheckAccountsScript.cpp.
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var fetchProfileOnly = !!host.params.fetchProfileOnly;
var pinCheckOnly = !!host.params.pinCheckOnly;
// 1. Резолвим device (id может быть как android_id, так и adb serial).
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) {
device = host.dao.deviceFindByAdbSerial(deviceId);
}
if (device && device.adbSerial) {
deviceId = device.adbSerial;
}
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("ozon", "FETCH_PROFILE", deviceId, {});
try {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, 1));
// 2. Логин + переход на домашний экран
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cant open the home screen: " + device.name + " (" + deviceId + ") " + appCode;
host.log.warn(err);
// Скриншот: захватываем, но эмит сигнала недоступен из JS — пишем
// только в БД через комментарий профиля.
host.adb.captureScreenshotBase64(deviceId);
host.dao.bankProfileUpdateComment(app.id, "Не прошла проверка " + formatTimestamp());
host.adb.tryToKillApplication(deviceId, app.package);
host.dump.endScope(err);
return err;
}
// 3. Только проверка пин-кода — выходим
if (pinCheckOnly) {
host.dump.endScope("");
return "";
}
// Захватываем скриншот домашнего экрана (просто фиксируем — без emit)
host.adb.captureScreenshotBase64(deviceId);
// 4. Парсим баланс с домашнего экрана
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, 2));
var parsedCards = host.parser.parseCardsOnHomeScreen();
var parsedBalance = parsedCards.length > 0 ? parsedCards[0].amount : 0.0;
host.log.debug("Parsed balance: " + parsedBalance + " cards: " + parsedCards.length);
if (parsedBalance > 0.0) {
host.dao.bankProfileUpdateAmount(app.id, parsedBalance);
}
// 5. Регистрируем/обновляем bank profile на сервере
host.helpers.createBankProfileIfNeeded(deviceId, appCode, parsedBalance);
// 6. Идём в "Финансы" и парсим список аккаунтов
var result = "";
if (host.helpers.goToAllProducts(deviceId, device.screenWidth, device.screenHeight)) {
var accountsToUpsert = [];
var entries = host.parser.parseAccountsInfo();
for (var i = 0; i < entries.length; ++i) {
var acc = entries[i].material;
acc.deviceId = device.id;
acc.appCode = appCode;
if (!host.dao.materialUpsert(acc)) {
result = "Not updated: " + acc.lastNumbers;
host.log.error(result);
}
accountsToUpsert.push(acc);
}
if (!fetchProfileOnly && accountsToUpsert.length > 0) {
host.helpers.postAccountsData(accountsToUpsert);
}
} else {
host.log.warn("Cant go to all products: " + deviceId + " " + appCode);
}
host.dump.endScope(result);
return result; // "" = success, иначе последняя не-фатальная ошибка upsert
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}
// "dd.MM.yyyy HH:mm:ss" — формат, который пишет C++ через
// QDateTime::currentDateTime().toString.
function formatTimestamp() {
var d = new Date();
function pad(n) { return n < 10 ? '0' + n : '' + n; }
return pad(d.getDate()) + "." + pad(d.getMonth() + 1) + "." + d.getFullYear()
+ " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
}

359
scripts/ozon/pay_by_card.js Normal file
View File

@ -0,0 +1,359 @@
// ozon/pay_by_card.js
//
// JS-порт Ozon::PayByCardScript. Логика 1:1 с C++ (см.
// android/ozon/PayByCardScript.cpp).
//
// Контракт:
// params.account — {id, materialId, appCode, deviceId, lastNumbers, cardNumber, currency}
// params.cardNumber — номер карты получателя
// params.amount — сумма перевода
// Возвращает:
// "" / null / undefined → success
// строка → бизнес-ошибка
//
// OCR для bank_transaction_id здесь не используется (см. комментарий в
// PayByCardScript.cpp:453). Externalid формируется как fallback "ozon_<phone>_<ts>".
function run(host) {
var account = host.params.account;
var cardNumber = host.params.cardNumber;
var amount = host.params.amount;
var device = host.dao.deviceGetById(account.deviceId);
var app = host.dao.bankProfileGet(account.deviceId, account.appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + account.deviceId;
host.log.error(msg);
return msg;
}
var adbId = device.adbSerial;
var counter = 0;
host.dump.beginScope("ozon", "CREATE_TRANSACTION_CARD", adbId, {
materialId: account.materialId,
amount: amount,
card: cardNumber,
});
try {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
// Закрываем рекламный bottom sheet
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.log.debug("Ad banner detected, closing...");
host.adb.makeTap(adbId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
// Если не на домашнем — перезапускаем
if (!host.parser.isHomeScreen()) {
if (!host.helpers.runAppAndGoToHomeScreen(adbId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
host.adb.tryToKillApplication(adbId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
// Закрываем баннеры
host.helpers.tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
// Google Play "Доступно обновление"
var gp = host.parser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight);
if (!gp.isEmpty) {
host.adb.makeTap(adbId, gp.x, gp.y);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
// Баннер "Заказать карту"
var orderCard = host.parser.findTextNode("Заказать карту");
if (!orderCard.isEmpty) {
host.adb.makeTap(adbId, Math.floor(device.screenWidth / 2),
Math.floor(device.screenHeight / 20));
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
var result = makePayment(host, adbId, app.package, app.pinCode, app.phone,
device.screenWidth, device.screenHeight,
account, cardNumber, amount, counter);
host.dump.endScope(result || "");
return result;
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}
// Возвращает m_error (пустая строка = success).
function makePayment(host, deviceId, packageName, pinCode, bankProfilePhone,
width, height, account, cardNumber, amount, counter) {
function checkApp() {
if (!host.helpers.isAppRunning(deviceId, packageName)) {
return "Приложение было закрыто";
}
return null;
}
// 1. "Перевести" на домашнем экране
var transferBtn = host.parser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty) return "Кнопка 'Перевести' не найдена на домашнем экране";
host.adb.makeTap(deviceId, transferBtn.x, transferBtn.y);
host.timer.sleep(2000);
// 2. Ждём экран "Платежи и переводы"
var cardBtn = {isEmpty: true};
var paymentsPageLoaded = false;
var titleSeen = false;
{
var freezePrev = "", freezeStreak = 0;
for (var i = 0; i < 10; ++i) {
var e = checkApp(); if (e) return e;
var xml = host.adb.getScreenDumpAsXml(deviceId, ++counter);
host.parser.parseAndSaveXml(xml);
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (host.helpers.isUsefulDump(xml)) {
if (freezePrev && xml === freezePrev) {
if (++freezeStreak >= 3) { host.log.warn("screen frozen waiting payments"); break; }
} else freezeStreak = 0;
freezePrev = xml;
}
var title = host.parser.findTextNode("Платежи и переводы");
if (!title.isEmpty) titleSeen = true;
cardBtn = host.parser.findButtonNode("По номеру карты", false);
if (!title.isEmpty && !cardBtn.isEmpty) { paymentsPageLoaded = true; break; }
host.timer.sleep(1500);
}
}
if (!paymentsPageLoaded) {
return titleSeen ? "Кнопка 'По номеру карты' не найдена на экране платежей"
: "Экран 'Платежи и переводы' не загрузился";
}
// 3. Тапаем "По номеру карты"
host.adb.makeTap(deviceId, cardBtn.x, cardBtn.y);
host.timer.sleep(2000);
// 4. Ждём "Перевод по номеру карты"
var cardScreenLoaded = false;
{
var fp = "", fs = 0;
for (var j = 0; j < 10; ++j) {
var e2 = checkApp(); if (e2) return e2;
var xml2 = host.adb.getScreenDumpAsXml(deviceId, ++counter);
host.parser.parseAndSaveXml(xml2);
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (host.helpers.isUsefulDump(xml2)) {
if (fp && xml2 === fp) { if (++fs >= 3) { host.log.warn("screen frozen waiting card screen"); break; } }
else fs = 0;
fp = xml2;
}
if (!host.parser.findTextNode("Перевод по номеру карты").isEmpty) { cardScreenLoaded = true; break; }
host.timer.sleep(1500);
}
}
if (!cardScreenLoaded) return "Экран 'Перевод по номеру карты' не загрузился";
// 5. Поле "Номер карты получателя" + PAN fallback
var cardInput = {isEmpty: true};
for (var k = 0; k < 10; ++k) {
var e3 = checkApp(); if (e3) return e3;
cardInput = host.parser.findEditTextByHint("Номер карты получателя");
if (cardInput.isEmpty) cardInput = host.parser.findPanCardNumberInput();
if (!cardInput.isEmpty) break;
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
}
if (cardInput.isEmpty) return "Поле 'Номер карты получателя' не найдено";
host.adb.makeTap(deviceId, cardInput.x, cardInput.y);
host.timer.sleep(500);
host.adb.inputText(deviceId, cardNumber);
host.timer.sleep(2000);
host.adb.hideKeyboard(deviceId);
host.timer.sleep(800);
// 6. Поле суммы (с fallback по input___ rid / bottom-most EditText)
var sumInput = {isEmpty: true};
for (var s = 0; s < 10; ++s) {
var e4 = checkApp(); if (e4) return e4;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
sumInput = host.parser.findEditTextByHint("Сумма от");
if (!sumInput.isEmpty) break;
var candidates = [];
var allNodes = host.parser.findAllNodes();
for (var ni = 0; ni < allNodes.length; ++ni) {
var n = allNodes[ni];
if (n.className !== "android.widget.EditText") continue;
if (n.height <= 0 || n.height > height * 4 / 5) continue;
candidates.push(n);
}
// Strategy 1: rid="input___"
for (var c1 = 0; c1 < candidates.length; ++c1) {
if (String(candidates[c1].resourceId || "").indexOf("input___") === 0) {
sumInput = candidates[c1]; break;
}
}
// Strategy 2: bottom-most
if (sumInput.isEmpty && candidates.length >= 2) {
candidates.sort(function(a, b) { return a.y1 - b.y1; });
sumInput = candidates[candidates.length - 1];
}
if (!sumInput.isEmpty) break;
host.timer.sleep(1500);
}
if (sumInput.isEmpty) return "Поле ввода суммы не найдено";
host.adb.makeTap(deviceId, sumInput.x, sumInput.y);
host.timer.sleep(500);
host.adb.inputText(deviceId, String(Math.floor(amount)));
host.timer.sleep(1000);
host.adb.hideKeyboard(deviceId);
host.timer.sleep(800);
// 7. Ждём расчёта комиссии
var fee = 0;
var feeCalculated = false;
for (var f = 0; f < 15; ++f) {
var e5 = checkApp(); if (e5) return e5;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
var foundKomissiya = false;
var nodesf = host.parser.findAllNodes();
for (var nf = 0; nf < nodesf.length; ++nf) {
var node = nodesf[nf];
if (node.className === "android.widget.TextView" && (node.text || "").trim() === "Комиссия") {
foundKomissiya = true; continue;
}
if (foundKomissiya && node.className === "android.widget.TextView" && node.text) {
var feeText = (node.text || "").trim();
if (feeText.indexOf("Рассчитаем") === 0) { foundKomissiya = false; break; }
var digits = feeText.replace(/[^0-9,\.]/g, '').replace(',', '.');
fee = parseFloat(digits) || 0;
feeCalculated = true;
host.log.debug("Fee parsed: " + fee + " from text: " + feeText);
break;
}
}
if (feeCalculated) break;
host.timer.sleep(1500);
}
if (!feeCalculated) host.log.warn("Fee not calculated, proceeding with fee=0");
// 8. Тапаем "Перевести" (retry если bounds 0)
var transferBtn2 = {isEmpty: true};
for (var a = 0; a < 5; ++a) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
transferBtn2 = host.parser.findButtonNode("Перевести", true);
if (!transferBtn2.isEmpty && transferBtn2.height > 0) break;
host.adb.hideKeyboard(deviceId);
host.timer.sleep(1000);
}
if (transferBtn2.isEmpty || transferBtn2.height === 0) {
return "Кнопка 'Перевести' не найдена или перекрыта клавиатурой";
}
host.adb.makeTap(deviceId, transferBtn2.x, transferBtn2.y);
host.timer.sleep(3000);
// 8.5. Возможный PIN-экран после confirm
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var pinNodes = host.parser.tryToFindPinCode(pinCode, "Введите ПИН");
if (pinNodes && pinNodes.length > 0) {
host.helpers.inputPinCode(deviceId, pinNodes);
host.timer.sleep(3000);
}
// 9. Ждём результат
var freezePrev2 = "", freezeStreak2 = 0;
for (var w = 0; w < 15; ++w) {
var e6 = checkApp(); if (e6) return e6;
var xml3 = host.adb.getScreenDumpAsXml(deviceId, ++counter);
host.parser.parseAndSaveXml(xml3);
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (host.helpers.isUsefulDump(xml3)) {
if (freezePrev2 && xml3 === freezePrev2) {
if (++freezeStreak2 >= 3) { host.log.warn("screen frozen waiting result"); break; }
} else freezeStreak2 = 0;
freezePrev2 = xml3;
}
// PIN-экран мог появиться позже
var pinLater = host.parser.tryToFindPinCode(pinCode, "Введите ПИН");
if (pinLater && pinLater.length > 0) {
host.helpers.inputPinCode(deviceId, pinLater);
host.timer.sleep(3000);
continue;
}
if (!host.parser.findTextNode("Успешно").isEmpty) {
// Сохраняем транзакцию
var nowMs = Date.now();
var tx = {
accountId: account.id,
amount: -amount,
fee: fee,
phone: cardNumber,
bankName: "ozon",
type: "card",
status: "complete",
timestamp: new Date().toISOString(),
completeTime: new Date().toISOString(),
bankTime: formatBankTime(new Date()),
};
var txId = host.dao.transactionInsert(tx);
host.log.debug("Saved tx to DB, id: " + txId + " fee: " + fee);
// Receipt + external_id
if (txId !== -1) {
host.helpers.saveTransactionReceipt(deviceId, txId);
var externalId = "ozon_" + bankProfilePhone + "_" + Math.floor(nowMs / 1000);
host.dao.transactionUpdateBankTrExternalId(txId, externalId);
}
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return ""; // success
}
if (!host.parser.findTextNode("Не удалось").isEmpty) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return "Перевод не удался (экран 'Не удалось')";
}
host.timer.sleep(2000);
}
return "Не дождались результата перевода";
}
// "dd.MM.yyyy, HH:mm" — MSK-таймзона как в C++
// (.toTimeZone(QTimeZone("Europe/Moscow"))).
function formatBankTime(d) {
// Берём смещение MSK (+03:00) от UTC и форматируем вручную, чтобы не зависеть
// от локали JS-движка.
var mskMs = d.getTime() + (3 * 60 - d.getTimezoneOffset() * -1) * 60 * 1000 + d.getTimezoneOffset() * 60 * 1000;
// Проще: добавляем 3 часа к UTC.
var utc = d.getTime();
var msk = new Date(utc + 3 * 3600 * 1000);
function pad(n) { return n < 10 ? '0' + n : '' + n; }
return pad(msk.getUTCDate()) + "." + pad(msk.getUTCMonth() + 1) + "." + msk.getUTCFullYear()
+ ", " + pad(msk.getUTCHours()) + ":" + pad(msk.getUTCMinutes());
}

View File

@ -0,0 +1,430 @@
// ozon/pay_by_phone.js
//
// JS-порт Ozon::PayByPhoneScript. Логика 1:1 с C++ (см.
// android/ozon/PayByPhoneScript.cpp).
//
// Контракт:
// params.account — {id, materialId, appCode, deviceId, lastNumbers, currency}
// params.phone — телефон получателя
// params.bankName — название банка получателя (для "Другой банк")
// params.amount — сумма перевода
function run(host) {
var account = host.params.account;
var phone = host.params.phone;
var bankName = host.params.bankName;
var amount = host.params.amount;
var device = host.dao.deviceGetById(account.deviceId);
var app = host.dao.bankProfileGet(account.deviceId, account.appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + account.deviceId;
host.log.error(msg);
return msg;
}
var adbId = device.adbSerial;
var counter = 0;
host.dump.beginScope("ozon", "CREATE_TRANSACTION_PHONE", adbId, {
materialId: account.materialId,
amount: amount,
phone: phone,
});
try {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.adb.makeTap(adbId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
if (!host.parser.isHomeScreen()) {
if (!host.helpers.runAppAndGoToHomeScreen(adbId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
host.adb.tryToKillApplication(adbId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
host.helpers.tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
var gp = host.parser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight);
if (!gp.isEmpty) {
host.adb.makeTap(adbId, gp.x, gp.y);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
var orderCard = host.parser.findTextNode("Заказать карту");
if (!orderCard.isEmpty) {
host.adb.makeTap(adbId, Math.floor(device.screenWidth / 2),
Math.floor(device.screenHeight / 20));
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
var result = makePayment(host, adbId, app.package, app.pinCode, app.phone,
device.screenWidth, device.screenHeight,
account, phone, bankName, amount, counter);
host.dump.endScope(result || "");
return result;
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}
function makePayment(host, deviceId, packageName, pinCode, bankProfilePhone,
width, height, account, phone, bankName, amount, counter) {
function checkApp() {
if (!host.helpers.isAppRunning(deviceId, packageName)) {
return "Приложение было закрыто";
}
return null;
}
// 1. "Перевести"
var transferBtn = host.parser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty) return "Кнопка 'Перевести' не найдена на домашнем экране";
host.adb.makeTap(deviceId, transferBtn.x, transferBtn.y);
host.timer.sleep(2000);
// 2. Ждём экран "Платежи и переводы" с первым EditText
var paymentsPageLoaded = false;
for (var i = 0; i < 10; ++i) {
var e = checkApp(); if (e) return e;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (!host.parser.findFirstEditText().isEmpty) { paymentsPageLoaded = true; break; }
host.timer.sleep(1500);
}
if (!paymentsPageLoaded) return "Экран 'Платежи и переводы' не загрузился";
// 3. Тапаем "Телефон получателя"
var phoneFieldTap = host.parser.findFirstEditText();
host.adb.makeTap(deviceId, phoneFieldTap.x, phoneFieldTap.y);
host.timer.sleep(2000);
// 4. Ждём "Кому перевести"
var phoneScreenLoaded = false;
for (var j = 0; j < 10; ++j) {
var e2 = checkApp(); if (e2) return e2;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
var notNow = host.parser.findButtonNode("Не сейчас", false);
if (!notNow.isEmpty) {
host.adb.makeTap(deviceId, notNow.x, notNow.y);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
if (!host.parser.findTextNode("Кому перевести").isEmpty) { phoneScreenLoaded = true; break; }
host.timer.sleep(1500);
}
if (!phoneScreenLoaded) return "Экран 'Кому перевести' не загрузился";
// 5. Вводим телефон
var phoneInput = {isEmpty: true};
for (var k = 0; k < 10; ++k) {
var e3 = checkApp(); if (e3) return e3;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
phoneInput = host.parser.findFirstEditText();
if (!phoneInput.isEmpty) break;
host.timer.sleep(1500);
}
if (phoneInput.isEmpty) return "Поле ввода телефона не найдено";
host.adb.makeTap(deviceId, phoneInput.x, phoneInput.y);
host.timer.sleep(500);
host.adb.inputText(deviceId, phone);
host.timer.sleep(2000);
// 6. Ждём экран "По номеру телефона" с обработкой выбора банка / последних
var sumScreenLoaded = false;
var bankSelected = false;
var otherBankClicked = false;
var freezePrev = "", freezeStreak = 0;
var phoneDigits = phone.replace(/[^0-9]/g, '');
for (var attempt = 0; attempt < 20; ++attempt) {
var e4 = checkApp(); if (e4) return e4;
var xml = host.adb.getScreenDumpAsXml(deviceId, ++counter);
host.parser.parseAndSaveXml(xml);
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (host.helpers.isUsefulDump(xml)) {
if (freezePrev && xml === freezePrev) {
if (++freezeStreak >= 3) { host.log.warn("screen frozen"); break; }
} else freezeStreak = 0;
freezePrev = xml;
}
// Уже на экране суммы?
var sumTitle = host.parser.findTextNode("По номеру телефона");
if (!sumTitle.isEmpty && sumTitle.y < height / 7) {
sumScreenLoaded = true;
break;
}
// Кнопка с этим телефоном (новый перевод / последние)
var foundPhoneBtn = false;
var allBtns = host.parser.findAllButtons();
for (var bi = 0; bi < allBtns.length; ++bi) {
var btn = allBtns[bi];
var btnDigits = (btn.text || "").replace(/[^0-9]/g, '');
if (btnDigits.indexOf(phoneDigits) !== -1
|| (btnDigits.length >= 10 && phoneDigits.indexOf(btnDigits.slice(-10)) !== -1)) {
host.log.debug("Found button with phone: " + btn.text);
host.adb.makeTap(deviceId, btn.x, btn.y);
host.timer.sleep(2000);
foundPhoneBtn = true;
break;
}
}
if (foundPhoneBtn) continue;
// Экран выбора банка
if (!bankSelected) {
// Вариант 1: "Другой банк" (content-desc="other-bank-item")
var foundOtherBank = false;
var allNodes = host.parser.findAllNodes();
for (var ai = 0; ai < allNodes.length; ++ai) {
var n = allNodes[ai];
if ((n.contentDesc || "").indexOf("other-bank-item") !== -1) {
host.adb.makeTap(deviceId, n.x, n.y);
host.timer.sleep(2000);
foundOtherBank = true;
otherBankClicked = true;
break;
}
}
if (foundOtherBank) continue;
// Вариант 2: после клика на "Другой банк" — поиск + выбор
if (otherBankClicked) {
var bankInput = host.parser.findFirstEditText();
if (!bankInput.isEmpty) {
// Переключаем IME до тапа — иначе layout сдвинется
if (!host.adb.enableAdbIMEKeyboard(deviceId)) {
return "Не удалось активировать ADB Keyboard (см. логи 'ime list -s')";
}
host.timer.sleep(800);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var freshBankInput = host.parser.findFirstEditText();
if (!freshBankInput.isEmpty) bankInput = freshBankInput;
host.adb.makeTap(deviceId, bankInput.x, bankInput.y);
host.timer.sleep(500);
host.adb.inputAdbIMEText(deviceId, bankName);
host.timer.sleep(2500);
// Верификация
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var verify = host.parser.findFirstEditText();
if (!verify.text) {
host.log.warn("Поле банка пустое — ретрай");
host.adb.makeTap(deviceId, bankInput.x, bankInput.y);
host.timer.sleep(500);
host.adb.inputAdbIMEText(deviceId, bankName);
host.timer.sleep(2500);
}
host.adb.disableAdbIMEKeyboard(deviceId);
host.timer.sleep(2000);
// Ищем банк в списке (Button с непустым content-desc, не служебный)
var bankFound = false;
for (var ba = 0; ba < 10; ++ba) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var searchField = host.parser.findFirstEditText();
if (!searchField.text || !(searchField.text || "").trim()) {
host.timer.sleep(1500);
continue;
}
var all2 = host.parser.findAllNodes();
for (var ai2 = 0; ai2 < all2.length; ++ai2) {
var node2 = all2[ai2];
if (node2.className === "android.widget.Button"
&& node2.contentDesc
&& node2.contentDesc !== "base-go-back-button"
&& node2.contentDesc !== "native-toolbar-close-button"
&& node2.contentDesc !== "clear-input-icon") {
var tapX = node2.x1 + Math.floor(node2.width / 4);
host.log.debug("Found bank: " + node2.contentDesc + " tapX=" + tapX);
host.adb.makeTap(deviceId, tapX, node2.y);
bankFound = true;
break;
}
}
if (bankFound) break;
host.timer.sleep(1500);
}
if (!bankFound) return "Банк '" + bankName + "' не найден в списке";
bankSelected = true;
host.timer.sleep(2000);
continue;
}
}
}
host.timer.sleep(1500);
}
if (!sumScreenLoaded) return "Экран ввода суммы не загрузился";
// 7. Вводим сумму — берём верхний (минимальный y1) EditText с input___
var sumInput = {isEmpty: true};
for (var sa = 0; sa < 10; ++sa) {
var e5 = checkApp(); if (e5) return e5;
var candidates = [];
var allNodes2 = host.parser.findAllNodes();
for (var ci = 0; ci < allNodes2.length; ++ci) {
var nn = allNodes2[ci];
if (nn.className !== "android.widget.EditText") continue;
if (nn.height <= 0 || nn.height > height * 4 / 5) continue;
if (String(nn.resourceId || "").indexOf("input___") !== 0) continue;
candidates.push(nn);
}
if (candidates.length > 0) {
candidates.sort(function(a, b) { return a.y1 - b.y1; });
sumInput = candidates[0]; // верхний = amount
break;
}
sumInput = host.parser.findFirstEditText();
if (!sumInput.isEmpty) break;
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
}
if (sumInput.isEmpty) return "Поле ввода суммы не найдено";
host.adb.makeTap(deviceId, sumInput.x, sumInput.y);
host.timer.sleep(500);
host.adb.inputText(deviceId, String(Math.floor(amount)));
host.timer.sleep(1000);
host.adb.hideKeyboard(deviceId);
host.timer.sleep(800);
// 8. "Продолжить"
var continueBtn = {isEmpty: true};
for (var ca = 0; ca < 5; ++ca) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
continueBtn = host.parser.findButtonNode("Продолжить", false);
if (!continueBtn.isEmpty && continueBtn.height > 0) break;
host.adb.hideKeyboard(deviceId);
host.timer.sleep(1000);
}
if (continueBtn.isEmpty || continueBtn.height === 0) {
return "Кнопка 'Продолжить' не найдена или перекрыта клавиатурой";
}
host.adb.makeTap(deviceId, continueBtn.x, continueBtn.y);
host.timer.sleep(2000);
// 9. Ждём "Подтвердите перевод"
var confirmLoaded = false;
for (var cf = 0; cf < 10; ++cf) {
var e6 = checkApp(); if (e6) return e6;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (!host.parser.findTextNode("Подтвердите перевод").isEmpty) { confirmLoaded = true; break; }
host.timer.sleep(1500);
}
if (!confirmLoaded) return "Экран подтверждения не загрузился";
// 10. Тапаем "Перевести ..."
var transferConfirmBtn = {isEmpty: true};
for (var tc = 0; tc < 10; ++tc) {
var e7 = checkApp(); if (e7) return e7;
transferConfirmBtn = host.parser.findButtonNode("Перевести", true);
if (!transferConfirmBtn.isEmpty) break;
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
}
if (transferConfirmBtn.isEmpty) return "Кнопка 'Перевести ...' не найдена на экране подтверждения";
host.adb.makeTap(deviceId, transferConfirmBtn.x, transferConfirmBtn.y);
host.timer.sleep(3000);
// PIN после confirm
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var pinNodes = host.parser.tryToFindPinCode(pinCode, "Введите ПИН");
if (pinNodes && pinNodes.length > 0) {
host.helpers.inputPinCode(deviceId, pinNodes);
host.timer.sleep(3000);
}
// 11. Ждём результат
for (var w = 0; w < 15; ++w) {
var e8 = checkApp(); if (e8) return e8;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
var pinLater = host.parser.tryToFindPinCode(pinCode, "Введите ПИН");
if (pinLater && pinLater.length > 0) {
host.helpers.inputPinCode(deviceId, pinLater);
host.timer.sleep(3000);
continue;
}
if (!host.parser.findTextNode("Успешно").isEmpty) {
var tx = {
accountId: account.id,
amount: -amount,
phone: phone,
bankName: "ozon",
type: "phone",
status: "complete",
timestamp: new Date().toISOString(),
completeTime: new Date().toISOString(),
bankTime: formatBankTime(new Date()),
};
var txId = host.dao.transactionInsert(tx);
host.log.debug("Saved tx to DB, id: " + txId);
if (txId !== -1) {
host.helpers.saveTransactionReceipt(deviceId, txId);
var externalId = "ozon_" + bankProfilePhone + "_" + Math.floor(Date.now() / 1000);
host.dao.transactionUpdateBankTrExternalId(txId, externalId);
}
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return "";
}
if (!host.parser.findTextNode("Не удалось").isEmpty) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return "Перевод не удался (экран 'Не удалось')";
}
host.timer.sleep(2000);
}
return "Не дождались результата перевода";
}
function formatBankTime(d) {
var utc = d.getTime();
var msk = new Date(utc + 3 * 3600 * 1000);
function pad(n) { return n < 10 ? '0' + n : '' + n; }
return pad(msk.getUTCDate()) + "." + pad(msk.getUTCMonth() + 1) + "." + msk.getUTCFullYear()
+ ", " + pad(msk.getUTCHours()) + ":" + pad(msk.getUTCMinutes());
}

View File

@ -22,7 +22,6 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "dao/SettingsDAO.h" #include "dao/SettingsDAO.h"
#include "net/NetworkErrorUtils.h"
DeviceScreener::DeviceScreener(QObject *parent) : QObject(parent) { DeviceScreener::DeviceScreener(QObject *parent) : QObject(parent) {
const QSettings settings("config.ini", QSettings::IniFormat); const QSettings settings("config.ini", QSettings::IniFormat);
@ -132,8 +131,7 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body)
QJsonValue result; QJsonValue result;
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical().noquote() << "DeviceScreener apiPost timeout:" qCritical() << "DeviceScreener apiPost timeout:" << path;
<< NetworkErrorUtils::describeClientTimeout(reply, path);
reply->abort(); reply->abort();
} else if (reply->error() == QNetworkReply::NoError) { } else if (reply->error() == QNetworkReply::NoError) {
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
@ -143,8 +141,7 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body)
else qCritical() << "DeviceScreener apiPost error:" << root; else qCritical() << "DeviceScreener apiPost error:" << root;
} }
} else { } else {
qCritical().noquote() << "DeviceScreener apiPost error:" qCritical() << "DeviceScreener apiPost network error:" << reply->errorString();
<< NetworkErrorUtils::describeNetError(reply, path);
} }
reply->deleteLater(); reply->deleteLater();
return result; return result;
@ -169,12 +166,10 @@ void DeviceScreener::apiPatch(const QString &path, const QJsonObject &body) {
loop.exec(); loop.exec();
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical().noquote() << "DeviceScreener apiPatch timeout:" qCritical() << "DeviceScreener apiPatch timeout:" << path;
<< NetworkErrorUtils::describeClientTimeout(reply, path);
reply->abort(); reply->abort();
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
qCritical().noquote() << "DeviceScreener apiPatch error:" qCritical() << "DeviceScreener apiPatch network error:" << reply->errorString();
<< NetworkErrorUtils::describeNetError(reply, path);
} }
reply->deleteLater(); reply->deleteLater();
} }
@ -230,12 +225,10 @@ void DeviceScreener::postScreenshotToApi(const QString &apiId, const QByteArray
loop.exec(); loop.exec();
if (!timeout.isActive()) { if (!timeout.isActive()) {
qWarning().noquote() << "postScreenshotToApi timeout:" << apiId qWarning() << "postScreenshotToApi timeout for device" << apiId;
<< NetworkErrorUtils::describeClientTimeout(reply, path);
reply->abort(); reply->abort();
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
qWarning().noquote() << "postScreenshotToApi error:" << apiId qWarning() << "postScreenshotToApi network error:" << reply->errorString();
<< NetworkErrorUtils::describeNetError(reply, path);
} else { } else {
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
if (doc.isObject() && doc.object()["success"].toBool()) { if (doc.isObject() && doc.object()["success"].toBool()) {
@ -266,6 +259,26 @@ void DeviceScreener::updateDeviceOnApi(const DeviceInfo &device) {
apiPatch("/api/v1/device/" + device.apiId, body); apiPatch("/api/v1/device/" + device.apiId, body);
} }
void DeviceScreener::toggleBankProfilesOnApi(const QString &deviceId, bool enable) {
if (enable) return; // не включаем автоматически при появлении устройства
const QList<BankProfileInfo> apps = BankProfileDAO::getAllApplicationsByDeviceId(deviceId);
for (const BankProfileInfo &app : apps) {
if (app.bankProfileId.isEmpty()) continue;
if (app.status != "active") continue;
apiPatch("/api/v1/profile/bank_profile/" + app.bankProfileId + "/false", QJsonObject());
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, app.code);
for (const MaterialInfo &mat : materials) {
if (mat.materialId.isEmpty()) continue;
apiPatch("/api/v1/profile/material/" + mat.materialId + "/false", QJsonObject());
}
}
BankProfileDAO::deactivateAppsForDevice(deviceId);
}
QJsonValue DeviceScreener::apiGet(const QString &path) { QJsonValue DeviceScreener::apiGet(const QString &path) {
if (!m_manager) m_manager = new QNetworkAccessManager(this); if (!m_manager) m_manager = new QNetworkAccessManager(this);
@ -285,8 +298,7 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
QJsonValue result; QJsonValue result;
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical().noquote() << "DeviceScreener apiGet timeout:" qCritical() << "DeviceScreener apiGet timeout:" << path;
<< NetworkErrorUtils::describeClientTimeout(reply, path);
reply->abort(); reply->abort();
} else if (reply->error() == QNetworkReply::NoError) { } else if (reply->error() == QNetworkReply::NoError) {
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
@ -296,8 +308,7 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
else qCritical() << "DeviceScreener apiGet error:" << root; else qCritical() << "DeviceScreener apiGet error:" << root;
} }
} else { } else {
qCritical().noquote() << "DeviceScreener apiGet error:" qCritical() << "DeviceScreener apiGet network error:" << reply->errorString();
<< NetworkErrorUtils::describeNetError(reply, path);
} }
reply->deleteLater(); reply->deleteLater();
return result; return result;
@ -306,12 +317,15 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
void DeviceScreener::start() { void DeviceScreener::start() {
while (m_running) { while (m_running) {
QList<QPair<QString, QString> > devices = readAdbDevices(); // Устройства, которые были онлайн в БД до текущего скана
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане const QStringList prevOnline = DeviceDAO::getOnlineDeviceCodes();
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
QList<QPair<QString, QString> > devices = readAdbDevices();
QStringList onlineIds;
QList<DeviceInfo> deviceInfos;
for (const auto &[adbSerial, status]: devices) { for (const auto &[adbSerial, status]: devices) {
DeviceInfo device; DeviceInfo device;
bool isChecked = true;
if (status == "device") { if (status == "device") {
device = readDeviceInfo(adbSerial); device = readDeviceInfo(adbSerial);
@ -339,7 +353,8 @@ void DeviceScreener::start() {
} }
} }
liveSet.insert(device.id); onlineIds << device.id;
isChecked = prevOnline.contains(device.id);
device.image = takeScreenshot(adbSerial); device.image = takeScreenshot(adbSerial);
if (!device.image.isEmpty()) { if (!device.image.isEmpty()) {
@ -352,6 +367,7 @@ void DeviceScreener::start() {
} }
device.status = status; device.status = status;
device.updateTime = QDateTime::currentDateTime(); device.updateTime = QDateTime::currentDateTime();
deviceInfos.append(device);
if (status == "device") { if (status == "device") {
if (!DeviceDAO::upsertDevice(device)) { if (!DeviceDAO::upsertDevice(device)) {
qDebug() << "Cant update device"; qDebug() << "Cant update device";
@ -362,22 +378,6 @@ void DeviceScreener::start() {
if (!BankProfileDAO::checkInstalledApps(device.id, apps)) { if (!BankProfileDAO::checkInstalledApps(device.id, apps)) {
qWarning() << "Cant check installed apps"; qWarning() << "Cant check installed apps";
} }
// Обновляем версии установленных банков (не чаще раза в 6 часов на профиль)
constexpr qint64 kAppVersionRefreshSecs = 6 * 60 * 60;
const QDateTime nowUtc = QDateTime::currentDateTimeUtc();
for (const BankProfileInfo &profile : BankProfileDAO::getApplicationsByDeviceId(device.id)) {
if (profile.package.isEmpty()) continue;
const bool stale = !profile.appVersionCheckedAt.isValid()
|| profile.appVersionCheckedAt.secsTo(nowUtc) >= kAppVersionRefreshSecs;
if (!stale) continue;
const QString ver = AdbUtils::getAppVersion(adbSerial, profile.package);
if (ver.isEmpty()) continue; // не затираем известную версию пустотой
if (!BankProfileDAO::updateAppVersion(profile.id, ver)) {
qWarning() << "Cant update app_version for" << profile.code << profile.package;
}
}
} }
// Синхронизация с API // Синхронизация с API
@ -405,57 +405,26 @@ void DeviceScreener::start() {
} }
} }
// Detect transitions: if (!DeviceDAO::markDevicesOfflineExcept(onlineIds)) {
// 1) Online: устройство есть в liveSet, но ещё не в m_onlineDevices — мгновенно qDebug() << "Cant mark devices offline";
for (const QString &id : liveSet) {
if (!m_onlineDevices.contains(id)) {
m_onlineDevices.insert(id);
m_offlineSince.remove(id);
qDebug() << "[DeviceScreener] device went ONLINE:" << id;
emit deviceWentOnline(id);
} else {
// Устройство всё ещё видно — сбрасываем pending offline-дебаунс (флап)
m_offlineSince.remove(id);
}
} }
// 2) Offline: устройство было в m_onlineDevices, но больше не видно // Было онлайн в БД, сейчас офлайн — выключаем профили и обновляем статус на сервере
// Засекаем m_offlineSince; срабатывает после 30s непрерывного отсутствия. for (const QString &id : prevOnline) {
QSet<QString> goneOffline; if (!onlineIds.contains(id)) {
for (const QString &id : m_onlineDevices) { toggleBankProfilesOnApi(id, false);
if (liveSet.contains(id)) continue; // Отправляем OFFLINE статус на сервер
if (!m_offlineSince.contains(id)) {
m_offlineSince[id] = nowSec;
qDebug() << "[DeviceScreener] device missing, offline debounce started:" << id;
} else if (nowSec - m_offlineSince[id] >= kOfflineDebounceSecs) {
goneOffline.insert(id);
}
}
for (const QString &id : goneOffline) {
m_onlineDevices.remove(id);
m_offlineSince.remove(id);
qDebug() << "[DeviceScreener] device went OFFLINE (debounced):" << id;
// Сообщаем серверу что устройство офлайн (отдельно от bank_profile-синка)
const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id); const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id);
if (!offlineDevice.apiId.isEmpty()) { if (!offlineDevice.apiId.isEmpty()) {
DeviceInfo offlineCopy = offlineDevice; DeviceInfo offlineCopy = offlineDevice;
offlineCopy.status = "offline"; offlineCopy.status = "offline";
updateDeviceOnApi(offlineCopy); updateDeviceOnApi(offlineCopy);
} }
emit deviceWentOffline(id);
} }
// БД status: считаем онлайном только debounced-онлайн.
QStringList debouncedOnlineList;
for (const QString &id : m_onlineDevices) debouncedOnlineList << id;
if (!DeviceDAO::markDevicesOfflineExcept(debouncedOnlineList)) {
qDebug() << "Cant mark devices offline";
} }
if (m_firstScan) { if (m_firstScan) {
m_firstScan = false; m_firstScan = false;
qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices;
emit firstPassDone();
} }
QThread::msleep(1000); QThread::msleep(1000);
} }

View File

@ -1,8 +1,6 @@
#pragma once #pragma once
#include <QObject> #include <QObject>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QSet>
#include <QMap>
#include <dao/DeviceDAO.h> #include <dao/DeviceDAO.h>
#include <db/DeviceInfo.h> #include <db/DeviceInfo.h>
@ -14,8 +12,6 @@ public:
~DeviceScreener() override; ~DeviceScreener() override;
static constexpr qint64 kOfflineDebounceSecs = 30;
public slots: public slots:
void start(); void start();
@ -23,9 +19,6 @@ public slots:
signals: signals:
void finished(); void finished();
void firstPassDone();
void deviceWentOnline(const QString &deviceId);
void deviceWentOffline(const QString &deviceId);
private: private:
bool m_running = true; bool m_running = true;
@ -35,8 +28,6 @@ private:
QString m_apiBase; QString m_apiBase;
QNetworkAccessManager *m_manager = nullptr; QNetworkAccessManager *m_manager = nullptr;
QMap<QString, qint64> m_lastApiUpdate; // deviceId → unix timestamp последнего PATCH QMap<QString, qint64> m_lastApiUpdate; // deviceId → unix timestamp последнего PATCH
QSet<QString> m_onlineDevices; // дебаунс-онлайн (true online после dedup)
QMap<QString, qint64> m_offlineSince; // deviceId → когда пропал из ADB (для дебаунса)
static QList<QPair<QString, QString> > readAdbDevices(); static QList<QPair<QString, QString> > readAdbDevices();
@ -53,4 +44,5 @@ private:
QString createDeviceOnApi(const DeviceInfo &device); QString createDeviceOnApi(const DeviceInfo &device);
void updateDeviceOnApi(const DeviceInfo &device); void updateDeviceOnApi(const DeviceInfo &device);
void postScreenshotToApi(const QString &apiId, const QByteArray &image); void postScreenshotToApi(const QString &apiId, const QByteArray &image);
void toggleBankProfilesOnApi(const QString &deviceId, bool enable);
}; };

View File

@ -14,7 +14,6 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/EventDAO.h" #include "dao/EventDAO.h"
#include "dao/SettingsDAO.h"
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
#include "db/BankProfileInfo.h" #include "db/BankProfileInfo.h"
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
@ -33,8 +32,8 @@
#include "dushanbe/PayByPhoneScript.h" #include "dushanbe/PayByPhoneScript.h"
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1); static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1);
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1, const QString &transactionId = {}); static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1);
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1, bool toApiBase = false); static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1);
EventHandler::EventHandler(QObject *parent) : QObject(parent) {} EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
@ -74,7 +73,7 @@ void EventHandler::start() {
if (e.sentToServer) continue; if (e.sentToServer) continue;
EventDAO::updateEvent(e.id, EventStatus::Error, "App restarted"); EventDAO::updateEvent(e.id, EventStatus::Error, "App restarted");
EventDAO::markSentToServer(e.id); EventDAO::markSentToServer(e.id);
sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending", {}, -1, e.transactionId); sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending");
qDebug() << "[EventHandler] Closed stale event id:" << e.id; qDebug() << "[EventHandler] Closed stale event id:" << e.id;
} }
} }
@ -108,14 +107,11 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
continue; continue;
} }
// transaction_id присутствует только в CREATE_TRANSACTION; для других типов это пусто.
const QString incomingTransactionId = task["body"].toObject()["transaction_id"].toString();
// Ищем аккаунт по material_id напрямую // Ищем аккаунт по material_id напрямую
const MaterialInfo account = MaterialDAO::findByMaterialId(materialId); const MaterialInfo account = MaterialDAO::findByMaterialId(materialId);
if (account.id == -1) { if (account.id == -1) {
qWarning() << "[EventHandler] Account not found for bank:" << bankName << "material_id:" << materialId; qWarning() << "[EventHandler] Account not found for bank:" << bankName << "material_id:" << materialId;
sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId, {}, -1, incomingTransactionId); sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId);
continue; continue;
} }
@ -123,7 +119,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
if (eventType == EventType::FetchProfile) { if (eventType == EventType::FetchProfile) {
const QList<EventInfo> cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile); const QList<EventInfo> cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile);
for (const EventInfo &c : cancelled) { for (const EventInfo &c : cancelled) {
sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task", {}, -1, c.transactionId); sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task");
EventDAO::markSentToServer(c.id); EventDAO::markSentToServer(c.id);
} }
} }
@ -145,7 +141,6 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
if (eventType == EventType::CreateTransaction) { if (eventType == EventType::CreateTransaction) {
event.amount = body["amount"].toDouble() / 100.0; event.amount = body["amount"].toDouble() / 100.0;
event.phone = body["bank_participator_id"].toString(); event.phone = body["bank_participator_id"].toString();
event.transactionId = body["transaction_id"].toString();
// Название банка-получателя из extra // Название банка-получателя из extra
const QString bankNameRu = body["extra"].toObject()["bank_participator_name_ru"].toString(); const QString bankNameRu = body["extra"].toObject()["bank_participator_name_ru"].toString();
@ -164,7 +159,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
if (!onlineDevices.contains(account.deviceId)) { if (!onlineDevices.contains(account.deviceId)) {
qWarning() << "[EventHandler] Device offline for material_id:" << materialId << "device:" << account.deviceId; qWarning() << "[EventHandler] Device offline for material_id:" << materialId << "device:" << account.deviceId;
EventDAO::updateEvent(eventId, EventStatus::Error, "Device is offline"); EventDAO::updateEvent(eventId, EventStatus::Error, "Device is offline");
sendTaskError(typeStr, materialId, bankName, "Device is offline", {}, -1, incomingTransactionId); sendTaskError(typeStr, materialId, bankName, "Device is offline");
} }
} }
@ -207,42 +202,29 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
} }
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName,
const QString &description, const QByteArray &screenshot, int eventId, const QString &description, const QByteArray &screenshot, int eventId) {
const QString &transactionId) {
QJsonObject errorData; QJsonObject errorData;
errorData["status"] = QStringLiteral("error"); errorData["status"] = QStringLiteral("error");
errorData["event_type"] = type; errorData["event_type"] = type;
errorData["description"] = description; errorData["description"] = description;
if (!transactionId.isEmpty())
errorData["transaction_id"] = transactionId;
sendTaskResult(QStringLiteral("ERROR"), materialId, bankName, errorData, eventId); sendTaskResult(QStringLiteral("ERROR"), materialId, bankName, errorData, eventId);
if (!screenshot.isEmpty()) { if (!screenshot.isEmpty()) {
const QString apiToken = SettingsDAO::get("api_key"); const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
QStringList lines; .arg(type, bankName, materialId, description);
lines << QStringLiteral("<b>[TASK_ERROR] [%1] [%2]</b>")
.arg(type.toHtmlEscaped(), bankName.toUpper().toHtmlEscaped());
if (!apiToken.isEmpty())
lines << QStringLiteral("<b>[%1]</b>").arg(apiToken.toHtmlEscaped());
lines << QStringLiteral("<b>[%1]</b>").arg(materialId.toHtmlEscaped());
lines << description.toHtmlEscaped();
const QString message = lines.join(QStringLiteral("\n"));
sendMonitoringLog("error", "task_error", message, screenshot, eventId); sendMonitoringLog("error", "task_error", message, screenshot, eventId);
} }
// application.log + БД отправляются из TaskDumpRecorder::finishInternal,
// чтобы покрывать также non-event-driven задачи (login flow и т.п.).
} }
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, static void sendMonitoringLog(const QString &type, const QString &event, const QString &message,
const QByteArray &image, int eventId, bool toApiBase) { const QByteArray &image, int eventId) {
auto *thread = new QThread; auto *thread = new QThread;
auto *networkService = new NetworkService; auto *networkService = new NetworkService;
networkService->moveToThread(thread); networkService->moveToThread(thread);
if (eventId > 0) if (eventId > 0)
networkService->addExtra("internal_task_id", eventId); networkService->addExtra("internal_task_id", eventId);
QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image, toApiBase]() { QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() {
networkService->postMonitoringLog(type, event, message, image, toApiBase); networkService->postMonitoringLog(type, event, message, image);
emit networkService->finished(); emit networkService->finished();
}); });
QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit); QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit);
@ -296,7 +278,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
.arg(eventTypeToString(event.type), deviceLabel, event.bankName, result); .arg(eventTypeToString(event.type), deviceLabel, event.bankName, result);
GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot); GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot);
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id, event.transactionId); sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id);
if (event.type == EventType::CreateTransaction) { if (event.type == EventType::CreateTransaction) {
const MaterialInfo declAcc = MaterialDAO::getAccountById(event.accountId); const MaterialInfo declAcc = MaterialDAO::getAccountById(event.accountId);
@ -325,8 +307,6 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
} }
QJsonObject declined; QJsonObject declined;
if (!event.transactionId.isEmpty())
declined["transaction_id"] = event.transactionId;
declined["bank_transaction_id"] = declTxId; declined["bank_transaction_id"] = declTxId;
declined["amount"] = -static_cast<int>(event.amount * 100); declined["amount"] = -static_cast<int>(event.amount * 100);
declined["currency"] = declCurrencyCode; declined["currency"] = declCurrencyCode;
@ -369,7 +349,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
if (parsedTx.isEmpty()) { if (parsedTx.isEmpty()) {
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx"; qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx";
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
"Transaction not parsed from screen", {}, -1, event.transactionId); "Transaction not parsed from screen");
EventDAO::updateEvent(event.id, EventStatus::Error, EventDAO::updateEvent(event.id, EventStatus::Error,
"Transaction not parsed from screen"); "Transaction not parsed from screen");
EventDAO::markSentToServer(event.id); EventDAO::markSentToServer(event.id);
@ -385,8 +365,6 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
const QString txType = taskJson["body"].toObject()["type"].toString(); const QString txType = taskJson["body"].toObject()["type"].toString();
QJsonObject obj; QJsonObject obj;
if (!event.transactionId.isEmpty())
obj["transaction_id"] = event.transactionId;
obj["amount"] = -static_cast<int>(event.amount * 100); obj["amount"] = -static_cast<int>(event.amount * 100);
obj["currency"] = currencyCode; obj["currency"] = currencyCode;
obj["transaction_type"] = txType.isEmpty() ? "top-up" : txType; obj["transaction_type"] = txType.isEmpty() ? "top-up" : txType;
@ -441,63 +419,21 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
} }
f.remove(); f.remove();
} }
sendMonitoringLog("info", "task_success", msg, screenshot, event.id, /*toApiBase=*/true); sendMonitoringLog("info", "task_success", msg, screenshot, event.id);
// Декрементируем материальный/профильный баланс на сумму расхода + комиссию.
// Между запусками FETCH_PROFILE это держит UI/локальный баланс
// в актуальном виде; drift по входящим всё равно сбросит следующий re-parse.
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
if (acc.id != -1) {
double fee = 0.0;
const QList<TransactionInfo> txList =
TransactionDAO::getTransactionsWithinHours(event.accountId, 1);
for (const auto &tx : txList) {
if (!tx.bankTrExternalId.isEmpty()
&& qAbs(tx.amount) == event.amount
&& tx.phone == event.phone) {
fee = tx.fee;
break;
}
}
const double delta = event.amount + fee;
// Для Dushanbe все карты сидят на одном счёте — двигаем
// все материалы (см. memory: project_dushanbe_single_account).
if (acc.appCode == "dushanbe_city_bank") {
if (!MaterialDAO::adjustAmountByApp(acc.deviceId, acc.appCode, delta)) {
qWarning() << "[EventHandler] adjustAmountByApp failed for"
<< acc.deviceId << acc.appCode;
}
} else {
if (!MaterialDAO::adjustAmount(acc.id, delta)) {
qWarning() << "[EventHandler] adjustAmount failed for material" << acc.id;
}
}
const BankProfileInfo app = BankProfileDAO::getApplication(acc.deviceId, acc.appCode);
if (app.id != -1) {
if (!BankProfileDAO::adjustAmount(app.id, delta)) {
qWarning() << "[EventHandler] BankProfileDAO::adjustAmount failed for app" << app.id;
}
}
qDebug() << "[EventHandler] balance decremented: material" << acc.id
<< "app" << acc.appCode << "delta=" << delta
<< "(amount=" << event.amount << "fee=" << fee << ")";
}
} }
if (event.type == EventType::FetchTransactions) { if (event.type == EventType::FetchTransactions) {
const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2") const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2")
.arg(event.bankName, event.externalId); .arg(event.bankName, event.externalId);
const QByteArray scr = m_screenshots.take(key); const QByteArray scr = m_screenshots.take(key);
sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true); sendMonitoringLog("info", "task_success", msg, scr, event.id);
} }
if (event.type == EventType::FetchProfile) { if (event.type == EventType::FetchProfile) {
const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2") const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2")
.arg(event.bankName, event.externalId); .arg(event.bankName, event.externalId);
const QByteArray scr = m_screenshots.take(key); const QByteArray scr = m_screenshots.take(key);
sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true); sendMonitoringLog("info", "task_success", msg, scr, event.id);
} }
} }
} }
@ -549,16 +485,15 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
// для поиска BankProfileInfo (чтобы PIN поднимался из правильной записи). // для поиска BankProfileInfo (чтобы PIN поднимался из правильной записи).
const QString adbSerial = account.deviceId; const QString adbSerial = account.deviceId;
// AutoConnection: worker emits из своего потока, лямбда выполняется в потоке
// EventHandler (где live `this`), там же где take()/clear() — m_screenshots
// не нужен mutex.
auto connectScreenshot = [this, key](Ozon::CommonScript *w) { auto connectScreenshot = [this, key](Ozon::CommonScript *w) {
connect(w, &Ozon::CommonScript::screenshotReady, this, connect(w, &Ozon::CommonScript::screenshotReady, this,
[this, key](const QByteArray &s) { m_screenshots[key] = s; }); [this, key](const QByteArray &s) { m_screenshots[key] = s; },
Qt::DirectConnection);
}; };
auto connectScreenshotDushanbe = [this, key](Dushanbe::CommonScript *w) { auto connectScreenshotDushanbe = [this, key](Dushanbe::CommonScript *w) {
connect(w, &Dushanbe::CommonScript::screenshotReady, this, connect(w, &Dushanbe::CommonScript::screenshotReady, this,
[this, key](const QByteArray &s) { m_screenshots[key] = s; }); [this, key](const QByteArray &s) { m_screenshots[key] = s; },
Qt::DirectConnection);
}; };
if (event.type == EventType::FetchProfile) { if (event.type == EventType::FetchProfile) {
@ -579,24 +514,14 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object(); const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
const QJsonObject body = taskJson["body"].toObject(); const QJsonObject body = taskJson["body"].toObject();
const double searchAmount = body["amount"].toDouble() / 100.0; // копейки → рубли (с учётом знака) const double searchAmount = body["amount"].toDouble() / 100.0; // копейки → рубли (с учётом знака)
const QString createdAtStr = body["created_at"].toString(); const QDateTime searchTime = QDateTime::fromString(body["created_at"].toString(), Qt::ISODate);
QDateTime searchTime = QDateTime::fromString(createdAtStr, Qt::ISODate);
// Backend контракт — UTC ISO с суффиксом 'Z' (как мы сами пишем
// на строках 317 и 373). Если 'Z'/смещение отсутствует — Qt парсит
// как LocalTime, что даёт off-by-one для вечерних транзакций
// (см. task_dump (26): Ozon группировал +100 на 14.05, backend
// прислал 13.05 без TZ). Жёстко переинтерпретируем как UTC.
if (searchTime.isValid() && searchTime.timeSpec() == Qt::LocalTime) {
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: created_at без TZ-маркера"
<< createdAtStr << "— интерпретирую как UTC";
searchTime.setTimeZone(QTimeZone::utc());
}
if (appCode == "ozon") { if (appCode == "ozon") {
auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime, account.materialId); auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime, account.materialId);
setup(w); connectScreenshot(w); setup(w); connectScreenshot(w);
connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this, connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }); [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
Qt::DirectConnection);
} }
else if (appCode == "black") else if (appCode == "black")
setup(new Black::GetLastTransactionsScript(adbSerial, appCode)); setup(new Black::GetLastTransactionsScript(adbSerial, appCode));
@ -604,7 +529,8 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime, account.materialId); auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime, account.materialId);
setup(w); connectScreenshotDushanbe(w); setup(w); connectScreenshotDushanbe(w);
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }); [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
Qt::DirectConnection);
} }
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode; else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
} }
@ -664,7 +590,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
const QString logMsg = QString("[%1] bank=%2 material=%3: Script timeout") const QString logMsg = QString("[%1] bank=%2 material=%3: Script timeout")
.arg(eventTypeToString(event.type), event.bankName, event.externalId); .arg(eventTypeToString(event.type), event.bankName, event.externalId);
GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg); GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg);
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)", {}, -1, event.transactionId); sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)");
EventDAO::markSentToServer(event.id); EventDAO::markSentToServer(event.id);
// Убиваем банковское приложение на устройстве после таймаута // Убиваем банковское приложение на устройстве после таймаута
@ -701,7 +627,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
if (waitSecs > 5 * 60) { if (waitSecs > 5 * 60) {
qWarning() << "[EventHandler] Task expired after" << waitSecs << "s, event id:" << event.id; qWarning() << "[EventHandler] Task expired after" << waitSecs << "s, event id:" << event.id;
EventDAO::updateEvent(event.id, EventStatus::Error, "Task expired: waited " + QString::number(waitSecs) + "s"); EventDAO::updateEvent(event.id, EventStatus::Error, "Task expired: waited " + QString::number(waitSecs) + "s");
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes", {}, -1, event.transactionId); sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes");
continue; continue;
} }
@ -711,7 +637,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
qWarning() << "[EventHandler] Bank profile disabled for" << account.appCode << "device:" << deviceId; qWarning() << "[EventHandler] Bank profile disabled for" << account.appCode << "device:" << deviceId;
EventDAO::updateEvent(event.id, EventStatus::Error, "Bank profile is disabled"); EventDAO::updateEvent(event.id, EventStatus::Error, "Bank profile is disabled");
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
"Bank profile is disabled: " + account.appCode, {}, -1, event.transactionId); "Bank profile is disabled: " + account.appCode);
continue; continue;
} }
@ -726,23 +652,16 @@ void EventHandler::processNextTask(const QString &deviceId) {
} }
void EventHandler::clearAll() { void EventHandler::clearAll() {
// Параллельная остановка: сначала всем requestInterruption + quit,
// потом ждём каждый. terminate() намеренно НЕ используем — он опасен
// (heap corruption, утечки DB-локов); если поток не успел остановиться,
// abandon'им — ОС зачистит ресурсы при выходе процесса. Этот же подход
// применяет watchdog в startThreadForTask.
for (QThread *thread : m_threads) { for (QThread *thread : m_threads) {
if (thread->isRunning()) { if (thread->isRunning()) {
thread->requestInterruption(); thread->requestInterruption();
thread->quit(); thread->quit();
if (!thread->wait(3000)) {
qWarning() << "[EventHandler] Thread did not stop in 3s, terminating";
thread->terminate();
thread->wait(2000);
} }
} }
for (QThread *thread : m_threads) {
if (thread->isRunning() && !thread->wait(5000)) {
qWarning() << "[EventHandler] Thread did not stop in 5s on shutdown,"
<< "abandoning (no terminate to avoid heap corruption)";
continue; // не deleteLater для всё ещё работающего QThread
}
thread->deleteLater(); thread->deleteLater();
} }
m_threads.clear(); m_threads.clear();

View File

@ -1,137 +0,0 @@
#include "LogArchiveSender.h"
#include <QByteArray>
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QProcess>
#include <QRegularExpression>
#include <QSettings>
#include <QStringList>
#include <QThread>
#include "dao/SettingsDAO.h"
#include "net/NetworkService.h"
namespace {
QString resolveDbPath() {
const QSettings settings("config.ini", QSettings::IniFormat);
QString dbPath = settings.value("db/dbPath").toString();
if (dbPath.startsWith("~"))
dbPath.replace(0, 1, QDir::homePath());
if (!dbPath.isEmpty() && QFileInfo(dbPath).isRelative())
dbPath = QDir(QCoreApplication::applicationDirPath()).absoluteFilePath(dbPath);
return dbPath;
}
// Запускается в worker-потоке. Возвращает байты zip или пустой массив.
QByteArray buildArchive() {
const QString logPath = QCoreApplication::applicationDirPath() + "/application.log";
const QString dbPath = resolveDbPath();
const bool hasLog = QFile::exists(logPath);
const bool hasDb = !dbPath.isEmpty() && QFile::exists(dbPath);
if (!hasLog && !hasDb) {
qWarning() << "[LogArchiveSender] neither log nor db exists — nothing to archive";
return {};
}
const QString tmpDir = QDir::tempPath() + QStringLiteral("/arc_logs_auto_") +
QString::number(QDateTime::currentMSecsSinceEpoch());
QDir().mkpath(tmpDir);
if (hasLog) {
QFile::copy(logPath, tmpDir + "/application.log");
}
if (hasDb) {
const QString dbBaseName = QFileInfo(dbPath).fileName();
QFile::copy(dbPath, tmpDir + "/" + dbBaseName);
for (const QString &suffix : {QStringLiteral("-wal"), QStringLiteral("-shm")}) {
const QString sidecar = dbPath + suffix;
if (QFile::exists(sidecar))
QFile::copy(sidecar, tmpDir + "/" + dbBaseName + suffix);
}
}
const QString archivePath = tmpDir + ".zip";
QFile::remove(archivePath);
QProcess archiver;
archiver.setWorkingDirectory(tmpDir);
#ifdef Q_OS_WIN
archiver.start("powershell", QStringList()
<< "-NoProfile" << "-Command"
<< QString("Compress-Archive -Path '%1/*' -DestinationPath '%2' -Force").arg(tmpDir, archivePath));
#else
archiver.start("zip", QStringList() << "-r" << archivePath << ".");
#endif
archiver.waitForFinished(30000);
QDir(tmpDir).removeRecursively();
if (!QFile::exists(archivePath)) {
qWarning() << "[LogArchiveSender] archive not created at" << archivePath;
return {};
}
QFile zip(archivePath);
if (!zip.open(QIODevice::ReadOnly)) {
qWarning() << "[LogArchiveSender] cannot open archive" << archivePath;
QFile::remove(archivePath);
return {};
}
const QByteArray bytes = zip.readAll();
zip.close();
QFile::remove(archivePath);
return bytes;
}
} // namespace
bool LogArchiveSender::dispatch(const QString &label, int eventId) {
const QString apiToken = SettingsDAO::get("api_key");
const QDateTime now = QDateTime::currentDateTime();
const QString timestamp = now.toString(Qt::ISODate);
QStringList head{QStringLiteral("[LOGS]")};
if (!apiToken.isEmpty()) head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped());
if (!label.isEmpty()) head << QStringLiteral("[%1]").arg(label.toHtmlEscaped());
const QString text = QStringLiteral("<b>%1</b>\n%2")
.arg(head.join(QStringLiteral(" ")), timestamp.toHtmlEscaped());
QString tokenSafe = apiToken;
tokenSafe.replace(QRegularExpression(R"([^\w\-.])"), "_");
const QString stamp = now.toString("yyyy-MM-dd_HH-mm-ss");
QStringList parts{QStringLiteral("LOGS")};
if (!tokenSafe.isEmpty()) parts << tokenSafe;
parts << stamp;
if (!label.isEmpty()) parts << label;
if (eventId > 0) parts << QStringLiteral("ev%1").arg(eventId);
const QString archiveFilename = parts.join(QStringLiteral("_")) + QStringLiteral(".zip");
auto *worker = QThread::create([text, archiveFilename]() {
const QByteArray archive = buildArchive();
if (archive.isEmpty()) {
qWarning() << "[LogArchiveSender] empty archive, skipping dispatch";
return;
}
auto *thread = new QThread;
auto *svc = new NetworkService;
svc->moveToThread(thread);
QObject::connect(thread, &QThread::started, svc,
[svc, text, archive, archiveFilename]() {
svc->postMonitoringDump(text, archive, QByteArray(), archiveFilename);
emit svc->finished();
});
QObject::connect(svc, &NetworkService::finished, thread, &QThread::quit);
QObject::connect(svc, &NetworkService::finished, svc, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
});
QObject::connect(worker, &QThread::finished, worker, &QObject::deleteLater);
worker->start();
return true;
}

View File

@ -1,16 +0,0 @@
#pragma once
#include <QString>
// Headless-варианты сборки и отправки в Telegram-relay архива из application.log
// + БД (+ WAL/SHM). Используется EventHandler-ом для авто-отправки при ошибках задач.
// FileLogWindow по-прежнему имеет свою UI-обвязку для ручной отправки.
namespace LogArchiveSender {
// Собирает архив и постит через свежий NetworkService::postMonitoringDump
// в собственных background-потоках (worker для zip + thread для HTTP).
// `label` — короткий тег ("TASK_ERROR" и т.п.), попадает в caption и в имя файла.
// `eventId` (если > 0) тоже уходит в имя файла для трассировки.
// Возвращает true если задача поставлена в очередь; реальный результат отправки
// не отслеживается (fire-and-forget, как у sendMonitoringLog).
bool dispatch(const QString &label, int eventId = -1);
}

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