1
0
forked from BRT/arc

Compare commits

..

No commits in common. "apk-shot" and "main" have entirely different histories.

195 changed files with 1247 additions and 9127 deletions

4
.gitignore vendored
View File

@ -13,7 +13,6 @@ tests/mock_server/.mock_screenshots/
cmake_install.cmake cmake_install.cmake
CMakeCache.txt CMakeCache.txt
Makefile Makefile
compile_commands.json
.qt/ .qt/
@ -24,7 +23,8 @@ compile_commands.json
/Testing/ /Testing/
/.ninja_deps /.ninja_deps
/.ninja_log /.ninja_log
/ARC /ARCDesktopProject_autogen/
/ARCDesktopProject
/Contents/ /Contents/
/application.log /application.log
/database/app_data.db /database/app_data.db

View File

@ -1,4 +1,4 @@
# ARC — сборка и запуск # ARCDesktopProject — сборка и запуск
## Windows (x64) ## Windows (x64)
@ -19,7 +19,7 @@ cmake --build . --config Release
### Деплой Qt DLL ### Деплой Qt DLL
```bash ```bash
"C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARC.exe "C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARCDesktopProject.exe
``` ```
### Копирование vcpkg DLL ### Копирование vcpkg DLL
@ -44,7 +44,7 @@ copy database\app_data.db .\Release\database\app_data.db
```bash ```bash
cd Release cd Release
ARC.exe ARCDesktopProject.exe
``` ```
--- ---
@ -77,5 +77,5 @@ cp ../database/app_data.db ./database/app_data.db
### Запуск ### Запуск
```bash ```bash
./ARC ./ARCDesktopProject
``` ```

View File

@ -58,7 +58,6 @@ find_package(Qt6 REQUIRED COMPONENTS
Widgets Widgets
Sql Sql
Xml Xml
Concurrent
) )
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
@ -105,7 +104,6 @@ add_executable(ARC WIN32
views/log/LogDetailWindow.h views/log/LogDetailWindow.h
database/dao/GeneralLogDAO.cpp database/dao/GeneralLogDAO.cpp
database/dao/GeneralLogDAO.h database/dao/GeneralLogDAO.h
android/dushanbe/AppVersionTrack.h
android/dushanbe/CommonScript.cpp android/dushanbe/CommonScript.cpp
android/dushanbe/CommonScript.h android/dushanbe/CommonScript.h
android/dushanbe/GetCardInfoScript.cpp android/dushanbe/GetCardInfoScript.cpp
@ -137,7 +135,6 @@ elseif (APPLE)
views/log/LogDetailWindow.h views/log/LogDetailWindow.h
database/dao/GeneralLogDAO.cpp database/dao/GeneralLogDAO.cpp
database/dao/GeneralLogDAO.h database/dao/GeneralLogDAO.h
android/dushanbe/AppVersionTrack.h
android/dushanbe/CommonScript.cpp android/dushanbe/CommonScript.cpp
android/dushanbe/CommonScript.h android/dushanbe/CommonScript.h
android/dushanbe/GetCardInfoScript.cpp android/dushanbe/GetCardInfoScript.cpp
@ -159,7 +156,6 @@ if (WIN32)
Qt6::Xml Qt6::Xml
Qt6::Sql Qt6::Sql
Qt6::Network Qt6::Network
Qt6::Concurrent
Tesseract::libtesseract Tesseract::libtesseract
) )
@ -175,7 +171,6 @@ if (WIN32)
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
# adb копируется в post-build чтобы не падать при configure если adb.exe занят # adb копируется в post-build чтобы не падать при configure если adb.exe занят
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*") file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
foreach(_adb_file ${_adb_files}) foreach(_adb_file ${_adb_files})
@ -231,10 +226,6 @@ if (WIN32)
COMMAND ${CMAKE_COMMAND} -E copy_if_different COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk"
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk" "$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk"
"$<TARGET_FILE_DIR:ARC>/assets/arc-shot.apk"
) )
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend) # Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
@ -285,7 +276,6 @@ elseif (APPLE)
Qt6::Sql Qt6::Sql
Qt6::Xml Qt6::Xml
Qt6::Network Qt6::Network
Qt6::Concurrent
tesseract tesseract
leptonica leptonica
) )
@ -301,7 +291,6 @@ elseif (APPLE)
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
endif () endif ()
target_include_directories(ARC PRIVATE target_include_directories(ARC PRIVATE

View File

@ -17,7 +17,7 @@ brew install qt tesseract leptonica
mkdir -p build && cd build mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build . cmake --build .
./ARC ./ARCDesktopProject
``` ```
Подробнее — см. [BUILD.md](BUILD.md) (macOS + Windows). Подробнее — см. [BUILD.md](BUILD.md) (macOS + Windows).

View File

@ -1,13 +0,0 @@
#include "BankConfigUtils.h"
#include <QSettings>
namespace BankConfigUtils {
int transactionSearchWindowDays(const QString &bankCode) {
const QSettings settings("config.ini", QSettings::IniFormat);
return qMax(1, settings.value(
bankCode + "/transaction_search_window_days", 2).toInt());
}
} // namespace BankConfigUtils

View File

@ -1,15 +0,0 @@
#pragma once
#include <QString>
// Пер-банковые настройки, читаемые из config.ini.
// bankCode совпадает с именем секции в config.ini и со значением appCode
// ("ozon", "black", "dushanbe_city_bank").
namespace BankConfigUtils {
// Окно поиска транзакций в днях: сегодня + (N-1) предыдущих дней.
// Источник: config.ini → [<bankCode>]/transaction_search_window_days.
// Дефолт 2 (сегодня + вчера). Гарантированный минимум — 1 (только сегодня).
int transactionSearchWindowDays(const QString &bankCode);
} // namespace BankConfigUtils

View File

@ -6,64 +6,9 @@
#include <QFileInfo> #include <QFileInfo>
#include <QRegularExpression> #include <QRegularExpression>
#include <QThread> #include <QThread>
#include <QSettings>
#include <QTcpSocket>
#include <QMutex>
#include <QDateTime>
#include <QHostAddress>
#include <QThreadPool>
#include <QtConcurrent>
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
namespace {
// Конфиг скриншот-стратегии (config.ini [screenshot]). Кэшируется при первом чтении.
struct ScreenshotCfg {
QString transport; // "http" | "execout"
int httpPort = 8099;
int httpTimeoutMs = 4000;
QString remotePath = "/sdcard/arc_shot.png";
};
const ScreenshotCfg &screenshotCfg() {
static ScreenshotCfg cfg = [] {
const QSettings s("config.ini", QSettings::IniFormat);
ScreenshotCfg c;
c.transport = s.value("screenshot/transport", "http").toString().trimmed().toLower();
c.httpPort = s.value("screenshot/httpPort", 8099).toInt();
c.httpTimeoutMs = s.value("screenshot/httpTimeoutMs", 4000).toInt();
c.remotePath = s.value("screenshot/remotePath", "/sdcard/arc_shot.png").toString();
return c;
}();
return cfg;
}
// Кэш IP телефона по serial (резолв дорогой — adb shell). Потокобезопасно.
// С TTL: IP редко меняется, перечитываем не чаще kIpTtlSecs (на случай смены DHCP).
constexpr qint64 kIpTtlSecs = 300;
QMutex g_ipCacheMutex;
QHash<QString, QString> g_ipCache;
QHash<QString, qint64> g_ipCacheTs;
// Негативный кэш: serial → unix ts последней неудачи HTTP. Пока не истёк бэкофф,
// идём сразу в exec-out fallback, не дёргая screencap+IP+таймаут каждый цикл.
constexpr qint64 kHttpFailBackoffSecs = 60;
QMutex g_httpFailMutex;
QHash<QString, qint64> g_httpFailAt;
// Лог смены пути скриншота (http ↔ execout) для наблюдаемости — без спама каждые 4с.
QMutex g_modeMutex;
QHash<QString, QString> g_lastMode;
void logScreenshotMode(const QString &deviceId, const QString &mode) {
QMutexLocker lock(&g_modeMutex);
if (g_lastMode.value(deviceId) != mode) {
g_lastMode.insert(deviceId, mode);
qInfo().noquote() << "[AdbUtils] screenshot path for" << deviceId << "" << mode;
}
}
} // namespace
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) { AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
} }
@ -125,23 +70,6 @@ bool AdbUtils::startProcess(const QString &program, const QStringList &args,
return true; return true;
} }
// Ждёт завершения процесса с таймаутом. Раньше здесь стоял бесконечный
// waitForFinished() — если adb-клиент зависал на неотвечающем устройстве/USB,
// рабочий поток блокировался навсегда (накопление зависших потоков → фриз
// приложения через несколько часов). При таймауте убиваем зависший процесс и
// реапим его, чтобы и поток освободился, и деструктор QProcess не блокировался.
// Возвращает true, если процесс завершился сам в отведённое время.
static bool waitOrKill(QProcess &process, int timeoutMs, const char *tag) {
if (process.waitForFinished(timeoutMs)) {
return true;
}
qWarning() << "[AdbUtils]" << tag << "timed out after" << timeoutMs
<< "ms — killing wedged adb process";
process.kill();
process.waitForFinished(2000);
return false;
}
// adb shell dumpsys package ru.rshb.dbo | grep permission // adb shell dumpsys package ru.rshb.dbo | grep permission
// TODO доделать обработку пермишинов // TODO доделать обработку пермишинов
// adb shell pm revoke ru.rshb.dbo android.permission.WRITE_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.READ_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_COARSE_LOCATION; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_FINE_LOCATION // adb shell pm revoke ru.rshb.dbo android.permission.WRITE_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.READ_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_COARSE_LOCATION; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_FINE_LOCATION
@ -208,104 +136,6 @@ QList<QPair<QString, QString> > AdbUtils::getListDevices() {
return devices; return devices;
} }
bool AdbUtils::adbPair(const QString &ip, int port, const QString &code) {
const QString endpoint = ip + ":" + QString::number(port);
QString out, err;
startProcess(adbPath(), {"pair", endpoint, code}, &out, &err);
const QString combined = out + err;
const bool ok = combined.contains("Successfully paired", Qt::CaseInsensitive);
if (ok) {
qInfo().noquote() << "[AdbUtils] adb pair" << endpoint << "→ ok";
} else {
qWarning().noquote() << "[AdbUtils] adb pair" << endpoint << "failed:" << combined.trimmed();
}
return ok;
}
QString AdbUtils::adbConnect(const QString &ip, int port) {
const QString endpoint = ip + ":" + QString::number(port);
QString out, err;
startProcess(adbPath(), {"connect", endpoint}, &out, &err);
const QString combined = out + err;
// adb connect возвращает exit 0 и при неудаче ("failed to connect"), поэтому
// ориентируемся на текст вывода.
if (combined.contains("connected to", Qt::CaseInsensitive)
|| combined.contains("already connected", Qt::CaseInsensitive)) {
qInfo().noquote() << "[AdbUtils] adb connect" << endpoint << "→ ok";
return endpoint;
}
qWarning().noquote() << "[AdbUtils] adb connect" << endpoint << "failed:" << combined.trimmed();
return {};
}
bool AdbUtils::adbDisconnect(const QString &endpoint) {
QString out, err;
startProcess(adbPath(), {"disconnect", endpoint}, &out, &err);
return true; // best-effort
}
QString AdbUtils::mdnsFindConnect(const QString &ip) {
QString out;
startProcess(adbPath(), {"mdns", "services"}, &out);
// Строки вида: adb-XXXX-YY._adb-tls-connect._tcp. 192.168.1.50:41555
static const QRegularExpression reEndpoint(R"((\d+\.\d+\.\d+\.\d+):(\d+))");
const QStringList lines = out.split('\n');
for (const QString &line : lines) {
if (!line.contains("_adb-tls-connect")) {
continue;
}
if (const auto m = reEndpoint.match(line); m.hasMatch() && m.captured(1) == ip) {
return m.captured(1) + ":" + m.captured(2);
}
}
return {};
}
QString AdbUtils::scanFindConnect(const QString &ip, int portStart, int portEnd) {
if (portStart < 1) portStart = 1;
if (portEnd > 65535) portEnd = 65535;
if (portEnd < portStart) return {};
// 1. Параллельно ищем открытые TCP-порты на IP телефона (сырой сокет, без adb).
QList<int> ports;
ports.reserve(portEnd - portStart + 1);
for (int p = portStart; p <= portEnd; ++p) ports.append(p);
QThreadPool pool;
pool.setMaxThreadCount(256);
const QList<int> openPorts = QtConcurrent::blockingFiltered(
&pool, ports, [&ip](int p) {
QTcpSocket s;
s.connectToHost(ip, static_cast<quint16>(p));
return s.waitForConnected(250);
});
if (openPorts.isEmpty()) {
qWarning().noquote() << "[AdbUtils] scanFindConnect: no open ports on" << ip;
return {};
}
qInfo().noquote() << "[AdbUtils] scanFindConnect: open ports on" << ip << ":" << openPorts;
// 2. На каждый открытый порт пробуем adb connect (порт спаривания откажет, connect-
// порт подключится — pairing уже есть, код не нужен).
for (const int p : openPorts) {
if (const QString ep = adbConnect(ip, p); !ep.isEmpty()) {
return ep;
}
}
return {};
}
QString AdbUtils::getAndroidId(const QString &deviceId) {
QString out;
if (!startProcess(adbPath(),
{"-s", deviceId, "shell", "settings", "get", "secure", "android_id"}, &out)) {
return {};
}
const QString id = out.trimmed();
return (id == "null" || id.isEmpty()) ? QString() : id;
}
QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &packageName) { QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &packageName) {
QProcess process; QProcess process;
process.setWorkingDirectory(adbDir()); process.setWorkingDirectory(adbDir());
@ -317,7 +147,7 @@ QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &
qWarning() << "[AdbUtils] Failed to start adb pidof:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb pidof:" << process.errorString();
return {}; return {};
} }
waitOrKill(process, 10000, "tryToGetRunningAppPid"); process.waitForFinished();
QString pid = process.readAllStandardOutput(); QString pid = process.readAllStandardOutput();
return pid.replace("\n", ""); return pid.replace("\n", "");
} }
@ -400,7 +230,7 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
qWarning() << "[AdbUtils] Failed to start adb monkey:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb monkey:" << process.errorString();
return false; return false;
} }
waitOrKill(process, 15000, "tryToStartApplication(monkey)"); process.waitForFinished();
const QString stdOut = QString::fromUtf8(process.readAllStandardOutput()); const QString stdOut = QString::fromUtf8(process.readAllStandardOutput());
const QString stdErr = QString::fromUtf8(process.readAllStandardError()); const QString stdErr = QString::fromUtf8(process.readAllStandardError());
const int exitCode = process.exitCode(); const int exitCode = process.exitCode();
@ -413,22 +243,15 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut); logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr); logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
const bool eventsInjected = stdOut.contains("Events injected: 1"); if (stdOut.contains("Events injected: 1")) {
const bool hadNativeCrash = stdOut.contains("NativeCrash");
if (eventsInjected && !hadNativeCrash) {
return true; return true;
} }
// Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code, // Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code,
// в отличие от monkey, который при пустом resolve просто молча печатает "No activities found". // в отличие от monkey, который при пустом resolve просто молча печатает "No activities found".
// Также используется когда monkey дал "Events injected: 1" но приложение упало (NativeCrash).
if (!launcherActivity.isEmpty()) { if (!launcherActivity.isEmpty()) {
if (hadNativeCrash) qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
qWarning().noquote() << "[AdbUtils::tryToStartApplication] NativeCrash detected, retrying via am start -n" << launcherActivity; << "пробую am start -n" << launcherActivity;
else
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
<< "пробую am start -n" << launcherActivity;
QProcess am; QProcess am;
am.setWorkingDirectory(adbDir()); am.setWorkingDirectory(adbDir());
am.start(adbPath(), am.start(adbPath(),
@ -464,7 +287,7 @@ bool AdbUtils::tryToKillApplication(const QString &deviceId, const QString &pack
qWarning() << "[AdbUtils] Failed to start adb force-stop:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb force-stop:" << process.errorString();
return false; return false;
} }
waitOrKill(process, 15000, "tryToKillApplication"); process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
} }
@ -602,36 +425,6 @@ bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
} }
QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) { QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
// http-режим: пробуем разгрузку через APK, при неудаче — fallback на exec-out.
if (screenshotCfg().transport == "http") {
// Негативный бэкофф: если HTTP недавно не сработал — не дёргаем его каждый цикл.
bool skipHttp = false;
{
QMutexLocker lock(&g_httpFailMutex);
const qint64 failAt = g_httpFailAt.value(deviceId, 0);
skipHttp = failAt != 0
&& QDateTime::currentSecsSinceEpoch() - failAt < kHttpFailBackoffSecs;
}
if (!skipHttp) {
if (const QByteArray png = captureScreenshotHttp(deviceId); !png.isEmpty()) {
QMutexLocker lock(&g_httpFailMutex);
g_httpFailAt.remove(deviceId);
logScreenshotMode(deviceId, "http");
return png;
}
{
QMutexLocker lock(&g_httpFailMutex);
g_httpFailAt[deviceId] = QDateTime::currentSecsSinceEpoch();
}
// arc-shot мог умереть — пробуем поднять (раз в бэкофф-окно, не спамит).
kickArcShot(deviceId);
}
}
logScreenshotMode(deviceId, "exec-out (fallback)");
return captureScreenshotExecOut(deviceId);
}
QByteArray AdbUtils::captureScreenshotExecOut(const QString &deviceId) {
QProcess process; QProcess process;
process.setWorkingDirectory(adbDir()); process.setWorkingDirectory(adbDir());
process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"}); process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"});
@ -647,172 +440,6 @@ QByteArray AdbUtils::captureScreenshotExecOut(const QString &deviceId) {
return process.readAllStandardOutput(); return process.readAllStandardOutput();
} }
QByteArray AdbUtils::captureScreenshotHttp(const QString &deviceId) {
const ScreenshotCfg &cfg = screenshotCfg();
// 1. Снимаем В ФАЙЛ на устройстве — байты НЕ идут через adb, команда быстрая.
QString stdErr;
if (!startProcess(adbPath(),
{"-s", deviceId, "shell", "screencap", "-p", cfg.remotePath},
nullptr, &stdErr)) {
qWarning() << "[AdbUtils] http screencap-to-file failed for" << deviceId << stdErr;
return {};
}
// 2. IP телефона в Wi-Fi.
const QString ip = resolvePhoneIp(deviceId);
if (ip.isEmpty()) {
return {};
}
// 3. Качаем файл по HTTP напрямую с телефона (мимо adb).
const QString fileName = cfg.remotePath.section('/', -1);
const QByteArray png = httpGet(ip, cfg.httpPort, "/" + fileName, cfg.httpTimeoutMs);
if (png.isEmpty()) {
// Не сбрасываем кэш IP — он переживёт неудачу http (TTL сам перечитает при
// реальной смене IP). Иначе IP не доезжал бы до БД на fallback-устройствах.
qWarning() << "[AdbUtils] http screenshot GET failed for" << deviceId << "ip:" << ip;
}
return png;
}
QString AdbUtils::screenshotMode(const QString &deviceId) {
QMutexLocker lock(&g_modeMutex);
return g_lastMode.value(deviceId);
}
QString AdbUtils::resolvePhoneIp(const QString &deviceId) {
const qint64 now = QDateTime::currentSecsSinceEpoch();
{
QMutexLocker lock(&g_ipCacheMutex);
if (const QString cached = g_ipCache.value(deviceId);
!cached.isEmpty() && now - g_ipCacheTs.value(deviceId, 0) < kIpTtlSecs) {
return cached;
}
}
QString ip;
// Для TCP-серийника IP:port — IP прямо из серийника.
static const QRegularExpression reIpPort(R"(^(\d+\.\d+\.\d+\.\d+):\d+$)");
if (const auto m = reIpPort.match(deviceId); m.hasMatch()) {
ip = m.captured(1);
} else {
// Иначе спрашиваем устройство: ip route get 1.1.1.1 → "... src <ipv4> ..."
QString out;
if (startProcess(adbPath(),
{"-s", deviceId, "shell", "ip", "route", "get", "1.1.1.1"},
&out)) {
static const QRegularExpression reSrc(R"(src\s+(\d+\.\d+\.\d+\.\d+))");
if (const auto m = reSrc.match(out); m.hasMatch()) {
ip = m.captured(1);
}
}
}
if (!ip.isEmpty()) {
QMutexLocker lock(&g_ipCacheMutex);
g_ipCache.insert(deviceId, ip);
g_ipCacheTs.insert(deviceId, now);
}
return ip;
}
QByteArray AdbUtils::httpGet(const QString &ip, int port, const QString &path, int timeoutMs) {
QTcpSocket sock;
sock.connectToHost(ip, static_cast<quint16>(port));
if (!sock.waitForConnected(timeoutMs)) {
return {};
}
const QByteArray req = "GET " + path.toUtf8() + " HTTP/1.0\r\n"
"Host: " + ip.toUtf8() + "\r\n"
"Connection: close\r\n\r\n";
sock.write(req);
if (!sock.waitForBytesWritten(timeoutMs)) {
return {};
}
// Читаем до закрытия соединения (HTTP/1.0 + Connection: close).
QByteArray resp;
while (sock.state() == QAbstractSocket::ConnectedState || sock.bytesAvailable() > 0) {
if (!sock.waitForReadyRead(timeoutMs)) {
break;
}
resp += sock.readAll();
}
resp += sock.readAll();
// Разбор: статус-строка + тело после "\r\n\r\n".
const int headerEnd = resp.indexOf("\r\n\r\n");
if (headerEnd < 0) {
return {};
}
const QByteArray header = resp.left(headerEnd);
if (!header.startsWith("HTTP/1.") || !header.contains(" 200")) {
return {};
}
return resp.mid(headerEnd + 4);
}
void AdbUtils::kickArcShot(const QString &deviceId) {
const QString adb = adbPath();
// 1. Установлен ли arc-shot? (pm path вернёт "package:..." если да)
QString pmOut;
const bool installed = startProcess(adb,
{"-s", deviceId, "shell", "pm", "path", "com.arc.shot"}, &pmOut)
&& pmOut.contains("package:");
// 2. Не установлен (удалили / новое устройство) — ставим bundled APK рядом с десктопом.
if (!installed) {
const QString apk = QCoreApplication::applicationDirPath() + "/assets/arc-shot.apk";
if (!QFileInfo::exists(apk)) {
qWarning() << "[AdbUtils] arc-shot apk not found at" << apk << "— skip auto-install";
return;
}
QString err;
if (!startProcess(adb, {"-s", deviceId, "install", "-r", apk}, nullptr, &err)) {
qWarning() << "[AdbUtils] arc-shot install failed for" << deviceId << ":" << err.trimmed();
return;
}
// Доступ к /sdcard без диалога на устройстве.
startProcess(adb, {"-s", deviceId, "shell", "appops", "set",
"com.arc.shot", "MANAGE_EXTERNAL_STORAGE", "allow"});
qInfo().noquote() << "[AdbUtils] arc-shot auto-installed on" << deviceId;
}
// 3. Защита от заморозки фоновых приложений (Doze + вендорские freezer'ы вроде
// TECNO HiOS/MIUI). adb-settable, без диалогов на устройстве. Без этого вендор
// замораживает userspace сервиса — порт принимает TCP, но HTTP не отвечает.
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "deviceidle", "whitelist",
"+com.arc.shot"});
startProcess(adb, {"-s", deviceId, "shell", "cmd", "appops", "set", "com.arc.shot",
"RUN_ANY_IN_BACKGROUND", "allow"});
// 4. Поднять сервис. Свежая установка — через Activity (am start -n .../MainActivity):
// надёжно снимает stopped-state, в котором у части вендоров FGS-старт молча
// игнорируется. Activity без UI и сразу finish(), сервис стартует из onCreate.
// Уже установленный (реанимация) — start-foreground-service, без Activity поверх
// работающего банк-скрипта.
QString err;
const bool ok = !installed
? startProcess(adb, {"-s", deviceId, "shell", "am", "start",
"-n", "com.arc.shot/.MainActivity"}, nullptr, &err)
: startProcess(adb, {"-s", deviceId, "shell", "am", "start-foreground-service",
"-n", "com.arc.shot/.ArcShotService"}, nullptr, &err);
if (ok) {
qInfo().noquote() << "[AdbUtils] arc-shot" << (installed ? "revive attempt" : "started")
<< "for" << deviceId;
if (!installed) {
// Свежая установка: сбрасываем http-бэкофф, чтобы http включился на следующем
// кадре (а не ждал 60с) — устройство быстрее уходит с exec-out.
QMutexLocker lock(&g_httpFailMutex);
g_httpFailAt.remove(deviceId);
}
} else {
qDebug().noquote() << "[AdbUtils] arc-shot kick failed for" << deviceId << ":" << err.trimmed();
}
}
bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) { bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) {
const QString adb = adbPath(); const QString adb = adbPath();
struct Step { QStringList args; }; struct Step { QStringList args; };
@ -846,7 +473,7 @@ bool AdbUtils::makeTap(const QString &deviceId, const int x, const int y) {
qWarning() << "[AdbUtils] Failed to start adb tap:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb tap:" << process.errorString();
return false; return false;
} }
waitOrKill(process, 10000, "makeTap"); process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
} }
@ -961,7 +588,7 @@ bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) {
qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString();
return false; return false;
} }
waitOrKill(process, 10000, "inputAdbIMEText"); process.waitForFinished();
qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode() qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode()
<< "text:" << text << "text:" << text
<< "output:" << process.readAllStandardOutput().trimmed() << "output:" << process.readAllStandardOutput().trimmed()
@ -978,7 +605,7 @@ bool AdbUtils::disableAdbIMEKeyboard(const QString &deviceId) {
qWarning() << "[AdbUtils] Failed to start adb ime disable:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb ime disable:" << process.errorString();
return false; return false;
} }
waitOrKill(process, 10000, "disableAdbIMEKeyboard"); process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
} }
@ -1003,7 +630,7 @@ bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, co
qWarning() << "[AdbUtils] Failed to start adb swipe:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb swipe:" << process.errorString();
return false; return false;
} }
waitOrKill(process, 10000, "makeSwipe"); process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
} }
@ -1019,7 +646,7 @@ bool AdbUtils::inputText(const QString &deviceId, const QString &text) {
qWarning() << "[AdbUtils] Failed to start adb input text:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb input text:" << process.errorString();
return false; return false;
} }
waitOrKill(process, 10000, "inputText"); process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
} }
@ -1072,7 +699,7 @@ bool AdbUtils::goBack(const QString &deviceId) {
qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString();
return false; return false;
} }
waitOrKill(process, 10000, "goBack"); process.waitForFinished();
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
} }
@ -1088,7 +715,7 @@ QSet<QString> AdbUtils::getListApps(const QString &deviceId) {
qWarning() << "[AdbUtils] Failed to start adb pm list:" << process.errorString(); qWarning() << "[AdbUtils] Failed to start adb pm list:" << process.errorString();
return {}; return {};
} }
waitOrKill(process, 15000, "getListApps"); process.waitForFinished();
const QString output = process.readAllStandardOutput(); const QString output = process.readAllStandardOutput();
QSet<QString> apps; QSet<QString> apps;
@ -1128,70 +755,6 @@ QString AdbUtils::getAppVersion(const QString &deviceId, const QString &packageN
return m.hasMatch() ? m.captured(1).trimmed() : QString(); return m.hasMatch() ? m.captured(1).trimmed() : QString();
} }
// Достаёт имя пакета из строки фокуса вида "... u0 com.bank.app/.MainActivity ...".
static QString packageFromFocusLine(const QString &out, const QString &marker) {
const qsizetype idx = out.indexOf(marker);
if (idx < 0) {
return {};
}
const qsizetype end = out.indexOf(QChar('\n'), idx);
const QString line = out.mid(idx, end < 0 ? -1 : end - idx);
static const QRegularExpression rePkg(R"(\bu0\s+([A-Za-z0-9_.]+)/)");
const QRegularExpressionMatch m = rePkg.match(line);
return m.hasMatch() ? m.captured(1) : QString();
}
QString AdbUtils::getForegroundPackage(const QString &deviceId) {
if (deviceId.isEmpty()) {
return {};
}
// mResumedActivity — реально активная (foreground) активность.
QString out;
if (startProcess(adbPath(),
{"-s", deviceId, "shell", "dumpsys", "activity", "activities"}, &out)) {
const QString pkg = packageFromFocusLine(out, "mResumedActivity");
if (!pkg.isEmpty()) {
return pkg;
}
}
// fallback: mCurrentFocus из dumpsys window (формат отличается между версиями Android).
out.clear();
if (startProcess(adbPath(), {"-s", deviceId, "shell", "dumpsys", "window"}, &out)) {
const QString pkg = packageFromFocusLine(out, "mCurrentFocus");
if (!pkg.isEmpty()) {
return pkg;
}
}
return {};
}
bool AdbUtils::ensurePortraitOrientation(const QString &deviceId) {
if (deviceId.isEmpty()) {
return false;
}
const QString adb = adbPath();
QString out;
if (!startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "input"}, &out)) {
return false; // adb недоступен — пропускаем (best-effort)
}
// SurfaceOrientation: 0=0°(portrait) 1=90°(landscape) 2=180° 3=270°(landscape)
static const QRegularExpression re(R"(SurfaceOrientation:\s*(\d))");
const QRegularExpressionMatch m = re.match(out);
const int orient = m.hasMatch() ? m.captured(1).toInt() : 0;
if (orient == 1 || orient == 3) {
qInfo() << "[AdbUtils] device" << deviceId
<< "in landscape (orient" << orient << ") → forcing portrait";
// Автоповорот выключаем, иначе сенсор вернёт landscape.
startProcess(adb, {"-s", deviceId, "shell", "settings", "put",
"system", "accelerometer_rotation", "0"});
startProcess(adb, {"-s", deviceId, "shell", "settings", "put",
"system", "user_rotation", "0"});
}
return true;
}
void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) { void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) {
const QString adb = adbPath(); const QString adb = adbPath();

View File

@ -13,28 +13,6 @@ public:
static QList<QPair<QString, QString> > getListDevices(); static QList<QPair<QString, QString> > getListDevices();
// --- Wi-Fi подключение (wireless debugging) ---
// Спаривание по коду: `adb pair IP:port code`. true при "Successfully paired".
static bool adbPair(const QString &ip, int port, const QString &code);
// `adb connect IP:port`. Возвращает рабочий "IP:port" при успехе, иначе пусто
// (adb может вернуть exit 0 и при неудаче — судим по тексту вывода).
static QString adbConnect(const QString &ip, int port);
// `adb disconnect <endpoint>` (best-effort).
static bool adbDisconnect(const QString &endpoint);
// Поиск connect-эндпоинта устройства по IP через `adb mdns services`
// (_adb-tls-connect._tcp). Возвращает "IP:port" или пусто.
static QString mdnsFindConnect(const QString &ip);
// android_id устройства (`settings get secure android_id`) — стабильная личность.
// Пусто при ошибке/таймауте.
static QString getAndroidId(const QString &deviceId);
// Fallback-поиск connect-порта, когда он сменился (тоггл/ребут wireless debugging),
// а mDNS в сети не работает: параллельно сканирует IP телефона на открытые порты и
// пробует `adb connect` (pairing сохранён → код не нужен). Возвращает рабочий
// "IP:port" или пусто. Тяжёлая операция — звать только как fallback.
static QString scanFindConnect(const QString &ip, int portStart = 30000, int portEnd = 50000);
static QString tryToGetRunningAppPid(const QString &deviceId, const QString &packageName); static QString tryToGetRunningAppPid(const QString &deviceId, const QString &packageName);
static bool tryToStartApplication(const QString &deviceId, const QString &packageName); static bool tryToStartApplication(const QString &deviceId, const QString &packageName);
@ -49,26 +27,9 @@ public:
static bool takeScreenshot(const QString &deviceId, const QString &name); static bool takeScreenshot(const QString &deviceId, const QString &name);
// IP телефона в Wi-Fi (кэш по serial с TTL). Для IP:port-серийника берётся из // Захватывает скриншот напрямую в память (PNG-байты), без временного файла
// серийника, иначе `adb shell ip route get 1.1.1.1` → src. Пусто, если не в Wi-Fi.
// Используется и для http-скриншота, и для сохранения IP в БД (wifi_connections).
static QString resolvePhoneIp(const QString &deviceId);
// Текущий режим скриншотов устройства: "http" | "exec-out (fallback)" | "" (ещё не
// определялся). Для индикации в UI. Ключ — adb-серийник.
static QString screenshotMode(const QString &deviceId);
// Захватывает скриншот напрямую в память (PNG-байты), без временного файла.
// Выбирает стратегию по config.ini [screenshot] transport: http (через APK
// arc-shot, мимо adb) с fallback на exec-out, либо сразу exec-out.
static QByteArray captureScreenshotBytes(const QString &deviceId); static QByteArray captureScreenshotBytes(const QString &deviceId);
// Скриншот через APK arc-shot: adb screencap в файл на /sdcard (дёшево, без
// передачи байтов через adb) + HTTP-GET файла напрямую с телефона по его
// Wi-Fi IP. Разгружает adb. Пусто при недоступности (нет APK/сети) — вызывающий
// откатывается на exec-out. Возвращает PNG-байты.
static QByteArray captureScreenshotHttp(const QString &deviceId);
static bool takeXmlDump(const QString &deviceId, const QString &name); static bool takeXmlDump(const QString &deviceId, const QString &name);
static bool makeTap(const QString &deviceId, int x, int y); static bool makeTap(const QString &deviceId, int x, int y);
@ -98,29 +59,8 @@ public:
// Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии // Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии
static QString getAppVersion(const QString &deviceId, const QString &packageName); static QString getAppVersion(const QString &deviceId, const QString &packageName);
// Возвращает пакет приложения на переднем плане (foreground), либо пустую строку при ошибке
static QString getForegroundPackage(const QString &deviceId);
// Проверяет реальную ориентацию экрана (dumpsys input → SurfaceOrientation).
// Если экран в горизонтальной ориентации (landscape, 90°/270°), принудительно
// ставит вертикальную: выключает автоповорот и выставляет user_rotation=0.
// Best-effort: при ошибке adb просто ничего не делает. Возвращает true, если
// экран в портрете (изначально или после форсирования).
static bool ensurePortraitOrientation(const QString &deviceId);
private: private:
static QString adbDir(); static QString adbDir();
static bool startProcess(const QString &program, const QStringList &args, static bool startProcess(const QString &program, const QStringList &args,
QString *stdOut = nullptr, QString *stdErr = nullptr); QString *stdOut = nullptr, QString *stdErr = nullptr);
// Старый способ: exec-out screencap -p (стримит PNG через adb). Fallback для http.
static QByteArray captureScreenshotExecOut(const QString &deviceId);
// Синхронный HTTP/1.0 GET по QTcpSocket (без event-loop, безопасно из worker-потоков).
// Возвращает тело ответа при 200, иначе пусто.
static QByteArray httpGet(const QString &ip, int port, const QString &path, int timeoutMs);
// Поднимает foreground-сервис arc-shot (ставит APK, если удалён). Дёргается из
// http-fail ветки, т.е. не чаще раза в бэкофф-окно.
static void kickArcShot(const QString &deviceId);
}; };

View File

@ -251,12 +251,7 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) { if (afterPost.bankProfileId.isEmpty()) {
QString err; const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) {
err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage();
} else {
err = "postBankProfile failed for " + deviceId + " " + appCode;
}
qWarning() << "[Black] createBankProfileIfNeeded:" << err; qWarning() << "[Black] createBankProfileIfNeeded:" << err;
return err; return err;
} }

View File

@ -11,7 +11,6 @@
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "net/MaterialPayload.h"
namespace Black { namespace Black {
@ -37,7 +36,7 @@ void GetCardInfoScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("black", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("black", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
// Читаем текущий экран // Читаем текущий экран
@ -127,33 +126,30 @@ void GetCardInfoScript::doStart() {
qWarning() << "[Black::GetCardInfo] Failed to upsert account"; qWarning() << "[Black::GetCardInfo] Failed to upsert account";
} }
// 6. Отправляем материал на сервер. // 6. Отправляем материал на сервер (если ещё нет material_id)
// Инвариант: на сервере одновременно живёт максимум один материал на (deviceId, appCode) —
// тот, что локально active. Пушим только active-карту и только если другой
// материал ещё не синхронизирован.
const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers); const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
if (savedAccount.id != -1 && !updatedApp.bankProfileId.isEmpty()) { if (savedAccount.id != -1 && !updatedApp.bankProfileId.isEmpty()) {
if (savedAccount.status != MaterialStatus::Active) { if (savedAccount.materialId.isEmpty()) {
qDebug() << "[Black::GetCardInfo] CVU" << lastNumbers << "is off, not pushing to server"; NetworkService ns;
} else if (!savedAccount.materialId.isEmpty()) { ns.setDeviceId(m_deviceId);
qDebug() << "[Black::GetCardInfo] material already synced for CVU" << lastNumbers << ", skipping";
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
QJsonObject materialBody;
materialBody["bank_profile_id"] = updatedApp.bankProfileId;
materialBody["card_number"] = savedAccount.cardNumber;
materialBody["name"] = updatedApp.fullName;
materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled";
materialBody["currency"] = currencyCodes.value(savedAccount.currency.toUpper(), 32);
ns.setPayload(QJsonDocument(materialBody).toJson());
ns.addExtra("account_id", savedAccount.id);
ns.postMaterial();
qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers;
} else { } else {
bool otherSynced = false; qDebug() << "[Black::GetCardInfo] material already exists for CVU" << lastNumbers << ", skipping";
for (const MaterialInfo &m : MaterialDAO::getAccounts(device.id, m_appCode)) {
if (m.id != savedAccount.id && !m.materialId.isEmpty()) { otherSynced = true; break; }
}
if (otherSynced) {
qDebug() << "[Black::GetCardInfo] another material already synced in app, skipping POST for CVU" << lastNumbers;
} else {
NetworkService ns;
ns.setDeviceId(m_deviceId);
ns.setPayload(buildMaterialPayload(savedAccount, updatedApp));
ns.addExtra("account_id", savedAccount.id);
ns.postMaterial();
qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers;
}
} }
} else { } else {
qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material"; qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material";

View File

@ -35,7 +35,7 @@ void GetProfileInfoScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
// Читаем текущий экран // Читаем текущий экран

View File

@ -36,7 +36,7 @@ void LoginAndCheckAccountsScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));

View File

@ -8,7 +8,6 @@
#include <QPointer> #include <QPointer>
#include <QProcess> #include <QProcess>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSettings>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTextStream> #include <QTextStream>
#include <QThread> #include <QThread>
@ -167,39 +166,22 @@ TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
} }
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const { QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
const QString apiToken = SettingsDAO::get("api_key");
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate); const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
// api-токен в caption больше не пишем: он общий на нескольких операторов и
// не позволяет отличить, чей профиль упал. Идентифицируем по UUID профиля,
// материалу и телефону профиля (см. m_meta).
QStringList head{QStringLiteral("[TASK_ERROR]")}; 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_bank.toHtmlEscaped());
head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped()); head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped());
const QString desktopId = SettingsDAO::get("desktop_id"); QString body = QStringLiteral("Device: %1").arg(m_adbDeviceIdRaw.toHtmlEscaped());
// Версия самого ARC-десктопа (клиента), не банковского приложения.
const QString clientVersion =
QSettings("config.ini", QSettings::IniFormat).value("common/version").toString();
QString body;
if (!desktopId.isEmpty())
body += QStringLiteral("Desktop: %1\n").arg(desktopId.toHtmlEscaped());
if (!clientVersion.isEmpty())
body += QStringLiteral("DesktopVersion: %1\n").arg(clientVersion.toHtmlEscaped());
body += QStringLiteral("Device: %1").arg(m_adbDeviceIdRaw.toHtmlEscaped());
if (!m_meta.androidId.isEmpty())
body += QStringLiteral("\nAndroidId: %1").arg(m_meta.androidId.toHtmlEscaped());
if (!m_meta.bankProfileId.isEmpty())
body += QStringLiteral("\nProfile: %1").arg(m_meta.bankProfileId.toHtmlEscaped());
if (!m_meta.materialId.isEmpty()) if (!m_meta.materialId.isEmpty())
body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped()); body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped());
if (!m_meta.profilePhone.isEmpty())
body += QStringLiteral("\nPhone: %1").arg(m_meta.profilePhone.toHtmlEscaped());
if (!m_meta.appVersion.isEmpty())
body += QStringLiteral("\nAppVersion: %1").arg(m_meta.appVersion.toHtmlEscaped());
if (m_meta.amount != 0) if (m_meta.amount != 0)
body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount)); body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
if (!m_meta.phone.isEmpty()) if (!m_meta.phone.isEmpty())
body += QStringLiteral("\nRecipient: %1").arg(m_meta.phone.toHtmlEscaped()); body += QStringLiteral("\nPhone: %1").arg(m_meta.phone.toHtmlEscaped());
if (!m_meta.card.isEmpty()) if (!m_meta.card.isEmpty())
body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped()); body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped());
body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped()); body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped());
@ -256,18 +238,9 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) { if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
QTextStream ts(&ef); QTextStream ts(&ef);
ts.setEncoding(QStringConverter::Utf8); ts.setEncoding(QStringConverter::Utf8);
const QString desktopId = SettingsDAO::get("desktop_id");
if (!desktopId.isEmpty()) ts << "desktop_id: " << desktopId << "\n";
const QString clientVersion =
QSettings("config.ini", QSettings::IniFormat).value("common/version").toString();
if (!clientVersion.isEmpty()) ts << "desktop_version: " << clientVersion << "\n";
ts << "bank: " << m_bank << "\n" ts << "bank: " << m_bank << "\n"
<< "task: " << m_taskKind << "\n" << "task: " << m_taskKind << "\n"
<< "device: " << m_adbDeviceIdRaw << "\n"; << "device: " << m_adbDeviceIdRaw << "\n";
if (!m_meta.androidId.isEmpty()) ts << "android_id: " << m_meta.androidId << "\n";
if (!m_meta.bankProfileId.isEmpty()) ts << "profile_id: " << m_meta.bankProfileId << "\n";
if (!m_meta.profilePhone.isEmpty()) ts << "profile_phone: " << m_meta.profilePhone << "\n";
if (!m_meta.appVersion.isEmpty()) ts << "app_version: " << m_meta.appVersion << "\n";
if (!m_meta.materialId.isEmpty()) ts << "material_id: " << m_meta.materialId << "\n"; if (!m_meta.materialId.isEmpty()) ts << "material_id: " << m_meta.materialId << "\n";
if (m_meta.amount != 0) ts << "amount: " << static_cast<qint64>(m_meta.amount) << "\n"; if (m_meta.amount != 0) ts << "amount: " << static_cast<qint64>(m_meta.amount) << "\n";
if (!m_meta.phone.isEmpty()) ts << "phone: " << m_meta.phone << "\n"; if (!m_meta.phone.isEmpty()) ts << "phone: " << m_meta.phone << "\n";
@ -282,12 +255,6 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
const QString archivePath = m_folder + ".zip"; const QString archivePath = m_folder + ".zip";
const QString text = buildCaption(errorMessage); const QString text = buildCaption(errorMessage);
// Имя файла архива на relay: <bank>_<task>_<timestamp>.zip — чтобы дамп
// сразу читался по банку и типу задачи без открытия caption'а.
const QString stamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
const QString archiveFilename =
QStringLiteral("%1_%2_%3.zip").arg(m_bank, m_taskKind, stamp);
// Финальный скриншот шлём отдельным полем `image`, поэтому сразу читаем // Финальный скриншот шлём отдельным полем `image`, поэтому сразу читаем
// его в память, пока папка ещё цела. // его в память, пока папка ещё цела.
QByteArray finalScreenshot; QByteArray finalScreenshot;
@ -298,12 +265,7 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
} }
} }
QThread *bg = QThread::create([folder, archivePath, text, finalScreenshot, archiveFilename]() { QThread *bg = QThread::create([folder, archivePath, text, finalScreenshot]() {
// Кладём application.log + БД (+WAL/SHM) прямо в папку задачи (подкаталог
// logs/), чтобы скрины задачи и логи ушли одним общим архивом, а не двумя
// отдельными сообщениями на relay.
LogArchiveSender::collectInto(folder + "/logs");
QByteArray archiveBytes; QByteArray archiveBytes;
if (zipFolder(folder, archivePath)) { if (zipFolder(folder, archivePath)) {
archiveBytes = readFileBytes(archivePath); archiveBytes = readFileBytes(archivePath);
@ -326,8 +288,7 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
Qt::QueuedConnection, Qt::QueuedConnection,
Q_ARG(QString, text), Q_ARG(QString, text),
Q_ARG(QByteArray, archiveBytes), Q_ARG(QByteArray, archiveBytes),
Q_ARG(QByteArray, finalScreenshot), Q_ARG(QByteArray, finalScreenshot));
Q_ARG(QString, archiveFilename));
if (!dispatched) { if (!dispatched) {
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — " qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
"slot not found or wrong signature for" << folder; "slot not found or wrong signature for" << folder;
@ -336,6 +297,11 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
<< "(setNetworkService was not called at app startup?)"; << "(setNetworkService was not called at app startup?)";
} }
// Следом — application.log + БД (+ WAL/SHM) отдельным архивом.
// Запускаем уже ПОСЛЕ того, как task-дамп поставлен в очередь
// отправки, чтобы порядок прихода на relay был "дамп → логи".
LogArchiveSender::dispatch(QStringLiteral("TASK_ERROR"));
}); });
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater); QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
bg->start(); bg->start();

View File

@ -8,13 +8,9 @@ class NetworkService;
// и в имя папки — выводятся только те, что релевантны конкретному типу задачи. // и в имя папки — выводятся только те, что релевантны конкретному типу задачи.
struct TaskDumpMeta { struct TaskDumpMeta {
QString materialId; QString materialId;
double amount = 0; // 0 = нет double amount = 0; // 0 = нет
QString phone; // пусто = нет (телефон получателя в транзакции) QString phone; // пусто = нет
QString card; // пусто = нет QString card; // пусто = нет
QString bankProfileId; // серверный UUID банковского профиля (детект в общих логах)
QString profilePhone; // телефон банковского профиля (не получателя)
QString androidId; // стабильный android_id устройства (в отличие от adbSerial не меняется)
QString appVersion; // версия банковского приложения (из pm dump versionName)
}; };
class TaskDumpRecorder { class TaskDumpRecorder {

View File

@ -1,87 +0,0 @@
#pragma once
#include <QString>
#include <QVersionNumber>
namespace Dushanbe {
// «Трек» формата UI приложения Dushanbe City Bank. Экранные дампы и порядок
// нод в content-desc менялись между версиями приложения; трек — это
// нормализованный ярлык версии, по которому код выбирает версионно-зависимую
// ветку парсинга (см. ScreenXmlParser::setVersionTrack).
//
// На момент введения каркаса форматы 3.2.33 и 3.2.38 практически идентичны,
// поэтому отдельных веток ещё нет — трек служит явным швом для будущих
// расхождений и читаемым маркером в логах/дампах.
//
// ─────────────────────────────────────────────────────────────────────────
// КАК ДОБАВЛЯТЬ НОВУЮ ВЕРСИЮ ПРИЛОЖЕНИЯ
// ─────────────────────────────────────────────────────────────────────────
// Отдельные скрипты на каждую версию НЕ нужны — по умолчанию все версии
// обслуживает один общий комплект скриптов. Выбирайте по объёму изменений:
//
// 1) Формат экранов не изменился (типичный апдейт банка — косметика,
// переименование полей в той же структуре).
// → НИЧЕГО не делаем. Новая версия новее последнего порога и автоматически
// трактуется как новейший трек (см. trackForVersion). Можно опционально
// завести эталонные дампы в assets/dushanbe/<версия>/ для сверки.
//
// 2) Изменился один-два экрана.
// → Добавляем новый элемент в enum (например V3_2_45) и НОВЫЙ порог в
// trackForVersion(). Затем в методе, который парсит изменившийся экран,
// ставим точечную ветку:
// if (m_track >= AppVersionTrack::V3_2_45) { …новый формат… }
// else { …существующий… }
// Скрипты остаются ОБЩИМИ. Старые ветки (Legacy/3.2.33) не удаляем —
// на устройствах в поле могут оставаться старые версии.
// Версионно-зависимые точки сегодня: ScreenXmlParser (профиль/боковое
// меню, label-value пары), GetLastTransactionsScript (детали транзакции),
// PayBy{Card,Phone}Script (чек «Платеж выполнен»).
//
// 3) Версия разошлась радикально (другой флоу, другие экраны почти везде) —
// КРАЙНИЙ случай.
// → Только тогда выносим версию в отдельный класс скрипта и выбираем его
// по треку в EventHandler::startThreadForTask(). Точка ветвления по
// версии уже есть (app.appVersion → trackForVersion), переход дешёвый.
//
// Значения enum упорядочены по возрастанию версии — можно сравнивать через
// <, <=, >, >= (например `m_track >= V3_2_38`).
enum class AppVersionTrack {
Legacy, // < 3.2.33 — старый blob-формат (один content-desc с "Версия:"/"Сборка:")
V3_2_33, // 3.2.33 .. < 3.2.38
V3_2_38, // >= 3.2.38 (и новейший по умолчанию)
};
// Резолвит трек из строки версии (например "3.2.38", полученной через
// AdbUtils::getAppVersion → BankProfileInfo.appVersion). Сравнение —
// через QVersionNumber, а не лексикографически по строке.
//
// Неизвестная/непарсящаяся версия, а также любая версия новее последнего
// известного порога, трактуется как новейший известный трек (V3_2_38): свежие
// апдейты по умолчанию должны идти по последнему формату, а не падать на Legacy.
inline AppVersionTrack trackForVersion(const QString &appVersion) {
static const QVersionNumber kV3_2_33(3, 2, 33);
static const QVersionNumber kV3_2_38(3, 2, 38);
qsizetype suffixIndex = 0;
const QVersionNumber v = QVersionNumber::fromString(appVersion.trimmed(), &suffixIndex);
if (v.isNull()) {
// Не смогли распарсить — считаем новейшим известным форматом.
return AppVersionTrack::V3_2_38;
}
if (v < kV3_2_33) return AppVersionTrack::Legacy;
if (v < kV3_2_38) return AppVersionTrack::V3_2_33;
return AppVersionTrack::V3_2_38;
}
// Человекочитаемое имя трека для логов.
inline const char *trackName(AppVersionTrack track) {
switch (track) {
case AppVersionTrack::Legacy: return "Legacy(<3.2.33)";
case AppVersionTrack::V3_2_33: return "3.2.33";
case AppVersionTrack::V3_2_38: return "3.2.38";
}
return "unknown";
}
} // namespace Dushanbe

View File

@ -317,12 +317,7 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) { if (afterPost.bankProfileId.isEmpty()) {
QString err; const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) {
err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage();
} else {
err = "postBankProfile failed for " + deviceId + " " + appCode;
}
qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err; qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err;
return err; return err;
} }

View File

@ -10,7 +10,6 @@
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "net/MaterialPayload.h"
namespace Dushanbe { namespace Dushanbe {
@ -36,14 +35,9 @@ void GetCardInfoScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
qDebug() << "[Dushanbe::GetCardInfo] appVersion=" << app.appVersion
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -269,28 +263,27 @@ void GetCardInfoScript::doStart() {
qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers; qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers;
} }
// Отправляем на сервер: инвариант — только active-карта и только если // Отправляем на сервер если нет material_id
// другая карта в (deviceId, appCode) ещё не синхронизирована.
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
if (!updatedApp.bankProfileId.isEmpty()) { if (!updatedApp.bankProfileId.isEmpty()) {
const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers); const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
if (savedAccount.id != -1 if (savedAccount.id != -1 && savedAccount.materialId.isEmpty()) {
&& savedAccount.status == MaterialStatus::Active NetworkService ns;
&& savedAccount.materialId.isEmpty()) { ns.setDeviceId(m_deviceId);
bool otherSynced = false; const QMap<QString, int> currencyCodes = {
for (const MaterialInfo &m : MaterialDAO::getAccounts(device.id, m_appCode)) { {"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
if (m.id != savedAccount.id && !m.materialId.isEmpty()) { otherSynced = true; break; } };
} QJsonObject materialBody;
if (otherSynced) { materialBody["bank_profile_id"] = updatedApp.bankProfileId;
qDebug() << "[Dushanbe::GetCardInfo] another material already synced, skipping POST for" << lastNumbers; materialBody["card_number"] = QString(fullNumber).remove(' ');
} else { materialBody["account_number"] = accountNumber;
NetworkService ns; materialBody["name"] = updatedApp.fullName;
ns.setDeviceId(m_deviceId); materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled";
ns.setPayload(buildMaterialPayload(savedAccount, updatedApp)); materialBody["currency"] = currencyCodes.value(savedAccount.currency.toUpper(), 972);
ns.addExtra("account_id", savedAccount.id); ns.setPayload(QJsonDocument(materialBody).toJson());
ns.postMaterial(); ns.addExtra("account_id", savedAccount.id);
qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers; ns.postMaterial();
} qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers;
} }
} }

View File

@ -15,12 +15,9 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "AppLogger.h" #include "AppLogger.h"
#include "BankConfigUtils.h"
#include <QCoreApplication> #include <QCoreApplication>
#include <algorithm>
// Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50", // Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50",
// "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0, // "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0,
// потому что требует, чтобы вся строка была валидным числом. // потому что требует, чтобы вся строка была валидным числом.
@ -252,13 +249,15 @@ namespace Dushanbe {
GetLastTransactionsScript::GetLastTransactionsScript( GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId, QString deviceId,
QString appCode, QString appCode,
QList<QPair<double, QDateTime>> targets, double searchAmount,
QDateTime searchTime,
QString materialId, QString materialId,
QObject *parent QObject *parent
) : CommonScript(parent), ) : CommonScript(parent),
m_deviceId(std::move(deviceId)), m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)), m_appCode(std::move(appCode)),
m_targets(std::move(targets)), m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)),
m_materialId(std::move(materialId)) { m_materialId(std::move(materialId)) {
} }
@ -278,15 +277,9 @@ void GetLastTransactionsScript::doStart() {
return; return;
} }
const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first; const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
const TaskDumpMeta dumpMeta{m_materialId, firstAmount, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
qDebug() << "[Dushanbe::GetLastTransactions] appVersion=" << app.appVersion
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {
@ -303,9 +296,9 @@ void GetLastTransactionsScript::doStart() {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
} }
// Тапаем "История" в нижнем меню (с геометрическим fallback при [0,0][0,0]). // Тапаем "История" в нижнем меню
const QPoint historyPt = bottomNavPoint(QString::fromUtf8("История"), device); Node historyBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История"));
if (historyPt.x() < 0) { if (historyBtn.x() == 0 && historyBtn.y() == 0) {
m_error = "Cannot find History button: " + m_deviceId; m_error = "Cannot find History button: " + m_deviceId;
qWarning() << m_error; qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package); AdbUtils::tryToKillApplication(m_deviceId, app.package);
@ -313,7 +306,7 @@ void GetLastTransactionsScript::doStart() {
return; return;
} }
AdbUtils::makeTap(m_deviceId, historyPt.x(), historyPt.y()); AdbUtils::makeTap(m_deviceId, historyBtn.x(), historyBtn.y());
QThread::msleep(3000); QThread::msleep(3000);
// Ждём экран истории // Ждём экран истории
@ -385,6 +378,9 @@ void GetLastTransactionsScript::doStart() {
} }
} }
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Находим accountId // Находим accountId
int accountId = -1; int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode); const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
@ -394,111 +390,13 @@ void GetLastTransactionsScript::doStart() {
qWarning() << "[Dushanbe::GetLastTransactions] No account found"; qWarning() << "[Dushanbe::GetLastTransactions] No account found";
} }
// История отсортирована сверху-вниз: новые операции выше старых. Ищем цели qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
// в том же порядке (по времени, от новых к старым) — тогда один непрерывный << "time=" << m_searchTime.toString(Qt::ISODate);
// проход вниз находит все, и возвращаться к началу между целями не нужно.
// Single-pass: один проход вниз обслуживает ВСЕ цели сразу. Список Выписки
// идёт сверху-вниз (новые→старые), сортируем цели так же и отдаём в searchAll.
// Найденные он эмитит через transactionParsed и убирает из pending; в остатке —
// ненайденные, по которым собираем агрегированную ошибку.
QList<QPair<double, QDateTime>> pending = m_targets;
std::sort(pending.begin(), pending.end(),
[](const QPair<double, QDateTime> &a, const QPair<double, QDateTime> &b) {
return a.second > b.second; // по убыванию времени (новые первыми)
});
searchAll(device, accountId, pending);
if (m_error != "Interrupted") {
QStringList notFound;
for (const auto &t : pending) {
notFound << QString("amount=%1 time=%2")
.arg(QString::number(t.first, 'f', 2), t.second.toString(Qt::ISODate));
}
m_error = notFound.isEmpty()
? QString()
: QString("Transactions not found (%1/%2): %3")
.arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; "));
}
// Возвращаемся на главную (с геометрическим fallback при [0,0][0,0]).
{
const QPoint homePt = bottomNavPoint(QString::fromUtf8("Главная"), device);
if (homePt.x() >= 0) {
AdbUtils::makeTap(m_deviceId, homePt.x(), homePt.y());
} else {
AdbUtils::goBack(m_deviceId);
}
QThread::msleep(1000);
}
emit finishedWithResult(m_error);
}
QPoint GetLastTransactionsScript::bottomNavPoint(const QString &label, const DeviceInfo &device) {
// 1) Норма: у таба есть реальные bounds в нижней полосе экрана. Проверка
// «центр в нижних 80% высоты» отсекает и вырожденные [0,0][0,0], и
// НЕнулевые-но-негодные координаты (одноимённый элемент не в нав-баре).
const Node lbl = xmlScreenParser.findNodeByContentDesc(label);
if (!lbl.isEmpty() && lbl.x() > 0
&& lbl.y() >= static_cast<int>(device.screenHeight * 0.80))
return {lbl.x(), lbl.y()};
// 2) Лейбл нав-таба вырожден ([0,0][0,0] — гибридный UI, напр. Infinix XOS не
// отдаёт bounds нижнего меню). Но ИКОНКИ табов имеют реальные координаты.
// Берём самый нижний ряд небольших нод и в нём крайнюю по X: История —
// самая правая иконка, Главная — самая левая (FAB по центру никогда не
// крайний; контентные плитки выше нижнего ряда и отсекаются по y2). Это
// фактические координаты → не зависит от размера экрана, плотности и
// высоты системной навигации Android (её фикс-высота в dp ломала бы доли).
const bool rightmost = (label == QString::fromUtf8("История"));
const bool leftmost = (label == QString::fromUtf8("Главная"));
if (rightmost || leftmost) {
const int H = device.screenHeight, W = device.screenWidth;
const auto nodes = xmlScreenParser.findAllNodes();
auto isNavIcon = [&](const Node &n) {
return n.x1() >= 0 && n.width() > 0 && n.height() >= 12
&& n.width() <= W / 3 && n.y() >= H / 2; // не контейнер, нижняя половина
};
int rowBottom = -1;
for (const Node &n : nodes)
if (isNavIcon(n)) rowBottom = qMax(rowBottom, n.y2());
if (rowBottom > 0) {
const int tol = qMax(8, H / 100); // «тот же ряд» — у самой нижней кромки
Node pick;
for (const Node &n : nodes) {
if (!isNavIcon(n) || n.y2() < rowBottom - tol) continue;
if (pick.isEmpty()
|| (rightmost && n.x() > pick.x())
|| (leftmost && n.x() < pick.x()))
pick = n;
}
if (!pick.isEmpty()) {
qWarning() << "[Dushanbe::GetLastTransactions] nav tab" << label
<< "— icon-row fallback tap at" << pick.x() << pick.y();
return {pick.x(), pick.y()};
}
}
}
return {-1, -1};
}
void GetLastTransactionsScript::searchAll(
const DeviceInfo &device, int accountId,
QList<QPair<double, QDateTime>> &pending) {
// Скриншот списка транзакций — отправим при неудаче
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
qDebug() << "[Dushanbe::GetLastTransactions] searchAll: targets=" << pending.size();
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства // Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
const QTimeZone tjTz("Asia/Dushanbe"); const QTimeZone tjTz("Asia/Dushanbe");
const int maxScrolls = 10; const int maxScrolls = 10;
// Окно поиска по датам: сегодня + (N-1) предыдущих дней. Настраивается в
// config.ini → [<bankCode>]/transaction_search_window_days (дефолт 2 = сегодня+вчера).
const int searchWindowDays = BankConfigUtils::transactionSearchWindowDays(m_appCode);
auto interrupted = []() { auto interrupted = []() {
return QThread::currentThread()->isInterruptionRequested(); return QThread::currentThread()->isInterruptionRequested();
}; };
@ -511,17 +409,10 @@ void GetLastTransactionsScript::searchAll(
Node node; Node node;
}; };
// Guard от «залипшей» прокрутки: если свайп не двигает список (банковское for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
// приложение перестало скроллиться — частый случай при сбоях UI/жестов),
// видимые транзакции не меняются. Тогда листать до maxScrolls бессмысленно,
// а при десятках целей это упирается в 10-минутный watchdog → "Interrupted".
// Детектируем по сигнатуре видимого набора: 2 одинаковых подряд = прогресса нет.
QString prevViewSig;
int stallScrolls = 0;
for (int scroll = 0; scroll <= maxScrolls && !pending.isEmpty(); ) {
if (interrupted()) { if (interrupted()) {
m_error = "Interrupted"; m_error = "Interrupted";
emit finishedWithResult(m_error);
return; return;
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
@ -591,11 +482,7 @@ void GetLastTransactionsScript::searchAll(
static const QRegularExpression reDateOrToday( static const QRegularExpression reDateOrToday(
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$") QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера"))); .arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
// Сумма: либо с десятичной частью ("-5577.95", "109,50"), либо целая static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)");
// ("109" — P2P-зачисления показываются без копеек). Целую часть
// ограничиваем 7 цифрами, чтобы не спутать с 1216-значным номером
// карты, если он окажется на отдельной строке без пробелов/дефисов.
static const QRegularExpression reAmountLine(R"(^-?(?:\d+[.,]\d{1,2}|\d{1,7})$)");
QList<VypiskaItem> items; QList<VypiskaItem> items;
for (const Node &n : xmlScreenParser.findAllNodes()) { for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.view.View") continue; if (n.className != "android.view.View") continue;
@ -611,14 +498,12 @@ void GetLastTransactionsScript::searchAll(
VypiskaItem it; VypiskaItem it;
bool ok = false; bool ok = false;
// Ищем строку-сумму — устойчиво и к 5-, и к 6-строчному DCWallet, // Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному
// и к P2P. Карты с пробелами ("9762 0002 ...") и дефисами, а также // DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые
// текстовые поля не подпадают под паттерн. // поля не подпадают под паттерн.
for (const QString &line : lines) { for (const QString &line : lines) {
const QString t = line.trimmed(); if (!reAmountLine.match(line.trimmed()).hasMatch()) continue;
if (!reAmountLine.match(t).hasMatch()) continue; it.amount = line.trimmed().toDouble(&ok);
QString v = t; v.replace(',', '.');
it.amount = v.toDouble(&ok);
if (ok) break; if (ok) break;
} }
if (!ok) continue; if (!ok) continue;
@ -711,58 +596,49 @@ void GetLastTransactionsScript::searchAll(
<< "y=" << items[i].node.y(); << "y=" << items[i].node.y();
} }
// Single-pass: сверяем каждую видимую транзакцию со ВСЕМ набором const double searchAbs = qAbs(m_searchAmount);
// оставшихся целей. Найденную убираем из pending и пере-сканируем тот же
// экран (matchedThisScreen), скроллим только когда matchей на экране нет.
bool matchedThisScreen = false;
for (const auto &tx : items) { for (const auto &tx : items) {
const double absListAmt = qAbs(tx.amount); const double absListAmt = qAbs(tx.amount);
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
// Выбираем подходящую цель: совпадение по сумме + пред-фильтры даты/времени. const bool amountX100 = qAbs(absListAmt * 100.0 - searchAbs) < 0.01;
// Список Выписки, экран деталей и поисковый таргет — все в мажорных if (!amountDirect && !amountX100) continue;
// единицах (TJS, 2 знака), поэтому сверяем сумму напрямую. Эвристик const bool matchedX100 = amountX100 && !amountDirect;
// "x100" убран: он ложно матчил реальную транзакцию на 1.00 к поиску
// на 100.00 (1.00*100==100.00) и затем домножал результат до 10000.
int matchIdx = -1;
for (int ti = 0; ti < pending.size(); ++ti) {
const double searchAbs = qAbs(pending[ti].first);
const QDateTime &cand = pending[ti].second;
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
if (!amountDirect) continue;
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
// DCWallet транзакции получают дату из groupDates ImageView, которая
// неправильно присваивается при скролле — не фильтруем по ней.
if (tx.dateReliable && cand.isValid() && !tx.date.isEmpty()) {
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
const QDate searchLocalDate = cand.toTimeZone(tjTz).date();
if (listDate.isValid() && searchLocalDate.isValid()) {
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
if (dayDiff > 3) continue;
}
}
// Проверка времени по HH:MM:SS (+10 минут, одностороннее окно, с учётом wraparound полуночи)
if (cand.isValid() && !tx.time.isEmpty()) {
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
const QTime searchLocalTime = cand.toTimeZone(tjTz).time();
if (listTime.isValid() && searchLocalTime.isValid()) {
int diffSecs = searchLocalTime.secsTo(listTime); // >0 = listTime (tx) позже цели
if (diffSecs < -43200) diffSecs += 86400; // wraparound через полночь вперёд
else if (diffSecs > 43200) diffSecs -= 86400; // wraparound назад
if (diffSecs < 0 || diffSecs > 600) continue; // tx раньше цели или позже неё на >10 мин
}
}
matchIdx = ti;
break;
}
if (matchIdx < 0) continue;
const QDateTime searchTime = pending[matchIdx].second;
qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount
<< "date:" << tx.date << "time:" << tx.time << (matchedX100 ? "(scaled x100)" : "(direct)")
<< "→ target idx" << matchIdx; << "date:" << tx.date << "time:" << tx.time;
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
// DCWallet транзакции получают дату из groupDates ImageView, которая
// неправильно присваивается при скролле — не фильтруем по ней.
if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) {
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
if (listDate.isValid() && searchLocalDate.isValid()) {
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
if (dayDiff > 3) {
qDebug() << "[Dushanbe::GetLastTransactions] Date pre-filter skip:"
<< tx.date << "vs" << searchLocalDate.toString("dd.MM.yyyy")
<< "diff=" << dayDiff << "days";
continue;
}
}
}
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи)
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
if (listTime.isValid() && searchLocalTime.isValid()) {
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m
qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:"
<< tx.time << "vs" << searchLocalTime.toString("HH:mm:ss")
<< "diff=" << diffSecs << "sec";
continue;
}
}
}
Node txNode = tx.node; Node txNode = tx.node;
@ -783,13 +659,13 @@ void GetLastTransactionsScript::searchAll(
const int tapX = txNode.x1() + 80; const int tapX = txNode.x1() + 80;
AdbUtils::makeTap(m_deviceId, tapX, txNode.y()); AdbUtils::makeTap(m_deviceId, tapX, txNode.y());
QThread::msleep(3000); QThread::msleep(3000);
if (interrupted()) { m_error = "Interrupted"; return; } if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж" // Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
OcrTransactionDetail detail; OcrTransactionDetail detail;
bool detailLoaded = false; bool detailLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
if (interrupted()) { m_error = "Interrupted"; return; } if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml): // 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml):
@ -917,8 +793,8 @@ void GetLastTransactionsScript::searchAll(
<< "date=" << detail.date << "time=" << detail.time << "date=" << detail.date << "time=" << detail.time
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount; << "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
// Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+10м, одностороннее окно) // Проверяем время ±10 минут
if (searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) { if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
QDateTime txTime = QDateTime::fromString( QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
if (!txTime.isValid()) { if (!txTime.isValid()) {
@ -926,9 +802,9 @@ void GetLastTransactionsScript::searchAll(
} }
if (txTime.isValid()) { if (txTime.isValid()) {
txTime.setTimeZone(tjTz); txTime.setTimeZone(tjTz);
const qint64 diffSecs = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs < 0 || diffSecs > 600) { if (diffSecs > 600) {
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (out of +10min window), skipping"; qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping";
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);
QThread::msleep(1000); QThread::msleep(1000);
continue; continue;
@ -947,8 +823,8 @@ void GetLastTransactionsScript::searchAll(
// Формируем task_result напрямую из распарсенных данных и отдаём // Формируем task_result напрямую из распарсенных данных и отдаём
// EventHandler через сигнал — без записи/чтения БД. // EventHandler через сигнал — без записи/чтения БД.
{ {
const double finalAmount = detail.amount; const double finalAmount = matchedX100 ? detail.amount * 100.0 : detail.amount;
const double finalFee = detail.fee; const double finalFee = matchedX100 ? detail.fee * 100.0 : detail.fee;
QDateTime txTime = QDateTime::fromString( QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
@ -958,15 +834,9 @@ void GetLastTransactionsScript::searchAll(
if (txTime.isValid()) txTime.setTimeZone(tjTz); if (txTime.isValid()) txTime.setTimeZone(tjTz);
QJsonObject txJson; QJsonObject txJson;
// Фолбэк-id (когда «Номер операции» не распарсился) строим из txJson["bank_transaction_id"] = detail.operationId.isEmpty()
// created_at задачи — он детерминирован и стабилен между запусками, ? QString("dushanbe_") + detail.date + "_" + detail.time
// что важно для дедупликации на сервере. Раньше брали распарсенные : detail.operationId;
// с экрана дату+время, которые могли «плавать» из-за OCR.
txJson["bank_transaction_id"] = !detail.operationId.isEmpty()
? detail.operationId
: (searchTime.isValid()
? QStringLiteral("dushanbe_") + searchTime.toUTC().toString(Qt::ISODate)
: QStringLiteral("dushanbe_") + detail.date + "_" + detail.time);
// Списание хранится с отрицательным знаком, пополнение — положительным. // Списание хранится с отрицательным знаком, пополнение — положительным.
// detail.amount всегда положителен (OCR), знак берём из списка Выписки. // detail.amount всегда положителен (OCR), знак берём из списка Выписки.
const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount); const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount);
@ -979,27 +849,9 @@ void GetLastTransactionsScript::searchAll(
QJsonObject extraObj; QJsonObject extraObj;
extraObj["fee"] = static_cast<int>(finalFee * 100); extraObj["fee"] = static_cast<int>(finalFee * 100);
txJson["extra"] = extraObj; txJson["extra"] = extraObj;
// created_at — время из задачи (поисковый target), real_created_at — txJson["created_at"] = txTime.isValid()
// фактическое время транзакции, спарсенное с экрана приложения.
txJson["created_at"] = searchTime.isValid()
? searchTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
txJson["real_created_at"] = txTime.isValid()
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null); ? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
// Проверяем глобальный кэш: если транзакция уже была найдена
// в рамках другой задачи этого материала — пропускаем её.
const QString realAt = txJson["real_created_at"].toString();
if (!realAt.isEmpty()) {
const QString ck = realAt + "|" + QString::number(qAbs(txJson["amount"].toInt()));
if (m_foundCache.contains(ck)) {
qDebug() << "[Dushanbe::GetLastTransactions] TX already found in another task, skip:" << ck;
AdbUtils::goBack(m_deviceId);
QThread::msleep(500);
continue;
}
m_foundCache.insert(ck);
}
qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:" qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:"
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact); << QJsonDocument(txJson).toJson(QJsonDocument::Compact);
emit transactionParsed(txJson); emit transactionParsed(txJson);
@ -1019,65 +871,13 @@ void GetLastTransactionsScript::searchAll(
} }
QThread::msleep(1000); QThread::msleep(1000);
// Цель найдена и отдана — убираем из набора и пере-сканируем этот же emit finishedWithResult(m_error); // m_error пустой — успех
// экран (вдруг на нём есть ещё цели), прежде чем скроллить дальше. return;
pending.removeAt(matchIdx);
matchedThisScreen = true;
break;
}
if (pending.isEmpty()) break;
// На экране что-то нашли — пере-сканируем его же (без скролла): другие
// цели могут быть на тех же позициях, а скролл их бы пропустил.
if (matchedThisScreen) continue;
// Окно поиска — "сегодня + (searchWindowDays-1) предыдущих дней". Список
// Выписки идёт сверху-вниз от новых к старым; группы-даты на текущем экране
// собраны в groupDates. Как только самая свежая видимая дата уже старше
// нижней границы окна — значит окно полностью проскроллено, дальше только
// старая история. Прекращаем скролл (целевой транзакции в окне нет), вместо
// того чтобы листать до упора и упереться в watchdog-таймаут.
{
const QDate cutoff = todayLocal.addDays(-(searchWindowDays - 1)); // нижняя граница окна
QDate newestVisible;
for (const QString &gd : groupDates) {
const QDate d = QDate::fromString(gd, "dd.MM.yyyy");
if (d.isValid() && (newestVisible.isNull() || d > newestVisible))
newestVisible = d;
}
if (newestVisible.isValid() && newestVisible < cutoff) {
qDebug() << "[Dushanbe::GetLastTransactions] Past today+yesterday window"
" (newest visible=" << newestVisible.toString("dd.MM.yyyy")
<< "< cutoff=" << cutoff.toString("dd.MM.yyyy")
<< ") — stopping scroll";
return; // дальше только старая история — оставшиеся цели не найдены
}
}
// Детект отсутствия прогресса прокрутки: сравниваем сигнатуру текущего
// видимого набора с предыдущей. Совпала — свайп ничего не сдвинул.
{
QStringList sigParts;
for (const VypiskaItem &it : items) {
sigParts << QString::number(it.amount, 'f', 2) + "|" + it.date + "|" + it.time;
}
const QString viewSig = sigParts.join(';') + "##" + groupDates.join(',');
if (!prevViewSig.isEmpty() && viewSig == prevViewSig) {
if (++stallScrolls >= 2) {
qWarning() << "[Dushanbe::GetLastTransactions] No scroll progress for"
<< stallScrolls << "swipes (list stuck) — aborting,"
<< pending.size() << "target(s) still not found";
break;
}
} else {
stallScrolls = 0;
}
prevViewSig = viewSig;
} }
// Скроллим вниз — свайп внутри области транзакций // Скроллим вниз — свайп внутри области транзакций
if (scroll < maxScrolls) { if (scroll < maxScrolls) {
if (interrupted()) { m_error = "Interrupted"; return; } if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
const int x = device.screenWidth / 2; const int x = device.screenWidth / 2;
// Определяем область контента из реальных Y позиций items // Определяем область контента из реальных Y позиций items
int contentBottom = device.screenHeight * 2 / 3; int contentBottom = device.screenHeight * 2 / 3;
@ -1092,19 +892,27 @@ void GetLastTransactionsScript::searchAll(
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500); AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(2000); QThread::msleep(2000);
} }
++scroll; // успешный match делает continue выше и сюда не доходит
} }
// Прошли весь доступный диапазон/окно. В pending остались ненайденные цели — m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
// агрегированную ошибку по ним соберёт doStart (он же вернёт на главную). + " time=" + m_searchTime.toString(Qt::ISODate);
if (!pending.isEmpty()) { qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
qWarning() << "[Dushanbe::GetLastTransactions] searchAll finished,"
<< pending.size() << "target(s) not found"; // Отправляем скриншот списка транзакций, сделанный до начала поиска
// Отправляем скриншот списка транзакций, сделанный до начала поиска m_resultScreenshot = listScreenshot.isEmpty()
m_resultScreenshot = listScreenshot.isEmpty() ? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot; if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
// Возвращаемся на главную
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {
AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y());
} else {
AdbUtils::goBack(m_deviceId);
} }
QThread::msleep(1000);
emit finishedWithResult(m_error);
} }
} // namespace Dushanbe } // namespace Dushanbe

View File

@ -2,10 +2,6 @@
#include "CommonScript.h" #include "CommonScript.h"
#include <QDateTime> #include <QDateTime>
#include <QJsonObject> #include <QJsonObject>
#include <QList>
#include <QPair>
#include <QPoint>
#include <QSet>
#include "db/DeviceInfo.h" #include "db/DeviceInfo.h"
#include "db/BankProfileInfo.h" #include "db/BankProfileInfo.h"
@ -18,7 +14,8 @@ public:
explicit GetLastTransactionsScript( explicit GetLastTransactionsScript(
QString deviceId, QString deviceId,
QString appCode, QString appCode,
QList<QPair<double, QDateTime>> targets = {}, double searchAmount = 0,
QDateTime searchTime = {},
QString materialId = {}, QString materialId = {},
QObject *parent = nullptr QObject *parent = nullptr
); );
@ -26,45 +23,21 @@ public:
~GetLastTransactionsScript() override; ~GetLastTransactionsScript() override;
signals: signals:
// Испускается на КАЖДУЮ найденную транзакцию из списка целей. // Испускается, когда транзакция найдена и распарсена с экрана деталей.
// EventHandler использует эти данные напрямую для task_result вместо // EventHandler использует эти данные напрямую для task_result вместо
// повторного чтения из БД. // повторного чтения из БД.
void transactionParsed(const QJsonObject &tx); void transactionParsed(const QJsonObject &tx);
public:
// Снимок глобального кэша найденных транзакций (от EventHandler).
// Ключи: "real_created_at|abs_amount_cents". Транзакции из этого набора
// пропускаются при поиске, чтобы одна и та же TX не подтверждала два задания.
void setFoundCache(const QSet<QString> &cache) { m_foundCache = cache; }
protected: protected:
void doStart() override; void doStart() override;
private: private:
// Single-pass поиск ВСЕХ целей за один проход вниз по "Выписке": на каждом
// экране сверяем видимые транзакции со всем набором оставшихся целей,
// найденные эмитим через transactionParsed и убираем из pending, затем
// пере-сканируем тот же экран. Это убирает N-кратный перескролл (раньше
// каждая цель искалась отдельным searchOne с возвратом к началу списка → при
// многих целях упор в 10-мин watchdog). По завершении в pending остаются
// только ненайденные цели. Прерывание выставляет m_error="Interrupted".
void searchAll(const DeviceInfo &device, int accountId,
QList<QPair<double, QDateTime>> &pending);
// Координаты тапа по табу НИЖНЕГО меню. Сначала пробуем реальные bounds из
// дерева доступности; если узел отсутствует или вырожден ([0,0][0,0] —
// гибридный UI на части устройств, напр. Infinix XOS, не отдаёт bounds
// нижнего бара, хотя он отрисован у самого низа экрана), падаем на геометрию
// (доли ширины/высоты). Возвращает {-1,-1}, если таб неизвестен.
QPoint bottomNavPoint(const QString &label, const DeviceInfo &device);
QString m_deviceId; QString m_deviceId;
const QString m_appCode; const QString m_appCode;
// Список целей: пары (сумма в сомони со знаком, примерное время UTC). const double m_searchAmount; // сумма в сомони (0 = все последние)
const QList<QPair<double, QDateTime>> m_targets; const QDateTime m_searchTime; // примерное время (UTC)
const QString m_materialId; // UUID задачи (для сохранения дампов) const QString m_materialId; // UUID задачи (для сохранения дампов)
QString m_error; QString m_error;
QSet<QString> m_foundCache; // снимок глобального кэша (от EventHandler)
}; };
} // namespace Dushanbe } // namespace Dushanbe

View File

@ -1,8 +1,5 @@
#include "GetProfileInfoScript.h" #include "GetProfileInfoScript.h"
#include <optional>
#include <QElapsedTimer>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QRegularExpression> #include <QRegularExpression>
@ -39,14 +36,9 @@ void GetProfileInfoScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
qDebug() << "[Dushanbe::GetProfileInfo] appVersion=" << app.appVersion
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {
@ -63,45 +55,27 @@ void GetProfileInfoScript::doStart() {
} }
// Всегда тапаем кнопку обновления — баланс на экране может быть закэширован // Всегда тапаем кнопку обновления — баланс на экране может быть закэширован
// и не отражать актуальное состояние счёта. Лоадера у refresh нет, но во // и не отражать актуальное состояние счёта. Лоадера у refresh нет,
// время загрузки родительский блок временно становится content-desc="TJS" // поэтому ждём фиксированно.
// (без даты, без числа) — это надёжный маркер «идёт обновление». Поэтому
// после тапа поллим экран до появления распарсиваемого баланса.
Node refreshBtn = xmlScreenParser.findRefreshButtonOnHomeScreen(); Node refreshBtn = xmlScreenParser.findRefreshButtonOnHomeScreen();
std::optional<double> parsedOpt;
if (refreshBtn.x() > 0) { if (refreshBtn.x() > 0) {
qDebug() << "[Dushanbe::GetProfileInfo] Tapping refresh to get up-to-date balance"; qDebug() << "[Dushanbe::GetProfileInfo] Tapping refresh to get up-to-date balance";
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y()); AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(3000);
constexpr int kPollIntervalMs = 500; xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
constexpr int kPollTimeoutMs = 15000;
QElapsedTimer t;
t.start();
while (true) {
QThread::msleep(kPollIntervalMs);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
parsedOpt = xmlScreenParser.parseBalanceFromHomeScreenOpt();
if (parsedOpt.has_value()) break;
if (t.elapsed() >= kPollTimeoutMs) break;
}
if (!parsedOpt.has_value()) {
m_error = "Balance did not load after refresh: " + m_deviceId + " " + m_appCode;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
} else { } else {
qWarning() << "[Dushanbe::GetProfileInfo] Refresh button not found, parsing as-is"; qWarning() << "[Dushanbe::GetProfileInfo] Refresh button not found, parsing as-is";
parsedOpt = xmlScreenParser.parseBalanceFromHomeScreenOpt();
} }
const double parsedBalance = parsedOpt.value_or(0.0); const double parsedBalance = xmlScreenParser.parseBalanceFromHomeScreen();
qDebug() << "[Dushanbe::GetProfileInfo] Parsed balance after refresh:" << parsedBalance; qDebug() << "[Dushanbe::GetProfileInfo] Parsed balance after refresh:" << parsedBalance;
// Нулевой баланс — валидное состояние счёта (пустой кошелёк), не ошибка. if (parsedBalance <= 0.0) {
// Failure-ветка выше (Balance did not load) ловит реальную проблему — m_error = "Balance is zero after refresh: " + m_deviceId + " " + m_appCode;
// зависший loading, когда блок "TJS\n<дата>" так и не появился. qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
emit finishedWithResult(m_error);
return;
}
BankProfileDAO::updateAmount(app.id, parsedBalance); BankProfileDAO::updateAmount(app.id, parsedBalance);
// У Душанбе все карты сидят на одном счёте, а экран деталей карты // У Душанбе все карты сидят на одном счёте, а экран деталей карты
// отдаёт placeholder "0,00 TJS" — единственный источник правды // отдаёт placeholder "0,00 TJS" — единственный источник правды

View File

@ -41,14 +41,9 @@ void LoginAndCheckAccountsScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
qDebug() << "[Dushanbe::Login] appVersion=" << app.appVersion
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) { if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {

View File

@ -97,48 +97,6 @@ static QString recognizeOperationId(const QByteArray &pngData) {
return {}; return {};
} }
// Координаты кнопки "Подтверждаю" на bottom sheet подтверждения платежа.
// Compose flatten-ит весь sheet в одну accessibility-ноду — отдельного узла
// кнопки в дампе нет, поэтому позицию ищем по скриншоту тремя способами по
// убыванию надёжности:
// 1) цвет — оранжевая sticky-кнопка RGB(255,117,40); не зависит от текста,
// контраста, языка и разрешения (самый стабильный путь);
// 2) OCR "Подтверждаю" — если вдруг тема/цвет кнопки иные;
// 3) геометрия — центр кнопки ≈ 6% высоты экрана над низом sheet'a; считаем
// относительно высоты, а не в абсолютных пикселях, чтобы работать на
// любом разрешении.
// `confirmNode` — нода sheet'a (на весь экран), `height` — высота экрана.
static QPoint locateConfirmButton(const QString &deviceId,
const Node &confirmNode,
int height) {
// Окно поиска — нижняя половина sheet'a, где живёт кнопка. Sheet-нода
// растянута на весь экран, поэтому y1/y2 сами по себе поиск не сужают.
const int yMin = (confirmNode.y1() + confirmNode.y2()) / 2;
const int yMax = confirmNode.y2();
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
// 1) Цвет.
QPoint tap = OcrUtils::findColorButtonCenter(screen, 255, 117, 40, 55, yMin, yMax);
if (tap.x() >= 0) {
qDebug() << "[Dushanbe::PayByCard] confirm button via color at" << tap;
return tap;
}
// 2) OCR.
tap = OcrUtils::findTextCenter(screen, QString::fromUtf8("Подтверждаю"), yMin, yMax);
if (tap.x() >= 0) {
qDebug() << "[Dushanbe::PayByCard] confirm button via OCR at" << tap;
return tap;
}
// 3) Геометрия: кнопка у самого низа sheet'a. Целимся ВНИЗ к y2, а не вверх.
const int sheetH = confirmNode.y2() - confirmNode.y1();
const int offset = qMax(sheetH * 6 / 100, height / 16);
tap = QPoint(confirmNode.x(), confirmNode.y2() - offset);
qDebug() << "[Dushanbe::PayByCard] confirm button geometry fallback at" << tap;
return tap;
}
namespace Dushanbe { namespace Dushanbe {
PayByCardScript::PayByCardScript( PayByCardScript::PayByCardScript(
@ -165,14 +123,9 @@ void PayByCardScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_CARD", device.adbSerial, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_CARD", device.adbSerial, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
qDebug() << "[Dushanbe::PayByCard] appVersion=" << app.appVersion
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {
@ -425,8 +378,23 @@ void PayByCardScript::makePayment(
QString::fromUtf8("Подтверждение платежа"), true); QString::fromUtf8("Подтверждение платежа"), true);
if (!confirmNode.isEmpty() && if (!confirmNode.isEmpty() &&
confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) { confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
// Позицию кнопки ищем: цвет → OCR → геометрия (см. locateConfirmButton). // Compose flatten-ит весь bottom sheet в одну accessibility-кнопку,
const QPoint tap = locateConfirmButton(deviceId, confirmNode, height); // отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
// нижней четверти sheet, выше системного nav-бара).
// Compose отдаёт sheet как одну ноду на весь экран ([0,0][720,1640]),
// поэтому confirmNode.y1/y2 не сужают поиск — OCR сканирует весь
// скриншот и ловит ложные совпадения. Ограничиваем нижней половиной.
const int ocrYMin = (confirmNode.y1() + confirmNode.y2()) / 2;
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
ocrYMin, confirmNode.y2());
if (tap.x() < 0) {
const int tapY = confirmNode.y2() - qMax(150, height / 14);
tap = QPoint(confirmNode.x(), tapY);
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
}
qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap; qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap;
AdbUtils::makeTap(deviceId, tap.x(), tap.y()); AdbUtils::makeTap(deviceId, tap.x(), tap.y());
confirmed = true; confirmed = true;
@ -514,9 +482,20 @@ void PayByCardScript::makePayment(
++confirmRetries; ++confirmRetries;
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap" qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap"
<< confirmRetries << "/" << maxConfirmRetries; << confirmRetries << "/" << maxConfirmRetries;
// Та же логика, что и на первом тапе: цвет → OCR → геометрия. const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
const QPoint tap = locateConfirmButton(deviceId, retryConfirm, height); const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
qDebug() << "[Dushanbe::PayByCard] Re-tapping 'Подтверждаю' at" << tap; QPoint tap = OcrUtils::findTextCenter(screen,
QString::fromUtf8("Подтверждаю"),
ocrYMin, retryConfirm.y2());
if (tap.x() < 0) {
// Варьируем Y: первый retry — выше fallback на 100px,
// второй — ещё на 100px выше. Compose-кнопка действия
// обычно на y2 - 150..350; перебираем диапазон.
const int extra = 100 * confirmRetries;
const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra);
tap = QPoint(retryConfirm.x(), tapY);
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
}
AdbUtils::makeTap(deviceId, tap.x(), tap.y()); AdbUtils::makeTap(deviceId, tap.x(), tap.y());
QThread::msleep(4000); QThread::msleep(4000);
continue; continue;

View File

@ -120,14 +120,9 @@ void PayByPhoneScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_PHONE", device.adbSerial, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_PHONE", device.adbSerial, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
qDebug() << "[Dushanbe::PayByPhone] appVersion=" << app.appVersion
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {

View File

@ -164,54 +164,31 @@ Node ScreenXmlParser::findPinCodeEditText() {
} }
bool ScreenXmlParser::isHomeScreen() { bool ScreenXmlParser::isHomeScreen() {
// Нижняя навигация (Главная + соседняя вкладка) присутствует на ВСЕХ
// основных вкладках — Главная, История, Переводы, MiniApps — поэтому одного
// таб-бара недостаточно: экран «История/Операции» иначе ошибочно
// принимается за home, и скрипт пытается открыть боковое меню/листать не с
// той страницы (см. «Side menu not detected»). Требуем дополнительно
// уникальный для главной якорь и явно отсекаем экран истории.
bool hasGlavnaya = false; bool hasGlavnaya = false;
bool hasBottomNav = false; bool hasBottomNav = false;
bool hasHomeAnchor = false;
bool isHistoryScreen = false;
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
const QString &d = node.contentDesc; if (node.contentDesc.contains(QString::fromUtf8("Главная"))) {
if (d.contains(QString::fromUtf8("Главная"))) {
hasGlavnaya = true; hasGlavnaya = true;
} }
// Нижняя навигация — любая из соседних вкладок указывает, что мы на // Нижняя навигация — любая из соседних вкладок указывает, что мы на
// основной странице приложения, а не на splash/PIN/диалоге. // основной странице приложения, а не на splash/PIN/диалоге.
if (d == QString::fromUtf8("Переводы") || if (node.contentDesc == QString::fromUtf8("Переводы") ||
d == QString::fromUtf8("Сервисы") || node.contentDesc == QString::fromUtf8("Сервисы") ||
d == QString::fromUtf8("MiniApps") || node.contentDesc == QString::fromUtf8("MiniApps") ||
d == QString::fromUtf8("История")) { node.contentDesc == QString::fromUtf8("История")) {
hasBottomNav = true; hasBottomNav = true;
} }
// Уникальные для главного экрана элементы (нет на Истории/Переводах): if (hasGlavnaya && hasBottomNav) {
// заголовок «Мини приложения», плитки переводов «DC по номеру …» return true;
// (в 3.2.38 — «DC (по номеру …)») и карточка баланса «TJS\n<дата>».
if (d == QString::fromUtf8("Мини приложения") ||
d.startsWith(QString::fromUtf8("DC по номеру")) ||
d.startsWith(QString::fromUtf8("DC (по номеру")) ||
(d.startsWith(QStringLiteral("TJS")) && d.contains('\n'))) {
hasHomeAnchor = true;
}
// Маркеры экрана «История/Операции» — на главном их нет.
if (d == QString::fromUtf8("Выписка") ||
d == QString::fromUtf8("Операции")) {
isHistoryScreen = true;
} }
} }
return hasGlavnaya && hasBottomNav && hasHomeAnchor && !isHistoryScreen; return false;
} }
std::optional<double> ScreenXmlParser::parseBalanceFromHomeScreenOpt() { double ScreenXmlParser::parseBalanceFromHomeScreen() {
// На домашнем экране: parent node content-desc="TJS\n03.04.26 - 21:46:15" // На домашнем экране: parent node content-desc="TJS\n03.04.26 - 21:46:15"
// внутри него child node content-desc="0.00" (или "10.00") // внутри него child node content-desc="0.00" (или "10.00")
// Стратегия: находим индекс parent, затем child сразу после него с числовым content-desc. // Стратегия: находим индекс parent, затем child сразу после него с числовым content-desc
// Во время refresh-загрузки parent временно превращается в content-desc="TJS" (без даты)
// и числового child нет — в этом случае возвращаем nullopt, чтобы вызывающий
// код мог отличить состояние загрузки от реального нулевого баланса.
for (int i = 0; i < m_nodes.size(); ++i) { for (int i = 0; i < m_nodes.size(); ++i) {
if (!m_nodes[i].contentDesc.startsWith("TJS")) continue; if (!m_nodes[i].contentDesc.startsWith("TJS")) continue;
// Проверяем что это блок с датой (не просто "TJS" текст) // Проверяем что это блок с датой (не просто "TJS" текст)
@ -228,11 +205,7 @@ std::optional<double> ScreenXmlParser::parseBalanceFromHomeScreenOpt() {
if (ok) return val; if (ok) return val;
} }
} }
return std::nullopt; return 0.0;
}
double ScreenXmlParser::parseBalanceFromHomeScreen() {
return parseBalanceFromHomeScreenOpt().value_or(0.0);
} }
QString ScreenXmlParser::parseUserNameFromHomeScreen() { QString ScreenXmlParser::parseUserNameFromHomeScreen() {

View File

@ -1,12 +1,9 @@
#pragma once #pragma once
#include <optional>
#include <QMap> #include <QMap>
#include <QObject> #include <QObject>
#include <QDomElement> #include <QDomElement>
#include "android/xml/Node.h" #include "android/xml/Node.h"
#include "AppVersionTrack.h"
#include "db/MaterialInfo.h" #include "db/MaterialInfo.h"
namespace Dushanbe { namespace Dushanbe {
@ -21,14 +18,6 @@ public:
void parseAndSaveXml(const QString &xml); void parseAndSaveXml(const QString &xml);
// Версионный трек приложения, под которое сейчас идёт парсинг. Скрипт
// выставляет его один раз после загрузки BankProfileInfo:
// parser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
// Версионно-зависимые методы парсинга используют m_track как явный шов
// (см. AppVersionTrack.h). По умолчанию — новейший известный формат.
void setVersionTrack(AppVersionTrack track) { m_track = track; }
AppVersionTrack versionTrack() const { return m_track; }
const QList<Node> &findAllNodes() const; const QList<Node> &findAllNodes() const;
Node findTextNode(const QString &text); Node findTextNode(const QString &text);
@ -48,20 +37,9 @@ public:
bool isHomeScreen(); bool isHomeScreen();
// Парсит баланс с домашнего экрана. Возвращает 0.0, если блок "TJS\n<дата>" // Парсит баланс с домашнего экрана
// не найден или внутри него нет числовой ноды (это может быть как реально
// нулевой баланс, так и loading state сразу после тапа refresh). Для
// различения этих случаев используйте parseBalanceFromHomeScreenOpt().
double parseBalanceFromHomeScreen(); double parseBalanceFromHomeScreen();
// То же, что parseBalanceFromHomeScreen, но возвращает std::nullopt, когда
// экран в состоянии загрузки (родитель имеет content-desc "TJS" без даты,
// либо блок "TJS\n<дата>" найден, но числовая нода рядом отсутствует) или
// когда мы вообще не на home screen. При nullopt вызывающий код может
// подождать и попробовать снова — это безопаснее, чем интерпретировать
// loading как реальный нулевой баланс.
std::optional<double> parseBalanceFromHomeScreenOpt();
// Находит кнопку обновления данных в блоке "TJS\n<дата>" — правый clickable // Находит кнопку обновления данных в блоке "TJS\n<дата>" — правый clickable
// ImageView (рядом слева сидит глазик-Button, его не трогаем). // ImageView (рядом слева сидит глазик-Button, его не трогаем).
Node findRefreshButtonOnHomeScreen(); Node findRefreshButtonOnHomeScreen();
@ -182,7 +160,6 @@ public:
private: private:
QList<Node> m_nodes; QList<Node> m_nodes;
QDomElement m_xml; QDomElement m_xml;
AppVersionTrack m_track = AppVersionTrack::V3_2_38;
}; };
} // namespace Dushanbe } // namespace Dushanbe

View File

@ -202,89 +202,4 @@ QPoint findTextCenter(const QByteArray &png,
return result; return result;
} }
QPoint findColorButtonCenter(const QByteArray &png,
int targetR,
int targetG,
int targetB,
int tolerance,
int yMin,
int yMax) {
if (png.isEmpty()) return {-1, -1};
QElapsedTimer timer;
timer.start();
Pix *src = pixReadMem(reinterpret_cast<const l_uint8 *>(png.constData()),
static_cast<size_t>(png.size()));
if (!src) {
qWarning() << "[OcrUtils] findColorButtonCenter: pixReadMem failed";
return {-1, -1};
}
// Приводим к 32bpp, чтобы pixGetRGBPixel работал независимо от исходной
// глубины (PNG со скриншота бывает 24/32 bpp).
Pix *pix = pixConvertTo32(src);
pixDestroy(&src);
if (!pix) return {-1, -1};
const int W = pixGetWidth(pix);
const int H = pixGetHeight(pix);
const int y0 = qBound(0, yMin, H);
const int y1 = (yMax > 0 && yMax <= H) ? yMax : H;
// Для каждой строки считаем число пикселей целевого цвета и их X-диапазон.
QVector<int> rowCount(H, 0), rowMinX(H, -1), rowMaxX(H, -1);
for (int y = y0; y < y1; ++y) {
int cnt = 0, mn = W, mx = -1;
for (int x = 0; x < W; ++x) {
l_int32 r, g, b;
pixGetRGBPixel(pix, x, y, &r, &g, &b);
if (qAbs(r - targetR) <= tolerance &&
qAbs(g - targetG) <= tolerance &&
qAbs(b - targetB) <= tolerance) {
++cnt;
if (x < mn) mn = x;
if (x > mx) mx = x;
}
}
rowCount[y] = cnt;
rowMinX[y] = mn;
rowMaxX[y] = mx;
}
// Кнопка — широкая сплошная полоса: ищем самую длинную серию строк, где
// заполнение целевым цветом ≥ 25% ширины. Мелкие цветные иконки в статус-
// баре такой порог не проходят.
const int minRowFill = qMax(1, W / 4);
int bestStart = -1, bestLen = 0, curStart = -1;
for (int y = y0; y <= y1; ++y) {
const bool filled = (y < y1) && (rowCount[y] >= minRowFill);
if (filled) {
if (curStart < 0) curStart = y;
} else if (curStart >= 0) {
const int len = y - curStart;
if (len > bestLen) { bestLen = len; bestStart = curStart; }
curStart = -1;
}
}
// Полоса должна быть не тоньше ~1% высоты — отсекаем шум/тонкие разделители.
if (bestStart < 0 || bestLen < qMax(8, H / 100)) {
pixDestroy(&pix);
qDebug() << "[OcrUtils] findColorButtonCenter: no button band in"
<< timer.elapsed() << "ms";
return {-1, -1};
}
int xmin = W, xmax = -1;
for (int y = bestStart; y < bestStart + bestLen; ++y) {
if (rowMinX[y] >= 0) { xmin = qMin(xmin, rowMinX[y]); xmax = qMax(xmax, rowMaxX[y]); }
}
pixDestroy(&pix);
const QPoint center((xmin + xmax) / 2, bestStart + bestLen / 2);
qDebug() << "[OcrUtils] findColorButtonCenter → (" << center.x() << ","
<< center.y() << ") band h=" << bestLen << "in" << timer.elapsed() << "ms";
return center;
}
} // namespace OcrUtils } // namespace OcrUtils

View File

@ -19,25 +19,4 @@ QPoint findTextCenter(const QByteArray &png,
int yMin = 0, int yMin = 0,
int yMax = 0); int yMax = 0);
// Находит крупную сплошную кнопку заданного цвета (по умолчанию — оранжевая
// sticky-action кнопка Dushanbe "Подтверждаю"/"Оплатить", RGB(255,117,40)) и
// возвращает центр её bounding box в координатах экрана (= пиксели скриншота).
//
// В отличие от findTextCenter не зависит от текста, контраста и языка: OCR на
// белом-по-оранжевому ненадёжен, а сплошной цветной прямоугольник ловится
// стабильно. Алгоритм — поиск самой длинной вертикальной полосы строк, где
// доля пикселей целевого цвета превышает порог по ширине; это отсекает мелкие
// цветные иконки в статус-баре и ловит именно широкую кнопку. Координаты
// относительные по построению, поэтому работает на любом разрешении.
//
// `yMin`/`yMax` — необязательное окно по Y (координаты экрана), 0/0 — весь
// кадр. Возвращает QPoint(-1, -1), если подходящей полосы не найдено.
QPoint findColorButtonCenter(const QByteArray &png,
int targetR = 255,
int targetG = 117,
int targetB = 40,
int tolerance = 55,
int yMin = 0,
int yMax = 0);
} // namespace OcrUtils } // namespace OcrUtils

View File

@ -1,79 +0,0 @@
#pragma once
#include <QString>
#include <QVersionNumber>
namespace Ozon {
// «Трек» формата UI приложения Ozon Банк. Между версиями менялся способ, которым
// uiautomator отдаёт интерактивные элементы: старый UI отдавал видимый text у кнопок
// и вкладок, новый (Compose-семантика, наблюдается на 19.19.1) — пустой text и
// стабильные тест-id в content-desc (например transfer-button-shared, replenish-button,
// "key 1" у пин-пада, Главная|FINANCE_TAB1). Трек — нормализованный ярлык версии, по
// которому код выбирает версионно-зависимую ветку парсинга (см. ScreenXmlParser::setVersionTrack).
//
// На момент введения каркаса детекторы уже сделаны обратносовместимыми (матч сначала по
// text, затем fallback на content-desc), поэтому отдельных веток по треку ещё нет — трек
// служит явным швом для будущих расхождений и читаемым маркером в логах/дампах.
//
// ─────────────────────────────────────────────────────────────────────────
// КАК ДОБАВЛЯТЬ НОВУЮ ВЕРСИЮ ПРИЛОЖЕНИЯ
// ─────────────────────────────────────────────────────────────────────────
// Отдельные скрипты на каждую версию НЕ нужны — по умолчанию все версии обслуживает
// один общий комплект скриптов. Выбирайте по объёму изменений:
//
// 1) Формат экранов не изменился (косметика, переименование в той же структуре).
// → НИЧЕГО не делаем. Версия новее последнего порога автоматически трактуется как
// новейший трек (см. trackForVersion). Опционально — эталонные дампы в
// assets/ozon/<версия>/ для сверки.
//
// 2) Изменился один-два экрана.
// → Добавляем элемент в enum (например V20_0) и НОВЫЙ порог в trackForVersion().
// В методе, который парсит изменившийся экран, ставим точечную ветку:
// if (m_track >= AppVersionTrack::V20_0) { …новый формат… }
// else { …существующий… }
// Скрипты остаются ОБЩИМИ. Старые ветки (Legacy/19_19_1) не удаляем — в поле
// могут оставаться старые версии. Версионно-зависимые точки сегодня:
// ScreenXmlParser (isHomeScreen / findPinCodeNodes — кнопки и пин-пад в content-desc),
// PayBy{Card,Phone}Script (домашняя «Перевести» = transfer-button-shared).
//
// 3) Версия разошлась радикально (другой флоу почти везде) — КРАЙНИЙ случай.
// → Только тогда выносим версию в отдельный класс скрипта и выбираем его по треку
// в EventHandler. Точка ветвления (app.appVersion → trackForVersion) уже есть.
//
// Значения enum упорядочены по возрастанию версии — можно сравнивать через <, <=, >, >=.
enum class AppVersionTrack {
Legacy, // < 19.19.1 — старый UI: подписи кнопок/вкладок в видимом text
V19_19_1, // >= 19.19.1 — Compose-семантика: тест-id в content-desc (и новейший по умолчанию)
};
// Резолвит трек из строки версии (например "19.19.1", полученной через
// AdbUtils::getAppVersion → BankProfileInfo.appVersion). Сравнение — через
// QVersionNumber, а не лексикографически по строке.
//
// Неизвестная/непарсящаяся версия, а также любая версия новее последнего известного
// порога, трактуется как новейший известный трек (V19_19_1): свежие апдейты по умолчанию
// идут по последнему формату, а не падают на Legacy.
inline AppVersionTrack trackForVersion(const QString &appVersion) {
static const QVersionNumber kV19_19_1(19, 19, 1);
qsizetype suffixIndex = 0;
const QVersionNumber v = QVersionNumber::fromString(appVersion.trimmed(), &suffixIndex);
if (v.isNull()) {
// Не смогли распарсить — считаем новейшим известным форматом.
return AppVersionTrack::V19_19_1;
}
if (v < kV19_19_1) return AppVersionTrack::Legacy;
return AppVersionTrack::V19_19_1;
}
// Человекочитаемое имя трека для логов.
inline const char *trackName(AppVersionTrack track) {
switch (track) {
case AppVersionTrack::Legacy: return "Legacy(<19.19.1)";
case AppVersionTrack::V19_19_1: return "19.19.1";
}
return "unknown";
}
} // namespace Ozon

View File

@ -179,14 +179,6 @@ bool CommonScript::runAppAndGoToHomeScreen(
continue; continue;
} }
// Проверяем на онбординг-баннер "Обновили главную" поверх главной
if (Node onboardingNode = xmlScreenParser.tryToFindOnboardingBanner(); !onboardingNode.isEmpty()) {
qDebug() << "[runAppAndGoToHomeScreen] Onboarding banner detected, tapping 'Пропустить'...";
AdbUtils::makeTap(deviceId, onboardingNode.x(), onboardingNode.y());
QThread::msleep(1500);
continue;
}
// Проверяем, что это экран ввода пин-кода // Проверяем, что это экран ввода пин-кода
if (!findAndInputPincode) { if (!findAndInputPincode) {
const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
@ -233,12 +225,6 @@ bool CommonScript::tryToCloseAllBannersOnHomePage(const QString &deviceId, const
closedAny = true; closedAny = true;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
continue; continue;
} else if (Node onboardingNode = xmlScreenParser.tryToFindOnboardingBanner(); !onboardingNode.isEmpty()) {
// Онбординг "Обновили главную" поверх главной — жмём "Пропустить"
qDebug() << "[tryToCloseAllBannersOnHomePage] Onboarding banner detected, tapping 'Пропустить'...";
AdbUtils::makeTap(deviceId, onboardingNode.x(), onboardingNode.y());
QThread::msleep(1500);
closedThis = true;
} else if (Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height); } else if (Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) { closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y()); AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
@ -466,12 +452,7 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) { if (afterPost.bankProfileId.isEmpty()) {
QString err; const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) {
err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage();
} else {
err = "postBankProfile failed for " + deviceId + " " + appCode;
}
qWarning() << "[Ozon] createBankProfileIfNeeded:" << err; qWarning() << "[Ozon] createBankProfileIfNeeded:" << err;
return err; return err;
} }

View File

@ -13,7 +13,6 @@
#include "dao/SettingsDAO.h" #include "dao/SettingsDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "net/MaterialPayload.h"
#include "AppLogger.h" #include "AppLogger.h"
namespace Ozon { namespace Ozon {
@ -42,14 +41,9 @@ void GetCardInfoScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
qDebug() << "[Ozon::GetCardInfo] appVersion=" << app.appVersion
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
// Читаем текущий экран // Читаем текущий экран
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
@ -240,12 +234,7 @@ void GetCardInfoScript::doStart() {
// Проверяем что bankProfileId сохранился // Проверяем что bankProfileId сохранился
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
if (afterPost.bankProfileId.isEmpty()) { if (afterPost.bankProfileId.isEmpty()) {
QString err; const QString err = "postBankProfile failed for " + m_deviceId;
if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) {
err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage();
} else {
err = "postBankProfile failed for " + m_deviceId;
}
qWarning() << "[GetCardInfo]" << err; qWarning() << "[GetCardInfo]" << err;
AppLogger::log("ozon/GetCardInfo", m_deviceId, err, AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId), AdbUtils::captureScreenshotBytes(m_deviceId),
@ -285,50 +274,13 @@ void GetCardInfoScript::doStart() {
continue; continue;
} }
// Открываем экран нужной карты. AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
if (xmlScreenParser.versionTrack() >= AppVersionTrack::V19_19_1) {
// Новый UI: тап по НИЖНЕЙ (чиповой) части тайла "Основной счёт" открывает экран
// счёта с РАЗДЕЛЬНЫМИ кнопками карт ("Bank Card NNNN"). Тапаем кнопку нужной
// карты — открывается именно она. (Тап в центр тайла открывал бы переднюю карту
// карусели, и для всех материалов читался номер одной карты.)
const int accY = cardBtn.y2() - cardBtn.height() / 5; // зона чипов карт внизу тайла
qDebug() << "[GetCardInfo] Opening account screen via tile bottom at" << cardBtn.x() << accY;
AdbUtils::makeTap(m_deviceId, cardBtn.x(), accY);
Node cardTile; // Скроллим WebView вверх, пока "Показать" не окажется в верхней трети экрана.
for (int a = 0; a < 8; ++a) { // На устройствах с короткой высотой (например, SM-A165F, h=2340) кнопка "Показать"
QThread::msleep(a == 0 ? 1500 : 700); // находится за bottom navigation, и тап уходит в нав-бар.
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); const int targetTopY = device.screenHeight / 3;
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); for (int scrollAttempt = 0; scrollAttempt < 6; ++scrollAttempt) {
cardTile = xmlScreenParser.findAccountCardButton(card.lastNumbers);
if (!cardTile.isEmpty()) break;
qDebug() << "[GetCardInfo] Waiting account card button" << card.lastNumbers
<< "attempt" << a + 1 << "/ 8";
}
if (!cardTile.isEmpty()) {
qDebug() << "[GetCardInfo] Tapping account card button" << card.lastNumbers
<< "at" << cardTile.x() << cardTile.y();
AdbUtils::makeTap(m_deviceId, cardTile.x(), cardTile.y());
} else {
qWarning() << "[GetCardInfo] Account card button not found for" << card.lastNumbers
<< "— fallback to tile center tap";
AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
}
} else {
// Старый UI: тап по тайлу открывает карту напрямую.
AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
}
// Подводим "Показать" в зону тапа [minTapY, midY]: ниже статус-бара/тулбара и
// ВЫШЕ середины экрана (после раскрытия номер/срок/CVV появляются ниже кнопки —
// ей нужно место снизу). Свайпы делаем МЕДЛЕННЫМИ (durationMs), иначе быстрый
// ф_линг перелистывает "Показать" под самый верх (y≈3, под статус-бар) и тап мимо.
// - cy > midY → кнопка ниже середины (или за нижней навигацией): листаем вверх.
// - cy < minTapY → перелистнули под статус-бар: листаем обратно вниз.
const int minTapY = device.screenHeight / 8;
const int midY = device.screenHeight / 2;
const int swipeMs = 700;
for (int scrollAttempt = 0; scrollAttempt < 8; ++scrollAttempt) {
QThread::msleep(scrollAttempt == 0 ? 2000 : 600); QThread::msleep(scrollAttempt == 0 ? 2000 : 600);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -344,24 +296,15 @@ void GetCardInfoScript::doStart() {
} }
continue; continue;
} }
const int cy = candidate.y(); if (candidate.y() <= targetTopY) {
if (cy >= minTapY && cy <= midY) { qDebug() << "[GetCardInfo] 'Показать' already at top y=" << candidate.y();
qDebug() << "[GetCardInfo] 'Показать' in tap zone (above middle) y=" << cy;
break; break;
} }
if (cy > midY) { const int swipeFromY = candidate.y();
// Ниже середины — листаем вверх, целясь в верхнюю треть (медленно, без перелёта). const int swipeToY = device.screenHeight / 6;
const int swipeToY = device.screenHeight / 3; qDebug() << "[GetCardInfo] Scrolling 'Показать' up:" << swipeFromY << "->" << swipeToY;
qDebug() << "[GetCardInfo] Scrolling 'Показать' up:" << cy << "->" << swipeToY; AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, swipeFromY,
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, cy, device.screenWidth / 2, swipeToY);
device.screenWidth / 2, swipeToY, swipeMs);
} else {
// Перелистнули под статус-бар (y≈3) — листаем обратно вниз в верхнюю треть.
const int swipeToY = device.screenHeight / 3;
qDebug() << "[GetCardInfo] 'Показать' overshot to y=" << cy << ", scrolling down to" << swipeToY;
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, device.screenHeight / 8,
device.screenWidth / 2, swipeToY, swipeMs);
}
} }
// Ждём экран карты и стабильной геометрии блока реквизитов. // Ждём экран карты и стабильной геометрии блока реквизитов.
@ -468,16 +411,7 @@ void GetCardInfoScript::doStart() {
} }
} }
if (!cardNumber.isEmpty() && !cardNumber.endsWith(card.lastNumbers)) { if (!cardNumber.isEmpty()) {
// Прочитан номер ЧУЖОЙ карты (карусель отдала не ту) — НЕ сохраняем, иначе
// материал получит неверный номер. Раньше из-за этого все карты получали
// номер передней карты карусели.
const QString err = "Card number " + cardNumber + " does not end with target "
+ card.lastNumbers + " — skipping save";
qWarning() << "[GetCardInfo]" << err;
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId), "FETCH_PROFILE");
} else if (!cardNumber.isEmpty()) {
qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "full number:" << cardNumber; qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "full number:" << cardNumber;
// Апсёртим запись только теперь — с полным номером карты. // Апсёртим запись только теперь — с полным номером карты.
MaterialInfo toSave = card; MaterialInfo toSave = card;
@ -498,16 +432,12 @@ void GetCardInfoScript::doStart() {
"FETCH_PROFILE"); "FETCH_PROFILE");
} }
// Возвращаемся на домашний экран для следующей карты. В новом UI путь длиннее // Возвращаемся на домашний экран для следующей карты
// (карта → "Основной счёт" → главная), поэтому жмём "назад" до главной (макс 3).
if (i < cards.size() - 1) { if (i < cards.size() - 1) {
for (int b = 0; b < 3; ++b) { AdbUtils::goBack(m_deviceId);
AdbUtils::goBack(m_deviceId); QThread::msleep(2000);
QThread::msleep(1500); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isHomeScreen()) break;
}
} }
} }
@ -611,30 +541,31 @@ void GetCardInfoScript::doStart() {
// } // }
// } // }
// 3. Отправляем материалы (карты) на сервер. // 3. Отправляем материалы (карты) — после OCR, когда номера карт уже в БД
// Инвариант: одновременно живёт максимум один материал на (deviceId, appCode) —
// тот, что локально active. Пушим только active-карту и только если другой
// материал ещё не синхронизирован (нет с непустым materialId).
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
if (!updatedApp.bankProfileId.isEmpty()) { if (!updatedApp.bankProfileId.isEmpty()) {
const QList<MaterialInfo> dbCards = MaterialDAO::getAccounts(device.id, m_appCode); for (const MaterialInfo &card : cards) {
bool alreadySynced = false; const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
for (const MaterialInfo &m : dbCards) { if (dbCard.cardNumber.isEmpty()) continue;
if (!m.materialId.isEmpty()) { alreadySynced = true; break; } if (!dbCard.materialId.isEmpty()) {
} qDebug() << "[GetCardInfo] material already exists for card" << dbCard.lastNumbers << ", skipping";
if (alreadySynced) { continue;
qDebug() << "[GetCardInfo] another material already synced, skipping POST for app" << m_appCode;
} else {
for (const MaterialInfo &card : cards) {
const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (dbCard.cardNumber.isEmpty()) continue;
if (dbCard.status != MaterialStatus::Active) continue;
ns.setPayload(buildMaterialPayload(dbCard, updatedApp));
ns.addExtra("account_id", dbCard.id);
ns.postMaterial();
qDebug() << "[GetCardInfo] postMaterial done for active card" << dbCard.lastNumbers;
break; // не больше одного материала на сервер
} }
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
QJsonObject materialBody;
materialBody["bank_profile_id"] = updatedApp.bankProfileId;
materialBody["card_number"] = dbCard.cardNumber;
materialBody["account_number"] = dbCard.accountNumber;
materialBody["name"] = updatedApp.fullName;
materialBody["state"] = materialStatusToString(dbCard.status) == "active" ? "enabled" : "disabled";
materialBody["currency"] = currencyCodes.value(dbCard.currency.toUpper(), 643);
ns.setPayload(QJsonDocument(materialBody).toJson());
ns.addExtra("account_id", dbCard.id);
ns.postMaterial();
qDebug() << "[GetCardInfo] postMaterial done for card" << dbCard.lastNumbers;
} }
} else { } else {
qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials"; qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials";

View File

@ -82,14 +82,16 @@ GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId, QString deviceId,
QString appCode, QString appCode,
int maxCount, int maxCount,
QList<QPair<double, QDateTime>> targets, double searchAmount,
QDateTime searchTime,
QString materialId, QString materialId,
QObject *parent QObject *parent
) : CommonScript(parent), ) : CommonScript(parent),
m_deviceId(std::move(deviceId)), m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)), m_appCode(std::move(appCode)),
m_maxCount(maxCount), m_maxCount(maxCount),
m_targets(std::move(targets)), m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)),
m_materialId(std::move(materialId)) { m_materialId(std::move(materialId)) {
} }
@ -108,15 +110,9 @@ void GetLastTransactionsScript::doStart() {
return; return;
} }
const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first; const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
const TaskDumpMeta dumpMeta{m_materialId, firstAmount, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
qDebug() << "[Ozon::GetLastTransactions] appVersion=" << app.appVersion
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
if (!navigateToTransactionList(device, app)) { if (!navigateToTransactionList(device, app)) {
emit finishedWithResult(m_error); emit finishedWithResult(m_error);
return; return;
@ -131,31 +127,9 @@ void GetLastTransactionsScript::doStart() {
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode; qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
} }
// Выбор режима: поиск списка конкретных транзакций или сканирование последних. // Выбор режима: поиск конкретной транзакции или сканирование последних
if (!m_targets.isEmpty()) { if (qAbs(m_searchAmount) > 0.01) {
// Ищем каждую цель из списка. Найденные отдаём сразу через searchTransaction(device, accountId);
// transactionParsed; ненайденные копим и репортим одной ошибкой в конце.
QStringList notFound;
for (int i = 0; i < m_targets.size(); ++i) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
emit finishedWithResult(m_error);
return;
}
const double amount = m_targets[i].first;
const QDateTime time = m_targets[i].second;
// Перед каждой целью (кроме первой) возвращаемся к началу списка,
// т.к. предыдущий поиск мог проскроллить вниз к другой дате.
if (i > 0) scrollListToTop(device);
if (!searchOne(device, accountId, amount, time)) {
notFound << QString("amount=%1 time=%2")
.arg(QString::number(amount, 'f', 2), time.toString(Qt::ISODate));
}
}
m_error = notFound.isEmpty()
? QString()
: QString("Transactions not found (%1/%2): %3")
.arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; "));
} else { } else {
scanRecentTransactions(device, accountId); scanRecentTransactions(device, accountId);
} }
@ -268,9 +242,8 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
return false; return false;
} }
bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountId, void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
double searchAmount, const QDateTime &searchTime) { // Скриншот списка транзакций — отправим при ошибке, удалим при успехе
// Скриншот списка транзакций — отправим при неудаче, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Время в Ozon показывается в таймзоне устройства, а не в MSK // Время в Ozon показывается в таймзоне устройства, а не в MSK
@ -293,12 +266,12 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
int navBarTop = detectNavBarTop(); int navBarTop = detectNavBarTop();
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке // Целевая дата в таймзоне устройства для поиска заголовка дня в списке
const QDateTime searchLocal = searchTime.toTimeZone(deviceTz); const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
const QDate searchDate = searchLocal.date(); const QDate searchDate = searchLocal.date();
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
<< "amount=" << searchAmount << "amount=" << m_searchAmount
<< "time=" << searchTime.toString(Qt::ISODate) << "time=" << m_searchTime.toString(Qt::ISODate)
<< "tz=" << tzName << "tz=" << tzName
<< "date(local)=" << searchDate.toString("dd.MM.yyyy") << "date(local)=" << searchDate.toString("dd.MM.yyyy")
<< "screen=" << device.screenWidth << "x" << device.screenHeight; << "screen=" << device.screenWidth << "x" << device.screenHeight;
@ -375,7 +348,7 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
for (int scroll = 0; scroll < maxScrolls; ++scroll) { for (int scroll = 0; scroll < maxScrolls; ++scroll) {
if (QThread::currentThread()->isInterruptionRequested()) { if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted"; m_error = "Interrupted";
return false; return;
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -396,12 +369,12 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
} }
if (!dateFound) { if (!dateFound) {
qWarning() << "[FetchTransactions] Date not found in transaction list: " m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
<< searchDate.toString("dd.MM.yyyy"); qWarning() << "[FetchTransactions]" << m_error;
m_resultScreenshot = listScreenshot.isEmpty() m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot; ? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return false; return;
} }
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране. // Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
@ -463,7 +436,7 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
for (int attempt = 0; attempt < 15; ++attempt) { for (int attempt = 0; attempt < 15; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) { if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted"; m_error = "Interrupted";
return false; return;
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -530,7 +503,7 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
for (int bi = 0; bi < txButtons.size(); ++bi) { for (int bi = 0; bi < txButtons.size(); ++bi) {
if (QThread::currentThread()->isInterruptionRequested()) { if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted"; m_error = "Interrupted";
return false; return;
} }
// Пропускаем кнопки вне Y-диапазона нужной даты. // Пропускаем кнопки вне Y-диапазона нужной даты.
@ -544,9 +517,9 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text); const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
<< "parsed amount=" << parsed.amount << "search=" << searchAmount << "parsed amount=" << parsed.amount << "search=" << m_searchAmount
<< "text=" << txButtons[bi].text.left(60); << "text=" << txButtons[bi].text.left(60);
if (qAbs(parsed.amount - searchAmount) > 0.01) continue; if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
const Node &visibleBtn = txButtons[bi]; const Node &visibleBtn = txButtons[bi];
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под // Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
@ -570,7 +543,7 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
for (int da = 0; da < 10; ++da) { for (int da = 0; da < 10; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) { if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted"; m_error = "Interrupted";
return false; return;
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -623,10 +596,10 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
} }
} }
// Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+10м, одностороннее окно) // Проверяем время (±10 минут)
if (txTime.isValid() && searchTime.isValid()) { if (txTime.isValid() && m_searchTime.isValid()) {
const qint64 diffSecs = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs < 0 || diffSecs > 600) { if (diffSecs > 600) {
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm") << "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
<< "diff=" << diffSecs << "sec"; << "diff=" << diffSecs << "sec";
@ -641,20 +614,6 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
<< "amount=" << parsed.amount << "time=" << dtStr << "amount=" << parsed.amount << "time=" << dtStr
<< "name=" << detail.name; << "name=" << detail.name;
// Проверяем глобальный кэш: если транзакция уже была найдена
// в рамках другой задачи этого материала — пропускаем её.
if (txTime.isValid()) {
const QString ck = txTime.toUTC().toString(Qt::ISODate)
+ "|" + QString::number(qAbs(static_cast<int>(parsed.amount * 100)));
if (m_foundCache.contains(ck)) {
qDebug() << "[FetchTransactions] TX already found in another task, skip:" << ck;
AdbUtils::goBack(m_deviceId);
QThread::msleep(500);
continue; // следующая кнопка в for(bi) — ищем другую транзакцию
}
m_foundCache.insert(ck);
}
// Скриншот экрана деталей транзакции — отдаём в EventHandler. // Скриншот экрана деталей транзакции — отдаём в EventHandler.
// Раньше дополнительно тапали "Документы" + OCR ради ID операции, // Раньше дополнительно тапали "Документы" + OCR ради ID операции,
// но это медленно и нестабильно. Теперь полагаемся на дату+время+ // но это медленно и нестабильно. Теперь полагаемся на дату+время+
@ -663,16 +622,6 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
QString externalId = detail.bankTrExternalId; QString externalId = detail.bankTrExternalId;
// Фолбэк-id (когда реальный ID банка не распарсился) строим из
// created_at задачи — он детерминирован и стабилен между запусками,
// что важно для дедупликации на сервере. Раньше брали распарсенное
// с экрана время (dtStr), которое могло «плавать» из-за OCR.
const QString finalExternalId = externalId.isEmpty()
? QStringLiteral("ozon_") + (searchTime.isValid()
? searchTime.toUTC().toString(Qt::ISODate)
: QString(dtStr).remove(',').trimmed().replace(' ', '_'))
: externalId;
// Сохраняем // Сохраняем
if (accountId != -1) { if (accountId != -1) {
TransactionInfo tx; TransactionInfo tx;
@ -680,7 +629,9 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
tx.amount = parsed.amount; tx.amount = parsed.amount;
tx.bankName = m_appCode; tx.bankName = m_appCode;
tx.bankTime = dtStr; tx.bankTime = dtStr;
tx.bankTrExternalId = finalExternalId; tx.bankTrExternalId = externalId.isEmpty()
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
: externalId;
tx.status = TransactionStatus::Complete; tx.status = TransactionStatus::Complete;
tx.timestamp = DateUtils::getUtcNow(); tx.timestamp = DateUtils::getUtcNow();
if (txTime.isValid()) tx.completeTime = txTime; if (txTime.isValid()) tx.completeTime = txTime;
@ -698,6 +649,10 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
// Отдаём результат в EventHandler через сигнал (как Dushanbe) // Отдаём результат в EventHandler через сигнал (как Dushanbe)
{ {
const QString finalExternalId = externalId.isEmpty()
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
: externalId;
QJsonObject txJson; QJsonObject txJson;
txJson["bank_transaction_id"] = finalExternalId; txJson["bank_transaction_id"] = finalExternalId;
txJson["amount"] = static_cast<int>(parsed.amount * 100); txJson["amount"] = static_cast<int>(parsed.amount * 100);
@ -710,11 +665,7 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
QJsonObject extraObj; QJsonObject extraObj;
extraObj["fee"] = 0; extraObj["fee"] = 0;
txJson["extra"] = extraObj; txJson["extra"] = extraObj;
// created_at — время из задачи (поисковый target), real_created_at — txJson["created_at"] = txTime.isValid()
// фактическое время транзакции, спарсенное с экрана приложения.
txJson["created_at"] = searchTime.isValid()
? searchTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
txJson["real_created_at"] = txTime.isValid()
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null); ? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
qDebug() << "[FetchTransactions] Emitting parsed tx:" qDebug() << "[FetchTransactions] Emitting parsed tx:"
@ -729,7 +680,7 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
QThread::msleep(1000); QThread::msleep(1000);
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);
QThread::msleep(1000); QThread::msleep(1000);
return true; // успех return; // успех
} }
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ // Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
@ -800,30 +751,14 @@ bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountI
} }
} }
// Эту цель не нашли. m_error не трогаем — агрегированную ошибку по всем m_error = "Transaction not found: device=" + m_deviceId
// ненайденным целям соберёт оркестратор в doStart. + " amount=" + QString::number(m_searchAmount, 'f', 2)
qWarning() << "[FetchTransactions] Transaction not found: device=" << m_deviceId + " date=" + searchDate.toString("dd.MM.yyyy");
<< "amount=" << QString::number(searchAmount, 'f', 2) qWarning() << "[FetchTransactions]" << m_error;
<< "date=" << searchDate.toString("dd.MM.yyyy");
// Отправляем скриншот списка транзакций, сделанный до начала поиска // Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty() m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot; ? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return false;
}
void GetLastTransactionsScript::scrollListToTop(const DeviceInfo &device) {
// Несколько быстрых свайпов вверх, чтобы вернуться к началу списка операций.
for (int i = 0; i < 5; ++i) {
if (QThread::currentThread()->isInterruptionRequested()) return;
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 200);
QThread::msleep(400);
}
QThread::msleep(800);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
} }
void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) { void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) {

View File

@ -2,9 +2,6 @@
#include "CommonScript.h" #include "CommonScript.h"
#include <QDateTime> #include <QDateTime>
#include <QJsonObject> #include <QJsonObject>
#include <QList>
#include <QPair>
#include <QSet>
#include "db/DeviceInfo.h" #include "db/DeviceInfo.h"
#include "db/BankProfileInfo.h" #include "db/BankProfileInfo.h"
@ -14,21 +11,15 @@ class GetLastTransactionsScript final : public CommonScript {
Q_OBJECT Q_OBJECT
signals: signals:
// Испускается на КАЖДУЮ найденную транзакцию из списка целей.
void transactionParsed(const QJsonObject &tx); void transactionParsed(const QJsonObject &tx);
public:
// Снимок глобального кэша найденных транзакций (от EventHandler).
// Ключи: "real_created_at|abs_amount_cents". Транзакции из этого набора
// пропускаются при поиске, чтобы одна и та же TX не подтверждала два задания.
void setFoundCache(const QSet<QString> &cache) { m_foundCache = cache; }
public: public:
explicit GetLastTransactionsScript( explicit GetLastTransactionsScript(
QString deviceId, QString deviceId,
QString appCode, QString appCode,
int maxCount = 0, int maxCount = 0,
QList<QPair<double, QDateTime>> targets = {}, double searchAmount = 0,
QDateTime searchTime = {},
QString materialId = {}, QString materialId = {},
QObject *parent = nullptr QObject *parent = nullptr
); );
@ -45,23 +36,16 @@ private:
// Сканирование последних транзакций (для UI: 24ч или maxCount) // Сканирование последних транзакций (для UI: 24ч или maxCount)
void scanRecentTransactions(const DeviceInfo &device, int accountId); void scanRecentTransactions(const DeviceInfo &device, int accountId);
// Поиск одной конкретной транзакции по сумме и времени (FETCH_TRANSACTIONS). // Поиск конкретной транзакции по сумме и времени (для задач FETCH_TRANSACTIONS)
// Возвращает true и эмитит transactionParsed, если нашли; false — если нет. void searchTransaction(const DeviceInfo &device, int accountId);
bool searchOne(const DeviceInfo &device, int accountId,
double searchAmount, const QDateTime &searchTime);
// Быстрый возврат к началу списка операций (между поисками целей)
void scrollListToTop(const DeviceInfo &device);
QString m_deviceId; QString m_deviceId;
const QString m_appCode; const QString m_appCode;
const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних
// Список целей поиска: пары (сумма в рублях со знаком, примерное время UTC). const double m_searchAmount; // сумма в рублях (0 = не искать конкретную)
// Пустой список → режим сканирования последних (scanRecentTransactions). const QDateTime m_searchTime; // примерное время (UTC)
const QList<QPair<double, QDateTime>> m_targets;
const QString m_materialId; // UUID задачи (для сохранения дампов) const QString m_materialId; // UUID задачи (для сохранения дампов)
QString m_error; QString m_error;
QSet<QString> m_foundCache; // снимок глобального кэша (от EventHandler)
}; };
} // namespace Ozon } // namespace Ozon

View File

@ -37,14 +37,9 @@ void GetProfileInfoScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
qDebug() << "[Ozon::GetProfileInfo] appVersion=" << app.appVersion
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
// Читаем текущий экран // Читаем текущий экран
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
@ -174,12 +169,7 @@ void GetProfileInfoScript::doStart() {
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
if (afterPost.bankProfileId.isEmpty()) { if (afterPost.bankProfileId.isEmpty()) {
QString err; const QString err = "postBankProfile failed for " + m_deviceId;
if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) {
err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage();
} else {
err = "postBankProfile failed for " + m_deviceId;
}
qWarning() << "[GetProfileInfo]" << err; qWarning() << "[GetProfileInfo]" << err;
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err, AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId), AdbUtils::captureScreenshotBytes(m_deviceId),

View File

@ -40,14 +40,9 @@ void LoginAndCheckAccountsScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
qDebug() << "[Ozon::LoginAndCheckAccounts] appVersion=" << app.appVersion
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) { if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {

View File

@ -91,14 +91,9 @@ void PayByCardScript::doStart() {
} }
const QString &adbId = device.adbSerial; const QString &adbId = device.adbSerial;
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_CARD", adbId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_CARD", adbId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
qDebug() << "[Ozon::PayByCard] appVersion=" << app.appVersion
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
// Закрываем рекламный bottom sheet // Закрываем рекламный bottom sheet
@ -167,10 +162,6 @@ void PayByCardScript::makePayment(
}; };
// 1. Нажимаем "Перевести" на домашнем экране // 1. Нажимаем "Перевести" на домашнем экране
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false); Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty()) {
// Compose-UI Ozon (>= 19.x): у кнопки пустой text, тест-id в content-desc.
transferBtn = xmlScreenParser.findNodeByContentDesc("transfer-button-shared");
}
if (transferBtn.isEmpty()) { if (transferBtn.isEmpty()) {
m_error = "Кнопка 'Перевести' не найдена на домашнем экране"; m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
qWarning() << "[PayByCard]" << m_error; qWarning() << "[PayByCard]" << m_error;

View File

@ -100,14 +100,9 @@ void PayByPhoneScript::doStart() {
} }
const QString &adbId = device.adbSerial; const QString &adbId = device.adbSerial;
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_PHONE", adbId, dumpMeta, &m_error); TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_PHONE", adbId, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
qDebug() << "[Ozon::PayByPhone] appVersion=" << app.appVersion
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
// Закрываем рекламный bottom sheet // Закрываем рекламный bottom sheet
@ -181,10 +176,6 @@ void PayByPhoneScript::makePayment(
// открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы // открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы
// гарантированно попасть мимо бейджа. // гарантированно попасть мимо бейджа.
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false); Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty()) {
// Compose-UI Ozon (>= 19.x): у кнопки пустой text, тест-id в content-desc.
transferBtn = xmlScreenParser.findNodeByContentDesc("transfer-button-shared");
}
if (transferBtn.isEmpty()) { if (transferBtn.isEmpty()) {
m_error = "Кнопка 'Перевести' не найдена на домашнем экране"; m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
qWarning() << "[PayByPhone]" << m_error; qWarning() << "[PayByPhone]" << m_error;

View File

@ -16,7 +16,6 @@
#include "db/MaterialInfo.h" #include "db/MaterialInfo.h"
#include "db/TransactionInfo.h" #include "db/TransactionInfo.h"
#include "time/DateUtils.h" #include "time/DateUtils.h"
#include "BankConfigUtils.h"
namespace Ozon { namespace Ozon {
@ -257,17 +256,13 @@ Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) {
} }
bool ScreenXmlParser::isHomeScreen() { bool ScreenXmlParser::isHomeScreen() {
// Домашний экран Ozon Банка: одновременно присутствуют кнопки "Перевести" и "Пополнить". // Домашний экран Ozon Банка: одновременно присутствуют кнопки "Перевести" и "Пополнить"
// Старый UI отдавал их видимым text. Compose-сборки Ozon (>= 19.x) text не отдают —
// у кнопок пустой text, а тест-id лежит в content-desc (transfer-button-shared /
// replenish-button). Поддерживаем оба варианта.
bool hasTransfer = false; bool hasTransfer = false;
bool hasTopUp = false; bool hasTopUp = false;
for (const Node &node: m_nodes) { for (const Node &node: m_nodes) {
const QString t = node.text.trimmed(); const QString t = node.text.trimmed();
const QString cd = node.contentDesc.trimmed(); if (t == "Перевести") hasTransfer = true;
if (t == "Перевести" || cd == "transfer-button-shared") hasTransfer = true; if (t == "Пополнить") hasTopUp = true;
if (t == "Пополнить" || cd == "replenish-button") hasTopUp = true;
if (hasTransfer && hasTopUp) return true; if (hasTransfer && hasTopUp) return true;
} }
return false; return false;
@ -328,28 +323,6 @@ Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int
return {}; return {};
} }
Node ScreenXmlParser::tryToFindOnboardingBanner() {
// Онбординг "Обновили главную" рендерится во ViewPager с этим resource-id.
// Кнопка "Пропустить" — TextView (text_atom) внутри кликабельного контейнера,
// занимающего всю ширину верхней полоски. Сам TextView некликабельный, но его
// bounds совпадают с кликабельным родителем, поэтому тап по его центру попадает.
bool hasOnboarding = false;
for (const Node &node : m_nodes) {
if (node.resourceId == "ru.ozon.fintech.finance:id/onboarding_viewpager") {
hasOnboarding = true;
break;
}
}
if (!hasOnboarding) return {};
for (const Node &node : m_nodes) {
if (node.text.trimmed() == QString::fromUtf8("Пропустить")) {
return node;
}
}
return {};
}
bool ScreenXmlParser::isProfileScreen() { bool ScreenXmlParser::isProfileScreen() {
// Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти" // Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти"
bool hasTitle = false; bool hasTitle = false;
@ -454,13 +427,7 @@ Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) {
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button" || !node.clickable) continue; if (node.className != "android.widget.Button" || !node.clickable) continue;
const QString t = node.text.trimmed(); const QString t = node.text.trimmed();
// Старый UI: отдельная кнопка карты оканчивается на 4 цифры. if (!t.endsWith(lastNumbers)) continue;
// Compose-UI (>= 19.x): обе карты в одной плитке "… Bank Card 7919 Bank Card 9865",
// поэтому endsWith находит только последнюю — ищем ещё "Bank Card NNNN"/"Карта NNNN".
const bool matches = t.endsWith(lastNumbers)
|| t.contains("Bank Card " + lastNumbers)
|| t.contains(QString::fromUtf8("Карта ") + lastNumbers);
if (!matches) continue;
// Отсекаем строки из ленты переводов (см. parseCardsOnHomeScreen) // Отсекаем строки из ленты переводов (см. parseCardsOnHomeScreen)
if (t.contains(QString::fromUtf8("Переводы")) if (t.contains(QString::fromUtf8("Переводы"))
|| t.contains(QStringLiteral("**")) || t.contains(QStringLiteral("**"))
@ -482,40 +449,6 @@ Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) {
return {}; return {};
} }
QString ScreenXmlParser::parseActiveCardLast4() {
// Маска реквизитов активной карты: "•••• •••• •••• 9865". Отличаем от плиток
// карусели ("…•• 9865") по наличию группы из 4 точек "••••".
static const QRegularExpression tail(R"((\d{4})\s*$)");
for (const Node &node : m_nodes) {
const QString t = node.text.trimmed();
if (!t.contains(QString::fromUtf8("••••"))) continue;
if (const auto m = tail.match(t); m.hasMatch())
return m.captured(1);
}
return {};
}
Node ScreenXmlParser::findCardCarouselTile(const QString &lastNumbers) {
// Плитка карты на экране "Карта": Button "Bank Card Виртуальная карта •• NNNN".
for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button" || !node.clickable) continue;
const QString t = node.text.trimmed();
if (t.contains(QString::fromUtf8("Виртуальная карта")) && t.endsWith(lastNumbers))
return node;
}
return {};
}
Node ScreenXmlParser::findAccountCardButton(const QString &lastNumbers) {
const QString needle = "Bank Card " + lastNumbers;
for (const Node &node : m_nodes) {
if (!node.clickable) continue;
if (node.text.trimmed() == needle || node.contentDesc.trimmed() == needle)
return node;
}
return {};
}
Node ScreenXmlParser::findMainAccountButton() { Node ScreenXmlParser::findMainAccountButton() {
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button") continue; if (node.className != "android.widget.Button") continue;
@ -589,63 +522,34 @@ QList<MaterialInfo> ScreenXmlParser::parseCardsOnHomeScreen() {
if (commonAmount == 0.0) if (commonAmount == 0.0)
qWarning() << "[parseCards] Balance button not found or amount=0"; qWarning() << "[parseCards] Balance button not found or amount=0";
// Маркеры записи из ленты переводов (не карта): // 2. Собираем все кнопки карт (текст заканчивается на 4 цифры).
// - слово "Переводы", маска "**" перед цифрами, знак суммы (минус U+2212 или плюс). // Записи из ленты переводов выглядят как кнопки и тоже заканчиваются на 4 цифры
auto isTransferFeedRow = [](const QString &t) { // ("Евгений Дмитриевич Р. 100 ₽ Переводы • Карта **1092") — отсеиваем по маркерам.
return t.contains(QString::fromUtf8("Переводы"))
|| t.contains(QStringLiteral("**"))
|| t.contains(QChar(0x2212)) || t.contains(QChar('+'));
};
// 2. Собираем номера карт.
// В Compose-UI Ozon (>= 19.x) ОБЕ карты лежат в тексте балансовой кнопки одной
// строкой: "Основной счёт … ₽ Bank Card 7919 Bank Card 9865". Вытаскиваем ВСЕ
// вхождения "Bank Card NNNN" / "Карта NNNN" из текста кнопок (а не только t.right(4),
// который терял все карты кроме последней).
QList<MaterialInfo> cards; QList<MaterialInfo> cards;
QStringList seen;
static const QRegularExpression bankCardRx(R"((?:Bank Card|Карта)\s+(\d{4}))");
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button") continue; if (node.className != "android.widget.Button" || !node.clickable) continue;
const QString t = node.text; QString t = node.text.trimmed();
if (isTransferFeedRow(t)) { 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; qDebug() << "[parseCards] Skipping transfer-feed row:" << t;
continue; continue;
} }
auto it = bankCardRx.globalMatch(t);
while (it.hasNext()) {
const QString ln = it.next().captured(1);
if (seen.contains(ln)) continue;
seen.append(ln);
MaterialInfo acc;
acc.lastNumbers = ln;
acc.amount = commonAmount;
acc.currency = "RUB";
acc.description = "Виртуальная карта";
cards.append(acc);
qDebug() << "[parseCards] Card found (Bank Card):" << ln << "from:" << t;
}
}
// 3. Fallback (старый UI): отдельные кнопки, текст которых оканчивается 4 цифрами. MaterialInfo acc;
if (cards.isEmpty()) { acc.lastNumbers = t.right(4);
for (const Node &node : m_nodes) { acc.amount = commonAmount;
if (node.className != "android.widget.Button" || !node.clickable) continue; acc.currency = "RUB";
const QString t = node.text.trimmed(); acc.description = "Виртуальная карта";
if (!endsWithFourDigits.match(t).hasMatch()) continue; qDebug() << "[parseCards] Card found: lastNumbers=" << acc.lastNumbers
if (isTransferFeedRow(t)) { << "text=" << node.text << "amount=" << commonAmount;
qDebug() << "[parseCards] Skipping transfer-feed row:" << t; cards.append(acc);
continue;
}
MaterialInfo acc;
acc.lastNumbers = t.right(4);
acc.amount = commonAmount;
acc.currency = "RUB";
acc.description = "Виртуальная карта";
qDebug() << "[parseCards] Card found (fallback):" << acc.lastNumbers
<< "text=" << node.text << "amount=" << commonAmount;
cards.append(acc);
}
} }
qDebug() << "[parseCards] Total cards found:" << cards.size(); qDebug() << "[parseCards] Total cards found:" << cards.size();
@ -678,11 +582,8 @@ QList<Node> ScreenXmlParser::findPinCodeNodes(const QString &pinCode) {
static const QRegularExpression digitRx(R"(^(?:key\s+)?(\d)$)", QRegularExpression::CaseInsensitiveOption); static const QRegularExpression digitRx(R"(^(?:key\s+)?(\d)$)", QRegularExpression::CaseInsensitiveOption);
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue; if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue;
// Цифра живёт в text ("1"/"key 1") в старом UI, либо — в Compose-сборках Ozon const QString t = node.text.trimmed();
// (>= 19.x) — в content-desc ("key 1") при пустом text. if (const auto match = digitRx.match(t); match.hasMatch()) {
auto match = digitRx.match(node.text.trimmed());
if (!match.hasMatch()) match = digitRx.match(node.contentDesc.trimmed());
if (match.hasMatch()) {
digitMap[match.captured(1)] = node; digitMap[match.captured(1)] = node;
} }
} }
@ -1295,46 +1196,31 @@ QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayReportHistory() {
return transactions; return transactions;
} }
// Строит заголовок дня в формате Ozon-Выписки для переданной даты.
// offset 0 → "сегодня, ...", 1 → "вчера, ...", иначе "<день> <месяц>, <дн.недели>".
static QString ozonDayTitle(const QDate &day, int offset) {
if (offset == 0)
return QString("сегодня, %1").arg(getDayOfWeekAsString(day.dayOfWeek()));
if (offset == 1)
return QString("вчера, %1").arg(getDayOfWeekAsString(day.dayOfWeek()));
return QString("%1 %2, %3").arg(
QString::number(day.day()),
getMonthAsString(day.month()),
getDayOfWeekAsString(day.dayOfWeek()));
}
QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayHistory() { QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayHistory() {
// Окно поиска: сегодня + (N-1) предыдущих дней. Настраивается в QDate today = DateUtils::getMoscowNow().date();
// config.ini → [ozon]/transaction_search_window_days (дефолт 2 = сегодня+вчера). QDate yesterday = today.addDays(-1);
const int windowDays = BankConfigUtils::transactionSearchWindowDays("ozon"); QDate beforeYesterday = today.addDays(-2);
const QDate today = DateUtils::getMoscowNow().date();
// Заголовки дней внутри окна [сегодня .. сегодня-(windowDays-1)] → дата дня.
struct DayTitle { QString title; QDate date; };
QList<DayTitle> windowTitles;
for (int i = 0; i < windowDays; ++i) {
const QDate d = today.addDays(-i);
windowTitles.append({ozonDayTitle(d, i), d});
}
// Заголовок первого дня ЗА окном — сигнал "дальше старая история, стоп".
const QDate boundaryDate = today.addDays(-windowDays);
const QString boundaryTitle = ozonDayTitle(boundaryDate, windowDays);
// Родительский контейнер списка: берём первый существующий заголовок окна. QString todayTitle = QString("сегодня, %1").arg(getDayOfWeekAsString(today.dayOfWeek()));
QString yesterdayTitle = QString("вчера, %1").arg(getDayOfWeekAsString(yesterday.dayOfWeek()));
QString beforeYesterdayTitle = QString("%1 %2, %3").arg(
QString::number(beforeYesterday.day()),
getMonthAsString(beforeYesterday.month()),
getDayOfWeekAsString(beforeYesterday.dayOfWeek())
);
QDomNode todayNode = findNode(m_xml, todayTitle);
QDomNode yesterdayNode = findNode(m_xml, yesterdayTitle);
QDomNode parentNode; QDomNode parentNode;
for (const DayTitle &dt : windowTitles) { if (!yesterdayNode.isNull()) {
const QDomNode node = findNode(m_xml, dt.title); parentNode = yesterdayNode.parentNode();
if (!node.isNull()) { } else if (!todayNode.isNull()) {
parentNode = node.parentNode(); parentNode = todayNode.parentNode();
break;
}
} }
QList<TransactionInfo> transactions; QList<TransactionInfo> transactions;
if (!parentNode.isNull()) { if (!parentNode.isNull()) {
QDate date = {}; QDate date = {};
@ -1342,19 +1228,17 @@ QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayHistory() {
QString transactionShortText = transactionNode.toElement().attribute("text"); QString transactionShortText = transactionNode.toElement().attribute("text");
const QString titleLowerCase = transactionShortText.toLower(); const QString titleLowerCase = transactionShortText.toLower();
if (titleLowerCase.startsWith(beforeYesterdayTitle)) {
// День за пределами окна — дальше только старая история, прекращаем. // только сегодня и вчера парсим, позавчера не трогаем
if (titleLowerCase.startsWith(boundaryTitle)) {
break; break;
} }
if (titleLowerCase.startsWith(todayTitle)) {
// Заголовок дня внутри окна — обновляем текущую дату и срезаем префикс. date = today;
for (const DayTitle &dt : windowTitles) { transactionShortText = transactionShortText.mid(todayTitle.length() + 1);
if (titleLowerCase.startsWith(dt.title)) { }
date = dt.date; if (titleLowerCase.startsWith(yesterdayTitle)) {
transactionShortText = transactionShortText.mid(dt.title.length() + 1); date = yesterday;
break; transactionShortText = transactionShortText.mid(yesterdayTitle.length() + 1);
}
} }
if (transactionShortText.contains("ЕСПП")) { if (transactionShortText.contains("ЕСПП")) {

View File

@ -5,7 +5,6 @@
#include "android/xml/Node.h" #include "android/xml/Node.h"
#include "db/MaterialInfo.h" #include "db/MaterialInfo.h"
#include "db/TransactionInfo.h" #include "db/TransactionInfo.h"
#include "AppVersionTrack.h"
namespace Ozon { namespace Ozon {
@ -17,13 +16,6 @@ public:
~ScreenXmlParser() override; ~ScreenXmlParser() override;
// Версионный трек приложения проставляется один раз в начале скрипта:
// parser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
// Версионно-зависимые методы парсинга используют m_track как явный шов
// (см. AppVersionTrack.h). По умолчанию — новейший известный формат.
void setVersionTrack(AppVersionTrack track) { m_track = track; }
AppVersionTrack versionTrack() const { return m_track; }
Node tryToFindBannerOrDialog(int width, int height); Node tryToFindBannerOrDialog(int width, int height);
Node tryToFindSecurityBanner(); Node tryToFindSecurityBanner();
@ -58,11 +50,6 @@ public:
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight); Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
// Онбординг-баннер "Обновили главную" (resource-id onboarding_viewpager),
// появляется поверх главного экрана Ozon. Возвращает кнопку "Пропустить"
// для тапа, либо пустой Node если баннер не виден.
Node tryToFindOnboardingBanner();
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android // Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android
// (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден. // (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден.
Node tryToFindSystemPermissionDenyButton(); Node tryToFindSystemPermissionDenyButton();
@ -73,19 +60,6 @@ public:
// Finds a card button whose text ends with the given last 4 digits // Finds a card button whose text ends with the given last 4 digits
Node findCardButtonByLastNumbers(const QString &lastNumbers); Node findCardButtonByLastNumbers(const QString &lastNumbers);
// Экран "Карта": last4 активной карты из маски "•••• •••• •••• NNNN".
// Пусто, если маскированный номер не найден.
QString parseActiveCardLast4();
// Экран "Карта": плитка карты в карусели по последним 4 цифрам
// ("Bank Card Виртуальная карта •• NNNN"). Тап по ней делает карту активной.
Node findCardCarouselTile(const QString &lastNumbers);
// Экран "Основной счёт": отдельная кнопка карты "Bank Card NNNN" (text или
// content-desc). Тап по ней открывает экран именно этой карты (а не передней
// карты карусели). NNNN — последние 4 цифры.
Node findAccountCardButton(const QString &lastNumbers);
// Returns all cards visible on the home screen as MaterialInfo stubs // Returns all cards visible on the home screen as MaterialInfo stubs
// (lastNumbers, description, amount filled; id=-1, cardNumber empty) // (lastNumbers, description, amount filled; id=-1, cardNumber empty)
QList<MaterialInfo> parseCardsOnHomeScreen(); QList<MaterialInfo> parseCardsOnHomeScreen();
@ -154,7 +128,6 @@ public:
private: private:
QList<Node> m_nodes; QList<Node> m_nodes;
QDomElement m_xml; QDomElement m_xml;
AppVersionTrack m_track = AppVersionTrack::V19_19_1;
QList<Node> findPinCodeNodes(const QString &pinCode); QList<Node> findPinCodeNodes(const QString &pinCode);
}; };

View File

@ -1,4 +0,0 @@
build/
.gradle/
*.keystore
local.properties

View File

@ -1,54 +0,0 @@
# arc-shot
Крошечное Android-приложение для фермы: foreground-сервис + HTTP-файл-сервер на
порту **8099**, раздающий скриншоты с `/sdcard`. Нужно, чтобы десктоп качал кадры
по HTTP **напрямую с телефона, мимо adb** — иначе `adb exec-out screencap` по Wi-Fi
виснет и душит adb-сервер (каскад `adb devices timeout` → mass-offline).
Экран приложение **не снимает** (это требовало бы MediaProjection с диалогом
согласия) — кадр делает десктоп через `adb shell screencap -p /sdcard/arc_shot.png`,
а arc-shot только отдаёт готовый файл по HTTP. Без внешних зависимостей (raw
`ServerSocket`).
## Эндпоинты
- `GET /health``200 ok`
- `GET /<file>.png` → файл из `/sdcard` (например `/arc_shot.png`)
## Сборка
**Офлайн, без Gradle/AGP** (только Android SDK build-tools):
```bash
./build_manual.sh
# -> build/arc-shot.apk
```
Переменные: `BT_VER` (build-tools, дефолт 35.0.1), `PLATFORM` (дефолт android-34),
`MIN_SDK`/`TARGET_SDK`, `ANDROID_SDK_ROOT`.
**Через Android Studio / Gradle** — см. `build.gradle`. Важно: для AGP-сборки убрать
атрибут `package="com.arc.shot"` из `AndroidManifest.xml` (AGP берёт пакет из
`namespace`). Ручной `build_manual.sh`, наоборот, требует `package=` в манифесте.
## Установка и выдача прав (с десктопа, через adb)
```bash
adb -s <serial> install -r build/arc-shot.apk
# доступ к /sdcard без диалога на устройстве:
adb -s <serial> shell appops set com.arc.shot MANAGE_EXTERNAL_STORAGE allow
# запустить сервис:
adb -s <serial> shell am start -n com.arc.shot/.MainActivity
```
## Проверка
```bash
adb -s <serial> shell screencap -p /sdcard/arc_shot.png
IP=$(adb -s <serial> shell ip route get 1.1.1.1 | sed -n 's/.*src \([0-9.]*\).*/\1/p')
curl -s "http://$IP:8099/arc_shot.png" -o shot.png && file shot.png
curl -s "http://$IP:8099/health" # -> ok
```
Десктоп использует этот путь автоматически при `config.ini [screenshot] transport=http`
(с fallback на `exec-out`, если arc-shot недоступен).

View File

@ -1,33 +0,0 @@
// Стандартная сборка через Android Studio / Gradle (онлайн, тянет AGP с Google Maven).
// Офлайн-сборка без AGP см. build_manual.sh.
//
// ВНИМАНИЕ: AGP 8 берёт package из namespace и НЕ допускает атрибут package= в
// AndroidManifest.xml. Для gradle-сборки убери `package="com.arc.shot"` из манифеста
// (он нужен только ручному build_manual.sh).
plugins {
id 'com.android.application' version '8.5.2'
}
android {
namespace 'com.arc.shot'
compileSdk 34
defaultConfig {
applicationId 'com.arc.shot'
minSdk 24
targetSdk 34
versionCode 1
versionName '1.0'
}
buildTypes {
release {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}

View File

@ -1,61 +0,0 @@
#!/usr/bin/env bash
# Офлайн-сборка APK arc-shot напрямую через Android build-tools (без Gradle/AGP).
# Использует только локальный SDK + android.jar. Требует: javac (JDK 17+), Android SDK.
set -euo pipefail
cd "$(dirname "$0")"
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}"
BT_VER="${BT_VER:-35.0.1}"
PLATFORM="${PLATFORM:-android-34}"
MIN_SDK="${MIN_SDK:-24}"
TARGET_SDK="${TARGET_SDK:-34}"
BT="$SDK/build-tools/$BT_VER"
ANDROID_JAR="$SDK/platforms/$PLATFORM/android.jar"
[ -d "$BT" ] || { echo "build-tools $BT_VER not found at $BT"; exit 1; }
[ -f "$ANDROID_JAR" ] || { echo "android.jar not found at $ANDROID_JAR"; exit 1; }
OUT="build"
rm -rf "$OUT"
mkdir -p "$OUT/classes" "$OUT/dex"
echo "[1/6] javac"
find src/main/java -name '*.java' > "$OUT/sources.txt"
javac --release 17 -classpath "$ANDROID_JAR" -d "$OUT/classes" @"$OUT/sources.txt"
echo "[2/6] d8 -> classes.dex"
CLASSES=$(find "$OUT/classes" -name '*.class')
"$BT/d8" --min-api "$MIN_SDK" --lib "$ANDROID_JAR" --output "$OUT/dex" $CLASSES
echo "[3/6] aapt2 link"
"$BT/aapt2" link \
-o "$OUT/app-unsigned.apk" \
-I "$ANDROID_JAR" \
--manifest src/main/AndroidManifest.xml \
--min-sdk-version "$MIN_SDK" \
--target-sdk-version "$TARGET_SDK"
echo "[4/6] add classes.dex"
cp "$OUT/dex/classes.dex" "$OUT/classes.dex"
( cd "$OUT" && zip -j -q app-unsigned.apk classes.dex )
echo "[5/6] zipalign"
"$BT/zipalign" -f -p 4 "$OUT/app-unsigned.apk" "$OUT/app-aligned.apk"
echo "[6/6] sign"
# Keystore держим ВНЕ build/ — иначе rm -rf build стирает ключ и подпись меняется
# каждую пересборку (INSTALL_FAILED_UPDATE_INCOMPATIBLE при обновлении на устройстве).
KS="debug.keystore"
if [ ! -f "$KS" ]; then
keytool -genkeypair -keystore "$KS" -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1
fi
"$BT/apksigner" sign \
--ks "$KS" --ks-pass pass:android --ks-key-alias androiddebugkey --key-pass pass:android \
--out "$OUT/arc-shot.apk" "$OUT/app-aligned.apk"
echo "OK -> $OUT/arc-shot.apk"
"$BT/aapt2" dump badging "$OUT/arc-shot.apk" 2>/dev/null | grep -E "package:|launchable-activity:|uses-permission: name='android.permission.(MANAGE_EXTERNAL_STORAGE|FOREGROUND_SERVICE)'" || true

View File

@ -1,14 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'arc-shot'

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.arc.shot">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="arc-shot"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".ArcShotService"
android:exported="true"
android:foregroundServiceType="dataSync"/>
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -1,207 +0,0 @@
package com.arc.shot;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.util.Log;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
/**
* Foreground-сервис arc-shot: крошечный HTTP-файл-сервер, раздающий скриншоты
* с /sdcard. Десктоп снимает кадр через `adb shell screencap -p /sdcard/arc_shot.png`
* (дёшево, без передачи байтов через adb) и качает файл по HTTP напрямую с телефона
* так мегабайты PNG идут по Wi-Fi мимо adb и не душат adb-сервер.
*
* Экран сам НЕ снимает (это требовало бы MediaProjection с диалогом согласия)
* только отдаёт уже снятый adb'ом файл. Доступ к /sdcard выдаётся через adb:
* adb shell appops set com.arc.shot MANAGE_EXTERNAL_STORAGE allow
*/
public class ArcShotService extends Service {
private static final String TAG = "ArcShot";
static final int PORT = 8099;
private static final String BASE_DIR = "/sdcard";
private static final String CHANNEL_ID = "arc_shot";
private volatile boolean running = false;
private ServerSocket serverSocket;
private Thread serverThread;
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
startForegroundCompat();
startServer();
return START_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onDestroy() {
running = false;
closeQuietly(serverSocket);
super.onDestroy();
}
private void startForegroundCompat() {
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm != null) {
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "arc-shot", NotificationManager.IMPORTANCE_MIN);
nm.createNotificationChannel(ch);
}
Notification.Builder b = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
? new Notification.Builder(this, CHANNEL_ID)
: new Notification.Builder(this);
Notification n = b.setContentTitle("arc-shot")
.setContentText("screenshot server :" + PORT)
.setSmallIcon(android.R.drawable.ic_menu_camera)
.setOngoing(true)
.build();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
} else {
startForeground(1, n);
}
}
private synchronized void startServer() {
// Перезапускаем, если поток ещё не создан ИЛИ умер (раньше тут было
// `if (running) return`, из-за чего мёртвый accept-поток уже не поднимался,
// а порт оставался LISTEN без обработчика соединения висли без ответа).
if (running && serverThread != null && serverThread.isAlive()) {
return;
}
running = true;
serverThread = new Thread(this::serverLoop, "arc-shot-http");
serverThread.setDaemon(true);
serverThread.start();
}
private void serverLoop() {
ServerSocket server = null;
try {
server = new ServerSocket();
server.setReuseAddress(true);
server.bind(new InetSocketAddress(PORT));
serverSocket = server;
Log.i(TAG, "listening on :" + PORT);
while (running) {
try {
final Socket sock = server.accept();
// Каждое соединение в своём потоке: медленный/битый клиент не
// блокирует accept (раньше один зависший handle вешал весь сервер).
Thread t = new Thread(() -> handle(sock), "arc-shot-conn");
t.setDaemon(true);
t.start();
} catch (Throwable t) {
if (!running) break;
// Одна сбойная итерация не должна ронять сервер.
Log.w(TAG, "accept error: " + t.getMessage());
}
}
} catch (Throwable t) {
Log.e(TAG, "server fatal: " + t.getMessage());
} finally {
// Освобождаем сокет, чтобы следующий startServer() (onStartCommand от
// desktop-kick) пересоздал слушатель начисто.
running = false;
closeQuietly(server);
}
}
private void handle(Socket sock) {
try (Socket s = sock) {
s.setSoTimeout(5000);
BufferedReader in = new BufferedReader(
new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
OutputStream out = s.getOutputStream();
String line = in.readLine();
if (line == null) {
return;
}
String[] parts = line.split(" ");
if (parts.length < 2 || !"GET".equals(parts[0])) {
writeResponse(out, "400 Bad Request", "text/plain", new byte[0]);
return;
}
String path = parts[1];
int q = path.indexOf('?');
if (q >= 0) {
path = path.substring(0, q);
}
if ("/health".equals(path)) {
writeResponse(out, "200 OK", "text/plain", "ok".getBytes(StandardCharsets.UTF_8));
return;
}
String name = path.startsWith("/") ? path.substring(1) : path;
File file = new File(BASE_DIR, name);
// Защита от path traversal файл должен лежать внутри BASE_DIR.
// /sdcard симлинк на /storage/emulated/0, поэтому сравниваем canonical
// обеих сторон, а не с литералом "/sdcard".
String baseCanon = new File(BASE_DIR).getCanonicalPath();
String canon = file.getCanonicalPath();
if (!canon.startsWith(baseCanon + "/") || !file.isFile()) {
writeResponse(out, "404 Not Found", "text/plain", new byte[0]);
return;
}
writeResponse(out, "200 OK", "image/png", readFile(file));
} catch (IOException e) {
Log.w(TAG, "handle error: " + e.getMessage());
}
}
private static byte[] readFile(File f) throws IOException {
try (FileInputStream fis = new FileInputStream(f);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) > 0) {
bos.write(buf, 0, n);
}
return bos.toByteArray();
}
}
private static void writeResponse(OutputStream out, String status, String ctype, byte[] body)
throws IOException {
String header = "HTTP/1.0 " + status + "\r\n"
+ "Content-Type: " + ctype + "\r\n"
+ "Content-Length: " + body.length + "\r\n"
+ "Connection: close\r\n\r\n";
out.write(header.getBytes(StandardCharsets.UTF_8));
if (body.length > 0) {
out.write(body);
}
out.flush();
}
private static void closeQuietly(ServerSocket s) {
if (s != null) {
try {
s.close();
} catch (IOException ignored) {
}
}
}
}

View File

@ -1,19 +0,0 @@
package com.arc.shot;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
/** Поднимает arc-shot после перезагрузки телефона. */
public class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context ctx, Intent intent) {
Intent i = new Intent(ctx, ArcShotService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(i);
} else {
ctx.startService(i);
}
}
}

View File

@ -1,25 +0,0 @@
package com.arc.shot;
import android.app.Activity;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
/**
* Служебное Activity без UI: при запуске поднимает foreground-сервис и закрывается.
* Нужно, чтобы у приложения был launcher-значок и чтобы сервис можно было поднять
* через `adb shell am start -n com.arc.shot/.MainActivity` или тапом.
*/
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent i = new Intent(this, ArcShotService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(i);
} else {
startService(i);
}
finish();
}
}

1
app.rc
View File

@ -1 +0,0 @@
IDI_ICON1 ICON "arc.ico"

BIN
arc.icns

Binary file not shown.

View File

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

Binary file not shown.

View File

@ -1 +0,0 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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 **** **** 0819&#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,1444]"><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,1444]" /></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,1444]" /></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,0][0,0]" /></node></hierarchy>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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,1444]"><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 **** **** 0819&#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,1444]"><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,1444]" /></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,1444]" /></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,0][0,0]" /></node></hierarchy>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1600]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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 **** **** 5947&#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,1504]"><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,1504]" /></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,1504]" /></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,1504][720,1600]" /></node></hierarchy>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

View File

@ -1 +0,0 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1600]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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,1504]"><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 **** **** 5947&#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,1504]"><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,1504]" /></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,1504]" /></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,1504][720,1600]" /></node></hierarchy>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,103 +0,0 @@
# Ozon 19.19.1 — расхождения вёрстки с парсером
Устройство: Pixel 7, Android 17. Версия Ozon: **19.19.1** (versionCode 16030308).
Пакет: `ru.ozon.fintech.finance`.
Общий вывод: приложение переехало на **Compose-семантику** — интерактивные контролы
(кнопки, вкладки) больше не несут видимый `text`, а выставляют стабильные тест-id в
`content-desc`. Текстовый контент (балансы, заголовки, строки списков) по-прежнему в `text`.
Парсер во многих местах матчит по `node.text` → ломается. Чинить переходом на `content-desc`.
## Найденные расхождения
### 1. PIN-пад (`findPinCodeNodes`, способ 1) — экран `pin_code`
- Было: цифра в `text="key 1"` (регулярка `^(?:key\s+)?(\d)$` по `node.text`).
- Стало: `text=""`, цифра в **`content-desc="key 1"`** (class Button).
- Эффект: способ 1 находит 0 цифр, спасает только геометрический fallback (способ 2) — хрупко
(кнопка отпечатка `bioauth` 86×87 почти попадает в группу «квадратных»).
- Фикс: в способе 1 матчить регуляркой и `node.text`, и `content-desc`.
### 2. Главный экран (`isHomeScreen`) — экран `home_page`
- Было: требует одновременно `text=="Перевести"` и `text=="Пополнить"`.
- Стало: этих текстов нет. Кнопки — `content-desc`: `transfer-button-shared` (Перевести),
`replenish-button` (Пополнить), `qr-code-button` (Оплата QR).
- Нижняя навигация — `content-desc`: `Главная|FINANCE_TAB1``Ещё|FINANCE_TAB5`.
- Эффект: `isHomeScreen()` всегда возвращает false → флоу не стартует.
- Фикс: детектить по `content-desc` (`transfer-button-shared`+`replenish-button` или `FINANCE_TAB1`).
### 2b. `findButtonNode("Перевести")` на главной — PayByPhone/PayByCard старт
- `findButtonNode()` матчит ТОЛЬКО `node.text` (class Button). Кнопка «Перевести» на главной —
`content-desc="transfer-button-shared"`, `text=""` → возвращает пусто.
- Эффект: `PayByPhoneScript` (стр.178) и `PayByCardScript` (стр.164) падают на старте:
«Кнопка 'Перевести' не найдена на домашнем экране». Аналогично «Пополнить» = `replenish-button`.
- Фикс: тапать кнопку действия на главной по `content-desc` (`transfer-button-shared` /
`replenish-button` / `qr-code-button`), либо расширить `findButtonNode` на `content-desc`.
## ПРИМЕНЁННЫЕ ПРАВКИ (собрано и проверено на дампах 19.19.1)
Все детекторы сделаны обратносовместимыми (сначала `text`, затем fallback на `content-desc`) —
старые сборки Ozon продолжают работать.
1. `ScreenXmlParser::findPinCodeNodes` (способ 1) — цифра матчится и в `text`, и в `content-desc`
(`key N`). На дампе нашёл 10/10 цифр.
2. `ScreenXmlParser::isHomeScreen` — детект по `text` ИЛИ `content-desc`
(`transfer-button-shared` / `replenish-button`). На дампе → true.
3. `PayByPhoneScript` + `PayByCardScript` — домашняя «Перевести»: fallback
`findNodeByContentDesc("transfer-button-shared")` после пустого `findButtonNode`.
### Каркас версионности — `android/ozon/AppVersionTrack.h` (по образцу Dushanbe)
- `enum AppVersionTrack { Legacy (<19.19.1), V19_19_1 (>=19.19.1, новейший по умолчанию) }`.
- `trackForVersion(appVersion)` через `QVersionNumber`; неизвестная/новее порога → новейший трек.
- `ScreenXmlParser`: `setVersionTrack()`/`versionTrack()`, член `m_track = V19_19_1`.
- Во всех 6 скриптах: `xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion))`
+ лог `appVersion → track` сразу после `dumpScope`.
- Веток по `m_track` пока НЕТ (детекторы обратносовместимы) — трек это явный шов под будущие
расхождения и маркер в логах. Как добавлять новую версию — см. комментарий в AppVersionTrack.h.
## Проверено на 19.19.1 и РАБОТАЕТ без правок
- `isProfileScreen()` — тексты «Аккаунт»+«Выйти» на месте.
- `parseProfilePhone()` — режет склейку «+7 953 803 83 18 Номер телефона» по подстроке.
- `parseProfileFullName()` — ФИО «Галина Терехина» берётся как TextView между «Аккаунт» и телефоном.
- `findUserNameOnHomeScreen()` — «Галина Т.» в верхней зоне.
- Карта: «Показать»/«Реквизиты карты»/`findFullCardNumber` — номер `2204 3210 8610 9865`,
срок `12/35`, CVV `774` читаются. Тексты в `text`.
- `isTransactionListScreen()` — WebView text=«Операции».
- `findTransactionButtons()` — находит строки; поддерживает оба варианта (текст в Button и во вложенной View с ₽).
- `parseTransactionDetail()` — дата `26.05.2026, 18:56`, телефон `+7 (950) 128-60-90`, имя над телефоном.
## Платёжный флоу — проверено реальными переводами (по скрипту)
Сделаны 2 реальных перевода с устройства: СБП 50₽ на Альфа (+7 985 553-28-96) и
по карте 100₽+45₽ комиссия на карту Альфа 2200 1545 1435 0684.
- Старт «Перевести» на главной — см. расхождение 2b (content-desc).
- Дальше всё на текстовых маркерах РАБОТАЕТ: «Платежи и переводы», «По номеру телефона»,
«По номеру карты», «Перевод по номеру карты», «Номер карты получателя», «Сумма от»,
«Продолжить», «Подтвердите перевод», «Перевести N ₽», «Успешно».
- Выбор банка (СБП): список — кнопки с числовым `content-desc` (ID банка, напр. Альфа=100000000008),
«Другой банк» = `content-desc="other-bank-item"`. Скрипт это уже поддерживает.
- Ввод названия банка кириллицей — ТОЛЬКО через ADBKeyboard (`com.android.adbkeyboard`):
`adb shell input text` кидает NullPointerException на кириллице. Скрипт использует
`enableAdbIMEKeyboard`/`inputAdbIMEText` — корректно.
- OCR ID операции: на СБП-чеке (Документы) есть «ID операции A6I5...» — `extractTransactionId` сработает.
На внутреннем Ozon-чеке поля «ID операции» НЕТ (см. transaction_document).
## Снятые дампы (assets/ozon/19.19.1/)
Корень: pin_code, home_page, profile, card_screen, card_screen_open, card_screen_hide,
payments_page, last_transactions (+ баннер «Аналитика финансов» = transaction_banner inline),
transaction_info, transaction_document.
pay/: pay_buy_phone, pay_to_new_number, pay_buy_phone_select_bank, pay_buy_phone_select_bank_1,
pay_buy_phone_enter_sum, confirm_payment (=pay_buy_phone_check_data), confirm_payment_success
(=pay_buy_phone_sent_success), pay_by_card, pay_by_card_enter_sum, pay_by_card_success.
## Ещё не снято
- account_requisites ⚠️ (флоу в коде закомментирован) — пропущено
- transaction_info_spb — пропущено по решению (есть свежая СБП-операция, если понадобится)
- pay_buy_phone_default_bank — пропущено (всегда вводим новый номер)
- confirm_payment_error — экран ошибки, сложно воспроизвести
- home_page_2, ask_biometry
- Баннеры/оверлеи: home_ad_1, google_play, banner_ipoteka, card_banner, select_bank,
onboarding ⚠️, security ⚠️, system_permission ⚠️ (всплывают ситуативно)

View File

@ -1,19 +0,0 @@
#!/usr/bin/env bash
# Снимок экрана Ozon: PNG + uiautomator XML под одним именем.
# Использование: ./capture.sh <имя> -> assets/ozon/19.19.1/<имя>.png|.xml
# ./capture.sh pay/<имя> -> кладёт в подпапку pay/
set -euo pipefail
NAME="${1:?usage: capture.sh <name>}"
DIR="$(cd "$(dirname "$0")" && pwd)"
OUT="$DIR/$NAME"
mkdir -p "$(dirname "$OUT")"
# screenshot
adb exec-out screencap -p > "$OUT.png"
# ui hierarchy
adb shell uiautomator dump /sdcard/window_dump.xml >/dev/null 2>&1
adb pull /sdcard/window_dump.xml "$OUT.xml" >/dev/null 2>&1
PXSIZE=$(stat -f%z "$OUT.png" 2>/dev/null || echo 0)
echo "saved: $NAME.png (${PXSIZE}b) + $NAME.xml"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

File diff suppressed because one or more lines are too long

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