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
107 changed files with 3128 additions and 1132 deletions

7
.gitignore vendored
View File

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

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", "");
}
// Логирует длинный вывод построчно с префиксом, иначе Qt-логгер режет строку.
static void logMultiline(const char *tag, const QString &text) {
if (text.isEmpty()) {
qDebug().noquote() << tag << "(empty)";
return;
}
const QStringList lines = text.split(QRegularExpression("\r?\n"));
for (const QString &line : lines) {
if (!line.isEmpty()) qDebug().noquote() << tag << line;
}
}
// Прогоняет adb-команду и возвращает stdout (stderr игнорируется — этим функциям важен только stdout).
static QString adbShellStdout(const QStringList &args, int timeoutMs = 10000) {
QProcess p;
p.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
p.start(AdbUtils::adbPath(), args);
if (!p.waitForStarted(5000)) return {};
p.waitForFinished(timeoutMs);
return QString::fromUtf8(p.readAllStandardOutput());
}
// Возвращает {launcherActivity, summary}. launcherActivity вида "pkg/.MainActivity" или пустая строка.
static QPair<QString, QString> probePackageState(const QString &deviceId, const QString &packageName) {
const QString resolveOut = adbShellStdout(
{"-s", deviceId, "shell", "cmd", "package", "resolve-activity", "--brief", packageName});
QString launcher;
// Формат: первая строка — приоритет, вторая — "pkg/activity".
for (const QString &raw : resolveOut.split(QRegularExpression("\r?\n"))) {
const QString line = raw.trimmed();
if (line.contains('/') && line.startsWith(packageName + "/")) {
launcher = line;
break;
}
}
const QString dump = adbShellStdout(
{"-s", deviceId, "shell", "dumpsys", "package", packageName}, 15000);
auto pickFlag = [&dump](const QString &key) -> QString {
const QRegularExpression re(key + "=(\\S+)");
const auto m = re.match(dump);
return m.hasMatch() ? m.captured(1) : QStringLiteral("?");
};
const bool installed = dump.contains("Package [" + packageName + "]");
const QString enabled = pickFlag("enabled");
const QString stopped = pickFlag("stopped");
const QString hidden = pickFlag("hidden");
const bool hasLauncherCategory = dump.contains("android.intent.category.LAUNCHER");
const QString summary = QString(
"installed=%1 enabled=%2 stopped=%3 hidden=%4 hasLauncherCategory=%5 resolveActivity=%6")
.arg(installed ? "true" : "false", enabled, stopped, hidden,
hasLauncherCategory ? "true" : "false",
launcher.isEmpty() ? "(none)" : launcher);
return {launcher, summary};
}
bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &packageName) {
// Pre-flight: состояние пакета. Без этого по логу нельзя отличить
// "нет иконки" от "disabled" от "hidden" от "не установлен".
const auto [launcherActivity, stateSummary] = probePackageState(deviceId, packageName);
qDebug().noquote() << "[AdbUtils::tryToStartApplication] device=" << deviceId
<< "pkg=" << packageName << "state:" << stateSummary;
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {
@ -167,16 +231,47 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
return false;
}
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();
// Если процесс запустился, там будет такая строка.
// Если нет, то: "** No activities found to run, monkey aborted."
if (output.contains("Events injected: 1")) {
// Полный вывод monkey пишем построчно — Qt-логгер режет длинные QString,
// из-за чего хвост (`Events injected` / `No activities found`) терялся.
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey exitStatus="
<< exitStatus << "exitCode=" << exitCode;
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
if (stdOut.contains("Events injected: 1")) {
return true;
}
// Тут надо в логи писать, почему не стартует
qDebug() << "Start app output:" << output;
// Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code,
// в отличие от monkey, который при пустом resolve просто молча печатает "No activities found".
if (!launcherActivity.isEmpty()) {
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
<< "пробую am start -n" << launcherActivity;
QProcess am;
am.setWorkingDirectory(adbDir());
am.start(adbPath(),
{"-s", deviceId, "shell", "am", "start", "-W", "-n", launcherActivity});
am.waitForStarted(5000);
am.waitForFinished(15000);
const QString amOut = QString::fromUtf8(am.readAllStandardOutput());
const QString amErr = QString::fromUtf8(am.readAllStandardError());
qDebug().noquote() << "[AdbUtils::tryToStartApplication] am exitCode=" << am.exitCode();
logMultiline("[AdbUtils::tryToStartApplication] am stdout:", amOut);
logMultiline("[AdbUtils::tryToStartApplication] am stderr:", amErr);
// `am start -W` печатает "Status: ok" при успехе.
if (am.exitCode() == 0 && amOut.contains("Status: ok")) {
return true;
}
} else {
qWarning() << "[AdbUtils::tryToStartApplication] нет launcher-активити для" << packageName
<< "— приложение либо не установлено, либо disabled/hidden, либо без LAUNCHER intent-filter";
}
return false;
}
@ -479,16 +574,23 @@ bool AdbUtils::enableAdbIMEKeyboard(const QString &deviceId) {
bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) {
QProcess process;
process.setWorkingDirectory(adbDir());
// Кодируем UTF-8 → base64 и шлём через ADB_INPUT_B64. Это исключает любые проблемы
// shell-кавычек на устройстве с пробелами, скобками и не-ASCII символами
// (типа "Озон Банк (Ozon)"). ADBKeyboard декодирует base64 и вызывает commitText.
const QString b64 = QString::fromLatin1(text.toUtf8().toBase64());
process.start(adbPath(), {
"-s", deviceId,
"shell", "am broadcast -a ADB_INPUT_TEXT --es msg '" + text + "'"
"shell", "am broadcast -a ADB_INPUT_B64 --es msg " + b64
});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString();
return false;
}
process.waitForFinished();
qDebug() << "[AdbUtils] inputAdbIMEText result:" << process.exitCode()
qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode()
<< "text:" << text
<< "output:" << process.readAllStandardOutput().trimmed()
<< "error:" << process.readAllStandardError().trimmed();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
@ -638,6 +740,21 @@ QString AdbUtils::getDeviceTimezone(const QString &deviceId) {
return out.trimmed();
}
QString AdbUtils::getAppVersion(const QString &deviceId, const QString &packageName) {
if (deviceId.isEmpty() || packageName.isEmpty()) {
return {};
}
QString out;
if (!startProcess(adbPath(),
{"-s", deviceId, "shell", "dumpsys", "package", packageName},
&out)) {
return {};
}
static const QRegularExpression re(R"(versionName=([^\s]+))");
const QRegularExpressionMatch m = re.match(out);
return m.hasMatch() ? m.captured(1).trimmed() : QString();
}
void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) {
const QString adb = adbPath();

View File

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

View File

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

View File

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

View File

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

View File

@ -164,7 +164,11 @@ void GetProfileInfoScript::doStart() {
}
// 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
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);

View File

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

View File

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

View File

@ -15,7 +15,9 @@
#include <qDebug>
#include "adb/AdbUtils.h"
#include "dao/SettingsDAO.h"
#include "net/NetworkService.h"
#include "LogArchiveSender.h"
namespace {
@ -164,14 +166,28 @@ TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
}
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
QString s = QString("Bank: %1 | Task: %2 | Device: %3")
.arg(m_bank, m_taskKind, m_adbDeviceIdRaw);
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));
if (!m_meta.phone.isEmpty()) s += QString("\nPhone: %1").arg(m_meta.phone);
if (!m_meta.card.isEmpty()) s += QString("\nCard: %1").arg(m_meta.card);
s += QString("\nError: %1").arg(errorMessage.left(800));
return s;
const QString apiToken = SettingsDAO::get("api_key");
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
QStringList head{QStringLiteral("[TASK_ERROR]")};
if (!apiToken.isEmpty())
head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped());
head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped());
head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped());
QString body = QStringLiteral("Device: %1").arg(m_adbDeviceIdRaw.toHtmlEscaped());
if (!m_meta.materialId.isEmpty())
body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped());
if (m_meta.amount != 0)
body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
if (!m_meta.phone.isEmpty())
body += QStringLiteral("\nPhone: %1").arg(m_meta.phone.toHtmlEscaped());
if (!m_meta.card.isEmpty())
body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped());
body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped());
return QStringLiteral("<b>%1</b>\n%2\n%3")
.arg(head.join(QStringLiteral(" ")), timestamp.toHtmlEscaped(), body);
}
void TaskDumpRecorder::saveXml(int counter, const QString &xml) {
@ -263,11 +279,7 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
QFile::remove(archivePath);
NetworkService *svc = networkServiceRef().data();
if (!svc) {
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
<< "(setNetworkService was not called at app startup?)";
return;
}
if (svc) {
qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService"
<< "archive.size=" << archiveBytes.size()
<< "image.size=" << finalScreenshot.size()
@ -281,6 +293,15 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
"slot not found or wrong signature for" << folder;
}
} else {
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
<< "(setNetworkService was not called at app startup?)";
}
// Следом — application.log + БД (+ WAL/SHM) отдельным архивом.
// Запускаем уже ПОСЛЕ того, как task-дамп поставлен в очередь
// отправки, чтобы порядок прихода на relay был "дамп → логи".
LogArchiveSender::dispatch(QStringLiteral("TASK_ERROR"));
});
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
bg->start();

View File

@ -46,6 +46,34 @@ bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode)
qWarning() << "[Dushanbe::inputPinCode] pinCode is EMPTY — nothing to enter!";
return false;
}
// Ждём, пока на экране появятся все уникальные цифры из PIN.
// DC Next на некоторых устройствах успевает показать заголовок
// "Введите код доступа" раньше, чем дорисует кнопочную сетку, —
// если начать тапать в этот момент, часть цифр не найдётся и PIN
// не введётся целиком.
bool seenInPin[10] = {false};
for (const QChar &d : pinCode) {
if (!d.isDigit()) continue;
const int idx = d.digitValue();
if (idx >= 0 && idx <= 9) seenInPin[idx] = true;
}
auto allDigitsPresent = [this, &seenInPin]() {
for (int d = 0; d < 10; ++d) {
if (!seenInPin[d]) continue;
if (xmlScreenParser.findNodeByContentDesc(QString::number(d)).isEmpty()) return false;
}
return true;
};
for (int i = 0; i < 6 && !allDigitsPresent(); ++i) {
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
}
if (!allDigitsPresent()) {
qWarning() << "[Dushanbe::inputPinCode] keypad incomplete after wait; aborting";
return false;
}
qDebug() << "[Dushanbe::inputPinCode] entering" << pinCode.length() << "digits";
// DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9")
for (const QChar &digit : pinCode) {
@ -124,10 +152,14 @@ bool CommonScript::runAppAndGoToHomeScreen(
if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe] PIN screen detected on attempt" << counter << "entering PIN";
if (!inputPinCode(deviceId, pinCode)) {
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter;
}
if (inputPinCode(deviceId, pinCode)) {
findAndInputPincode = true;
} else {
// Клавиатура могла не дорисоваться или PIN ввёлся частично —
// оставляем флаг false, следующая итерация повторит ввод,
// когда кнопки появятся.
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter << "— will retry";
}
QThread::msleep(3000);
continue;
}
@ -235,12 +267,12 @@ void CommonScript::updateTransactionData(
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);
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
qWarning() << "[Dushanbe] createBankProfileIfNeeded: skipping — fullName or phone is empty";
return;
return {};
}
const QMap<QString, int> currencyCodes = {
@ -283,12 +315,19 @@ void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QStr
if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id);
ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) {
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err;
return err;
}
qDebug() << "[Dushanbe] createBankProfileIfNeeded: postBankProfile done";
} else {
ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile();
qDebug() << "[Dushanbe] createBankProfileIfNeeded: patchBankProfile done";
}
return {};
}
void CommonScript::stop() {

View File

@ -45,7 +45,7 @@ protected:
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.
// Безопасно вызывать на любом экране; возвращает true если баннер был закрыт.

View File

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

View File

@ -2,6 +2,7 @@
#include <QJsonDocument>
#include <QRegularExpression>
#include <QSet>
#include <QThread>
#include <QTimeZone>
@ -16,6 +17,24 @@
#include "AppLogger.h"
#include <QCoreApplication>
// Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50",
// "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0,
// потому что требует, чтобы вся строка была валидным числом.
static double parseDushanbeAmount(const QString &raw) {
QString s = raw;
s.remove(QRegularExpression(R"([^\d,.\-])"));
s.replace(',', '.');
const int firstDot = s.indexOf('.');
if (firstDot >= 0) {
QString head = s.left(firstDot);
QString tail = s.mid(firstDot + 1);
tail.remove('.');
s = head + '.' + tail;
}
return s.toDouble();
}
static QString recognizeTextFromImage(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
@ -453,13 +472,17 @@ void GetLastTransactionsScript::doStart() {
}
// 2. Собираем View-транзакции (формат Выписки)
// Два формата content-desc:
// a) DCWallet/*** — старый: DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
// b) P2P — новый: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
// Известные форматы content-desc (всё через '\n'):
// a) DCWallet (старый, 5 строк): DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
// b) DCWallet (новый, 6 строк): DCWallet*WDC...\nphone-card-\ncard_owner\namount\nописание\n HH:MM:SS
// c) P2P: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
// Позиция amount уехала между версиями приложения, поэтому ищем по
// паттерну "[-]?digits.dd" среди всех строк — устойчиво к перестановке полей.
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
static const QRegularExpression reDateOrToday(
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)");
QList<VypiskaItem> items;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.view.View") continue;
@ -475,10 +498,14 @@ void GetLastTransactionsScript::doStart() {
VypiskaItem it;
bool ok = false;
// В P2P формате amount на позиции [3] (после даты, двух карт), в DCWallet — [2]
const int amtIdx = isP2P ? 3 : 2;
if (amtIdx >= lines.size()) continue;
it.amount = lines[amtIdx].trimmed().toDouble(&ok);
// Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному
// DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые
// поля не подпадают под паттерн.
for (const QString &line : lines) {
if (!reAmountLine.match(line.trimmed()).hasMatch()) continue;
it.amount = line.trimmed().toDouble(&ok);
if (ok) break;
}
if (!ok) continue;
it.time = last;
it.node = n;
@ -598,7 +625,7 @@ void GetLastTransactionsScript::doStart() {
}
}
// Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи)
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи)
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
@ -641,30 +668,113 @@ void GetLastTransactionsScript::doStart() {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml):
// одна нода со всем content-desc через \n. Лейблы без двоеточий, сумма —
// отдельной строкой со знаком сразу после даты+времени.
// "Успешный платёж\n21.05.2026 16:58:32\n-10\nКарта\n...\nПолучатель\n...\nЗакрыть"
for (const Node &n : xmlScreenParser.findAllNodes()) {
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) {
const QString key = lines[i].trimmed();
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 = val.toDouble();
} else if (key == QString::fromUtf8("Дата:")) {
detail.date = val;
} else if (key == QString::fromUtf8("Время:")) {
detail.time = val;
}
}
if (n.contentDesc.contains(QString::fromUtf8("Успешный"))) {
const QString cd = n.contentDesc;
if (!cd.contains(QString::fromUtf8("Карта"))) continue;
if (!cd.contains(QString::fromUtf8("Получатель"))) continue;
if (!cd.contains(QString::fromUtf8("Операция"))) continue;
const QStringList lines = cd.split('\n', Qt::SkipEmptyParts);
if (lines.size() < 6) continue;
// lines[0] — заголовок: "Успешный платёж" / "Поступление" / ...
if (lines[0].contains(QString::fromUtf8("Успешн"))
|| lines[0].contains(QString::fromUtf8("Поступление"))
|| lines[0].contains(QString::fromUtf8("выполнен"))) {
detail.status = QString::fromUtf8("Успешный");
}
// lines[1] — "dd.MM.yyyy HH:mm:ss"
{
static const QRegularExpression reDt(
R"((\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2}))");
const auto m = reDt.match(lines[1]);
if (m.hasMatch()) {
detail.date = m.captured(1);
detail.time = m.captured(2);
}
}
// lines[2] — сумма со знаком: "-10" / "30" / "30.50" / "1 000,50".
detail.amount = parseDushanbeAmount(lines[2]);
// Дальше — пары label/value; значение может быть multi-line (Получатель).
// На сервер шлём первую строку получателя — таков сложившийся контракт.
static const QSet<QString> labels{
QString::fromUtf8("Карта"),
QString::fromUtf8("Получатель"),
QString::fromUtf8("Операция"),
QString::fromUtf8("Дата"),
QString::fromUtf8("Время"),
QString::fromUtf8("Закрыть"),
QString::fromUtf8("Назад"),
};
for (int i = 3; i < lines.size(); ++i) {
const QString lbl = lines[i].trimmed();
if (!labels.contains(lbl)) continue;
QStringList vals;
for (int j = i + 1; j < lines.size(); ++j) {
const QString cand = lines[j].trimmed();
if (labels.contains(cand)) break;
vals << cand;
}
if (vals.isEmpty()) continue;
if (lbl == QString::fromUtf8("Получатель")) {
detail.receiverAccount = vals.first();
} else if (lbl == QString::fromUtf8("Дата") && detail.date.isEmpty()) {
detail.date = vals.first();
} else if (lbl == QString::fromUtf8("Время") && detail.time.isEmpty()) {
detail.time = vals.first();
}
}
detailLoaded = true;
break;
}
// 2) Старый blob-формат (до 3.2.33): "Успешный платеж" / "Поступление" —
// одна нода с ключами и значениями через \n.
if (!detailLoaded) for (const Node &n : xmlScreenParser.findAllNodes()) {
if (!n.contentDesc.contains(QString::fromUtf8("Дата"))) continue;
if (!n.contentDesc.contains(QString::fromUtf8("Время"))) continue;
const QStringList lines = n.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
QString key = lines[i].trimmed();
if (key.endsWith(':')) key.chop(1); // нормализуем "Дата:" → "Дата"
if (i + 1 >= lines.size()) continue;
const QString val = lines[i + 1].trimmed();
if (key == QString::fromUtf8("Получатель")) {
detail.receiverAccount = val;
} else if (key == QString::fromUtf8("Сумма")) {
detail.amount = parseDushanbeAmount(val);
} else if (key == QString::fromUtf8("Дата")) {
detail.date = val;
} else if (key == QString::fromUtf8("Время")) {
detail.time = val;
}
}
detailLoaded = true;
break;
}
// 3) Split-формат: лейблы (с ':') и значения в отдельных View-нодах
// на одной Y-полосе. Промежуточная версия между blob и bottom-sheet.
if (!detailLoaded) {
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
const QString d = pairs.value(QString::fromUtf8("Дата"));
const QString t = pairs.value(QString::fromUtf8("Время"));
if (!d.isEmpty() && !t.isEmpty()) {
detail.date = d;
detail.time = t;
detail.receiverAccount = pairs.value(QString::fromUtf8("Получатель"));
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("Сумма")));
if (detail.amount == 0.0) {
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("К зачислению")));
}
detail.fee = parseDushanbeAmount(pairs.value(QString::fromUtf8("Комиссия")));
detail.operationId = pairs.value(QString::fromUtf8("Номер операции"));
detail.status = pairs.value(QString::fromUtf8("Статус"));
detailLoaded = true;
}
}
if (detailLoaded) break;
QThread::msleep(1500);
}
@ -683,7 +793,7 @@ void GetLastTransactionsScript::doStart() {
<< "date=" << detail.date << "time=" << detail.time
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
// Проверяем время ±1 час
// Проверяем время ±10 минут
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");

View File

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

View File

@ -307,11 +307,33 @@ void PayByCardScript::makePayment(
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
QThread::msleep(2000);
// 8. После "Далее" кнопка меняется на "Оплатить" — ждём и нажимаем
// 8. После "Далее" возможны три исхода:
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
// c) экран ошибки.
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
bool oplatitFound = false;
bool earlySuccess = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
qDebug() << "[Dushanbe::PayByCard] Payment completed immediately after 'Далее'";
oplatitFound = true;
earlySuccess = true;
break;
}
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected after 'Далее'";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (oplatitBtn.isEmpty()) {
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
@ -338,8 +360,8 @@ void PayByCardScript::makePayment(
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
// content-desc содержит "Подтверждение платежа...Подтверждаю"
// Кнопка "Подтверждаю" визуально внизу bottom sheet
bool confirmed = false;
for (int attempt = 0; attempt < 10; ++attempt) {
bool confirmed = earlySuccess;
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -360,10 +382,14 @@ void PayByCardScript::makePayment(
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
// нижней четверти sheet, выше системного nav-бара).
// Compose отдаёт sheet как одну ноду на весь экран ([0,0][720,1640]),
// поэтому confirmNode.y1/y2 не сужают поиск — OCR сканирует весь
// скриншот и ловит ложные совпадения. Ограничиваем нижней половиной.
const int ocrYMin = (confirmNode.y1() + confirmNode.y2()) / 2;
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
confirmNode.y1(), confirmNode.y2());
ocrYMin, confirmNode.y2());
if (tap.x() < 0) {
const int tapY = confirmNode.y2() - qMax(150, height / 14);
tap = QPoint(confirmNode.x(), tapY);
@ -391,8 +417,17 @@ void PayByCardScript::makePayment(
// 10. Ждём результат — "Платеж выполнен" или ошибку.
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
// подтверждает перевод, поэтому общий таймаут — 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));
closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -436,20 +471,62 @@ void PayByCardScript::makePayment(
QString::fromUtf8("Подтверждение платежа"), true);
if (!retryConfirm.isEmpty() &&
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);
QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
retryConfirm.y1(), retryConfirm.y2());
ocrYMin, retryConfirm.y2());
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);
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
}
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
QThread::msleep(4000);
continue;
}
// Sheet "Подтверждение платежа" пропал, но снова виден input-экран
// (кнопка "Оплатить" вернулась) — значит тап по "Подтверждаю" промахнулся
// и Compose дисмиснул bottom sheet (clip на scrim/Закрыть/X). Перезапускаем
// цикл: тапаем "Оплатить" → следующая итерация увидит sheet и retryConfirm
// выше повторно тапнет "Подтверждаю".
Node retryOplatit = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (retryOplatit.isEmpty()) {
retryOplatit = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!retryOplatit.isEmpty() && retryOplatit.clickable) {
if (oplatitRetries >= maxOplatitRetries) {
m_error = "Sheet dismissed and re-tap exhausted ("
+ QString::number(oplatitRetries) + " retries)";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++oplatitRetries;
qDebug() << "[Dushanbe::PayByCard] Sheet dismissed, re-tap 'Оплатить'"
<< oplatitRetries << "/" << maxOplatitRetries;
AdbUtils::makeTap(deviceId, retryOplatit.x(), retryOplatit.y());
QThread::msleep(3000);
continue;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for result, attempt" << attempt + 1;
QThread::msleep(2000);
}
@ -471,8 +548,9 @@ void PayByCardScript::makePayment(
if (!m_resultScreenshot.isEmpty())
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;
double fee = 0;
@ -490,8 +568,19 @@ void PayByCardScript::makePayment(
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 (без перехода в историю).
// Уникальность за счёт <bankProfilePhone, секунда>.

View File

@ -307,11 +307,33 @@ void PayByPhoneScript::makePayment(
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
QThread::msleep(2000);
// 8. После "Далее" кнопка меняется на "Оплатить"
// 8. После "Далее" возможны три исхода:
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
// c) экран ошибки.
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
bool oplatitFound = false;
bool earlySuccess = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
qDebug() << "[Dushanbe::PayByPhone] Payment completed immediately after 'Далее'";
oplatitFound = true;
earlySuccess = true;
break;
}
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected after 'Далее'";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (oplatitBtn.isEmpty()) {
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
@ -336,8 +358,8 @@ void PayByPhoneScript::makePayment(
}
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
bool confirmed = false;
for (int attempt = 0; attempt < 10; ++attempt) {
bool confirmed = earlySuccess;
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -356,10 +378,15 @@ void PayByPhoneScript::makePayment(
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
// нижней четверти sheet, выше системного nav-бара).
// Compose отдаёт sheet как одну ноду на весь экран ([0,0][720,1640]),
// поэтому confirmNode.y1/y2 не сужают поиск — OCR сканирует весь
// скриншот и ловит ложные совпадения (буква "в" в шапке).
// Ограничиваем нижней половиной — действие-кнопка всегда внизу sheet'a.
const int ocrYMin = (confirmNode.y1() + confirmNode.y2()) / 2;
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
confirmNode.y1(), confirmNode.y2());
ocrYMin, confirmNode.y2());
if (tap.x() < 0) {
const int tapY = confirmNode.y2() - qMax(150, height / 14);
tap = QPoint(confirmNode.x(), tapY);
@ -387,8 +414,17 @@ void PayByPhoneScript::makePayment(
// 10. Ждём результат — "Платеж выполнен" или ошибку.
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
// подтверждает перевод, поэтому общий таймаут — 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));
closeGooglePlayBannerIfVisible(deviceId, width, height);
@ -428,20 +464,62 @@ void PayByPhoneScript::makePayment(
QString::fromUtf8("Подтверждение платежа"), true);
if (!retryConfirm.isEmpty() &&
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);
QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
retryConfirm.y1(), retryConfirm.y2());
ocrYMin, retryConfirm.y2());
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);
qDebug() << "[Dushanbe::PayByPhone] OCR miss, fallback tap at" << tap;
}
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
QThread::msleep(4000);
continue;
}
// Sheet "Подтверждение платежа" пропал, но снова виден input-экран
// (кнопка "Оплатить" вернулась) — значит тап по "Подтверждаю" промахнулся
// и Compose дисмиснул bottom sheet (clip на scrim/Закрыть/X). Перезапускаем
// цикл: тапаем "Оплатить" → следующая итерация увидит sheet и retryConfirm
// выше повторно тапнет "Подтверждаю".
Node retryOplatit = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (retryOplatit.isEmpty()) {
retryOplatit = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!retryOplatit.isEmpty() && retryOplatit.clickable) {
if (oplatitRetries >= maxOplatitRetries) {
m_error = "Sheet dismissed and re-tap exhausted ("
+ QString::number(oplatitRetries) + " retries)";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++oplatitRetries;
qDebug() << "[Dushanbe::PayByPhone] Sheet dismissed, re-tap 'Оплатить'"
<< oplatitRetries << "/" << maxOplatitRetries;
AdbUtils::makeTap(deviceId, retryOplatit.x(), retryOplatit.y());
QThread::msleep(3000);
continue;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for result, attempt" << attempt + 1;
QThread::msleep(2000);
}
@ -463,7 +541,8 @@ void PayByPhoneScript::makePayment(
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
// Парсим детали из content-desc "Платеж выполнен"
// Парсим детали из экрана "Платеж выполнен". С 3.2.33 поля разнесены по
// отдельным View-узлам — пробуем сначала старый blob-формат, потом fallback.
QString bankTime;
double fee = 0;
@ -481,8 +560,19 @@ void PayByPhoneScript::makePayment(
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 (без перехода в историю).
// Уникальность за счёт <bankProfilePhone, секунда>.

View File

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

View File

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

View File

@ -70,7 +70,12 @@ bool fuzzyMatch(const QString &word, const QString &needle) {
if (word.isEmpty() || needle.isEmpty()) return false;
const QString w = word.toLower();
const QString n = needle.toLower();
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 на «Подтверждаю» иногда
// даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания.
const int tolerance = qMax(1, n.size() / 5);

View File

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

View File

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

View File

@ -90,6 +90,7 @@ void GetCardInfoScript::doStart() {
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isProfileScreen()) break;
QThread::msleep(1000);
}
@ -106,12 +107,14 @@ void GetCardInfoScript::doStart() {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
} else {
qDebug() << "[GetCardInfo] Profile info already in DB or username not found, skipping profile parse";
}
// Проверяем что на домашнем экране перед поиском карт
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[GetCardInfo] Not on home screen before card search, restarting...";
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
@ -130,6 +133,9 @@ void GetCardInfoScript::doStart() {
QList<MaterialInfo> cards;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight)) {
// баннер закрыт, дамп уже обновлён внутри helper'а
}
cards = xmlScreenParser.parseCardsOnHomeScreen();
if (!cards.isEmpty()) break;
qDebug() << "[GetCardInfo] No cards found on home screen, attempt" << attempt + 1 << "/ 5";
@ -146,17 +152,30 @@ void GetCardInfoScript::doStart() {
qDebug() << "[GetCardInfo] Found" << cards.size() << "card(s) on home screen";
// Апсёртим аккаунты для всех найденных карт
// Заполняем метаданные на найденных картах. Новые карты (которых ещё нет в БД)
// не создаём здесь — запись появится только после успешного OCR полного номера
// в success-ветке ниже. Это защищает от записей из ленты переводов и не оставляет
// в БД материал с пустым card_number.
//
// Для карт, которые уже в БД, обновляем только amount/update_time —
// прочие поля (cardNumber, materialId, status, ...) сохраняем как было.
for (MaterialInfo &card : cards) {
card.deviceId = device.id;
card.appCode = m_appCode;
card.updateTime = QDateTime::currentDateTimeUtc();
card.status = MaterialStatus::Active;
if (!MaterialDAO::upsertAccount(card)) {
qWarning() << "[GetCardInfo] Failed to upsert account for card" << card.lastNumbers;
card.status = MaterialStatus::Off;
const MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (existing.id == -1) continue;
MaterialInfo refresh = existing;
refresh.amount = card.amount;
refresh.updateTime = card.updateTime;
if (!MaterialDAO::upsertAccount(refresh)) {
qWarning() << "[GetCardInfo] Failed to refresh amount for card" << card.lastNumbers;
} else {
qDebug() << "[GetCardInfo] Upserted account:" << card.lastNumbers
<< card.description << card.amount;
qDebug() << "[GetCardInfo] Refreshed amount for card"
<< card.lastNumbers << "to" << card.amount;
}
}
@ -220,9 +239,11 @@ void GetCardInfoScript::doStart() {
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId),
"FETCH_PROFILE");
} else {
qDebug() << "[GetCardInfo] postBankProfile done";
m_error = err;
emit finishedWithResult(m_error);
return;
}
qDebug() << "[GetCardInfo] postBankProfile done";
} else {
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
ns.patchBankProfile();
@ -262,6 +283,7 @@ void GetCardInfoScript::doStart() {
for (int scrollAttempt = 0; scrollAttempt < 6; ++scrollAttempt) {
QThread::msleep(scrollAttempt == 0 ? 2000 : 600);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
Node candidate = xmlScreenParser.findButtonNode("Показать", false);
if (candidate.isEmpty()) {
@ -293,6 +315,7 @@ void GetCardInfoScript::doStart() {
for (int attempt = 0; attempt < 10; ++attempt) {
QThread::msleep(attempt == 0 ? 2000 : 500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
Node candidate = xmlScreenParser.findButtonNode("Показать", false);
Node requisites = xmlScreenParser.findTextNode("Реквизиты карты");
@ -332,6 +355,7 @@ void GetCardInfoScript::doStart() {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
continue;
}
@ -343,6 +367,7 @@ void GetCardInfoScript::doStart() {
for (int attempt = 0; attempt < 10; ++attempt) {
QThread::msleep(2000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
cardNumber = xmlScreenParser.findFullCardNumber();
if (!cardNumber.isEmpty()) {
qDebug() << "[GetCardInfo] Card number found on attempt" << attempt + 1;
@ -388,19 +413,20 @@ void GetCardInfoScript::doStart() {
if (!cardNumber.isEmpty()) {
qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "full number:" << cardNumber;
// Получаем id аккаунта из БД и сохраняем номер карты
MaterialInfo saved = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (saved.id != -1) {
MaterialDAO::updateCardNumber(saved.id, cardNumber);
// Апсёртим запись только теперь — с полным номером карты.
MaterialInfo toSave = card;
toSave.cardNumber = cardNumber;
toSave.updateTime = QDateTime::currentDateTimeUtc();
if (!MaterialDAO::upsertAccount(toSave)) {
qWarning() << "[GetCardInfo] Failed to upsert account for card" << card.lastNumbers;
} else {
qDebug() << "[GetCardInfo] Upserted account:" << card.lastNumbers
<< card.description << card.amount;
}
} else {
const QString err = "Card number not found for " + card.lastNumbers;
qWarning() << "[GetCardInfo]" << err;
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,
AdbUtils::captureScreenshotBytes(m_deviceId),
"FETCH_PROFILE");
@ -411,6 +437,7 @@ void GetCardInfoScript::doStart() {
AdbUtils::goBack(m_deviceId);
QThread::msleep(2000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
}
}

View File

@ -379,37 +379,53 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню.
// Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё
// видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY
// принадлежат нашему дню.
// Возвращает специальные коды через dateY:
// dateY >= 0 : header виден на этом Y (нормальный случай)
// dateY == 0 : header выше viewport, есть более старая видимая дата
// dateY == -1 : header не виден / лежит в нижней sticky-пачке
// (скролл вниз, чтобы target выехал в видимую часть)
// dateY == -2 : header в верхней sticky-пачке (несколько дат свёрнуто
// над viewport) — скролл вверх, чтобы пачка распалась
auto findDateYRange = [&]() -> QPair<int, int> {
const QMap<int, int> yCount = computeDateYCount();
auto 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 nextDateY = device.screenHeight;
int targetPileY = -1;
bool foundOlderDate = false;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (!d.isValid()) continue;
if (d == searchDate) {
if (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) {
foundOlderDate = true;
if (isVisible(node.y()) && (dateY < 0 || node.y() > dateY)
if (isAnchored(node.y())
&& (dateY < 0 || node.y() > dateY)
&& node.y() < nextDateY) {
nextDateY = node.y();
}
}
}
// Target в sticky-пачке: пачка над viewport (малый Y) → секция уже
// прокручена выше, надо вернуться (dateY = -2). Пачка снизу viewport
// (большой Y) → target ещё не доехал до экрана, скролл вниз (dateY = -1).
if (dateY < 0 && targetPileY >= 0) {
dateY = (targetPileY < viewportMid) ? -2 : -1;
}
// Target-header не виден, но видна более старая дата → target ушёл вверх:
// всё выше nextDateY принадлежит нашему дню.
if (dateY < 0 && nextDateY < device.screenHeight) {
if (dateY < 0 && nextDateY < device.screenHeight && targetPileY < 0) {
dateY = 0;
}
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate;
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate
<< "targetPileY=" << targetPileY;
return {dateY, nextDateY};
};
@ -440,6 +456,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
// Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
if (dateHeaderY == -2) {
// Target застрял в sticky-пачке (Ozon стекает заголовки выше viewport
// в одну Y-позицию). Ниже пачки есть anchored более старая дата →
// секцию target уже проскроллили мимо. Скроллим обратно вверх,
// чтобы target распался из пачки и занял реальный Y.
qDebug() << "[FetchTransactions] Target piled in sticky band — scrolling up to detach";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 600);
QThread::msleep(1500);
continue;
}
if (dateHeaderY < 0) {
// Target-header не виден и ни одной более старой даты не видно →
// target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх).

View File

@ -174,9 +174,11 @@ void GetProfileInfoScript::doStart() {
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId),
"FETCH_PROFILE");
} else {
qDebug() << "[GetProfileInfo] postBankProfile done";
m_error = err;
emit finishedWithResult(m_error);
return;
}
qDebug() << "[GetProfileInfo] postBankProfile done";
} else {
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
ns.patchBankProfile();

View File

@ -76,8 +76,31 @@ void LoginAndCheckAccountsScript::doStart() {
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 на сервере
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)) {
QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();

View File

@ -287,30 +287,17 @@ void PayByCardScript::makePayment(
if (!sumInput.isEmpty()) break;
// Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO).
// На экране "Перевод по номеру карты" всегда 2 EditText: карта (сверху)
// и сумма (снизу). Стратегии по приоритету:
// 1) Svelte генерирует resource-id="input___xxxx" ТОЛЬКО для суммы —
// берём этот EditText.
// 2) Если rid нет (старая версия Ozon UI) — берём второй EditText
// по y-порядку (карта y1=меньше, сумма y1=больше).
QList<Node> candidateInputs;
// На экране "Перевод по номеру карты" всегда 2 EditText в DOM-порядке:
// карта (сверху), сумма (снизу). Оба имеют resource-id="input___xxxx",
// поэтому отличать по rid нельзя — берём второй по порядку обхода XML.
QList<Node> editTexts;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.widget.EditText") continue;
if (n.height() <= 0 || n.height() > height * 4 / 5) continue;
candidateInputs.append(n);
editTexts.append(n);
}
// Strategy 1: rid="input___"
for (const Node &n : candidateInputs) {
if (n.resourceId.startsWith("input___")) {
sumInput = n;
break;
}
}
// Strategy 2: bottom-most по y1 (если их 2 — берём второй)
if (sumInput.isEmpty() && candidateInputs.size() >= 2) {
std::sort(candidateInputs.begin(), candidateInputs.end(),
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
sumInput = candidateInputs.last();
if (editTexts.size() >= 2) {
sumInput = editTexts[1];
}
if (!sumInput.isEmpty()) break;

View File

@ -67,6 +67,14 @@ static QString extractTransactionId(const QString &ocrText) {
namespace Ozon {
static QString sanitizeBankName(const QString &raw) {
// "Озон банк (Ozon)" -> "Озон банк"; убираем содержимое круглых скобок
// вместе с предшествующими пробелами и обрезаем края.
QString s = raw;
s.remove(QRegularExpression(R"(\s*\([^)]*\))"));
return s.trimmed();
}
PayByPhoneScript::PayByPhoneScript(
MaterialInfo account,
QString phone,
@ -75,7 +83,7 @@ PayByPhoneScript::PayByPhoneScript(
QObject *parent
) : CommonScript(parent),
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;
@ -162,7 +170,11 @@ void PayByPhoneScript::makePayment(
}
return true;
};
// 1. Нажимаем "Перевести" на домашнем экране
// 1. Нажимаем "Перевести" на домашнем экране.
// Внимание: поверх правой части кнопки сидит referral-бейдж "badge icon за друга"
// (bounds типа [550,90][602,140]), который перекрывает центр кнопки. Тап в центр
// открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы
// гарантированно попасть мимо бейджа.
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty()) {
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
@ -170,8 +182,13 @@ void PayByPhoneScript::makePayment(
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);
// 2. Ждём экран "Платежи и переводы" с полем "Телефон получателя"
@ -296,28 +313,54 @@ void PayByPhoneScript::makePayment(
// Экран выбора банка: ищем "Другой банк" (текст) или EditText с hint "Название банка"
if (!bankSelected) {
// Вариант 1: кнопка "Другой банк" (content-desc="other-bank-item", text пустой)
bool foundOtherBank = false;
{
Node node;
// Вариант 1: кнопка "Другой банк" (content-desc="other-bank-item", text пустой).
// На устройствах с маленьким экраном кнопка лежит в конце виртуализированного
// списка и приходит с bounds=[0,0][0,0] — Node::isEmpty это не ловит, тап
// улетает в (0,0). Скроллим список вниз, пока bounds не станут ненулевыми
// (после первого скролла кнопка обычно материализуется у нижнего края, но
// тап по ней работает — это её настоящая позиция в layout).
auto findOtherBank = [&]() -> Node {
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.contentDesc.contains("other-bank-item")) {
node = n;
break;
if (n.contentDesc.contains("other-bank-item")) return n;
}
return {};
};
bool foundOtherBank = false;
Node node = findOtherBank();
for (int scrollAttempt = 0;
!node.isEmpty() && (node.width() == 0 || node.height() == 0) && scrollAttempt < 6;
++scrollAttempt) {
qDebug() << "[PayByPhone] 'Другой банк' has collapsed bounds, scrolling list down, attempt"
<< scrollAttempt + 1 << "/ 6";
AdbUtils::makeSwipe(deviceId, width / 2, height * 3 / 4, width / 2, height / 4);
QThread::msleep(800);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
node = findOtherBank();
}
if (!node.isEmpty()) {
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item)...";
if (!node.isEmpty() && node.width() > 0 && node.height() > 0) {
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item) at"
<< node.x() << "," << node.y();
AdbUtils::makeTap(deviceId, node.x(), node.y());
QThread::msleep(2000);
foundOtherBank = true;
otherBankClicked = true;
}
}
if (foundOtherBank) continue;
// Вариант 2: уже на экране поиска банка (EditText) — только после клика на "Другой банк"
if (otherBankClicked) {
// После перехода на экран поиска банка поле ввода прячется под верхним
// тулбаром (рендерится Svelte WebView'ом). Свайпаем сверху вниз, чтобы
// подтянуть содержимое и сделать поле физически видимым/активным.
qDebug() << "[PayByPhone] Scrolling up to reveal hidden bank search input...";
AdbUtils::makeSwipe(deviceId, width / 2, height / 4, width / 2, height * 3 / 4);
QThread::msleep(800);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
Node bankInput = xmlScreenParser.findFirstEditText();
if (!bankInput.isEmpty()) {
qDebug() << "[PayByPhone] Bank search screen detected, entering bank name...";

View File

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

View File

@ -3,7 +3,7 @@ list=ozon, black, dushanbe_city_bank
[common]
currencies=USD, ARS, RUB, TJS
version=0.0.9
version=1.0.64
[db]
dbPath=./database/app_data.db
@ -18,8 +18,11 @@ pathDesktopCreate=/api/v1/desktop/
pathDevice=/api/v1/device/
pathDeviceScreenshot=/api/v1/device/add_screenshot/
pathMaterial=/api/v1/profile/material
pathProfilePing=/api/v1/profile/ping
pathMonitoringLog=/monitoring/log
pathMonitoringDump=/monitoring/dump
monitoringBase=http://142.93.102.40:8080
monitoringToken=509b91592749959b9ff56f9ed9189e5b3e91e1260003f63c477255e1c187298a
[black]
android_package_name=app.blackwallet.ar
@ -27,6 +30,7 @@ currency=ARS
country=argentina
name=Black
phone_code=54
app_version=0
[ozon]
android_package_name=ru.ozon.fintech.finance
@ -34,6 +38,7 @@ currency=RUB
country=russia
name=Ozon
phone_code=7
app_version=19.17.0
[dushanbe_city_bank]
android_package_name=tj.dc.next1
@ -41,3 +46,4 @@ currency=TJS
country=tajikistan
name=Dushanbe City
phone_code=992
app_version=3.2.34

View File

@ -119,10 +119,16 @@ void DatabaseManager::initSchema() {
bank_profile_id TEXT DEFAULT '',
currency TEXT DEFAULT '',
amount REAL DEFAULT 0,
app_version TEXT DEFAULT '',
app_version_checked_at DATETIME,
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE,
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT,
@ -170,6 +176,7 @@ void DatabaseManager::initSchema() {
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER,
external_id TEXT,
transaction_id TEXT,
device_id TEXT,
amount REAL,
phone TEXT,
@ -186,6 +193,7 @@ void DatabaseManager::initSchema() {
FOREIGN KEY (account_id) REFERENCES materials (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 (
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) {
QSqlDatabase db = DatabaseManager::instance().database();
if (!db.transaction()) {
qWarning() << "[BankDAO] replaceAll: cannot begin transaction:" << db.lastError().text();
return;
}
QSqlQuery query(db);
if (!query.exec("DELETE FROM banks")) {
qWarning() << "[BankDAO] replaceAll: DELETE failed:" << query.lastError().text();
db.rollback();
return;
}
db.transaction();
query.exec("DELETE FROM banks");
query.prepare(R"(
if (!query.prepare(R"(
INSERT INTO banks (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) {
query.bindValue(":alias", bank.alias);
query.bindValue(":name", bank.name);
query.bindValue(":nspk_name", bank.nspkName);
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.currency = query.value("currency").toString();
app.amount = query.value("amount").toDouble();
app.appVersion = query.value("app_version").toString();
app.appVersionCheckedAt = query.value("app_version_checked_at").toDateTime();
return app;
}
@ -158,7 +160,7 @@ BankProfileInfo BankProfileDAO::getApplication(const QString &deviceId, const QS
QSqlQuery query(DatabaseManager::instance().database());
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
WHERE device_id = :device_id AND code = :code
LIMIT 1
@ -183,7 +185,7 @@ QList<BankProfileInfo> BankProfileDAO::getApplicationsByDeviceId(const QString &
QList<BankProfileInfo> list;
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
WHERE device_id = :device_id AND install = 'yes'
)");
@ -206,7 +208,7 @@ QList<BankProfileInfo> BankProfileDAO::getAllApplicationsByDeviceId(const QStrin
QList<BankProfileInfo> list;
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
WHERE device_id = :device_id
)");
@ -262,6 +264,35 @@ bool BankProfileDAO::updateAmount(const int appId, const double amount) {
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) {
QSqlQuery query(DatabaseManager::instance().database());
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;
QSqlQuery query(DatabaseManager::instance().database());
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
WHERE bank_profile_id = :bank_profile_id
)");

View File

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

View File

@ -128,6 +128,50 @@ bool MaterialDAO::updateMaterialId(const int accountId, const QString &materialI
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 acc;
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
FROM materials
WHERE device_id = :device_id AND app_code = :app_code
ORDER BY id
)");
query.bindValue(":device_id", deviceId);

View File

@ -26,6 +26,18 @@ public:
[[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 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]`
- **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
```

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

@ -65,7 +65,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) {
#include <QPushButton>
#include <QFileDialog>
void startDeviceScreener(const QCoreApplication &app) {
void startDeviceScreener(const QCoreApplication &app, NetworkService *networkService = nullptr) {
auto *thread = new QThread();
auto *deviceScreenerWorker = new DeviceScreener;
@ -76,6 +76,20 @@ void startDeviceScreener(const QCoreApplication &app) {
// Когда поток запустится, автоматически вызовется worker->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 строки делают автоматическую зачистку:
// Когда worker закончит работу (emit finished()), мы:
// - останавливаем поток (thread->quit())
@ -165,7 +179,7 @@ void startBlackPayByCardTest() {
thread->start();
}
void startNetworkService(const QCoreApplication &app) {
NetworkService* startNetworkService(const QCoreApplication &app) {
auto *paymentThread = new QThread;
auto *networkService = new NetworkService;
AppLogger::setNetworkService(networkService);
@ -209,6 +223,7 @@ void startNetworkService(const QCoreApplication &app) {
}, Qt::QueuedConnection);
paymentThread->start();
return networkService;
}
static QFile logFile;
@ -357,8 +372,11 @@ int main(int argc, char *argv[]) {
auto *mainWindow = new MainWindow();
mainWindow->show();
registrationWindow->close();
startNetworkService(app);
startDeviceScreener(app);
NetworkService *net = startNetworkService(app);
QObject::connect(net, &NetworkService::syncHealthChanged,
mainWindow, &MainWindow::setSyncHealth,
Qt::QueuedConnection);
startDeviceScreener(app, net);
startEventHandler(app);
// startBlackPayByCardTest();
}, Qt::QueuedConnection);
@ -388,8 +406,11 @@ int main(int argc, char *argv[]) {
if (r == 1) {
auto *mainWindow = new MainWindow();
mainWindow->show();
startNetworkService(app);
startDeviceScreener(app);
NetworkService *net = startNetworkService(app);
QObject::connect(net, &NetworkService::syncHealthChanged,
mainWindow, &MainWindow::setSyncHealth,
Qt::QueuedConnection);
startDeviceScreener(app, net);
startEventHandler(app);
// startBlackPayByCardTest();
} else if (r == 0) {
@ -409,8 +430,11 @@ int main(int argc, char *argv[]) {
} else if (result == 1) {
auto *mainWindow = new MainWindow();
mainWindow->show();
startNetworkService(app);
startDeviceScreener(app);
NetworkService *net = startNetworkService(app);
QObject::connect(net, &NetworkService::syncHealthChanged,
mainWindow, &MainWindow::setSyncHealth,
Qt::QueuedConnection);
startDeviceScreener(app, net);
startEventHandler(app);
// startBlackPayByCardTest();
} else if (result == 0) {

View File

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

View File

@ -13,7 +13,7 @@ public:
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();
}

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 currency;
double amount = 0.0;
QString appVersion;
QDateTime appVersionCheckedAt;
QDateTime updateTime;
};

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
#pragma once
#include <QObject>
#include <QNetworkAccessManager>
#include <QSet>
#include <QMap>
#include <dao/DeviceDAO.h>
#include <db/DeviceInfo.h>
@ -12,6 +14,8 @@ public:
~DeviceScreener() override;
static constexpr qint64 kOfflineDebounceSecs = 30;
public slots:
void start();
@ -19,6 +23,9 @@ public slots:
signals:
void finished();
void firstPassDone();
void deviceWentOnline(const QString &deviceId);
void deviceWentOffline(const QString &deviceId);
private:
bool m_running = true;
@ -28,6 +35,8 @@ private:
QString m_apiBase;
QNetworkAccessManager *m_manager = nullptr;
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();
@ -44,5 +53,4 @@ private:
QString createDeviceOnApi(const DeviceInfo &device);
void updateDeviceOnApi(const DeviceInfo &device);
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/DeviceDAO.h"
#include "dao/EventDAO.h"
#include "dao/SettingsDAO.h"
#include "dao/TransactionDAO.h"
#include "db/BankProfileInfo.h"
#include "adb/AdbUtils.h"
@ -32,8 +33,8 @@
#include "dushanbe/PayByPhoneScript.h"
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 sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, 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, bool toApiBase = false);
EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
@ -73,7 +74,7 @@ void EventHandler::start() {
if (e.sentToServer) continue;
EventDAO::updateEvent(e.id, EventStatus::Error, "App restarted");
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;
}
}
@ -107,11 +108,14 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
continue;
}
// transaction_id присутствует только в CREATE_TRANSACTION; для других типов это пусто.
const QString incomingTransactionId = task["body"].toObject()["transaction_id"].toString();
// Ищем аккаунт по material_id напрямую
const MaterialInfo account = MaterialDAO::findByMaterialId(materialId);
if (account.id == -1) {
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;
}
@ -119,7 +123,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
if (eventType == EventType::FetchProfile) {
const QList<EventInfo> cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile);
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);
}
}
@ -141,6 +145,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
if (eventType == EventType::CreateTransaction) {
event.amount = body["amount"].toDouble() / 100.0;
event.phone = body["bank_participator_id"].toString();
event.transactionId = body["transaction_id"].toString();
// Название банка-получателя из extra
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)) {
qWarning() << "[EventHandler] Device offline for material_id:" << materialId << "device:" << account.deviceId;
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,
const QString &description, const QByteArray &screenshot, int eventId) {
const QString &description, const QByteArray &screenshot, int eventId,
const QString &transactionId) {
QJsonObject errorData;
errorData["status"] = QStringLiteral("error");
errorData["event_type"] = type;
errorData["description"] = description;
if (!transactionId.isEmpty())
errorData["transaction_id"] = transactionId;
sendTaskResult(QStringLiteral("ERROR"), materialId, bankName, errorData, eventId);
if (!screenshot.isEmpty()) {
const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
.arg(type, bankName, materialId, description);
const QString apiToken = SettingsDAO::get("api_key");
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);
}
// application.log + БД отправляются из TaskDumpRecorder::finishInternal,
// чтобы покрывать также non-event-driven задачи (login flow и т.п.).
}
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 *networkService = new NetworkService;
networkService->moveToThread(thread);
if (eventId > 0)
networkService->addExtra("internal_task_id", eventId);
QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() {
networkService->postMonitoringLog(type, event, message, image);
QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image, toApiBase]() {
networkService->postMonitoringLog(type, event, message, image, toApiBase);
emit networkService->finished();
});
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);
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) {
const MaterialInfo declAcc = MaterialDAO::getAccountById(event.accountId);
@ -307,6 +325,8 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
}
QJsonObject declined;
if (!event.transactionId.isEmpty())
declined["transaction_id"] = event.transactionId;
declined["bank_transaction_id"] = declTxId;
declined["amount"] = -static_cast<int>(event.amount * 100);
declined["currency"] = declCurrencyCode;
@ -349,7 +369,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
if (parsedTx.isEmpty()) {
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx";
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,
"Transaction not parsed from screen");
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();
QJsonObject obj;
if (!event.transactionId.isEmpty())
obj["transaction_id"] = event.transactionId;
obj["amount"] = -static_cast<int>(event.amount * 100);
obj["currency"] = currencyCode;
obj["transaction_type"] = txType.isEmpty() ? "top-up" : txType;
@ -419,21 +441,63 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
}
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) {
const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2")
.arg(event.bankName, event.externalId);
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) {
const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2")
.arg(event.bankName, event.externalId);
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 поднимался из правильной записи).
const QString adbSerial = account.deviceId;
// AutoConnection: worker emits из своего потока, лямбда выполняется в потоке
// EventHandler (где live `this`), там же где take()/clear() — m_screenshots
// не нужен mutex.
auto connectScreenshot = [this, key](Ozon::CommonScript *w) {
connect(w, &Ozon::CommonScript::screenshotReady, this,
[this, key](const QByteArray &s) { m_screenshots[key] = s; },
Qt::DirectConnection);
[this, key](const QByteArray &s) { m_screenshots[key] = s; });
};
auto connectScreenshotDushanbe = [this, key](Dushanbe::CommonScript *w) {
connect(w, &Dushanbe::CommonScript::screenshotReady, this,
[this, key](const QByteArray &s) { m_screenshots[key] = s; },
Qt::DirectConnection);
[this, key](const QByteArray &s) { m_screenshots[key] = s; });
};
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 body = taskJson["body"].toObject();
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") {
auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime, account.materialId);
setup(w); connectScreenshot(w);
connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
Qt::DirectConnection);
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; });
}
else if (appCode == "black")
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);
setup(w); connectScreenshotDushanbe(w);
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
Qt::DirectConnection);
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; });
}
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")
.arg(eventTypeToString(event.type), event.bankName, event.externalId);
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);
// Убиваем банковское приложение на устройстве после таймаута
@ -627,7 +701,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
if (waitSecs > 5 * 60) {
qWarning() << "[EventHandler] Task expired after" << waitSecs << "s, event id:" << event.id;
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;
}
@ -637,7 +711,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
qWarning() << "[EventHandler] Bank profile disabled for" << account.appCode << "device:" << deviceId;
EventDAO::updateEvent(event.id, EventStatus::Error, "Bank profile is disabled");
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
"Bank profile is disabled: " + account.appCode);
"Bank profile is disabled: " + account.appCode, {}, -1, event.transactionId);
continue;
}
@ -652,16 +726,23 @@ void EventHandler::processNextTask(const QString &deviceId) {
}
void EventHandler::clearAll() {
// Параллельная остановка: сначала всем requestInterruption + quit,
// потом ждём каждый. terminate() намеренно НЕ используем — он опасен
// (heap corruption, утечки DB-локов); если поток не успел остановиться,
// abandon'им — ОС зачистит ресурсы при выходе процесса. Этот же подход
// применяет watchdog в startThreadForTask.
for (QThread *thread : m_threads) {
if (thread->isRunning()) {
thread->requestInterruption();
thread->quit();
if (!thread->wait(3000)) {
qWarning() << "[EventHandler] Thread did not stop in 3s, terminating";
thread->terminate();
thread->wait(2000);
}
}
for (QThread *thread : m_threads) {
if (thread->isRunning() && !thread->wait(5000)) {
qWarning() << "[EventHandler] Thread did not stop in 5s on shutdown,"
<< "abandoning (no terminate to avoid heap corruption)";
continue; // не deleteLater для всё ещё работающего QThread
}
thread->deleteLater();
}
m_threads.clear();

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);
}

View File

@ -0,0 +1,80 @@
#pragma once
#include <QNetworkReply>
#include <QString>
// Хелперы для информативного логирования сетевых ошибок. Используются
// и в NetworkService, и в DeviceScreener — категория сразу показывает
// характер сбоя: timeout / refused / DNS / SSL / HTTP-уровень.
namespace NetworkErrorUtils {
// Удаляет схему+хост из URL, оставляет только путь (для логов/UI без секретов).
inline QString sanitizePath(const QString &fullUrl) {
const int schemeEnd = fullUrl.indexOf(QStringLiteral("://"));
if (schemeEnd < 0) return fullUrl;
const int pathStart = fullUrl.indexOf(QLatin1Char('/'), schemeEnd + 3);
return pathStart >= 0 ? fullUrl.mid(pathStart) : QStringLiteral("/");
}
// Категория ошибки в виде короткого ярлыка. См. QNetworkReply::NetworkError.
inline QString networkErrorCategory(QNetworkReply::NetworkError err) {
switch (err) {
case QNetworkReply::NoError: return QStringLiteral("NO_ERROR");
case QNetworkReply::ConnectionRefusedError: return QStringLiteral("CONNECTION_REFUSED");
case QNetworkReply::RemoteHostClosedError: return QStringLiteral("REMOTE_CLOSED");
case QNetworkReply::HostNotFoundError: return QStringLiteral("DNS_FAIL");
case QNetworkReply::TimeoutError: return QStringLiteral("QT_TIMEOUT");
case QNetworkReply::OperationCanceledError: return QStringLiteral("CANCELLED");
case QNetworkReply::SslHandshakeFailedError: return QStringLiteral("SSL_HANDSHAKE");
case QNetworkReply::TemporaryNetworkFailureError: return QStringLiteral("TEMP_NET_FAIL");
case QNetworkReply::NetworkSessionFailedError: return QStringLiteral("NO_NETWORK_SESSION");
case QNetworkReply::BackgroundRequestNotAllowedError: return QStringLiteral("BG_NOT_ALLOWED");
case QNetworkReply::TooManyRedirectsError: return QStringLiteral("TOO_MANY_REDIRECTS");
case QNetworkReply::InsecureRedirectError: return QStringLiteral("INSECURE_REDIRECT");
case QNetworkReply::UnknownNetworkError: return QStringLiteral("UNKNOWN_NETWORK");
case QNetworkReply::ProxyConnectionRefusedError: return QStringLiteral("PROXY_REFUSED");
case QNetworkReply::ProxyConnectionClosedError: return QStringLiteral("PROXY_CLOSED");
case QNetworkReply::ProxyNotFoundError: return QStringLiteral("PROXY_NOT_FOUND");
case QNetworkReply::ProxyTimeoutError: return QStringLiteral("PROXY_TIMEOUT");
case QNetworkReply::ProxyAuthenticationRequiredError: return QStringLiteral("PROXY_AUTH_REQ");
case QNetworkReply::ContentAccessDenied: return QStringLiteral("HTTP_403");
case QNetworkReply::ContentOperationNotPermittedError:return QStringLiteral("OP_NOT_PERMITTED");
case QNetworkReply::ContentNotFoundError: return QStringLiteral("HTTP_404");
case QNetworkReply::AuthenticationRequiredError: return QStringLiteral("HTTP_401");
case QNetworkReply::ContentReSendError: return QStringLiteral("RESEND_REQUIRED");
case QNetworkReply::ContentConflictError: return QStringLiteral("HTTP_409");
case QNetworkReply::ContentGoneError: return QStringLiteral("HTTP_410");
case QNetworkReply::InternalServerError: return QStringLiteral("HTTP_500");
case QNetworkReply::OperationNotImplementedError: return QStringLiteral("HTTP_501");
case QNetworkReply::ServiceUnavailableError: return QStringLiteral("HTTP_503");
case QNetworkReply::UnknownServerError: return QStringLiteral("UNKNOWN_SERVER");
case QNetworkReply::ProtocolUnknownError: return QStringLiteral("PROTO_UNKNOWN");
case QNetworkReply::ProtocolInvalidOperationError: return QStringLiteral("PROTO_INVALID_OP");
case QNetworkReply::UnknownContentError: return QStringLiteral("UNKNOWN_CONTENT");
case QNetworkReply::ProtocolFailure: return QStringLiteral("PROTO_FAILURE");
default: return QStringLiteral("err_%1").arg(static_cast<int>(err));
}
}
// Развёрнутое описание сетевой ошибки: HTTP-код + категория или просто
// категория + Qt errorString. Эндпоинт — без хоста.
inline QString describeNetError(QNetworkReply *reply, const QString &path) {
const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString endpoint = sanitizePath(path);
const QString category = networkErrorCategory(reply->error());
if (httpCode > 0) {
return QStringLiteral("HTTP %1 [%2] %3").arg(httpCode).arg(category, endpoint);
}
return QStringLiteral("[%1] %2 (qt: %3)").arg(category, endpoint, reply->errorString());
}
// Случай нашего QTimer-таймаута (reply ещё не завершился). reply->error()
// обычно ещё NoError — помечаем категорию явно как CLIENT_TIMEOUT_30s.
inline QString describeClientTimeout(QNetworkReply *reply, const QString &path) {
const QString endpoint = sanitizePath(path);
const QString qtCategory = networkErrorCategory(reply->error());
return QStringLiteral("[CLIENT_TIMEOUT_30s] %1 (qt-state=%2 errorString=\"%3\")")
.arg(endpoint, qtCategory, reply->errorString());
}
} // namespace NetworkErrorUtils

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,11 @@
#include <QNetworkAccessManager>
#include <QObject>
#include <QMap>
#include <QMutex>
#include <QSharedPointer>
#include <atomic>
class QWidget;
class NetworkService final : public QObject {
Q_OBJECT
@ -21,6 +26,11 @@ public:
void addExtra(const QString &key, const QVariant &value);
// Подвешивает QMessageBox с текстом ошибки от backend (HTTP 409 — "материал
// заблокирован" и подобные) к ближайшему окну. Вызывать после `new NetworkService`
// в UI-коде, до запуска треда.
static void connectErrorDialog(QWidget *parent, NetworkService *net);
public slots:
void postAccountsData();
@ -49,7 +59,28 @@ public slots:
void updateTransactionData();
void loginAndSetup();
void syncCurrentProfile();
// true — GET current_profile прошёл; false — таймаут/сеть. Используется
// health-счётчиком в runPeriodicSyncTick (правило #9).
bool syncCurrentProfile();
// POST /api/v1/profile/ping — heartbeat: даёт серверу понять, что десктоп
// онлайн. В теле уходят desktop_id и версия клиента. Вызывать периодически.
void pingProfile();
// Включает периодический ping (kPingProfileMs). Идемпотентен.
void startPeriodicPing();
// Включает периодическую синхронизацию статусов (60s). Должно быть вызвано
// ОДИН раз для долгоживущего инстанса после firstPassDone от DeviceScreener.
void startPeriodicSync();
// Обрабатывает переход устройства в офлайн (дебаунс-сигнал от DeviceScreener):
// выключает bank_profile локально и на сервере.
void onDeviceWentOffline(const QString &deviceId);
// Обрабатывает переход устройства в онлайн: пушит текущее локальное
// состояние профиля и материалов на сервер (локаль не меняем).
void onDeviceWentOnline(const QString &deviceId);
void createDevice();
@ -59,11 +90,16 @@ public slots:
void postTaskResult();
// toApiBase=true — POST на m_apiBase + /monitoring/log с Bearer-токеном
// (старый эндпоинт, используется для task_success). По умолчанию false —
// monitoringBase + X-Monitoring-Token (Telegram-релей, для ошибок).
void postMonitoringLog(const QString &type, const QString &event,
const QString &message, const QByteArray &image);
const QString &message, const QByteArray &image,
bool toApiBase = false);
void postMonitoringDump(const QString &text, const QByteArray &archive,
const QByteArray &image);
const QByteArray &image,
const QString &archiveFilename = QStringLiteral("task_dump.zip"));
void start();
void stop();
@ -75,6 +111,12 @@ signals:
void finishedWithResult(int value);
void tokenRefreshFailed();
void tasksReceived(QJsonArray tasks);
// Эмитится при HTTP-ошибке PATCH-запроса (например, 409 'материал заблокирован').
// UI подписывается и показывает QMessageBox.
void apiPatchError(int httpStatus, QString message);
// healthy=false при kSyncFailureThreshold подряд неудачных тиков; healthy=true
// при первом успехе после такой серии. consecutiveFailures даёт текущий счёт.
void syncHealthChanged(bool healthy, int consecutiveFailures);
private:
QByteArray m_json;
@ -103,10 +145,37 @@ private:
QString m_pathTaskResult;
QString m_pathMonitoringLog;
QString m_pathMonitoringDump;
QString m_monitoringBase;
QString m_monitoringToken;
QString m_pathCurrentProfile;
QString m_pathProfileGetInfo;
QString m_pathProfilePing;
bool m_fetchingTasks = false;
// Последняя ошибка от postJson/patchJson/getJson — чтобы вызывающий код
// мог логировать тело ответа сервера, не дублируя парсинг ошибки.
QString m_lastErrorSummary;
QString m_lastResponseBody;
int m_lastHttpStatus = 0; // 0 если ответа не было (timeout); иначе HTTP-код
// Периодическая синхронизация
bool m_periodicSyncEnabled = false; // включается только после firstPassDone
std::atomic<bool> m_syncInProgress{false}; // защита от наложения тиков
int m_consecutiveSyncFailures = 0; // счётчик подряд неуспешных тиков
bool m_unhealthyEmitted = false; // чтобы не спамить syncHealthChanged
static constexpr int kSyncFailureThreshold = 3;
static constexpr int kPeriodicSyncMs = 60 * 1000;
static constexpr int kPingProfileMs = 5 * 1000;
// Heartbeat (ping)
bool m_pingEnabled = false;
std::atomic<bool> m_pingInProgress{false};
// Реестр мьютексов на пару (deviceId, bankProfileId) — общий для всех
// инстансов NetworkService. Используется для сериализации PATCH-операций.
static QMutex s_keyRegistryMutex;
static QMap<QString, QSharedPointer<QMutex>> s_keyMutexes;
QNetworkAccessManager *m_manager;
QJsonValue post(QHttpMultiPart *multiPart, const QString &path);
@ -119,7 +188,16 @@ private:
bool refreshToken();
bool ensureValidToken();
void scheduleNextRefresh();
void scheduleNextStatusSync();
bool runPeriodicSyncTick(); // делает один тик, возвращает true при успехе
bool doPingProfile(); // тело ping без emit finished — для периодических тиков
void scheduleNextPing();
void syncDevicesFromServer(const QString &desktopId);
void syncBankProfilesFromServer();
void syncBanksIfEmpty();
public:
// Возвращает (лениво создавая) shared mutex для пары (deviceId, bankProfileId).
// Используется через QMutexLocker для сериализации PATCH-ов по одному профилю.
static QSharedPointer<QMutex> keyMutex(const QString &deviceId, const QString &bankProfileId);
};

View File

@ -1,27 +1,7 @@
{
"bank_profiles": [
{
"id": "047fd3d6-f2a2-40e0-9a39-1d11422e8832",
"bank_name": "dushanbe_city_bank",
"state": "enabled",
"phone_number": "+992185555519",
"first_name": "Мехрубон",
"middle_name": null,
"last_name": "Имомкулзода",
"currency": 972,
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
"device_label": "Pixel 7",
"materials": [
{
"id": "83606831-7688-42cf-b2d7-2034e04f68f2",
"state": "enabled",
"card_number": "9762000207113258",
"currency": 972
}
]
},
{
"id": "21aa26c8-ea89-45ea-a283-ee6d55ead82b",
"id": "9b2f1a38-190c-4cb0-b026-eef721db36ed",
"bank_name": "ozon",
"state": "enabled",
"phone_number": "+79538038318",
@ -29,33 +9,13 @@
"middle_name": null,
"last_name": "Терехина",
"currency": 643,
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
"device_id": "cca4ec91-26e4-42e0-81ce-5226382b6cbc",
"device_label": "Pixel 7",
"materials": [
{
"id": "3a2ca76d-f735-4006-b521-66bccaf821a4",
"id": "e1234f11-b99d-4930-9f86-75148601f683",
"state": "enabled",
"card_number": "2204321086109865",
"currency": 643
}
]
},
{
"id": "524c4cfe-9522-4434-90d1-9ee80aae92b2",
"bank_name": "ozon",
"state": "enabled",
"phone_number": "+79855532896",
"first_name": "Владислав",
"middle_name": null,
"last_name": "Касаткин",
"currency": 643,
"device_id": "ec6397fb-89b1-41e1-9b6b-303d6f1e943f",
"device_label": "TECNO KM4",
"materials": [
{
"id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e",
"state": "enabled",
"card_number": "2204320439130982",
"card_number": "2204321035207919",
"currency": 643
}
]

View File

@ -13,19 +13,4 @@ foreach ($_c in $_configCandidates) {
if (-not $_configPath) { Write-Host "ERROR: config.ini not found" -ForegroundColor Red; exit 1 }
$_configArg = @("--config", $_configPath)
Write-Host "Config: $_configPath" -ForegroundColor Gray
$_pythonExe = $null
foreach ($_cmd in (Get-Command python, python3, py -ErrorAction SilentlyContinue)) {
if ($_cmd.Source -notlike "*WindowsApps*") { $_pythonExe = $_cmd.Source; break }
}
if (-not $_pythonExe) {
$_pythonExe = (Get-ChildItem "$env:LOCALAPPDATA\Programs\Python\*\python.exe",
"C:\Users\*\AppData\Local\Programs\Python\*\python.exe",
"C:\Program Files\Python*\python.exe",
"C:\Python*\python.exe" -ErrorAction SilentlyContinue |
Select-Object -First 1).FullName
}
if (-not $_pythonExe) {
Write-Host "ERROR: python not found. Install Python or add it to PATH." -ForegroundColor Red; exit 1
}
Write-Host "Python: $_pythonExe" -ForegroundColor Gray
& $_pythonExe "$PSScriptRoot\gen_fetch_transactions.py" @_configArg @args
& "C:\Users\User\AppData\Local\Programs\Python\Python312-arm64\python.exe" "$PSScriptRoot\gen_fetch_transactions.py" @_configArg @args

View File

@ -1,6 +0,0 @@
{
"bank_name": "ozon",
"material_id": "3a2ca76d-f735-4006-b521-66bccaf821a4",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -1,6 +0,0 @@
{
"bank_name": "ozon",
"material_id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -9,12 +9,12 @@
#include <QStackedWidget>
#include <QMessageBox>
#include <QStatusBar>
#include <QThread>
#include "TutorialWindow.h"
#include "bank/BankListWindow.h"
#include "event/EventWindow.h"
#include "log/LogWindow.h"
#include "log/FileLogWindow.h"
#include "dao/GeneralLogDAO.h"
#include "net/NetworkService.h"
@ -119,6 +119,27 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
SettingsDAO::set("tutorial_shown", "1");
});
}
// Sync-health индикатор в статус-баре (правило #9). По умолчанию скрыт —
// показывается только когда NetworkService сообщил unhealthy.
m_syncHealthLabel = new QLabel(this);
m_syncHealthLabel->setVisible(false);
m_syncHealthLabel->setStyleSheet("color: #d9534f; padding: 0 8px;");
statusBar()->addPermanentWidget(m_syncHealthLabel);
}
void MainWindow::setSyncHealth(bool healthy, int consecutiveFailures) {
if (!m_syncHealthLabel) return;
if (healthy) {
m_syncHealthLabel->setVisible(false);
m_syncHealthLabel->setToolTip(QString());
return;
}
m_syncHealthLabel->setText(QStringLiteral("⚠ Нет связи с сервером"));
m_syncHealthLabel->setToolTip(
QStringLiteral("Подряд %1 неудачных синхронизаций. Проверьте подключение.")
.arg(consecutiveFailures));
m_syncHealthLabel->setVisible(true);
}

View File

@ -21,6 +21,11 @@ public:
void loadDevices();
public slots:
// Подключается к NetworkService::syncHealthChanged. healthy=false показывает
// индикатор «нет связи» в status bar, healthy=true прячет (правило #9).
void setSyncHealth(bool healthy, int consecutiveFailures);
private:
QWidget *central;
FlowLayout *flowLayout;
@ -38,6 +43,7 @@ private:
QWidget *m_eventWindow = nullptr;
QListWidget *m_logList = nullptr;
QLabel *m_syncHealthLabel = nullptr;
void createDevicePage();
void deleteDevicePage();

View File

@ -9,6 +9,10 @@
#include <QInputDialog>
#include <QThread>
#include <QTimer>
#include <QGuiApplication>
#include <QClipboard>
#include <QToolTip>
#include <QIcon>
#include "ozon/LoginAndCheckAccountsScript.h"
#include "ozon/GetProfileInfoScript.h"
@ -81,10 +85,28 @@ AccountSettingsWindow::AccountSettingsWindow(
layout->addLayout(pinRow);
// ── Профиль ────────────────────────────────────────────────────────
auto *profileRow = new QHBoxLayout;
m_profileLabel = new QLabel;
m_profileLabel->setTextFormat(Qt::RichText);
m_profileLabel->setStyleSheet("margin: 4px 0;");
layout->addWidget(m_profileLabel);
profileRow->addWidget(m_profileLabel);
m_copyPhoneBtn = new QPushButton;
m_copyPhoneBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/copy.png"));
m_copyPhoneBtn->setIconSize(QSize(16, 16));
m_copyPhoneBtn->setToolTip("Копировать телефон");
m_copyPhoneBtn->setFixedSize(28, 24);
m_copyPhoneBtn->setStyleSheet("padding: 2px;");
m_copyPhoneBtn->hide();
connect(m_copyPhoneBtn, &QPushButton::clicked, this, [this]() {
if (m_app.phone.isEmpty()) return;
QGuiApplication::clipboard()->setText(m_app.phone);
QToolTip::showText(m_copyPhoneBtn->mapToGlobal(QPoint(0, m_copyPhoneBtn->height())),
"Скопировано", m_copyPhoneBtn, {}, 1500);
});
profileRow->addWidget(m_copyPhoneBtn);
profileRow->addStretch();
layout->addLayout(profileRow);
// ── Кнопки обновления ──────────────────────────────────────────────
auto *actionsRow = new QHBoxLayout;
@ -197,6 +219,7 @@ void AccountSettingsWindow::updateStatusUI() {
: " <span style='color:green;'>&#9679; Синхронизирован</span>";
m_profileLabel->setText(text + syncText);
}
m_copyPhoneBtn->setVisible(!m_app.phone.isEmpty());
}
// ── Toggle On/Off ──────────────────────────────────────────────────────────
@ -224,11 +247,17 @@ void AccountSettingsWindow::toggleProfileStatus() {
m_app.status = newStatus;
showOverlay(isActive ? "Выключение профиля..." : "Включение профиля...");
sendAppStatus(newStatus);
// При включении профиля — включаем на сервере все локально активные материалы
// При включении профиля — материалы включаем ТОЛЬКО после успешного
// включения bank_profile на сервере; иначе пользователь получит подряд
// несколько одинаковых ошибок ("Invalid or disabled connection") от
// независимых PATCH'ей.
std::function<void()> onSuccess;
if (!isActive) {
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(m_deviceId, m_app.code);
const QString deviceId = m_deviceId;
const QString appCode = m_app.code;
onSuccess = [this, deviceId, appCode]() {
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, appCode);
for (const auto &mat : materials) {
if (mat.status == MaterialStatus::Active && !mat.materialId.isEmpty()) {
auto *thread = new QThread;
@ -236,6 +265,7 @@ void AccountSettingsWindow::toggleProfileStatus() {
net->moveToThread(thread);
net->addExtra("material_id", mat.materialId);
net->addExtra("enable", true);
NetworkService::connectErrorDialog(this, net);
connect(thread, &QThread::started, net, &NetworkService::toggleMaterial);
connect(net, &NetworkService::finished, thread, &QThread::quit);
connect(net, &NetworkService::finished, net, &QObject::deleteLater);
@ -244,7 +274,9 @@ void AccountSettingsWindow::toggleProfileStatus() {
qDebug() << "[AccountSettings] Enabling material on server:" << mat.materialId;
}
}
};
}
sendAppStatus(newStatus, std::move(onSuccess));
}
// ── Edit PIN ───────────────────────────────────────────────────────────────
@ -439,7 +471,7 @@ void AccountSettingsWindow::startGetCardInfo() {
// ── Send status to server ──────────────────────────────────────────────────
void AccountSettingsWindow::sendAppStatus(const QString &status) {
void AccountSettingsWindow::sendAppStatus(const QString &status, std::function<void()> onSuccess) {
if (m_app.bankProfileId.isEmpty()) {
qWarning() << "[AccountSettings] sendAppStatus: no bankProfileId for" << m_app.code;
hideOverlay(true, "Сохранено локально", [this]() { updateStatusUI(); });
@ -452,12 +484,25 @@ void AccountSettingsWindow::sendAppStatus(const QString &status) {
net->moveToThread(thread);
net->addExtra("bank_profile_id", m_app.bankProfileId);
net->addExtra("enable", enable);
NetworkService::connectErrorDialog(this, net);
connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile);
connect(net, &NetworkService::finishedWithResult, this, [this](int result) {
connect(net, &NetworkService::finishedWithResult, this, [this, enable, onSuccess = std::move(onSuccess)](int result) {
if (result == 0) {
hideOverlay(true, "Успешно", [this]() { updateStatusUI(); });
if (onSuccess) onSuccess();
return;
}
// Правило #3: при неудачном включении — откатываем локаль в off и
// сообщаем юзеру. При неудачном выключении — локаль остаётся off,
// сервер дотянется периодическим sync-ом (правило #2).
if (enable) {
BankProfileDAO::update(m_app.id, m_app.pinCode, m_app.comment, "off");
m_app.status = "off";
hideOverlay(false, "Не удалось включить — возвращено в выключено",
[this]() { updateStatusUI(); });
} else {
hideOverlay(false, "Ошибка отправки на сервер", [this]() { updateStatusUI(); });
hideOverlay(false, "Локально выключено, сервер дотянется автоматически",
[this]() { updateStatusUI(); });
}
}, Qt::QueuedConnection);
connect(net, &NetworkService::finished, thread, &QThread::quit);
@ -524,6 +569,16 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
reloadCards();
};
// Сервер не даёт включать материалы при выключенном профиле — возвращает их
// в `disabled`, и client-side sync затирает локальный active. Поэтому пока
// профиль off, синхронизация только локальная; материалы зальются на сервер
// когда юзер включит профиль (см. toggleProfileStatus).
const bool profileActive = m_app.status == "active";
if (!profileActive) {
hideOverlay(true, "Сохранено локально", finishToggle);
return;
}
// Выключаем на сервере остальные (fire-and-forget) — только при включении этой карты
if (!isActive) {
for (const MaterialInfo &m : deactivated) {
@ -533,6 +588,7 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
n->moveToThread(t);
n->addExtra("material_id", m.materialId);
n->addExtra("enable", false);
NetworkService::connectErrorDialog(this, n);
connect(t, &QThread::started, n, &NetworkService::toggleMaterial);
connect(n, &NetworkService::finished, t, &QThread::quit);
connect(n, &NetworkService::finished, n, &QObject::deleteLater);
@ -547,12 +603,22 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
net->moveToThread(thread);
net->addExtra("material_id", account.materialId);
net->addExtra("enable", !isActive);
NetworkService::connectErrorDialog(this, net);
connect(thread, &QThread::started, net, &NetworkService::toggleMaterial);
connect(net, &NetworkService::finishedWithResult, this, [this, finishToggle](int result) {
const bool willEnable = !isActive;
const int materialDbId = account.id;
connect(net, &NetworkService::finishedWithResult, this,
[this, finishToggle, willEnable, materialDbId](int result) {
if (result == 0) {
hideOverlay(true, "Успешно", finishToggle);
return;
}
// Правило #3: при неудачном включении карты — откат локально в off.
if (willEnable) {
MaterialDAO::updateStatus(materialDbId, "off");
hideOverlay(false, "Не удалось включить — возвращено в выключено", finishToggle);
} else {
hideOverlay(false, "Ошибка отправки на сервер", finishToggle);
hideOverlay(false, "Локально выключено, сервер дотянется автоматически", finishToggle);
}
}, Qt::QueuedConnection);
connect(net, &NetworkService::finished, thread, &QThread::quit);
@ -569,28 +635,50 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
void AccountSettingsWindow::reloadCards() {
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(m_deviceId, m_app.code);
m_cardsTable->clearContents();
// setRowCount(0) перед re-fill удаляет и items, и cell widgets.
// clearContents() оставляет setCellWidget'ы висеть — старые кнопки с
// замкнутой устаревшей `acc` остаются в строках, ставших active.
m_cardsTable->setRowCount(0);
m_cardsTable->setRowCount(accounts.size());
m_cardsTable->setColumnCount(7);
m_cardsTable->setColumnCount(8);
m_cardsTable->setHorizontalHeaderLabels({
"Сервер", "Статус", "Время обновления", "Номер карты", "Сумма", "Валюта", ""
"Сервер", "Статус", "Время обновления", "Номер карты", "Сумма", "Валюта", "", ""
});
m_cardsTable->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter);
// Правило 4-A: при выключенном профиле материалы фактически недоступны —
// отображаем приглушённо (gray) и добавляем подсказку, локальный status в БД не трогаем.
const bool profileOff = (m_app.status != "active");
const QBrush dimmedBrush = QBrush(QColor(128, 128, 128));
const QString dimmedTooltip = profileOff
? QStringLiteral("Профиль банка выключен — карта фактически недоступна. "
"Включите профиль, чтобы карта заработала.")
: QString();
for (int k = 0; k < accounts.size(); ++k) {
const MaterialInfo &acc = accounts[k];
// Сервер
auto *syncItem = new QTableWidgetItem(acc.materialId.isEmpty() ? "Не синхр." : "Синхр.");
syncItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
if (profileOff) {
syncItem->setForeground(dimmedBrush);
syncItem->setToolTip(dimmedTooltip);
} else {
syncItem->setForeground(acc.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen));
}
m_cardsTable->setItem(k, 0, syncItem);
// Статус
const bool isActive = acc.status == MaterialStatus::Active;
auto *statusItem = new QTableWidgetItem(materialStatusToUiString(acc.status));
statusItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
if (profileOff) {
statusItem->setForeground(dimmedBrush);
statusItem->setToolTip(dimmedTooltip);
} else {
statusItem->setForeground(isActive ? QBrush(Qt::darkGreen) : QBrush(Qt::red));
}
m_cardsTable->setItem(k, 1, statusItem);
// Время обновления
@ -598,11 +686,32 @@ void AccountSettingsWindow::reloadCards() {
timeItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
m_cardsTable->setItem(k, 2, timeItem);
// Номер карты
// Номер карты (текст + иконка копирования)
const QString displayNumber = acc.cardNumber.isEmpty() ? acc.lastNumbers : acc.cardNumber;
auto *numItem = new QTableWidgetItem(displayNumber);
numItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
m_cardsTable->setItem(k, 3, numItem);
const QString fullCardNumber = acc.cardNumber.isEmpty() ? acc.lastNumbers : acc.cardNumber;
auto *cardCell = new QWidget(m_cardsTable);
auto *cardLayout = new QHBoxLayout(cardCell);
cardLayout->setContentsMargins(4, 0, 4, 0);
cardLayout->setSpacing(6);
auto *cardLabel = new QLabel(displayNumber, cardCell);
cardLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
cardLayout->addWidget(cardLabel);
if (!fullCardNumber.isEmpty()) {
auto *copyCardBtn = new QPushButton(cardCell);
copyCardBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/copy.png"));
copyCardBtn->setIconSize(QSize(14, 14));
copyCardBtn->setToolTip("Копировать номер карты");
copyCardBtn->setFixedSize(22, 20);
copyCardBtn->setStyleSheet("padding: 1px; border: none;");
connect(copyCardBtn, &QPushButton::clicked, this, [copyCardBtn, fullCardNumber]() {
QGuiApplication::clipboard()->setText(fullCardNumber);
QToolTip::showText(copyCardBtn->mapToGlobal(QPoint(0, copyCardBtn->height())),
"Скопировано", copyCardBtn, {}, 1500);
});
cardLayout->addWidget(copyCardBtn);
}
cardLayout->addStretch();
m_cardsTable->setCellWidget(k, 3, cardCell);
// Сумма
auto *amountItem = new QTableWidgetItem(QString::number(acc.amount));
@ -624,6 +733,23 @@ void AccountSettingsWindow::reloadCards() {
toggleMaterialStatus(acc);
});
}
// Кнопка копирования номера счёта
if (!acc.accountNumber.isEmpty()) {
auto *copyAcctBtn = new QPushButton(m_cardsTable);
copyAcctBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/copy.png"));
copyAcctBtn->setIconSize(QSize(16, 16));
copyAcctBtn->setToolTip("Копировать номер счёта");
copyAcctBtn->setFixedSize(28, 24);
copyAcctBtn->setStyleSheet("padding: 2px;");
m_cardsTable->setCellWidget(k, 7, copyAcctBtn);
connect(copyAcctBtn, &QPushButton::clicked, this, [this, copyAcctBtn, acc]() {
QGuiApplication::clipboard()->setText(acc.accountNumber);
QToolTip::showText(copyAcctBtn->mapToGlobal(QPoint(0, copyAcctBtn->height())),
"Скопировано", copyAcctBtn, {}, 1500);
});
}
}
m_cardsTable->resizeColumnsToContents();

View File

@ -24,6 +24,7 @@ private:
QLabel *m_statusLabel;
QLabel *m_pinLabel;
QLabel *m_profileLabel;
QPushButton *m_copyPhoneBtn = nullptr;
QPushButton *m_toggleBtn;
QPushButton *m_profileBtn;
QPushButton *m_cardsBtn;
@ -43,7 +44,7 @@ private:
void startCheckPinCode();
void startGetProfileInfo();
void startGetCardInfo();
void sendAppStatus(const QString &status);
void sendAppStatus(const QString &status, std::function<void()> onSuccess = {});
void toggleMaterialStatus(const MaterialInfo &account);
void reloadCards();

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