1
0
forked from BRT/arc

Compare commits

..

40 Commits

Author SHA1 Message Date
slava
3f9d3eebd0 Add transactionId support to EventHandler, EventDAO, and related components for improved event tracking and error handling. Update database queries, error reporting, and UI logic accordingly. 2026-05-26 19:34:16 +05:00
slava
9fb209378f Merge branch 'dev' into main: align main with dev content (dev takes precedence in all conflicts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

# Conflicts:
#	database/app_data.db
#	views/DeviceWidget.cpp
#	views/DeviceWidget.h
2026-05-25 15:56:12 +05:00
slava
cff6ec7216 Add README with build instructions, architecture overview, and links to project documentation (docs/, BUILD.md, API specs, mock server, tutorials).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:48:53 +05:00
slava
82ac22f635 Remove outdated AppLogDAO, LogWindow, and related functionalities. Introduce NetworkErrorUtils for detailed error categorization and enhanced logging. Refactor EventHandler to support balance adjustments and optimize thread cleanup. Add retry and timeout mechanisms to network services with improved error handling. 2026-05-23 18:37:49 +05:00
slava
36567e78a4 Integrate DeviceScreener with NetworkService for status synchronization, including debounce logic for online/offline transitions and periodic sync initiation. Refactor startDeviceScreener and startNetworkService to enable inter-component interaction. 2026-05-22 16:19:41 +05:00
slava
8abe5a915f Improve GetLastTransactionsScript to parse bottom-sheet transaction details for version 3.2.33. Add parseDushanbeAmount for enhanced number extraction. 2026-05-21 17:29:49 +05:00
slava
3670e34938 Restrict OCR search area to the lower half of the confirm sheet in PayByPhoneScript and PayByCardScript to reduce false positives. Adjust minimum word length for substring matching in OcrUtils to improve tap accuracy. 2026-05-21 16:12:20 +05:00
slava
9da2a6fc52 Refactor TaskDumpRecorder to adjust LogArchiveSender invocation for improved dump and log dispatch sequencing, and remove redundant usages in EventHandler. 2026-05-21 15:06:52 +05:00
slava
0045c37d64 Refactor buildCaption in TaskDumpRecorder to improve HTML-escaped formatting, include additional metadata, and integrate LogArchiveSender for automatic error archive dispatch. 2026-05-21 14:48:54 +05:00
slava
4ab46b8b49 Add LogArchiveSender for automated log and database archiving with support for background processing and dispatching through NetworkService. 2026-05-21 13:52:42 +05:00
slava
07871231a3 Add enhanced error handling, logging, and monitoring capabilities across systems. Include archive naming, retry logic for partial PIN entry, API response error dialogs, and support for monitoring tokens. 2026-05-21 13:52:29 +05:00
slava
56ba89522c Handle missing timezone in created_at field for transaction parsing by enforcing UTC interpretation. Improve date range detection and scrolling logic in GetLastTransactionsScript to handle sticky headers. Prevent unregistered devices from displaying bank profiles in DeviceWidget. 2026-05-20 18:22:17 +05:00
slava
14e46cac84 Add retry limits for "Подтверждаю" and "Оплатить" taps in PayByPhoneScript and PayByCardScript to prevent infinite loops and improve error handling with detailed logs. 2026-05-19 23:15:44 +05:00
slava
feb839daf2 Improve transaction amount extraction in GetLastTransactionsScript by using regex to support varying field positions across formats. Adjust logic for compatibility with 5- and 6-line DCWallet and P2P formats. 2026-05-19 19:36:47 +05:00
slava
6b3f275781 Handle immediate payment completion and error screen scenarios in PayByPhoneScript and PayByCardScript. Adjust flow to skip redundant steps on early success and improve error detection after "Далее". 2026-05-19 19:18:43 +05:00
slava
f65840f28b Fix card balance retrieval from incorrect placeholder in GetCardInfoScript. Add retry logic for "Оплатить" button tap in PayByPhoneScript and PayByCardScript to handle bottom sheet dismissal. 2026-05-19 18:13:43 +05:00
slava
e7cf9a311e Fix for windows 2026-05-19 17:23:34 +07:00
slava
6c5b5a91c2 Fix for windows 2026-05-19 17:15:57 +07:00
slava
1457064423 Add support for split formats (v3.2.33+) in payment details parsing, fallback logic for older formats, and XML label-value pair extraction. Update ScreenXmlParser for enhanced node parsing. 2026-05-19 11:48:58 +05:00
slava
542fabf880 Expand .gitignore to exclude cache, generated files, dumps, and test build directories. 2026-05-19 11:24:40 +05:00
slava
078aae0d2f Add documentation for BankSetupWizard, detailing lifecycle, UI structure, verification flow, error handling, and worker thread management. Include XML assets for Dushanbe City Bank. 2026-05-19 11:22:22 +05:00
slava
7bf49ba749 Add app_version tracking: database schema migration, ADB utility support, and UI enhancements. 2026-05-18 18:42:59 +05:00
slava
0adb57d87e Refactor createBankProfileIfNeeded to return error messages instead of void. Update call sites to handle and propagate errors appropriately, improving error handling across card info and profile scripts. 2026-05-15 16:54:02 +07:00
slava
01190bfa69 Adjust "Перевести" button tap position in PayByPhoneScript to the lower part of the button, avoiding the "за друга" badge. Update debug logs accordingly. 2026-05-14 22:00:59 +07:00
slava
f696e816e2 Adjust "Перевести" button tap position in PayByPhoneScript to the lower part of the button, avoiding the "за друга" badge. Update debug logs accordingly. 2026-05-14 21:16:16 +07:00
slava
373d103212 Avoid referral badge overlap when tapping "Перевести" in PayByPhoneScript by adjusting tap position to the left quarter of the button. Add explanatory debug logs. 2026-05-14 20:48:10 +07:00
slava
53becd0183 Refine "Other Bank" button handling in PayByPhoneScript by simplifying bounds checks, reducing scroll attempts, and improving debug logging. 2026-05-14 20:21:12 +07:00
slava
ae04874c48 Mark isEmpty method in Node class as [[nodiscard]] and const to enforce proper usage and ensure immutability. 2026-05-14 19:36:49 +07:00
slava
a8d41baab7 Improve "Other Bank" button handling in PayByPhoneScript by ensuring full visibility with enhanced bounds checks and increased scroll attempts. 2026-05-14 19:33:44 +07:00
slava
7e5f90a450 Improve package state probing and logging in AdbUtils and enhance "Other Bank" button handling in PayByPhoneScript. 2026-05-14 18:36:33 +07:00
slava
ff4a468b35 Synchronize material statuses by setting MaterialStatus::Off in card info scripts and adding state enforcement logic to postMaterial in NetworkService. 2026-05-13 21:29:07 +07:00
slava
98f1d0f9d6 Simplify sum field detection in PayByCardScript by removing resource-id-based strategies and relying on DOM order. 2026-05-13 17:08:14 +07:00
slava
72d582f0ab Add closeGooglePlayBannerIfVisible helper to handle update banners and permission dialogs. Integrate into card info scripts for automated dismissal during screen parsing. Extend ScreenXmlParser with methods to detect specific UI elements. 2026-05-13 16:01:26 +07:00
slava
c3fed927fc Remove macOS-specific Scrcpy embedding and cleanup debug logs
Removed Scrcpy embedding code and related configurations for macOS. Commented out excessive debug logging in multiple files to reduce console noise. Simplified CMakeLists.txt by unifying platform-specific sections for improved build system clarity.
2025-04-26 15:16:42 +07:00
slava
675fa7ca5f Fix scrcpy 2025-04-26 14:11:07 +07:00
slava
cf543917b4 Fix scrcpy 2025-04-26 13:50:05 +07:00
slava
b125c509b3 Fix database 2025-04-26 10:28:10 -07:00
slava
bf976f04c7 Refactor CMakeLists to conditionally include SCRCPY for macOS.
Moved the inclusion of SCRCPY and executable definition to within the APPLE conditional block. This ensures platform-specific sources are handled appropriately, improving configurability and avoiding redundancy.
2025-04-26 10:13:36 +07:00
slava
d52574c50f Merge remote-tracking branch 'origin/main' 2025-04-26 10:11:38 +07:00
slava
327b206fe8 Add ScrcpyWindow integration for screen mirroring
Introduced a ScrcpyWindow to enable integration of the scrcpy tool for device screen mirroring within the application. Platform-specific embedding is supported for macOS and Windows, with structure adjustments made in MainWindow and the build system (CMakeLists.txt).
2025-04-26 10:11:29 +07:00
128 changed files with 3128 additions and 6208 deletions

7
.gitignore vendored
View File

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

View File

@ -58,7 +58,6 @@ 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
@ -157,7 +156,6 @@ if (WIN32)
Qt6::Xml Qt6::Xml
Qt6::Sql Qt6::Sql
Qt6::Network Qt6::Network
Qt6::Qml
Tesseract::libtesseract Tesseract::libtesseract
) )
@ -173,10 +171,6 @@ 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})
@ -234,14 +228,6 @@ 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")
@ -290,7 +276,6 @@ elseif (APPLE)
Qt6::Sql Qt6::Sql
Qt6::Xml Qt6::Xml
Qt6::Network Qt6::Network
Qt6::Qml
tesseract tesseract
leptonica leptonica
) )
@ -306,9 +291,6 @@ 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 Normal file
View File

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

View File

@ -152,7 +152,71 @@ 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(), {
@ -167,16 +231,47 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
return false; return false;
} }
process.waitForFinished(); process.waitForFinished();
const QString output = process.readAllStandardOutput(); const QString stdOut = QString::fromUtf8(process.readAllStandardOutput());
const QString stdErr = QString::fromUtf8(process.readAllStandardError());
const int exitCode = process.exitCode();
const QProcess::ExitStatus exitStatus = process.exitStatus();
// Если процесс запустился, там будет такая строка. // Полный вывод monkey пишем построчно — Qt-логгер режет длинные QString,
// Если нет, то: "** No activities found to run, monkey aborted." // из-за чего хвост (`Events injected` / `No activities found`) терялся.
if (output.contains("Events injected: 1")) { qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey exitStatus="
<< exitStatus << "exitCode=" << exitCode;
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
if (stdOut.contains("Events injected: 1")) {
return true; return true;
} }
// Тут надо в логи писать, почему не стартует // Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code,
qDebug() << "Start app output:" << output; // в отличие от monkey, который при пустом resolve просто молча печатает "No activities found".
if (!launcherActivity.isEmpty()) {
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
<< "пробую am start -n" << launcherActivity;
QProcess am;
am.setWorkingDirectory(adbDir());
am.start(adbPath(),
{"-s", deviceId, "shell", "am", "start", "-W", "-n", launcherActivity});
am.waitForStarted(5000);
am.waitForFinished(15000);
const QString amOut = QString::fromUtf8(am.readAllStandardOutput());
const QString amErr = QString::fromUtf8(am.readAllStandardError());
qDebug().noquote() << "[AdbUtils::tryToStartApplication] am exitCode=" << am.exitCode();
logMultiline("[AdbUtils::tryToStartApplication] am stdout:", amOut);
logMultiline("[AdbUtils::tryToStartApplication] am stderr:", amErr);
// `am start -W` печатает "Status: ok" при успехе.
if (am.exitCode() == 0 && amOut.contains("Status: ok")) {
return true;
}
} else {
qWarning() << "[AdbUtils::tryToStartApplication] нет launcher-активити для" << packageName
<< "— приложение либо не установлено, либо disabled/hidden, либо без LAUNCHER intent-filter";
}
return false; return false;
} }
@ -479,16 +574,23 @@ 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_TEXT --es msg '" + text + "'" "shell", "am broadcast -a ADB_INPUT_B64 --es msg " + b64
}); });
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 result:" << process.exitCode() qDebug() << "[AdbUtils] inputAdbIMEText (b64) 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;
@ -638,6 +740,21 @@ QString AdbUtils::getDeviceTimezone(const QString &deviceId) {
return out.trimmed(); return out.trimmed();
} }
QString AdbUtils::getAppVersion(const QString &deviceId, const QString &packageName) {
if (deviceId.isEmpty() || packageName.isEmpty()) {
return {};
}
QString out;
if (!startProcess(adbPath(),
{"-s", deviceId, "shell", "dumpsys", "package", packageName},
&out)) {
return {};
}
static const QRegularExpression re(R"(versionName=([^\s]+))");
const QRegularExpressionMatch m = re.match(out);
return m.hasMatch() ? m.captured(1).trimmed() : QString();
}
void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) { void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) {
const QString adb = adbPath(); const QString adb = adbPath();

View File

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

View File

@ -200,12 +200,12 @@ void CommonScript::updateTransactionData(
thread->start(); thread->start();
} }
void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) { QString 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,16 +249,45 @@ void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QStr
if (app.bankProfileId.isEmpty()) { if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id); ns.addExtra("app_id", app.id);
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) {
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
qWarning() << "[Black] createBankProfileIfNeeded:" << err;
return err;
}
qDebug() << "[Black] createBankProfileIfNeeded: postBankProfile done"; qDebug() << "[Black] createBankProfileIfNeeded: postBankProfile done";
} else { } else {
ns.addExtra("bank_profile_id", app.bankProfileId); ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile(); ns.patchBankProfile();
qDebug() << "[Black] createBankProfileIfNeeded: patchBankProfile done"; qDebug() << "[Black] createBankProfileIfNeeded: patchBankProfile done";
} }
return {};
} }
void CommonScript::stop() { void CommonScript::stop() {
m_running = false; m_running = false;
} }
bool CommonScript::closeGooglePlayBannerIfVisible(
const QString &deviceId, const int width, const int height
) {
// 1. Системный диалог запроса разрешения Android (permissioncontroller) — тапаем "ЗАПРЕТИТЬ"
if (Node denyBtn = xmlScreenParser.tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
qDebug() << "[Black] System permission dialog detected mid-task, tapping 'ЗАПРЕТИТЬ'...";
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
return true;
}
// 2. Google Play in-app update bottom sheet
Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height);
if (gpNode.isEmpty()) return false;
qDebug() << "[Black] Google Play update banner detected mid-task, closing...";
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
return true;
}
} // namespace Black } // namespace Black

View File

@ -46,8 +46,16 @@ protected:
const QString &newDesc const QString &newDesc
); );
// Создаёт bank_profile на сервере если ещё не создан // Создаёт bank_profile на сервере если ещё не создан.
void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0); // Возвращает пустую строку при успехе, текст ошибки при провале.
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
// Если виден баннер Google Play "Доступно обновление" или системный
// диалог разрешений — закрывает его и перепарсивает XML. Безопасно
// вызывать на любом экране; возвращает true если что-то было закрыто.
bool closeGooglePlayBannerIfVisible(
const QString &deviceId, int width, int height
);
bool m_running = true; bool m_running = true;
ScreenXmlParser xmlScreenParser; ScreenXmlParser xmlScreenParser;

View File

@ -41,6 +41,7 @@ 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()) {
@ -54,6 +55,7 @@ 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. Парсим баланс с домашнего экрана
@ -78,6 +80,7 @@ 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;
@ -116,7 +119,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::Active; account.status = MaterialStatus::Off;
account.updateTime = QDateTime::currentDateTimeUtc(); account.updateTime = QDateTime::currentDateTimeUtc();
if (!MaterialDAO::upsertAccount(account)) { if (!MaterialDAO::upsertAccount(account)) {

View File

@ -3,7 +3,6 @@
#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"
@ -11,8 +10,6 @@
#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 {
@ -28,26 +25,6 @@ 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);
@ -187,7 +164,11 @@ void GetProfileInfoScript::doStart() {
} }
// 7. Создаём bank profile если нет // 7. Создаём bank profile если нет
createBankProfileIfNeeded(m_deviceId, m_appCode); if (const QString err = createBankProfileIfNeeded(m_deviceId, m_appCode); !err.isEmpty()) {
m_error = err;
emit finishedWithResult(m_error);
return;
}
// 8. Отправляем данные аккаунтов на сервер если нет material_id // 8. Отправляем данные аккаунтов на сервер если нет material_id
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode); const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);

View File

@ -542,4 +542,37 @@ Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
return {}; return {};
} }
Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int screenHeight) {
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) {
return node;
}
}
for (const Node &node : m_nodes) {
if (node.contentDesc.contains("Доступно обновление")) {
Node tapNode;
const int cx = screenWidth / 2;
const int cy = screenHeight / 20;
tapNode.setBounds(cx, cy, cx + 1, cy + 1);
return tapNode;
}
}
return {};
}
Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() {
for (const Node &node : m_nodes) {
if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button"
&& node.clickable) {
return node;
}
}
for (const Node &node : m_nodes) {
if (node.clickable && node.text.trimmed() == QString::fromUtf8("ЗАПРЕТИТЬ")) {
return node;
}
}
return {};
}
} // namespace Black } // namespace Black

View File

@ -110,6 +110,13 @@ public:
// Находит EditText по hint // Находит EditText по hint
Node findEditTextByHint(const QString &hint); Node findEditTextByHint(const QString &hint);
// Находит кнопку закрытия Google Play in-app update bottom sheet
// ("com.android.vending" оверлей "Доступно обновление"), если он показан
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге разрешений Android
Node tryToFindSystemPermissionDenyButton();
private: private:
QList<Node> m_nodes; QList<Node> m_nodes;
QDomElement m_xml; QDomElement m_xml;

View File

@ -15,7 +15,9 @@
#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 {
@ -164,14 +166,28 @@ TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
} }
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const { QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
QString s = QString("Bank: %1 | Task: %2 | Device: %3") const QString apiToken = SettingsDAO::get("api_key");
.arg(m_bank, m_taskKind, m_adbDeviceIdRaw); const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
if (!m_meta.materialId.isEmpty()) s += QString("\nMaterial ID: %1").arg(m_meta.materialId);
if (m_meta.amount != 0) s += QString("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount)); QStringList head{QStringLiteral("[TASK_ERROR]")};
if (!m_meta.phone.isEmpty()) s += QString("\nPhone: %1").arg(m_meta.phone); if (!apiToken.isEmpty())
if (!m_meta.card.isEmpty()) s += QString("\nCard: %1").arg(m_meta.card); head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped());
s += QString("\nError: %1").arg(errorMessage.left(800)); head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped());
return s; head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped());
QString body = QStringLiteral("Device: %1").arg(m_adbDeviceIdRaw.toHtmlEscaped());
if (!m_meta.materialId.isEmpty())
body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped());
if (m_meta.amount != 0)
body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
if (!m_meta.phone.isEmpty())
body += QStringLiteral("\nPhone: %1").arg(m_meta.phone.toHtmlEscaped());
if (!m_meta.card.isEmpty())
body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped());
body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped());
return QStringLiteral("<b>%1</b>\n%2\n%3")
.arg(head.join(QStringLiteral(" ")), timestamp.toHtmlEscaped(), body);
} }
void TaskDumpRecorder::saveXml(int counter, const QString &xml) { void TaskDumpRecorder::saveXml(int counter, const QString &xml) {
@ -263,24 +279,29 @@ 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();

View File

@ -46,6 +46,34 @@ 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) {
@ -124,10 +152,14 @@ 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)) {
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter; findAndInputPincode = true;
} else {
// Клавиатура могла не дорисоваться или PIN ввёлся частично —
// оставляем флаг false, следующая итерация повторит ввод,
// когда кнопки появятся.
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter << "— will retry";
} }
findAndInputPincode = true;
QThread::msleep(3000); QThread::msleep(3000);
continue; continue;
} }
@ -235,12 +267,12 @@ void CommonScript::updateTransactionData(
thread->start(); thread->start();
} }
void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) { QString 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 = {
@ -283,12 +315,19 @@ void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QStr
if (app.bankProfileId.isEmpty()) { if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id); ns.addExtra("app_id", app.id);
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) {
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err;
return err;
}
qDebug() << "[Dushanbe] createBankProfileIfNeeded: postBankProfile done"; qDebug() << "[Dushanbe] createBankProfileIfNeeded: postBankProfile done";
} else { } else {
ns.addExtra("bank_profile_id", app.bankProfileId); ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile(); ns.patchBankProfile();
qDebug() << "[Dushanbe] createBankProfileIfNeeded: patchBankProfile done"; qDebug() << "[Dushanbe] createBankProfileIfNeeded: patchBankProfile done";
} }
return {};
} }
void CommonScript::stop() { void CommonScript::stop() {

View File

@ -45,7 +45,7 @@ protected:
const QString &newDesc const QString &newDesc
); );
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. // Если виден баннер Google Play "Доступно обновление" — закрывает его и перепарсивает XML.
// Безопасно вызывать на любом экране; возвращает true если баннер был закрыт. // Безопасно вызывать на любом экране; возвращает true если баннер был закрыт.

View File

@ -39,6 +39,7 @@ 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...";
@ -51,6 +52,7 @@ 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);
} }
// Парсим баланс с домашнего экрана // Парсим баланс с домашнего экрана
@ -77,6 +79,7 @@ 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;
@ -127,6 +130,7 @@ 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;
@ -142,25 +146,32 @@ 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();
const double cardBalance = xmlScreenParser.parseCardDetailBalance(); // Баланс берём с домашнего экрана (parseBalanceFromHomeScreen выше):
// в Душанбе все карты привязаны к одному счёту, и на экране детали
// карты баланс часто отображается как 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()) { // Без полного номера карты материал не создаём и на сервер не отправляем —
qWarning() << "[Dushanbe::GetCardInfo] No card number for card" << i; // защита от записей с пустым card_number (см. Ozon::GetCardInfo).
if (fullNumberClean.isEmpty()) {
qWarning() << "[Dushanbe::GetCardInfo] Full card number not parsed for card" << i
<< "(home-screen lastNumbers=" << cards[i].lastNumbers << ") — skipping";
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);
QThread::msleep(1500); QThread::msleep(1500);
continue; continue;
} }
const QString lastNumbers = fullNumberClean.right(4);
// Достаём номер счёта: Настройки карты → Реквизиты карты → таб "Реквизиты счета". // Достаём номер счёта: Настройки карты → Реквизиты карты → таб "Реквизиты счета".
// ВРЕМЕННО ОТКЛЮЧЕНО // ВРЕМЕННО ОТКЛЮЧЕНО
QString accountNumber; QString accountNumber;
@ -243,7 +254,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::Active; account.status = MaterialStatus::Off;
account.updateTime = QDateTime::currentDateTimeUtc(); account.updateTime = QDateTime::currentDateTimeUtc();
qDebug() << "[Dushanbe::GetCardInfo] Saving card:" << "lastNumbers=" << lastNumbers qDebug() << "[Dushanbe::GetCardInfo] Saving card:" << "lastNumbers=" << lastNumbers
@ -283,6 +294,7 @@ void GetCardInfoScript::doStart() {
// Ждём возврата на экран "Мои карты" // Ждём возврата на экран "Мои карты"
for (int attempt = 0; attempt < 5; ++attempt) { for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isCardsScreen()) break; if (xmlScreenParser.isCardsScreen()) break;
QThread::msleep(1000); QThread::msleep(1000);
} }

View File

@ -2,6 +2,7 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSet>
#include <QThread> #include <QThread>
#include <QTimeZone> #include <QTimeZone>
@ -16,6 +17,24 @@
#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")) {
@ -453,13 +472,17 @@ void GetLastTransactionsScript::doStart() {
} }
// 2. Собираем View-транзакции (формат Выписки) // 2. Собираем View-транзакции (формат Выписки)
// Два формата content-desc: // Известные форматы content-desc (всё через '\n'):
// a) DCWallet/*** — старый: DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS // a) DCWallet (старый, 5 строк): DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
// b) P2P — новый: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS // b) DCWallet (новый, 6 строк): DCWallet*WDC...\nphone-card-\ncard_owner\namount\nописание\n HH:MM:SS
// c) P2P: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
// Позиция amount уехала между версиями приложения, поэтому ищем по
// паттерну "[-]?digits.dd" среди всех строк — устойчиво к перестановке полей.
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})"); static const QRegularExpression 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;
@ -475,10 +498,14 @@ void GetLastTransactionsScript::doStart() {
VypiskaItem it; VypiskaItem it;
bool ok = false; bool ok = false;
// В P2P формате amount на позиции [3] (после даты, двух карт), в DCWallet — [2] // Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному
const int amtIdx = isP2P ? 3 : 2; // DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые
if (amtIdx >= lines.size()) continue; // поля не подпадают под паттерн.
it.amount = lines[amtIdx].trimmed().toDouble(&ok); for (const QString &line : lines) {
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;
@ -598,7 +625,7 @@ void GetLastTransactionsScript::doStart() {
} }
} }
// Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи) // Проверка времени по HH:MM:SS (±10 минут, с учётом 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();
@ -641,30 +668,113 @@ 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()) {
if (!n.contentDesc.contains(QString::fromUtf8("Успешный платеж"))) continue; const QString cd = n.contentDesc;
if (!n.contentDesc.contains(QString::fromUtf8("Дата:"))) continue; if (!cd.contains(QString::fromUtf8("Карта"))) continue;
const QStringList lines = n.contentDesc.split('\n'); if (!cd.contains(QString::fromUtf8("Получатель"))) continue;
for (int i = 0; i < lines.size(); ++i) { if (!cd.contains(QString::fromUtf8("Операция"))) continue;
const QString key = lines[i].trimmed(); const QStringList lines = cd.split('\n', Qt::SkipEmptyParts);
if (i + 1 >= lines.size()) continue; if (lines.size() < 6) continue;
const QString val = lines[i + 1].trimmed();
if (key == QString::fromUtf8("Получатель:")) { // lines[0] — заголовок: "Успешный платёж" / "Поступление" / ...
detail.receiverAccount = val; if (lines[0].contains(QString::fromUtf8("Успешн"))
} else if (key == QString::fromUtf8("Сумма:")) { || lines[0].contains(QString::fromUtf8("Поступление"))
detail.amount = val.toDouble(); || lines[0].contains(QString::fromUtf8("выполнен"))) {
} else if (key == QString::fromUtf8("Дата:")) { detail.status = QString::fromUtf8("Успешный");
detail.date = val; }
} else if (key == QString::fromUtf8("Время:")) { // lines[1] — "dd.MM.yyyy HH:mm:ss"
detail.time = val; {
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);
} }
} }
if (n.contentDesc.contains(QString::fromUtf8("Успешный"))) { // lines[2] — сумма со знаком: "-10" / "30" / "30.50" / "1 000,50".
detail.status = QString::fromUtf8("Успешный"); 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; detailLoaded = true;
break; break;
} }
// 2) Старый blob-формат (до 3.2.33): "Успешный платеж" / "Поступление" —
// одна нода с ключами и значениями через \n.
if (!detailLoaded) for (const Node &n : xmlScreenParser.findAllNodes()) {
if (!n.contentDesc.contains(QString::fromUtf8("Дата"))) continue;
if (!n.contentDesc.contains(QString::fromUtf8("Время"))) continue;
const QStringList lines = n.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
QString key = lines[i].trimmed();
if (key.endsWith(':')) key.chop(1); // нормализуем "Дата:" → "Дата"
if (i + 1 >= lines.size()) continue;
const QString val = lines[i + 1].trimmed();
if (key == QString::fromUtf8("Получатель")) {
detail.receiverAccount = val;
} else if (key == QString::fromUtf8("Сумма")) {
detail.amount = parseDushanbeAmount(val);
} else if (key == QString::fromUtf8("Дата")) {
detail.date = val;
} else if (key == QString::fromUtf8("Время")) {
detail.time = val;
}
}
detailLoaded = true;
break;
}
// 3) Split-формат: лейблы (с ':') и значения в отдельных View-нодах
// на одной Y-полосе. Промежуточная версия между blob и bottom-sheet.
if (!detailLoaded) {
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
const QString d = pairs.value(QString::fromUtf8("Дата"));
const QString t = pairs.value(QString::fromUtf8("Время"));
if (!d.isEmpty() && !t.isEmpty()) {
detail.date = d;
detail.time = t;
detail.receiverAccount = pairs.value(QString::fromUtf8("Получатель"));
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("Сумма")));
if (detail.amount == 0.0) {
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("К зачислению")));
}
detail.fee = parseDushanbeAmount(pairs.value(QString::fromUtf8("Комиссия")));
detail.operationId = pairs.value(QString::fromUtf8("Номер операции"));
detail.status = pairs.value(QString::fromUtf8("Статус"));
detailLoaded = true;
}
}
if (detailLoaded) break; if (detailLoaded) break;
QThread::msleep(1500); QThread::msleep(1500);
} }
@ -683,7 +793,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;
// Проверяем время ±1 час // Проверяем время ±10 минут
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) { if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
QDateTime txTime = QDateTime::fromString( QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");

View File

@ -77,6 +77,10 @@ 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 в верхней панели
@ -129,7 +133,11 @@ void GetProfileInfoScript::doStart() {
BankProfileDAO::updateProfileInfo(app.id, fullName, phone, ""); BankProfileDAO::updateProfileInfo(app.id, fullName, phone, "");
} }
createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) {
m_error = err;
emit finishedWithResult(m_error);
return;
}
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode); const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
QList<QPair<MaterialInfo, Node>> accountsToPost; QList<QPair<MaterialInfo, Node>> accountsToPost;

View File

@ -307,11 +307,33 @@ 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);
@ -338,8 +360,8 @@ void PayByCardScript::makePayment(
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю" // 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
// content-desc содержит "Подтверждение платежа...Подтверждаю" // content-desc содержит "Подтверждение платежа...Подтверждаю"
// Кнопка "Подтверждаю" визуально внизу bottom sheet // Кнопка "Подтверждаю" визуально внизу bottom sheet
bool confirmed = false; bool confirmed = earlySuccess;
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; !earlySuccess && 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);
@ -360,10 +382,14 @@ 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("Подтверждаю"),
confirmNode.y1(), confirmNode.y2()); ocrYMin, 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);
@ -391,8 +417,17 @@ void PayByCardScript::makePayment(
// 10. Ждём результат — "Платеж выполнен" или ошибку. // 10. Ждём результат — "Платеж выполнен" или ошибку.
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк // Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин. // подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
bool success = false; // Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал,
for (int attempt = 0; attempt < 60; ++attempt) { // bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий
// sheet → пробуем другую Y. Без лимита получали бесконечный
// "Оплатить ↔ Подтверждаю" со случайными попаданиями в системные экраны
// (security/launcher).
bool success = earlySuccess;
int confirmRetries = 0;
int oplatitRetries = 0;
const int maxConfirmRetries = 2;
const int maxOplatitRetries = 2;
for (int attempt = 0; !earlySuccess && attempt < 60; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -436,20 +471,62 @@ 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("Подтверждаю"))) {
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tapping 'Подтверждаю'"; if (confirmRetries >= maxConfirmRetries) {
m_error = "Confirm tap missed " + QString::number(confirmRetries)
+ " times (sheet still visible)";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++confirmRetries;
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap"
<< confirmRetries << "/" << maxConfirmRetries;
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId); const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen, QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"), QString::fromUtf8("Подтверждаю"),
retryConfirm.y1(), retryConfirm.y2()); ocrYMin, retryConfirm.y2());
if (tap.x() < 0) { if (tap.x() < 0) {
const int tapY = retryConfirm.y2() - qMax(150, height / 14); // Варьируем Y: первый retry — выше fallback на 100px,
// второй — ещё на 100px выше. Compose-кнопка действия
// обычно на y2 - 150..350; перебираем диапазон.
const int extra = 100 * confirmRetries;
const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra);
tap = QPoint(retryConfirm.x(), tapY); 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);
} }
@ -471,8 +548,9 @@ void PayByCardScript::makePayment(
if (!m_resultScreenshot.isEmpty()) if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot); emit screenshotReady(m_resultScreenshot);
// Парсим детали из content-desc "Платеж выполнен" // Парсим детали из экрана "Платеж выполнен".
// Формат: "Платеж выполнен\nПоставщик:\n...\nКомиссия:\n0.00\nДата:\n05.04.26\nВремя:\n20:19:56\nНа главную" // До 3.2.33 все поля шли одним content-desc с ключами "Дата:" / "Время:" / "Комиссия:".
// С 3.2.33 лейблы и значения — отдельные View-ноды в двух колонках.
QString bankTime; QString bankTime;
double fee = 0; double fee = 0;
@ -490,8 +568,19 @@ 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, секунда>.

View File

@ -307,11 +307,33 @@ 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);
@ -336,8 +358,8 @@ void PayByPhoneScript::makePayment(
} }
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю" // 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
bool confirmed = false; bool confirmed = earlySuccess;
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; !earlySuccess && 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);
@ -356,10 +378,15 @@ 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("Подтверждаю"),
confirmNode.y1(), confirmNode.y2()); ocrYMin, 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);
@ -387,8 +414,17 @@ void PayByPhoneScript::makePayment(
// 10. Ждём результат — "Платеж выполнен" или ошибку. // 10. Ждём результат — "Платеж выполнен" или ошибку.
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк // Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин. // подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
bool success = false; // Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал,
for (int attempt = 0; attempt < 60; ++attempt) { // bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий
// sheet → пробуем другую Y. Без лимита получали бесконечный
// "Оплатить ↔ Подтверждаю" со случайными попаданиями в системные экраны
// (security/launcher).
bool success = earlySuccess;
int confirmRetries = 0;
int oplatitRetries = 0;
const int maxConfirmRetries = 2;
const int maxOplatitRetries = 2;
for (int attempt = 0; !earlySuccess && attempt < 60; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -428,20 +464,62 @@ 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("Подтверждаю"))) {
qDebug() << "[Dushanbe::PayByPhone] Confirm sheet still visible, re-tapping 'Подтверждаю'"; if (confirmRetries >= maxConfirmRetries) {
m_error = "Confirm tap missed " + QString::number(confirmRetries)
+ " times (sheet still visible)";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++confirmRetries;
qDebug() << "[Dushanbe::PayByPhone] Confirm sheet still visible, re-tap"
<< confirmRetries << "/" << maxConfirmRetries;
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId); const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen, QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"), QString::fromUtf8("Подтверждаю"),
retryConfirm.y1(), retryConfirm.y2()); ocrYMin, retryConfirm.y2());
if (tap.x() < 0) { if (tap.x() < 0) {
const int tapY = retryConfirm.y2() - qMax(150, height / 14); // Варьируем Y: первый retry — выше fallback на 100px,
// второй — ещё на 100px выше. Compose-кнопка действия
// обычно на y2 - 150..350; перебираем диапазон.
const int extra = 100 * confirmRetries;
const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra);
tap = QPoint(retryConfirm.x(), tapY); 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);
} }
@ -463,7 +541,8 @@ void PayByPhoneScript::makePayment(
if (!m_resultScreenshot.isEmpty()) if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot); emit screenshotReady(m_resultScreenshot);
// Парсим детали из content-desc "Платеж выполнен" // Парсим детали из экрана "Платеж выполнен". С 3.2.33 поля разнесены по
// отдельным View-узлам — пробуем сначала старый blob-формат, потом fallback.
QString bankTime; QString bankTime;
double fee = 0; double fee = 0;
@ -481,8 +560,19 @@ 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, секунда>.

View File

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

View File

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

View File

@ -5,7 +5,6 @@
#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>
@ -71,7 +70,12 @@ 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();
if (w.contains(n) || n.contains(w)) return true; // Substring-ветка работает только если word достаточно длинный.
// Иначе любая одиночная буква, входящая в needle (для «подтверждаю»:
// п/о/д/т/в/е/р/ж/а/ю), даст ложный матч на случайный текст экрана —
// ровно так бот тапал в (620,302) по букве «в» где-то в шапке sheet'a.
const int minLen = qMax(qMin(n.size(), 3), n.size() * 3 / 5);
if (w.size() >= minLen && (w.contains(n) || n.contains(w))) return true;
// Допускаем до 20% ошибок (но не меньше 1) — Tesseract на «Подтверждаю» иногда // Допускаем до 20% ошибок (но не меньше 1) — Tesseract на «Подтверждаю» иногда
// даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания. // даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания.
const int tolerance = qMax(1, n.size() / 5); const int tolerance = qMax(1, n.size() / 5);
@ -198,63 +202,4 @@ QPoint findTextCenter(const QByteArray &png,
return result; return result;
} }
QString recognizeText(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
const QByteArray &td = tessdataBytes();
int initResult;
if (td.isEmpty()) {
const QByteArray appDirBytes = QCoreApplication::applicationDirPath().toUtf8();
initResult = api->Init(appDirBytes.constData(), "rus+eng");
} else {
initResult = api->Init(td.constData(), td.size(), "rus+eng", tesseract::OEM_DEFAULT,
nullptr, 0, nullptr, nullptr, false, nullptr);
}
if (initResult) {
qWarning() << "[OcrUtils::recognizeText] Failed to init Tesseract";
delete api;
return {};
}
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) {
qWarning() << "[OcrUtils::recognizeText] Failed to read image";
api->End();
delete api;
return {};
}
Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f);
pixDestroy(&pix);
if (!gray) {
qWarning() << "[OcrUtils::recognizeText] Failed to convert to grayscale";
api->End();
delete api;
return {};
}
api->SetImage(gray);
api->SetSourceResolution(300);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&gray);
api->End();
delete api;
return result;
}
QString extractTransactionId(const QString &ocrText) {
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-z0-9\-]+(?:[^\S\n]+[A-Za-z0-9\-]+)?))");
if (auto m = reId.match(ocrText); m.hasMatch()) {
QString id = m.captured(1);
id.remove(' ');
id.remove(QChar(0x00A0));
return id;
}
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
return {};
}
} // namespace OcrUtils } // namespace OcrUtils

View File

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

View File

@ -401,12 +401,12 @@ void CommonScript::updateTransactionData(
thread->start(); thread->start();
} }
void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) { QString 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,12 +450,19 @@ void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QStr
if (app.bankProfileId.isEmpty()) { if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id); ns.addExtra("app_id", app.id);
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) {
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
qWarning() << "[Ozon] createBankProfileIfNeeded:" << err;
return err;
}
qDebug() << "[Ozon] createBankProfileIfNeeded: postBankProfile done"; qDebug() << "[Ozon] createBankProfileIfNeeded: postBankProfile done";
} else { } else {
ns.addExtra("bank_profile_id", app.bankProfileId); ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile(); ns.patchBankProfile();
qDebug() << "[Ozon] createBankProfileIfNeeded: patchBankProfile done"; qDebug() << "[Ozon] createBankProfileIfNeeded: patchBankProfile done";
} }
return {};
} }
bool CommonScript::isAppRunning(const QString &deviceId, const QString &packageName) { bool CommonScript::isAppRunning(const QString &deviceId, const QString &packageName) {

View File

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

View File

@ -13,8 +13,6 @@
#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 {
@ -33,27 +31,6 @@ 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);
@ -113,6 +90,7 @@ 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);
} }
@ -129,12 +107,14 @@ 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,
@ -153,6 +133,9 @@ 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";
@ -169,17 +152,30 @@ 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::Active; card.status = MaterialStatus::Off;
if (!MaterialDAO::upsertAccount(card)) {
qWarning() << "[GetCardInfo] Failed to upsert account for card" << card.lastNumbers; const MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (existing.id == -1) continue;
MaterialInfo refresh = existing;
refresh.amount = card.amount;
refresh.updateTime = card.updateTime;
if (!MaterialDAO::upsertAccount(refresh)) {
qWarning() << "[GetCardInfo] Failed to refresh amount for card" << card.lastNumbers;
} else { } else {
qDebug() << "[GetCardInfo] Upserted account:" << card.lastNumbers qDebug() << "[GetCardInfo] Refreshed amount for card"
<< card.description << card.amount; << card.lastNumbers << "to" << card.amount;
} }
} }
@ -243,9 +239,11 @@ 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");
} else { m_error = err;
qDebug() << "[GetCardInfo] postBankProfile done"; emit finishedWithResult(m_error);
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();
@ -285,6 +283,7 @@ 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()) {
@ -316,6 +315,7 @@ 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,6 +355,7 @@ 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;
} }
@ -366,6 +367,7 @@ 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;
@ -411,19 +413,20 @@ 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 saved = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers); MaterialInfo toSave = card;
if (saved.id != -1) { toSave.cardNumber = cardNumber;
MaterialDAO::updateCardNumber(saved.id, cardNumber); toSave.updateTime = QDateTime::currentDateTimeUtc();
if (!MaterialDAO::upsertAccount(toSave)) {
qWarning() << "[GetCardInfo] Failed to upsert account for card" << card.lastNumbers;
} else {
qDebug() << "[GetCardInfo] Upserted account:" << card.lastNumbers
<< card.description << card.amount;
} }
} else { } 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");
@ -434,6 +437,7 @@ void GetCardInfoScript::doStart() {
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);
QThread::msleep(2000); QThread::msleep(2000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
} }
} }

View File

@ -16,8 +16,6 @@
#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"
@ -100,29 +98,6 @@ 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;
@ -404,37 +379,53 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране. // Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню. // Кнопки транзакций между этими Y принадлежат нужному дню.
// Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё // Возвращает специальные коды через dateY:
// видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY // dateY >= 0 : header виден на этом Y (нормальный случай)
// принадлежат нашему дню. // 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 isVisible = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; }; auto isAnchored = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; };
auto isPiled = [&](int y) { return y > 0 && yCount.value(y, 0) > 1; };
const int viewportMid = device.screenHeight / 2;
int dateY = -1; int 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 (isVisible(node.y())) dateY = node.y(); if (isAnchored(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 (isVisible(node.y()) && (dateY < 0 || node.y() > dateY) if (isAnchored(node.y())
&& (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) { if (dateY < 0 && nextDateY < device.screenHeight && targetPileY < 0) {
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};
}; };
@ -465,6 +456,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
// Находим Y-диапазон заголовка нужной даты // Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange(); const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
if (dateHeaderY == -2) {
// Target застрял в sticky-пачке (Ozon стекает заголовки выше viewport
// в одну Y-позицию). Ниже пачки есть anchored более старая дата →
// секцию target уже проскроллили мимо. Скроллим обратно вверх,
// чтобы target распался из пачки и занял реальный Y.
qDebug() << "[FetchTransactions] Target piled in sticky band — scrolling up to detach";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 600);
QThread::msleep(1500);
continue;
}
if (dateHeaderY < 0) { if (dateHeaderY < 0) {
// Target-header не виден и ни одной более старой даты не видно → // Target-header не виден и ни одной более старой даты не видно →
// target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх). // target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх).

View File

@ -12,8 +12,6 @@
#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 {
@ -29,26 +27,6 @@ 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);
@ -196,9 +174,11 @@ 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");
} else { m_error = err;
qDebug() << "[GetProfileInfo] postBankProfile done"; emit finishedWithResult(m_error);
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();

View File

@ -7,8 +7,6 @@
#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"
@ -31,36 +29,6 @@ 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);
@ -108,8 +76,31 @@ 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 на сервере
createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) {
m_error = err;
emit finishedWithResult(m_error);
return;
}
if (goToAllProducts(m_deviceId, device.screenWidth, device.screenHeight)) { if (goToAllProducts(m_deviceId, device.screenWidth, device.screenHeight)) {
QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo(); QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();

View File

@ -16,8 +16,6 @@
#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) {
@ -82,34 +80,6 @@ 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);
@ -317,30 +287,17 @@ void PayByCardScript::makePayment(
if (!sumInput.isEmpty()) break; if (!sumInput.isEmpty()) break;
// Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO). // Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO).
// На экране "Перевод по номеру карты" всегда 2 EditText: карта (сверху) // На экране "Перевод по номеру карты" всегда 2 EditText в DOM-порядке:
// и сумма (снизу). Стратегии по приоритету: // карта (сверху), сумма (снизу). Оба имеют resource-id="input___xxxx",
// 1) Svelte генерирует resource-id="input___xxxx" ТОЛЬКО для суммы — // поэтому отличать по rid нельзя — берём второй по порядку обхода XML.
// берём этот EditText. QList<Node> editTexts;
// 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;
candidateInputs.append(n); editTexts.append(n);
} }
// Strategy 1: rid="input___" if (editTexts.size() >= 2) {
for (const Node &n : candidateInputs) { sumInput = editTexts[1];
if (n.resourceId.startsWith("input___")) {
sumInput = n;
break;
}
}
// Strategy 2: bottom-most по y1 (если их 2 — берём второй)
if (sumInput.isEmpty() && candidateInputs.size() >= 2) {
std::sort(candidateInputs.begin(), candidateInputs.end(),
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
sumInput = candidateInputs.last();
} }
if (!sumInput.isEmpty()) break; if (!sumInput.isEmpty()) break;

View File

@ -15,8 +15,6 @@
#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>
@ -69,6 +67,14 @@ 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,
@ -77,40 +83,12 @@ 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(std::move(bankName)), m_amount(amount) { m_bankName(sanitizeBankName(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);
@ -192,7 +170,11 @@ 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 = "Кнопка 'Перевести' не найдена на домашнем экране";
@ -200,8 +182,13 @@ void PayByPhoneScript::makePayment(
return; return;
} }
qDebug() << "[PayByPhone] Tapping 'Перевести'..."; // Тапаем в нижнюю часть кнопки (там, где визуально надпись "Перевести"),
AdbUtils::makeTap(deviceId, transferBtn.x(), transferBtn.y()); // ниже бейджа "за друга" (он заканчивается на y≈140).
const int transferTapX = transferBtn.x();
const int transferTapY = transferBtn.y1() + transferBtn.height() * 4 / 5;
qDebug() << "[PayByPhone] Tapping 'Перевести' at" << transferTapX << "," << transferTapY
<< "(below 'за друга' badge)";
AdbUtils::makeTap(deviceId, transferTapX, transferTapY);
QThread::msleep(2000); QThread::msleep(2000);
// 2. Ждём экран "Платежи и переводы" с полем "Телефон получателя" // 2. Ждём экран "Платежи и переводы" с полем "Телефон получателя"
@ -326,28 +313,54 @@ 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 пустой).
bool foundOtherBank = false; // На устройствах с маленьким экраном кнопка лежит в конце виртуализированного
{ // списка и приходит с bounds=[0,0][0,0] — Node::isEmpty это не ловит, тап
Node node; // улетает в (0,0). Скроллим список вниз, пока bounds не станут ненулевыми
// (после первого скролла кнопка обычно материализуется у нижнего края, но
// тап по ней работает — это её настоящая позиция в layout).
auto findOtherBank = [&]() -> Node {
for (const Node &n : xmlScreenParser.findAllNodes()) { for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.contentDesc.contains("other-bank-item")) { if (n.contentDesc.contains("other-bank-item")) return n;
node = n;
break;
}
}
if (!node.isEmpty()) {
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item)...";
AdbUtils::makeTap(deviceId, node.x(), node.y());
QThread::msleep(2000);
foundOtherBank = true;
otherBankClicked = true;
} }
return {};
};
bool foundOtherBank = false;
Node node = findOtherBank();
for (int scrollAttempt = 0;
!node.isEmpty() && (node.width() == 0 || node.height() == 0) && scrollAttempt < 6;
++scrollAttempt) {
qDebug() << "[PayByPhone] 'Другой банк' has collapsed bounds, scrolling list down, attempt"
<< scrollAttempt + 1 << "/ 6";
AdbUtils::makeSwipe(deviceId, width / 2, height * 3 / 4, width / 2, height / 4);
QThread::msleep(800);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
node = findOtherBank();
}
if (!node.isEmpty() && node.width() > 0 && node.height() > 0) {
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item) at"
<< node.x() << "," << node.y();
AdbUtils::makeTap(deviceId, node.x(), node.y());
QThread::msleep(2000);
foundOtherBank = true;
otherBankClicked = true;
} }
if (foundOtherBank) continue; if (foundOtherBank) continue;
// Вариант 2: уже на экране поиска банка (EditText) — только после клика на "Другой банк" // Вариант 2: уже на экране поиска банка (EditText) — только после клика на "Другой банк"
if (otherBankClicked) { if (otherBankClicked) {
// После перехода на экран поиска банка поле ввода прячется под верхним
// тулбаром (рендерится Svelte WebView'ом). Свайпаем сверху вниз, чтобы
// подтянуть содержимое и сделать поле физически видимым/активным.
qDebug() << "[PayByPhone] Scrolling up to reveal hidden bank search input...";
AdbUtils::makeSwipe(deviceId, width / 2, height / 4, width / 2, height * 3 / 4);
QThread::msleep(800);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
Node bankInput = xmlScreenParser.findFirstEditText(); Node bankInput = xmlScreenParser.findFirstEditText();
if (!bankInput.isEmpty()) { if (!bankInput.isEmpty()) {
qDebug() << "[PayByPhone] Bank search screen detected, entering bank name..."; qDebug() << "[PayByPhone] Bank search screen detected, entering bank name...";

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.ImageView" package="tj.dc.next1" content-desc="Успешный платёж&#10;21.05.2026 16:58:32&#10;-10&#10;Карта&#10;9762 0002 2091 6042&#10;Получатель&#10;DCWallet*WDC0000**TAJIKISTAN&#10;992176827668-9762000191282739-&#10;Операция&#10;Дебетная часть P2P/операции&#10;Дата&#10;21.05.2026&#10;Время&#10;16:58:32&#10;Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>

View File

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

View File

@ -3,7 +3,7 @@ list=ozon, black, dushanbe_city_bank
[common] [common]
currencies=USD, ARS, RUB, TJS currencies=USD, ARS, RUB, TJS
version=0.0.9 version=1.0.64
[db] [db]
dbPath=./database/app_data.db dbPath=./database/app_data.db
@ -18,8 +18,11 @@ 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
@ -27,6 +30,7 @@ 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
@ -34,6 +38,7 @@ 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
@ -41,17 +46,4 @@ currency=TJS
country=tajikistan country=tajikistan
name=Dushanbe City name=Dushanbe City
phone_code=992 phone_code=992
app_version=3.2.34
[scripting]
# Глобальный выключатель JS-скриптов. При false весь scripting-слой выключен.
enabled=false
# Список <bank>/<script>, для которых пробуем JS-версию (через запятую).
# При engine-ошибке runtime прозрачно откатывается на C++.
use_js_for=black/get_profile_info, ozon/get_profile_info, ozon/login_and_check_accounts, ozon/get_card_info, ozon/pay_by_card, ozon/pay_by_phone, ozon/get_last_transactions
# URL JSON-манифеста удалённых обновлений. Пустой → без удалённых обновлений,
# работает только bundled+cache (см. docs/remote_scripts.md).
manifest_url=
# Каталог локального кэша. Если пусто — берётся <AppDataLocation>/scripts/.
cache_dir=
# Каталог bundled-скриптов. Если пусто — рядом с бинарником: ./scripts/
bundled_dir=

View File

@ -119,10 +119,16 @@ 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,
@ -167,25 +173,27 @@ 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,
device_id TEXT, transaction_id TEXT,
amount REAL, device_id TEXT,
phone TEXT, amount REAL,
bank_name TEXT, phone TEXT,
comment TEXT, bank_name TEXT,
status TEXT, comment TEXT,
type TEXT, status TEXT,
update_time DATETIME, type TEXT,
complete_time DATETIME, update_time DATETIME,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, complete_time DATETIME,
json TEXT DEFAULT '', timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
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,

View File

@ -1,97 +0,0 @@
#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;
}

View File

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

View File

@ -44,25 +44,42 @@ 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;
}
db.transaction(); if (!query.prepare(R"(
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() << "Ошибка при вставке bank:" << query.lastError().text(); qWarning() << "[BankDAO] replaceAll: INSERT failed for alias" << bank.alias
<< ":" << query.lastError().text() << "— rolling back";
db.rollback();
return;
} }
} }
db.commit(); if (!db.commit()) {
qWarning() << "[BankDAO] replaceAll: commit failed:" << db.lastError().text();
db.rollback();
}
} }

View File

@ -151,6 +151,8 @@ 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;
} }
@ -158,7 +160,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 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
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
@ -183,7 +185,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 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
FROM bank_profiles FROM bank_profiles
WHERE device_id = :device_id AND install = 'yes' WHERE device_id = :device_id AND install = 'yes'
)"); )");
@ -206,7 +208,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 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
FROM bank_profiles FROM bank_profiles
WHERE device_id = :device_id WHERE device_id = :device_id
)"); )");
@ -262,6 +264,35 @@ 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");
@ -278,7 +309,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 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
FROM bank_profiles FROM bank_profiles
WHERE bank_profile_id = :bank_profile_id WHERE bank_profile_id = :bank_profile_id
)"); )");

View File

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

View File

@ -13,6 +13,7 @@ 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();
@ -36,15 +37,16 @@ int EventDAO::insertEvent(const EventInfo &event) {
query.prepare(R"( query.prepare(R"(
INSERT INTO events ( INSERT INTO events (
status, external_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type status, external_id, transaction_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type
) VALUES ( ) VALUES (
:status, :external_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type :status, :external_id, :transaction_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);
@ -139,7 +141,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, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, transaction_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
@ -161,7 +163,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, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, transaction_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
@ -193,7 +195,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, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, transaction_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
@ -276,7 +278,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, bank_name, type FROM events SELECT id, external_id, transaction_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);
@ -288,8 +290,9 @@ 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.bankName = selectQuery.value(2).toString(); e.transactionId = selectQuery.value(2).toString();
e.type = eventTypeFromString(selectQuery.value(3).toString()); e.bankName = selectQuery.value(3).toString();
e.type = eventTypeFromString(selectQuery.value(4).toString());
cancelled.append(e); cancelled.append(e);
} }
} }
@ -315,12 +318,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, bank_name, type FROM events SELECT id, external_id, transaction_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, bank_name, type FROM events SELECT id, external_id, transaction_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);
@ -334,8 +337,9 @@ 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.bankName = selectQuery.value(2).toString(); e.transactionId = selectQuery.value(2).toString();
e.type = eventTypeFromString(selectQuery.value(3).toString()); e.bankName = selectQuery.value(3).toString();
e.type = eventTypeFromString(selectQuery.value(4).toString());
cancelled.append(e); cancelled.append(e);
} }
} }
@ -369,7 +373,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, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, transaction_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
@ -425,7 +429,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, amount, phone, bank_name, SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events FROM events
WHERE timestamp >= datetime('now', :hours) WHERE timestamp >= datetime('now', :hours)

View File

@ -128,6 +128,50 @@ 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();
@ -205,6 +249,7 @@ QList<MaterialInfo> MaterialDAO::getAccounts(const QString &deviceId, const QStr
SELECT id, device_id, app_code, last_numbers, card_number, account_number, material_id, amount, update_time, status, description, currency SELECT id, device_id, app_code, last_numbers, card_number, account_number, material_id, amount, update_time, status, description, currency
FROM materials FROM materials
WHERE device_id = :device_id AND app_code = :app_code WHERE device_id = :device_id AND app_code = :app_code
ORDER BY id
)"); )");
query.bindValue(":device_id", deviceId); query.bindValue(":device_id", deviceId);

View File

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

View File

@ -102,3 +102,54 @@ 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-а подряд это редкая ситуация. Если станет проблемой — добавлять идемпотентные ключи на стороне бэкенда.

120
docs/bank-setup-wizard.md Normal file
View File

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

View File

@ -11,7 +11,6 @@
| Раздел | Описание | | Раздел | Описание |
|--------|----------| |--------|----------|
| [События и задачи](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`)

View File

@ -1,480 +0,0 @@
# Удалённые 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-версии.

105
docs/status-sync.md Normal file
View File

@ -0,0 +1,105 @@
# Синхронизация статусов 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 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -55,7 +55,6 @@ 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"
@ -66,7 +65,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) {
#include <QPushButton> #include <QPushButton>
#include <QFileDialog> #include <QFileDialog>
void startDeviceScreener(const QCoreApplication &app) { void startDeviceScreener(const QCoreApplication &app, NetworkService *networkService = nullptr) {
auto *thread = new QThread(); auto *thread = new QThread();
auto *deviceScreenerWorker = new DeviceScreener; auto *deviceScreenerWorker = new DeviceScreener;
@ -77,6 +76,20 @@ void startDeviceScreener(const QCoreApplication &app) {
// Когда поток запустится, автоматически вызовется 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())
@ -166,17 +179,7 @@ void startBlackPayByCardTest() {
thread->start(); thread->start();
} }
void startScriptUpdater() { NetworkService* startNetworkService(const QCoreApplication &app) {
// Опрашивает 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);
@ -220,6 +223,7 @@ void startNetworkService(const QCoreApplication &app) {
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
paymentThread->start(); paymentThread->start();
return networkService;
} }
static QFile logFile; static QFile logFile;
@ -368,10 +372,12 @@ int main(int argc, char *argv[]) {
auto *mainWindow = new MainWindow(); auto *mainWindow = new MainWindow();
mainWindow->show(); mainWindow->show();
registrationWindow->close(); registrationWindow->close();
startNetworkService(app); NetworkService *net = startNetworkService(app);
startDeviceScreener(app); QObject::connect(net, &NetworkService::syncHealthChanged,
mainWindow, &MainWindow::setSyncHealth,
Qt::QueuedConnection);
startDeviceScreener(app, net);
startEventHandler(app); startEventHandler(app);
startScriptUpdater();
// startBlackPayByCardTest(); // startBlackPayByCardTest();
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
}; };
@ -400,8 +406,11 @@ int main(int argc, char *argv[]) {
if (r == 1) { if (r == 1) {
auto *mainWindow = new MainWindow(); auto *mainWindow = new MainWindow();
mainWindow->show(); mainWindow->show();
startNetworkService(app); NetworkService *net = startNetworkService(app);
startDeviceScreener(app); QObject::connect(net, &NetworkService::syncHealthChanged,
mainWindow, &MainWindow::setSyncHealth,
Qt::QueuedConnection);
startDeviceScreener(app, net);
startEventHandler(app); startEventHandler(app);
// startBlackPayByCardTest(); // startBlackPayByCardTest();
} else if (r == 0) { } else if (r == 0) {
@ -421,10 +430,12 @@ 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();
startNetworkService(app); NetworkService *net = startNetworkService(app);
startDeviceScreener(app); QObject::connect(net, &NetworkService::syncHealthChanged,
mainWindow, &MainWindow::setSyncHealth,
Qt::QueuedConnection);
startDeviceScreener(app, net);
startEventHandler(app); startEventHandler(app);
startScriptUpdater();
// startBlackPayByCardTest(); // startBlackPayByCardTest();
} else if (result == 0) { } else if (result == 0) {
SettingsDAO::remove("api_key"); SettingsDAO::remove("api_key");

View File

@ -36,6 +36,8 @@ 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)
); );
@ -86,22 +88,23 @@ 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,
device_id TEXT, transaction_id TEXT,
amount REAL, device_id TEXT,
phone TEXT, amount REAL,
bank_name TEXT, phone TEXT,
comment TEXT, bank_name TEXT,
status TEXT, comment TEXT,
type TEXT, status TEXT,
update_time DATETIME, type TEXT,
complete_time DATETIME, update_time DATETIME,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, complete_time DATETIME,
json TEXT DEFAULT '', timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
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
); );

View File

@ -13,7 +13,7 @@ public:
m_y2 = y2; m_y2 = y2;
} }
bool isEmpty() { [[nodiscard]] bool isEmpty() const {
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();
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,118 +0,0 @@
// 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());
}

View File

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

View File

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

View File

@ -22,6 +22,7 @@
#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);
@ -131,7 +132,8 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body)
QJsonValue result; QJsonValue result;
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical() << "DeviceScreener apiPost timeout:" << path; qCritical().noquote() << "DeviceScreener apiPost timeout:"
<< 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());
@ -141,7 +143,8 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body)
else qCritical() << "DeviceScreener apiPost error:" << root; else qCritical() << "DeviceScreener apiPost error:" << root;
} }
} else { } else {
qCritical() << "DeviceScreener apiPost network error:" << reply->errorString(); qCritical().noquote() << "DeviceScreener apiPost error:"
<< NetworkErrorUtils::describeNetError(reply, path);
} }
reply->deleteLater(); reply->deleteLater();
return result; return result;
@ -166,10 +169,12 @@ void DeviceScreener::apiPatch(const QString &path, const QJsonObject &body) {
loop.exec(); loop.exec();
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical() << "DeviceScreener apiPatch timeout:" << path; qCritical().noquote() << "DeviceScreener apiPatch timeout:"
<< NetworkErrorUtils::describeClientTimeout(reply, path);
reply->abort(); reply->abort();
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
qCritical() << "DeviceScreener apiPatch network error:" << reply->errorString(); qCritical().noquote() << "DeviceScreener apiPatch error:"
<< NetworkErrorUtils::describeNetError(reply, path);
} }
reply->deleteLater(); reply->deleteLater();
} }
@ -225,10 +230,12 @@ void DeviceScreener::postScreenshotToApi(const QString &apiId, const QByteArray
loop.exec(); loop.exec();
if (!timeout.isActive()) { if (!timeout.isActive()) {
qWarning() << "postScreenshotToApi timeout for device" << apiId; qWarning().noquote() << "postScreenshotToApi timeout:" << apiId
<< NetworkErrorUtils::describeClientTimeout(reply, path);
reply->abort(); reply->abort();
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
qWarning() << "postScreenshotToApi network error:" << reply->errorString(); qWarning().noquote() << "postScreenshotToApi error:" << apiId
<< 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()) {
@ -259,26 +266,6 @@ 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);
@ -298,7 +285,8 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
QJsonValue result; QJsonValue result;
if (!timeout.isActive()) { if (!timeout.isActive()) {
qCritical() << "DeviceScreener apiGet timeout:" << path; qCritical().noquote() << "DeviceScreener apiGet timeout:"
<< 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());
@ -308,7 +296,8 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
else qCritical() << "DeviceScreener apiGet error:" << root; else qCritical() << "DeviceScreener apiGet error:" << root;
} }
} else { } else {
qCritical() << "DeviceScreener apiGet network error:" << reply->errorString(); qCritical().noquote() << "DeviceScreener apiGet error:"
<< NetworkErrorUtils::describeNetError(reply, path);
} }
reply->deleteLater(); reply->deleteLater();
return result; return result;
@ -317,15 +306,12 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
void DeviceScreener::start() { void DeviceScreener::start() {
while (m_running) { while (m_running) {
// Устройства, которые были онлайн в БД до текущего скана
const QStringList prevOnline = DeviceDAO::getOnlineDeviceCodes();
QList<QPair<QString, QString> > devices = readAdbDevices(); QList<QPair<QString, QString> > devices = readAdbDevices();
QStringList onlineIds; QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
QList<DeviceInfo> deviceInfos; const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
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);
@ -353,8 +339,7 @@ void DeviceScreener::start() {
} }
} }
onlineIds << device.id; liveSet.insert(device.id);
isChecked = prevOnline.contains(device.id);
device.image = takeScreenshot(adbSerial); device.image = takeScreenshot(adbSerial);
if (!device.image.isEmpty()) { if (!device.image.isEmpty()) {
@ -367,7 +352,6 @@ 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";
@ -378,6 +362,22 @@ 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,26 +405,57 @@ void DeviceScreener::start() {
} }
} }
if (!DeviceDAO::markDevicesOfflineExcept(onlineIds)) { // Detect transitions:
qDebug() << "Cant mark devices offline"; // 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, но больше не видно
for (const QString &id : prevOnline) { // Засекаем m_offlineSince; срабатывает после 30s непрерывного отсутствия.
if (!onlineIds.contains(id)) { QSet<QString> goneOffline;
toggleBankProfilesOnApi(id, false); for (const QString &id : m_onlineDevices) {
// Отправляем OFFLINE статус на сервер if (liveSet.contains(id)) continue;
const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id); if (!m_offlineSince.contains(id)) {
if (!offlineDevice.apiId.isEmpty()) { m_offlineSince[id] = nowSec;
DeviceInfo offlineCopy = offlineDevice; qDebug() << "[DeviceScreener] device missing, offline debounce started:" << id;
offlineCopy.status = "offline"; } else if (nowSec - m_offlineSince[id] >= kOfflineDebounceSecs) {
updateDeviceOnApi(offlineCopy); 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";
}
if (m_firstScan) { if (m_firstScan) {
m_firstScan = false; m_firstScan = false;
qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices;
emit firstPassDone();
} }
QThread::msleep(1000); QThread::msleep(1000);
} }

View File

@ -1,6 +1,8 @@
#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>
@ -12,6 +14,8 @@ public:
~DeviceScreener() override; ~DeviceScreener() override;
static constexpr qint64 kOfflineDebounceSecs = 30;
public slots: public slots:
void start(); void start();
@ -19,6 +23,9 @@ 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;
@ -28,6 +35,8 @@ 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();
@ -44,5 +53,4 @@ private:
QString createDeviceOnApi(const DeviceInfo &device); QString createDeviceOnApi(const DeviceInfo &device);
void updateDeviceOnApi(const DeviceInfo &device); void updateDeviceOnApi(const DeviceInfo &device);
void postScreenshotToApi(const QString &apiId, const QByteArray &image); void postScreenshotToApi(const QString &apiId, const QByteArray &image);
void toggleBankProfilesOnApi(const QString &deviceId, bool enable);
}; };

View File

@ -14,6 +14,7 @@
#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"
@ -32,8 +33,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); 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 sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1); static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1, bool toApiBase = false);
EventHandler::EventHandler(QObject *parent) : QObject(parent) {} EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
@ -73,7 +74,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"); sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending", {}, -1, e.transactionId);
qDebug() << "[EventHandler] Closed stale event id:" << e.id; qDebug() << "[EventHandler] Closed stale event id:" << e.id;
} }
} }
@ -107,11 +108,14 @@ 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); sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId, {}, -1, incomingTransactionId);
continue; continue;
} }
@ -119,7 +123,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"); sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task", {}, -1, c.transactionId);
EventDAO::markSentToServer(c.id); EventDAO::markSentToServer(c.id);
} }
} }
@ -141,6 +145,7 @@ 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();
@ -159,7 +164,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"); sendTaskError(typeStr, materialId, bankName, "Device is offline", {}, -1, incomingTransactionId);
} }
} }
@ -202,29 +207,42 @@ 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 message = QStringLiteral("[%1] bank=%2 material=%3: %4") const QString apiToken = SettingsDAO::get("api_key");
.arg(type, bankName, materialId, description); QStringList lines;
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) { const QByteArray &image, int eventId, bool toApiBase) {
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]() { QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image, toApiBase]() {
networkService->postMonitoringLog(type, event, message, image); networkService->postMonitoringLog(type, event, message, image, toApiBase);
emit networkService->finished(); emit networkService->finished();
}); });
QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit); QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit);
@ -278,7 +296,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); sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id, event.transactionId);
if (event.type == EventType::CreateTransaction) { if (event.type == EventType::CreateTransaction) {
const MaterialInfo declAcc = MaterialDAO::getAccountById(event.accountId); const MaterialInfo declAcc = MaterialDAO::getAccountById(event.accountId);
@ -307,6 +325,8 @@ 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;
@ -349,7 +369,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"); "Transaction not parsed from screen", {}, -1, event.transactionId);
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);
@ -365,6 +385,8 @@ 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;
@ -419,21 +441,63 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
} }
f.remove(); f.remove();
} }
sendMonitoringLog("info", "task_success", msg, screenshot, event.id); sendMonitoringLog("info", "task_success", msg, screenshot, event.id, /*toApiBase=*/true);
// Декрементируем материальный/профильный баланс на сумму расхода + комиссию.
// Между запусками 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); sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true);
} }
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); sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true);
} }
} }
} }
@ -485,15 +549,16 @@ 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) {
@ -514,14 +579,24 @@ 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 QDateTime searchTime = QDateTime::fromString(body["created_at"].toString(), Qt::ISODate); const QString createdAtStr = body["created_at"].toString();
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));
@ -529,8 +604,7 @@ 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;
} }
@ -590,7 +664,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)"); sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)", {}, -1, event.transactionId);
EventDAO::markSentToServer(event.id); EventDAO::markSentToServer(event.id);
// Убиваем банковское приложение на устройстве после таймаута // Убиваем банковское приложение на устройстве после таймаута
@ -627,7 +701,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"); sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes", {}, -1, event.transactionId);
continue; continue;
} }
@ -637,7 +711,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); "Bank profile is disabled: " + account.appCode, {}, -1, event.transactionId);
continue; continue;
} }
@ -652,15 +726,22 @@ void EventHandler::processNextTask(const QString &deviceId) {
} }
void EventHandler::clearAll() { void EventHandler::clearAll() {
// Параллельная остановка: сначала всем requestInterruption + quit,
// потом ждём каждый. terminate() намеренно НЕ используем — он опасен
// (heap corruption, утечки DB-локов); если поток не успел остановиться,
// abandon'им — ОС зачистит ресурсы при выходе процесса. Этот же подход
// применяет watchdog в startThreadForTask.
for (QThread *thread : m_threads) { for (QThread *thread : m_threads) {
if (thread->isRunning()) { if (thread->isRunning()) {
thread->requestInterruption(); thread->requestInterruption();
thread->quit(); thread->quit();
if (!thread->wait(3000)) { }
qWarning() << "[EventHandler] Thread did not stop in 3s, terminating"; }
thread->terminate(); for (QThread *thread : m_threads) {
thread->wait(2000); if (thread->isRunning() && !thread->wait(5000)) {
} qWarning() << "[EventHandler] Thread did not stop in 5s on shutdown,"
<< "abandoning (no terminate to avoid heap corruption)";
continue; // не deleteLater для всё ещё работающего QThread
} }
thread->deleteLater(); thread->deleteLater();
} }

View File

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

View File

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

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