Compare commits
2 Commits
main
...
remote-scr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c5a6656f | ||
|
|
cae345cf61 |
7
.gitignore
vendored
@ -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/
|
|
||||||
|
|||||||
@ -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
@ -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`)
|
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,29 +263,24 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
|||||||
QFile::remove(archivePath);
|
QFile::remove(archivePath);
|
||||||
|
|
||||||
NetworkService *svc = networkServiceRef().data();
|
NetworkService *svc = networkServiceRef().data();
|
||||||
if (svc) {
|
if (!svc) {
|
||||||
qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService"
|
|
||||||
<< "archive.size=" << archiveBytes.size()
|
|
||||||
<< "image.size=" << finalScreenshot.size()
|
|
||||||
<< "text.size=" << text.size();
|
|
||||||
const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump",
|
|
||||||
Qt::QueuedConnection,
|
|
||||||
Q_ARG(QString, text),
|
|
||||||
Q_ARG(QByteArray, archiveBytes),
|
|
||||||
Q_ARG(QByteArray, finalScreenshot));
|
|
||||||
if (!dispatched) {
|
|
||||||
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
|
|
||||||
"slot not found or wrong signature for" << folder;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
|
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
|
||||||
<< "(setNetworkService was not called at app startup?)";
|
<< "(setNetworkService was not called at app startup?)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService"
|
||||||
|
<< "archive.size=" << archiveBytes.size()
|
||||||
|
<< "image.size=" << finalScreenshot.size()
|
||||||
|
<< "text.size=" << text.size();
|
||||||
|
const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump",
|
||||||
|
Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, text),
|
||||||
|
Q_ARG(QByteArray, archiveBytes),
|
||||||
|
Q_ARG(QByteArray, finalScreenshot));
|
||||||
|
if (!dispatched) {
|
||||||
|
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
|
||||||
|
"slot not found or wrong signature for" << folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Следом — 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();
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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 если баннер был закрыт.
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee;
|
||||||
}
|
}
|
||||||
if (bankTime.isEmpty()) {
|
|
||||||
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
|
|
||||||
const QString d = pairs.value(QString::fromUtf8("Дата"));
|
|
||||||
const QString t = pairs.value(QString::fromUtf8("Время"));
|
|
||||||
if (!d.isEmpty() || !t.isEmpty()) {
|
|
||||||
bankTime = (d + " " + t).trimmed();
|
|
||||||
}
|
|
||||||
if (fee == 0) {
|
|
||||||
fee = pairs.value(QString::fromUtf8("Комиссия")).toDouble();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee;
|
|
||||||
|
|
||||||
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
|
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
|
||||||
// Уникальность за счёт <bankProfilePhone, секунда>.
|
// Уникальность за счёт <bankProfilePhone, секунда>.
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Result: date=" << bankTime << "fee=" << fee;
|
||||||
}
|
}
|
||||||
if (bankTime.isEmpty()) {
|
|
||||||
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
|
|
||||||
const QString d = pairs.value(QString::fromUtf8("Дата"));
|
|
||||||
const QString t = pairs.value(QString::fromUtf8("Время"));
|
|
||||||
if (!d.isEmpty() || !t.isEmpty()) {
|
|
||||||
bankTime = (d + " " + t).trimmed();
|
|
||||||
}
|
|
||||||
if (fee == 0) {
|
|
||||||
fee = pairs.value(QString::fromUtf8("Комиссия")).toDouble();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
qDebug() << "[Dushanbe::PayByPhone] Result: date=" << bankTime << "fee=" << fee;
|
|
||||||
|
|
||||||
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
|
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
|
||||||
// Уникальность за счёт <bankProfilePhone, секунда>.
|
// Уникальность за счёт <bankProfilePhone, секунда>.
|
||||||
|
|||||||
@ -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("Мои карты")) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
qDebug() << "[GetCardInfo] postBankProfile done";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 вверх).
|
||||||
|
|||||||
@ -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);
|
qDebug() << "[GetProfileInfo] postBankProfile done";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
qDebug() << "[GetProfileInfo] postBankProfile done";
|
|
||||||
} else {
|
} else {
|
||||||
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
|
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
|
||||||
ns.patchBankProfile();
|
ns.patchBankProfile();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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));
|
if (!node.isEmpty()) {
|
||||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item)...";
|
||||||
node = findOtherBank();
|
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
||||||
}
|
QThread::msleep(2000);
|
||||||
|
foundOtherBank = true;
|
||||||
if (!node.isEmpty() && node.width() > 0 && node.height() > 0) {
|
otherBankClicked = true;
|
||||||
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item) at"
|
}
|
||||||
<< node.x() << "," << node.y();
|
|
||||||
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
|
||||||
QThread::msleep(2000);
|
|
||||||
foundOtherBank = true;
|
|
||||||
otherBankClicked = true;
|
|
||||||
}
|
}
|
||||||
if (foundOtherBank) continue;
|
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...";
|
||||||
|
|||||||
@ -426,19 +426,11 @@ 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;
|
qDebug() << "[findCardBtn] Found by lastNumbers" << lastNumbers
|
||||||
// Отсекаем строки из ленты переводов (см. parseCardsOnHomeScreen)
|
<< "text=" << node.text << "at" << node.x() << node.y();
|
||||||
if (t.contains(QString::fromUtf8("Переводы"))
|
return node;
|
||||||
|| 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
|
|
||||||
<< "text=" << node.text << "at" << node.x() << node.y();
|
|
||||||
return node;
|
|
||||||
}
|
}
|
||||||
qWarning() << "[findCardBtn] Not found for lastNumbers=" << lastNumbers
|
qWarning() << "[findCardBtn] Not found for lastNumbers=" << lastNumbers
|
||||||
<< ". Clickable buttons:";
|
<< ". Clickable buttons:";
|
||||||
@ -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;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 86 KiB |
@ -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="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру карты) Номер/Счет 9762000186263942 Способ оплаты: 9762 **** **** 8865 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" 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>
|
|
||||||
|
Before Width: | Height: | Size: 86 KiB |
@ -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="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру карты) Номер/Счет 9762000104225205 Способ оплаты: 9762 **** **** 8865 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" 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>
|
|
||||||
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 83 KiB |
@ -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="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру телефона) Номер/Счет 559411001 Способ оплаты: 9762 **** **** 8865 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" 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>
|
|
||||||
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 80 KiB |
@ -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="Успешный платёж 21.05.2026 16:58:32 -10 Карта 9762 0002 2091 6042 Получатель DCWallet*WDC0000**TAJIKISTAN 992176827668-9762000191282739- Операция Дебетная часть P2P/операции Дата 21.05.2026 Время 16:58:32 Закрыть" 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>
|
|
||||||
@ -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!
|
||||||
|
|||||||
22
config.ini
@ -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=
|
||||||
|
|||||||
@ -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,
|
||||||
@ -173,27 +167,25 @@ void DatabaseManager::initSchema() {
|
|||||||
))");
|
))");
|
||||||
|
|
||||||
q.exec(R"(CREATE TABLE IF NOT EXISTS events (
|
q.exec(R"(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,
|
bank_name TEXT,
|
||||||
bank_name TEXT,
|
comment TEXT,
|
||||||
comment TEXT,
|
status TEXT,
|
||||||
status TEXT,
|
type TEXT,
|
||||||
type TEXT,
|
update_time DATETIME,
|
||||||
update_time DATETIME,
|
complete_time DATETIME,
|
||||||
complete_time DATETIME,
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
json TEXT DEFAULT '',
|
||||||
json TEXT DEFAULT '',
|
|
||||||
sent_to_server INTEGER DEFAULT 0,
|
sent_to_server INTEGER DEFAULT 0,
|
||||||
screenshot BLOB,
|
screenshot BLOB,
|
||||||
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,
|
||||||
|
|||||||
97
database/dao/AppLogDAO.cpp
Normal 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
@ -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);
|
||||||
|
};
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
)");
|
)");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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-а подряд это редкая ситуация. Если станет проблемой — добавлять идемпотентные ключи на стороне бэкенда.
|
|
||||||
|
|||||||
@ -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
|
|
||||||
```
|
|
||||||
@ -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
@ -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-версии.
|
||||||
@ -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` по дизайну — пользователь должен включить его вручную.
|
|
||||||
BIN
images/copy.png
|
Before Width: | Height: | Size: 6.8 KiB |
53
main.cpp
@ -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");
|
||||||
|
|||||||
@ -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)
|
||||||
);
|
);
|
||||||
@ -88,23 +86,22 @@ CREATE TABLE IF NOT EXISTS transactions
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS events
|
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,
|
bank_name TEXT,
|
||||||
bank_name TEXT,
|
comment TEXT,
|
||||||
comment TEXT,
|
status TEXT,
|
||||||
status TEXT,
|
type TEXT,
|
||||||
type TEXT,
|
update_time DATETIME,
|
||||||
update_time DATETIME,
|
complete_time DATETIME,
|
||||||
complete_time DATETIME,
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
json TEXT DEFAULT '',
|
||||||
json TEXT DEFAULT '',
|
|
||||||
sent_to_server INTEGER DEFAULT 0,
|
sent_to_server INTEGER DEFAULT 0,
|
||||||
screenshot BLOB,
|
screenshot BLOB,
|
||||||
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
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
@ -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;
|
||||||
|
};
|
||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
173
scripts/black/get_profile_info.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
358
scripts/ozon/get_card_info.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
665
scripts/ozon/get_last_transactions.js
Normal 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
|
||||||
|
}
|
||||||
166
scripts/ozon/get_profile_info.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
scripts/ozon/login_and_check_accounts.js
Normal 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
@ -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());
|
||||||
|
}
|
||||||
430
scripts/ozon/pay_by_phone.js
Normal 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());
|
||||||
|
}
|
||||||
@ -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 — мгновенно
|
|
||||||
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 непрерывного отсутствия.
|
|
||||||
QSet<QString> goneOffline;
|
|
||||||
for (const QString &id : m_onlineDevices) {
|
|
||||||
if (liveSet.contains(id)) continue;
|
|
||||||
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);
|
|
||||||
if (!offlineDevice.apiId.isEmpty()) {
|
|
||||||
DeviceInfo offlineCopy = offlineDevice;
|
|
||||||
offlineCopy.status = "offline";
|
|
||||||
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";
|
qDebug() << "Cant mark devices offline";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Было онлайн в БД, сейчас офлайн — выключаем профили и обновляем статус на сервере
|
||||||
|
for (const QString &id : prevOnline) {
|
||||||
|
if (!onlineIds.contains(id)) {
|
||||||
|
toggleBankProfilesOnApi(id, false);
|
||||||
|
// Отправляем OFFLINE статус на сервер
|
||||||
|
const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id);
|
||||||
|
if (!offlineDevice.apiId.isEmpty()) {
|
||||||
|
DeviceInfo offlineCopy = offlineDevice;
|
||||||
|
offlineCopy.status = "offline";
|
||||||
|
updateDeviceOnApi(offlineCopy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,22 +652,15 @@ 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";
|
||||||
for (QThread *thread : m_threads) {
|
thread->terminate();
|
||||||
if (thread->isRunning() && !thread->wait(5000)) {
|
thread->wait(2000);
|
||||||
qWarning() << "[EventHandler] Thread did not stop in 5s on shutdown,"
|
}
|
||||||
<< "abandoning (no terminate to avoid heap corruption)";
|
|
||||||
continue; // не deleteLater для всё ещё работающего QThread
|
|
||||||
}
|
}
|
||||||
thread->deleteLater();
|
thread->deleteLater();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||