Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e7c210c1 | ||
|
|
da72c4eda6 | ||
|
|
7257a5207e | ||
|
|
7929f7c351 | ||
|
|
2fb6146fd9 | ||
|
|
2c2d58021f | ||
|
|
e8ddde7e36 | ||
|
|
ba476bae50 | ||
|
|
ce3bc99ca8 | ||
|
|
fac49447bf | ||
|
|
cef3515790 | ||
|
|
9f23991c61 | ||
|
|
e760f2bb5f | ||
|
|
cfc562eae6 | ||
|
|
39e07ddb15 | ||
|
|
49f1323706 | ||
|
|
d7fc838d1a | ||
|
|
521101463b | ||
|
|
a0583c4966 | ||
|
|
a72a00cc3f | ||
|
|
c2994d9041 | ||
|
|
2b4bd5a9d1 | ||
|
|
1f20c9437c | ||
|
|
615febc8b5 | ||
|
|
cd98644f32 | ||
|
|
66279d4767 | ||
|
|
3391591e33 | ||
|
|
b486e922b3 | ||
|
|
fb9fa279a6 | ||
|
|
0168cf7610 | ||
|
|
569eefb3e8 | ||
|
|
6d60cecef4 | ||
|
|
e5a11b0a57 | ||
|
|
7d953add94 | ||
|
|
9744fbf990 | ||
|
|
c6c35cff2c | ||
|
|
e08514ea2e | ||
|
|
29ae105c64 | ||
|
|
f6d895ab34 | ||
|
|
c5f0c035fe | ||
|
|
5c2aba0505 | ||
|
|
35b2b3bcaa | ||
|
|
8d86d5f835 | ||
|
|
65d3b00bb9 | ||
|
|
63fa25a087 | ||
|
|
92e754ef6b | ||
|
|
f179c581d0 | ||
|
|
ce61986035 | ||
|
|
992073d43d | ||
|
|
709c7d0490 | ||
|
|
3832a658ce | ||
|
|
b8da23fa6c | ||
|
|
942d6137e9 | ||
|
|
07399670c0 | ||
|
|
a7840b640d | ||
|
|
34e80f04bb | ||
|
|
c5a6524e16 | ||
|
|
50a7b20f0a | ||
|
|
d3b109d87f | ||
|
|
54edae7600 |
4
.gitignore
vendored
@ -13,6 +13,7 @@ tests/mock_server/.mock_screenshots/
|
||||
cmake_install.cmake
|
||||
CMakeCache.txt
|
||||
Makefile
|
||||
compile_commands.json
|
||||
|
||||
.qt/
|
||||
|
||||
@ -23,8 +24,7 @@ Makefile
|
||||
/Testing/
|
||||
/.ninja_deps
|
||||
/.ninja_log
|
||||
/ARCDesktopProject_autogen/
|
||||
/ARCDesktopProject
|
||||
/ARC
|
||||
/Contents/
|
||||
/application.log
|
||||
/database/app_data.db
|
||||
|
||||
8
BUILD.md
@ -1,4 +1,4 @@
|
||||
# ARCDesktopProject — сборка и запуск
|
||||
# ARC — сборка и запуск
|
||||
|
||||
## Windows (x64)
|
||||
|
||||
@ -19,7 +19,7 @@ cmake --build . --config Release
|
||||
### Деплой Qt DLL
|
||||
|
||||
```bash
|
||||
"C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARCDesktopProject.exe
|
||||
"C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARC.exe
|
||||
```
|
||||
|
||||
### Копирование vcpkg DLL
|
||||
@ -44,7 +44,7 @@ copy database\app_data.db .\Release\database\app_data.db
|
||||
|
||||
```bash
|
||||
cd Release
|
||||
ARCDesktopProject.exe
|
||||
ARC.exe
|
||||
```
|
||||
|
||||
---
|
||||
@ -77,5 +77,5 @@ cp ../database/app_data.db ./database/app_data.db
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
./ARCDesktopProject
|
||||
./ARC
|
||||
```
|
||||
|
||||
@ -58,6 +58,7 @@ find_package(Qt6 REQUIRED COMPONENTS
|
||||
Widgets
|
||||
Sql
|
||||
Xml
|
||||
Concurrent
|
||||
)
|
||||
|
||||
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
|
||||
@ -104,6 +105,7 @@ add_executable(ARC WIN32
|
||||
views/log/LogDetailWindow.h
|
||||
database/dao/GeneralLogDAO.cpp
|
||||
database/dao/GeneralLogDAO.h
|
||||
android/dushanbe/AppVersionTrack.h
|
||||
android/dushanbe/CommonScript.cpp
|
||||
android/dushanbe/CommonScript.h
|
||||
android/dushanbe/GetCardInfoScript.cpp
|
||||
@ -135,6 +137,7 @@ elseif (APPLE)
|
||||
views/log/LogDetailWindow.h
|
||||
database/dao/GeneralLogDAO.cpp
|
||||
database/dao/GeneralLogDAO.h
|
||||
android/dushanbe/AppVersionTrack.h
|
||||
android/dushanbe/CommonScript.cpp
|
||||
android/dushanbe/CommonScript.h
|
||||
android/dushanbe/GetCardInfoScript.cpp
|
||||
@ -156,6 +159,7 @@ if (WIN32)
|
||||
Qt6::Xml
|
||||
Qt6::Sql
|
||||
Qt6::Network
|
||||
Qt6::Concurrent
|
||||
Tesseract::libtesseract
|
||||
)
|
||||
|
||||
@ -171,6 +175,7 @@ if (WIN32)
|
||||
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}/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 занят
|
||||
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
|
||||
foreach(_adb_file ${_adb_files})
|
||||
@ -226,6 +231,10 @@ if (WIN32)
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/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)
|
||||
@ -276,6 +285,7 @@ elseif (APPLE)
|
||||
Qt6::Sql
|
||||
Qt6::Xml
|
||||
Qt6::Network
|
||||
Qt6::Concurrent
|
||||
tesseract
|
||||
leptonica
|
||||
)
|
||||
@ -291,6 +301,7 @@ elseif (APPLE)
|
||||
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}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||
endif ()
|
||||
|
||||
target_include_directories(ARC PRIVATE
|
||||
|
||||
@ -17,7 +17,7 @@ brew install qt tesseract leptonica
|
||||
mkdir -p build && cd build
|
||||
cmake .. -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build .
|
||||
./ARCDesktopProject
|
||||
./ARC
|
||||
```
|
||||
|
||||
Подробнее — см. [BUILD.md](BUILD.md) (macOS + Windows).
|
||||
|
||||
13
android/BankConfigUtils.cpp
Normal file
@ -0,0 +1,13 @@
|
||||
#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
|
||||
15
android/BankConfigUtils.h
Normal file
@ -0,0 +1,15 @@
|
||||
#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
|
||||
@ -6,9 +6,64 @@
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QThread>
|
||||
#include <QSettings>
|
||||
#include <QTcpSocket>
|
||||
#include <QMutex>
|
||||
#include <QDateTime>
|
||||
#include <QHostAddress>
|
||||
#include <QThreadPool>
|
||||
#include <QtConcurrent>
|
||||
|
||||
#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) {
|
||||
}
|
||||
|
||||
@ -70,6 +125,23 @@ bool AdbUtils::startProcess(const QString &program, const QStringList &args,
|
||||
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
|
||||
// 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
|
||||
@ -136,6 +208,104 @@ QList<QPair<QString, QString> > AdbUtils::getListDevices() {
|
||||
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) {
|
||||
QProcess process;
|
||||
process.setWorkingDirectory(adbDir());
|
||||
@ -147,7 +317,7 @@ QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &
|
||||
qWarning() << "[AdbUtils] Failed to start adb pidof:" << process.errorString();
|
||||
return {};
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 10000, "tryToGetRunningAppPid");
|
||||
QString pid = process.readAllStandardOutput();
|
||||
return pid.replace("\n", "");
|
||||
}
|
||||
@ -230,7 +400,7 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
|
||||
qWarning() << "[AdbUtils] Failed to start adb monkey:" << process.errorString();
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 15000, "tryToStartApplication(monkey)");
|
||||
const QString stdOut = QString::fromUtf8(process.readAllStandardOutput());
|
||||
const QString stdErr = QString::fromUtf8(process.readAllStandardError());
|
||||
const int exitCode = process.exitCode();
|
||||
@ -243,13 +413,20 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
|
||||
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
|
||||
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
|
||||
|
||||
if (stdOut.contains("Events injected: 1")) {
|
||||
const bool eventsInjected = stdOut.contains("Events injected: 1");
|
||||
const bool hadNativeCrash = stdOut.contains("NativeCrash");
|
||||
|
||||
if (eventsInjected && !hadNativeCrash) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code,
|
||||
// в отличие от monkey, который при пустом resolve просто молча печатает "No activities found".
|
||||
// Также используется когда monkey дал "Events injected: 1" но приложение упало (NativeCrash).
|
||||
if (!launcherActivity.isEmpty()) {
|
||||
if (hadNativeCrash)
|
||||
qWarning().noquote() << "[AdbUtils::tryToStartApplication] NativeCrash detected, retrying via am start -n" << launcherActivity;
|
||||
else
|
||||
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
|
||||
<< "пробую am start -n" << launcherActivity;
|
||||
QProcess am;
|
||||
@ -287,7 +464,7 @@ bool AdbUtils::tryToKillApplication(const QString &deviceId, const QString &pack
|
||||
qWarning() << "[AdbUtils] Failed to start adb force-stop:" << process.errorString();
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 15000, "tryToKillApplication");
|
||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||
}
|
||||
|
||||
@ -425,6 +602,36 @@ bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
|
||||
}
|
||||
|
||||
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;
|
||||
process.setWorkingDirectory(adbDir());
|
||||
process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"});
|
||||
@ -440,6 +647,172 @@ QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
|
||||
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) {
|
||||
const QString adb = adbPath();
|
||||
struct Step { QStringList args; };
|
||||
@ -473,7 +846,7 @@ bool AdbUtils::makeTap(const QString &deviceId, const int x, const int y) {
|
||||
qWarning() << "[AdbUtils] Failed to start adb tap:" << process.errorString();
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 10000, "makeTap");
|
||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||
}
|
||||
|
||||
@ -588,7 +961,7 @@ bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) {
|
||||
qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString();
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 10000, "inputAdbIMEText");
|
||||
qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode()
|
||||
<< "text:" << text
|
||||
<< "output:" << process.readAllStandardOutput().trimmed()
|
||||
@ -605,7 +978,7 @@ bool AdbUtils::disableAdbIMEKeyboard(const QString &deviceId) {
|
||||
qWarning() << "[AdbUtils] Failed to start adb ime disable:" << process.errorString();
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 10000, "disableAdbIMEKeyboard");
|
||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||
}
|
||||
|
||||
@ -630,7 +1003,7 @@ bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, co
|
||||
qWarning() << "[AdbUtils] Failed to start adb swipe:" << process.errorString();
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 10000, "makeSwipe");
|
||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||
}
|
||||
|
||||
@ -646,7 +1019,7 @@ bool AdbUtils::inputText(const QString &deviceId, const QString &text) {
|
||||
qWarning() << "[AdbUtils] Failed to start adb input text:" << process.errorString();
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 10000, "inputText");
|
||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||
}
|
||||
|
||||
@ -699,7 +1072,7 @@ bool AdbUtils::goBack(const QString &deviceId) {
|
||||
qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString();
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 10000, "goBack");
|
||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||
}
|
||||
|
||||
@ -715,7 +1088,7 @@ QSet<QString> AdbUtils::getListApps(const QString &deviceId) {
|
||||
qWarning() << "[AdbUtils] Failed to start adb pm list:" << process.errorString();
|
||||
return {};
|
||||
}
|
||||
process.waitForFinished();
|
||||
waitOrKill(process, 15000, "getListApps");
|
||||
|
||||
const QString output = process.readAllStandardOutput();
|
||||
QSet<QString> apps;
|
||||
@ -755,6 +1128,70 @@ QString AdbUtils::getAppVersion(const QString &deviceId, const QString &packageN
|
||||
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) {
|
||||
const QString adb = adbPath();
|
||||
|
||||
|
||||
@ -13,6 +13,28 @@ public:
|
||||
|
||||
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 bool tryToStartApplication(const QString &deviceId, const QString &packageName);
|
||||
@ -27,9 +49,26 @@ public:
|
||||
|
||||
static bool takeScreenshot(const QString &deviceId, const QString &name);
|
||||
|
||||
// Захватывает скриншот напрямую в память (PNG-байты), без временного файла
|
||||
// IP телефона в Wi-Fi (кэш по serial с TTL). Для IP:port-серийника берётся из
|
||||
// серийника, иначе `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);
|
||||
|
||||
// Скриншот через 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 makeTap(const QString &deviceId, int x, int y);
|
||||
@ -59,8 +98,29 @@ public:
|
||||
// Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии
|
||||
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:
|
||||
static QString adbDir();
|
||||
static bool startProcess(const QString &program, const QStringList &args,
|
||||
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);
|
||||
};
|
||||
|
||||
@ -251,7 +251,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
|
||||
ns.postBankProfile();
|
||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||
if (afterPost.bankProfileId.isEmpty()) {
|
||||
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
|
||||
QString err;
|
||||
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;
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
#include "dao/DeviceDAO.h"
|
||||
#include "dump/TaskDumpRecorder.h"
|
||||
#include "net/NetworkService.h"
|
||||
#include "net/MaterialPayload.h"
|
||||
|
||||
namespace Black {
|
||||
|
||||
@ -36,7 +37,7 @@ void GetCardInfoScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{};
|
||||
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
TaskDumpRecorder::Scope dumpScope("black", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
|
||||
|
||||
// Читаем текущий экран
|
||||
@ -126,30 +127,33 @@ void GetCardInfoScript::doStart() {
|
||||
qWarning() << "[Black::GetCardInfo] Failed to upsert account";
|
||||
}
|
||||
|
||||
// 6. Отправляем материал на сервер (если ещё нет material_id)
|
||||
// 6. Отправляем материал на сервер.
|
||||
// Инвариант: на сервере одновременно живёт максимум один материал на (deviceId, appCode) —
|
||||
// тот, что локально active. Пушим только active-карту и только если другой
|
||||
// материал ещё не синхронизирован.
|
||||
const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
|
||||
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||
|
||||
if (savedAccount.id != -1 && !updatedApp.bankProfileId.isEmpty()) {
|
||||
if (savedAccount.materialId.isEmpty()) {
|
||||
if (savedAccount.status != MaterialStatus::Active) {
|
||||
qDebug() << "[Black::GetCardInfo] CVU" << lastNumbers << "is off, not pushing to server";
|
||||
} else if (!savedAccount.materialId.isEmpty()) {
|
||||
qDebug() << "[Black::GetCardInfo] material already synced for CVU" << lastNumbers << ", skipping";
|
||||
} else {
|
||||
bool otherSynced = false;
|
||||
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);
|
||||
|
||||
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.setPayload(buildMaterialPayload(savedAccount, updatedApp));
|
||||
ns.addExtra("account_id", savedAccount.id);
|
||||
ns.postMaterial();
|
||||
qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers;
|
||||
} else {
|
||||
qDebug() << "[Black::GetCardInfo] material already exists for CVU" << lastNumbers << ", skipping";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material";
|
||||
|
||||
@ -35,7 +35,7 @@ void GetProfileInfoScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{};
|
||||
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
|
||||
|
||||
// Читаем текущий экран
|
||||
|
||||
@ -36,7 +36,7 @@ void LoginAndCheckAccountsScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{};
|
||||
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
|
||||
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
#include <QPointer>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QSettings>
|
||||
#include <QStandardPaths>
|
||||
#include <QTextStream>
|
||||
#include <QThread>
|
||||
@ -166,22 +167,39 @@ TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
|
||||
}
|
||||
|
||||
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
|
||||
const QString apiToken = SettingsDAO::get("api_key");
|
||||
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
|
||||
|
||||
// api-токен в caption больше не пишем: он общий на нескольких операторов и
|
||||
// не позволяет отличить, чей профиль упал. Идентифицируем по UUID профиля,
|
||||
// материалу и телефону профиля (см. m_meta).
|
||||
QStringList head{QStringLiteral("[TASK_ERROR]")};
|
||||
if (!apiToken.isEmpty())
|
||||
head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped());
|
||||
head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped());
|
||||
head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped());
|
||||
|
||||
QString body = QStringLiteral("Device: %1").arg(m_adbDeviceIdRaw.toHtmlEscaped());
|
||||
const QString desktopId = SettingsDAO::get("desktop_id");
|
||||
// Версия самого 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())
|
||||
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)
|
||||
body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
|
||||
if (!m_meta.phone.isEmpty())
|
||||
body += QStringLiteral("\nPhone: %1").arg(m_meta.phone.toHtmlEscaped());
|
||||
body += QStringLiteral("\nRecipient: %1").arg(m_meta.phone.toHtmlEscaped());
|
||||
if (!m_meta.card.isEmpty())
|
||||
body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped());
|
||||
body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped());
|
||||
@ -238,9 +256,18 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
||||
if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
QTextStream ts(&ef);
|
||||
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"
|
||||
<< "task: " << m_taskKind << "\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.amount != 0) ts << "amount: " << static_cast<qint64>(m_meta.amount) << "\n";
|
||||
if (!m_meta.phone.isEmpty()) ts << "phone: " << m_meta.phone << "\n";
|
||||
@ -255,6 +282,12 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
||||
const QString archivePath = m_folder + ".zip";
|
||||
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`, поэтому сразу читаем
|
||||
// его в память, пока папка ещё цела.
|
||||
QByteArray finalScreenshot;
|
||||
@ -265,7 +298,12 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
QThread *bg = QThread::create([folder, archivePath, text, finalScreenshot]() {
|
||||
QThread *bg = QThread::create([folder, archivePath, text, finalScreenshot, archiveFilename]() {
|
||||
// Кладём application.log + БД (+WAL/SHM) прямо в папку задачи (подкаталог
|
||||
// logs/), чтобы скрины задачи и логи ушли одним общим архивом, а не двумя
|
||||
// отдельными сообщениями на relay.
|
||||
LogArchiveSender::collectInto(folder + "/logs");
|
||||
|
||||
QByteArray archiveBytes;
|
||||
if (zipFolder(folder, archivePath)) {
|
||||
archiveBytes = readFileBytes(archivePath);
|
||||
@ -288,7 +326,8 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
||||
Qt::QueuedConnection,
|
||||
Q_ARG(QString, text),
|
||||
Q_ARG(QByteArray, archiveBytes),
|
||||
Q_ARG(QByteArray, finalScreenshot));
|
||||
Q_ARG(QByteArray, finalScreenshot),
|
||||
Q_ARG(QString, archiveFilename));
|
||||
if (!dispatched) {
|
||||
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
|
||||
"slot not found or wrong signature for" << folder;
|
||||
@ -297,11 +336,6 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
||||
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
|
||||
<< "(setNetworkService was not called at app startup?)";
|
||||
}
|
||||
|
||||
// Следом — application.log + БД (+ WAL/SHM) отдельным архивом.
|
||||
// Запускаем уже ПОСЛЕ того, как task-дамп поставлен в очередь
|
||||
// отправки, чтобы порядок прихода на relay был "дамп → логи".
|
||||
LogArchiveSender::dispatch(QStringLiteral("TASK_ERROR"));
|
||||
});
|
||||
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
|
||||
bg->start();
|
||||
|
||||
@ -9,8 +9,12 @@ class NetworkService;
|
||||
struct TaskDumpMeta {
|
||||
QString materialId;
|
||||
double amount = 0; // 0 = нет
|
||||
QString phone; // пусто = нет
|
||||
QString phone; // пусто = нет (телефон получателя в транзакции)
|
||||
QString card; // пусто = нет
|
||||
QString bankProfileId; // серверный UUID банковского профиля (детект в общих логах)
|
||||
QString profilePhone; // телефон банковского профиля (не получателя)
|
||||
QString androidId; // стабильный android_id устройства (в отличие от adbSerial не меняется)
|
||||
QString appVersion; // версия банковского приложения (из pm dump versionName)
|
||||
};
|
||||
|
||||
class TaskDumpRecorder {
|
||||
|
||||
87
android/dushanbe/AppVersionTrack.h
Normal file
@ -0,0 +1,87 @@
|
||||
#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
|
||||
@ -317,7 +317,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
|
||||
ns.postBankProfile();
|
||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||
if (afterPost.bankProfileId.isEmpty()) {
|
||||
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
|
||||
QString err;
|
||||
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;
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
#include "dao/DeviceDAO.h"
|
||||
#include "dump/TaskDumpRecorder.h"
|
||||
#include "net/NetworkService.h"
|
||||
#include "net/MaterialPayload.h"
|
||||
|
||||
namespace Dushanbe {
|
||||
|
||||
@ -35,9 +36,14 @@ void GetCardInfoScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{};
|
||||
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
|
||||
@ -263,29 +269,30 @@ void GetCardInfoScript::doStart() {
|
||||
qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers;
|
||||
}
|
||||
|
||||
// Отправляем на сервер если нет material_id
|
||||
// Отправляем на сервер: инвариант — только active-карта и только если
|
||||
// другая карта в (deviceId, appCode) ещё не синхронизирована.
|
||||
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||
if (!updatedApp.bankProfileId.isEmpty()) {
|
||||
const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
|
||||
if (savedAccount.id != -1 && savedAccount.materialId.isEmpty()) {
|
||||
if (savedAccount.id != -1
|
||||
&& savedAccount.status == MaterialStatus::Active
|
||||
&& savedAccount.materialId.isEmpty()) {
|
||||
bool otherSynced = false;
|
||||
for (const MaterialInfo &m : MaterialDAO::getAccounts(device.id, m_appCode)) {
|
||||
if (m.id != savedAccount.id && !m.materialId.isEmpty()) { otherSynced = true; break; }
|
||||
}
|
||||
if (otherSynced) {
|
||||
qDebug() << "[Dushanbe::GetCardInfo] another material already synced, skipping POST for" << lastNumbers;
|
||||
} else {
|
||||
NetworkService ns;
|
||||
ns.setDeviceId(m_deviceId);
|
||||
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"] = QString(fullNumber).remove(' ');
|
||||
materialBody["account_number"] = accountNumber;
|
||||
materialBody["name"] = updatedApp.fullName;
|
||||
materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled";
|
||||
materialBody["currency"] = currencyCodes.value(savedAccount.currency.toUpper(), 972);
|
||||
ns.setPayload(QJsonDocument(materialBody).toJson());
|
||||
ns.setPayload(buildMaterialPayload(savedAccount, updatedApp));
|
||||
ns.addExtra("account_id", savedAccount.id);
|
||||
ns.postMaterial();
|
||||
qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Назад к списку карт
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
|
||||
@ -15,9 +15,12 @@
|
||||
#include "dao/BankProfileDAO.h"
|
||||
#include "dao/DeviceDAO.h"
|
||||
#include "AppLogger.h"
|
||||
#include "BankConfigUtils.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
// Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50",
|
||||
// "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0,
|
||||
// потому что требует, чтобы вся строка была валидным числом.
|
||||
@ -249,15 +252,13 @@ namespace Dushanbe {
|
||||
GetLastTransactionsScript::GetLastTransactionsScript(
|
||||
QString deviceId,
|
||||
QString appCode,
|
||||
double searchAmount,
|
||||
QDateTime searchTime,
|
||||
QList<QPair<double, QDateTime>> targets,
|
||||
QString materialId,
|
||||
QObject *parent
|
||||
) : CommonScript(parent),
|
||||
m_deviceId(std::move(deviceId)),
|
||||
m_appCode(std::move(appCode)),
|
||||
m_searchAmount(searchAmount),
|
||||
m_searchTime(std::move(searchTime)),
|
||||
m_targets(std::move(targets)),
|
||||
m_materialId(std::move(materialId)) {
|
||||
}
|
||||
|
||||
@ -277,9 +278,15 @@ void GetLastTransactionsScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
|
||||
const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first;
|
||||
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);
|
||||
|
||||
// Версионный трек приложения → версионно-зависимый парсинг (см. 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));
|
||||
|
||||
if (!xmlScreenParser.isHomeScreen()) {
|
||||
@ -296,9 +303,9 @@ void GetLastTransactionsScript::doStart() {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
}
|
||||
|
||||
// Тапаем "История" в нижнем меню
|
||||
Node historyBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История"));
|
||||
if (historyBtn.x() == 0 && historyBtn.y() == 0) {
|
||||
// Тапаем "История" в нижнем меню (с геометрическим fallback при [0,0][0,0]).
|
||||
const QPoint historyPt = bottomNavPoint(QString::fromUtf8("История"), device);
|
||||
if (historyPt.x() < 0) {
|
||||
m_error = "Cannot find History button: " + m_deviceId;
|
||||
qWarning() << m_error;
|
||||
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||
@ -306,7 +313,7 @@ void GetLastTransactionsScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
AdbUtils::makeTap(m_deviceId, historyBtn.x(), historyBtn.y());
|
||||
AdbUtils::makeTap(m_deviceId, historyPt.x(), historyPt.y());
|
||||
QThread::msleep(3000);
|
||||
|
||||
// Ждём экран истории
|
||||
@ -378,9 +385,6 @@ void GetLastTransactionsScript::doStart() {
|
||||
}
|
||||
}
|
||||
|
||||
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
|
||||
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||
|
||||
// Находим accountId
|
||||
int accountId = -1;
|
||||
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||
@ -390,13 +394,111 @@ void GetLastTransactionsScript::doStart() {
|
||||
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), не устройства
|
||||
const QTimeZone tjTz("Asia/Dushanbe");
|
||||
const int maxScrolls = 10;
|
||||
|
||||
// Окно поиска по датам: сегодня + (N-1) предыдущих дней. Настраивается в
|
||||
// config.ini → [<bankCode>]/transaction_search_window_days (дефолт 2 = сегодня+вчера).
|
||||
const int searchWindowDays = BankConfigUtils::transactionSearchWindowDays(m_appCode);
|
||||
|
||||
auto interrupted = []() {
|
||||
return QThread::currentThread()->isInterruptionRequested();
|
||||
};
|
||||
@ -409,10 +511,17 @@ void GetLastTransactionsScript::doStart() {
|
||||
Node node;
|
||||
};
|
||||
|
||||
for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
|
||||
// Guard от «залипшей» прокрутки: если свайп не двигает список (банковское
|
||||
// приложение перестало скроллиться — частый случай при сбоях UI/жестов),
|
||||
// видимые транзакции не меняются. Тогда листать до maxScrolls бессмысленно,
|
||||
// а при десятках целей это упирается в 10-минутный watchdog → "Interrupted".
|
||||
// Детектируем по сигнатуре видимого набора: 2 одинаковых подряд = прогресса нет.
|
||||
QString prevViewSig;
|
||||
int stallScrolls = 0;
|
||||
|
||||
for (int scroll = 0; scroll <= maxScrolls && !pending.isEmpty(); ) {
|
||||
if (interrupted()) {
|
||||
m_error = "Interrupted";
|
||||
emit finishedWithResult(m_error);
|
||||
return;
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
@ -482,7 +591,11 @@ void GetLastTransactionsScript::doStart() {
|
||||
static const QRegularExpression reDateOrToday(
|
||||
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
|
||||
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
|
||||
static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)");
|
||||
// Сумма: либо с десятичной частью ("-5577.95", "109,50"), либо целая
|
||||
// ("109" — P2P-зачисления показываются без копеек). Целую часть
|
||||
// ограничиваем 7 цифрами, чтобы не спутать с 12–16-значным номером
|
||||
// карты, если он окажется на отдельной строке без пробелов/дефисов.
|
||||
static const QRegularExpression reAmountLine(R"(^-?(?:\d+[.,]\d{1,2}|\d{1,7})$)");
|
||||
QList<VypiskaItem> items;
|
||||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||
if (n.className != "android.view.View") continue;
|
||||
@ -498,12 +611,14 @@ void GetLastTransactionsScript::doStart() {
|
||||
|
||||
VypiskaItem it;
|
||||
bool ok = false;
|
||||
// Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному
|
||||
// DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые
|
||||
// поля не подпадают под паттерн.
|
||||
// Ищем строку-сумму — устойчиво и к 5-, и к 6-строчному DCWallet,
|
||||
// и к P2P. Карты с пробелами ("9762 0002 ...") и дефисами, а также
|
||||
// текстовые поля не подпадают под паттерн.
|
||||
for (const QString &line : lines) {
|
||||
if (!reAmountLine.match(line.trimmed()).hasMatch()) continue;
|
||||
it.amount = line.trimmed().toDouble(&ok);
|
||||
const QString t = line.trimmed();
|
||||
if (!reAmountLine.match(t).hasMatch()) continue;
|
||||
QString v = t; v.replace(',', '.');
|
||||
it.amount = v.toDouble(&ok);
|
||||
if (ok) break;
|
||||
}
|
||||
if (!ok) continue;
|
||||
@ -596,49 +711,58 @@ void GetLastTransactionsScript::doStart() {
|
||||
<< "y=" << items[i].node.y();
|
||||
}
|
||||
|
||||
const double searchAbs = qAbs(m_searchAmount);
|
||||
// Single-pass: сверяем каждую видимую транзакцию со ВСЕМ набором
|
||||
// оставшихся целей. Найденную убираем из pending и пере-сканируем тот же
|
||||
// экран (matchedThisScreen), скроллим только когда matchей на экране нет.
|
||||
bool matchedThisScreen = false;
|
||||
for (const auto &tx : items) {
|
||||
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;
|
||||
const bool matchedX100 = amountX100 && !amountDirect;
|
||||
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount
|
||||
<< (matchedX100 ? "(scaled x100)" : "(direct)")
|
||||
<< "date:" << tx.date << "time:" << tx.time;
|
||||
// Выбираем подходящую цель: совпадение по сумме + пред-фильтры даты/времени.
|
||||
// Список Выписки, экран деталей и поисковый таргет — все в мажорных
|
||||
// единицах (TJS, 2 знака), поэтому сверяем сумму напрямую. Эвристик
|
||||
// "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 && m_searchTime.isValid() && !tx.date.isEmpty()) {
|
||||
if (tx.dateReliable && cand.isValid() && !tx.date.isEmpty()) {
|
||||
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
|
||||
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
|
||||
const QDate searchLocalDate = cand.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;
|
||||
}
|
||||
if (dayDiff > 3) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи)
|
||||
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
|
||||
// Проверка времени по HH:MM:SS (+10 минут, одностороннее окно, с учётом wraparound полуночи)
|
||||
if (cand.isValid() && !tx.time.isEmpty()) {
|
||||
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
|
||||
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
|
||||
const QTime searchLocalTime = cand.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;
|
||||
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
|
||||
<< "date:" << tx.date << "time:" << tx.time
|
||||
<< "→ target idx" << matchIdx;
|
||||
|
||||
Node txNode = tx.node;
|
||||
|
||||
@ -659,13 +783,13 @@ void GetLastTransactionsScript::doStart() {
|
||||
const int tapX = txNode.x1() + 80;
|
||||
AdbUtils::makeTap(m_deviceId, tapX, txNode.y());
|
||||
QThread::msleep(3000);
|
||||
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
||||
if (interrupted()) { m_error = "Interrupted"; return; }
|
||||
|
||||
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
|
||||
OcrTransactionDetail detail;
|
||||
bool detailLoaded = false;
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
||||
if (interrupted()) { m_error = "Interrupted"; return; }
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
// 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml):
|
||||
@ -793,8 +917,8 @@ void GetLastTransactionsScript::doStart() {
|
||||
<< "date=" << detail.date << "time=" << detail.time
|
||||
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
|
||||
|
||||
// Проверяем время ±10 минут
|
||||
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
|
||||
// Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+10м, одностороннее окно)
|
||||
if (searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
|
||||
QDateTime txTime = QDateTime::fromString(
|
||||
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
||||
if (!txTime.isValid()) {
|
||||
@ -802,9 +926,9 @@ void GetLastTransactionsScript::doStart() {
|
||||
}
|
||||
if (txTime.isValid()) {
|
||||
txTime.setTimeZone(tjTz);
|
||||
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
|
||||
if (diffSecs > 600) {
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping";
|
||||
const qint64 diffSecs = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели
|
||||
if (diffSecs < 0 || diffSecs > 600) {
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (out of +10min window), skipping";
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(1000);
|
||||
continue;
|
||||
@ -823,8 +947,8 @@ void GetLastTransactionsScript::doStart() {
|
||||
// Формируем task_result напрямую из распарсенных данных и отдаём
|
||||
// EventHandler через сигнал — без записи/чтения БД.
|
||||
{
|
||||
const double finalAmount = matchedX100 ? detail.amount * 100.0 : detail.amount;
|
||||
const double finalFee = matchedX100 ? detail.fee * 100.0 : detail.fee;
|
||||
const double finalAmount = detail.amount;
|
||||
const double finalFee = detail.fee;
|
||||
|
||||
QDateTime txTime = QDateTime::fromString(
|
||||
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
||||
@ -834,9 +958,15 @@ void GetLastTransactionsScript::doStart() {
|
||||
if (txTime.isValid()) txTime.setTimeZone(tjTz);
|
||||
|
||||
QJsonObject txJson;
|
||||
txJson["bank_transaction_id"] = detail.operationId.isEmpty()
|
||||
? QString("dushanbe_") + detail.date + "_" + detail.time
|
||||
: detail.operationId;
|
||||
// Фолбэк-id (когда «Номер операции» не распарсился) строим из
|
||||
// created_at задачи — он детерминирован и стабилен между запусками,
|
||||
// что важно для дедупликации на сервере. Раньше брали распарсенные
|
||||
// с экрана дату+время, которые могли «плавать» из-за 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), знак берём из списка Выписки.
|
||||
const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount);
|
||||
@ -849,9 +979,27 @@ void GetLastTransactionsScript::doStart() {
|
||||
QJsonObject extraObj;
|
||||
extraObj["fee"] = static_cast<int>(finalFee * 100);
|
||||
txJson["extra"] = extraObj;
|
||||
txJson["created_at"] = txTime.isValid()
|
||||
// created_at — время из задачи (поисковый target), real_created_at —
|
||||
// фактическое время транзакции, спарсенное с экрана приложения.
|
||||
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);
|
||||
|
||||
// Проверяем глобальный кэш: если транзакция уже была найдена
|
||||
// в рамках другой задачи этого материала — пропускаем её.
|
||||
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:"
|
||||
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
|
||||
emit transactionParsed(txJson);
|
||||
@ -871,13 +1019,65 @@ void GetLastTransactionsScript::doStart() {
|
||||
}
|
||||
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 (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
||||
if (interrupted()) { m_error = "Interrupted"; return; }
|
||||
const int x = device.screenWidth / 2;
|
||||
// Определяем область контента из реальных Y позиций items
|
||||
int contentBottom = device.screenHeight * 2 / 3;
|
||||
@ -892,27 +1092,19 @@ void GetLastTransactionsScript::doStart() {
|
||||
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
|
||||
QThread::msleep(2000);
|
||||
}
|
||||
++scroll; // успешный match делает continue выше и сюда не доходит
|
||||
}
|
||||
|
||||
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
|
||||
+ " time=" + m_searchTime.toString(Qt::ISODate);
|
||||
qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
|
||||
|
||||
// Прошли весь доступный диапазон/окно. В pending остались ненайденные цели —
|
||||
// агрегированную ошибку по ним соберёт doStart (он же вернёт на главную).
|
||||
if (!pending.isEmpty()) {
|
||||
qWarning() << "[Dushanbe::GetLastTransactions] searchAll finished,"
|
||||
<< pending.size() << "target(s) not found";
|
||||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||||
m_resultScreenshot = listScreenshot.isEmpty()
|
||||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||
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
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
#include "CommonScript.h"
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QPair>
|
||||
#include <QPoint>
|
||||
#include <QSet>
|
||||
#include "db/DeviceInfo.h"
|
||||
#include "db/BankProfileInfo.h"
|
||||
|
||||
@ -14,8 +18,7 @@ public:
|
||||
explicit GetLastTransactionsScript(
|
||||
QString deviceId,
|
||||
QString appCode,
|
||||
double searchAmount = 0,
|
||||
QDateTime searchTime = {},
|
||||
QList<QPair<double, QDateTime>> targets = {},
|
||||
QString materialId = {},
|
||||
QObject *parent = nullptr
|
||||
);
|
||||
@ -23,21 +26,45 @@ public:
|
||||
~GetLastTransactionsScript() override;
|
||||
|
||||
signals:
|
||||
// Испускается, когда транзакция найдена и распарсена с экрана деталей.
|
||||
// Испускается на КАЖДУЮ найденную транзакцию из списка целей.
|
||||
// EventHandler использует эти данные напрямую для task_result вместо
|
||||
// повторного чтения из БД.
|
||||
void transactionParsed(const QJsonObject &tx);
|
||||
|
||||
public:
|
||||
// Снимок глобального кэша найденных транзакций (от EventHandler).
|
||||
// Ключи: "real_created_at|abs_amount_cents". Транзакции из этого набора
|
||||
// пропускаются при поиске, чтобы одна и та же TX не подтверждала два задания.
|
||||
void setFoundCache(const QSet<QString> &cache) { m_foundCache = cache; }
|
||||
|
||||
protected:
|
||||
void doStart() override;
|
||||
|
||||
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;
|
||||
const QString m_appCode;
|
||||
const double m_searchAmount; // сумма в сомони (0 = все последние)
|
||||
const QDateTime m_searchTime; // примерное время (UTC)
|
||||
// Список целей: пары (сумма в сомони со знаком, примерное время UTC).
|
||||
const QList<QPair<double, QDateTime>> m_targets;
|
||||
const QString m_materialId; // UUID задачи (для сохранения дампов)
|
||||
QString m_error;
|
||||
QSet<QString> m_foundCache; // снимок глобального кэша (от EventHandler)
|
||||
};
|
||||
|
||||
} // namespace Dushanbe
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
#include "GetProfileInfoScript.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QRegularExpression>
|
||||
@ -36,9 +39,14 @@ void GetProfileInfoScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{};
|
||||
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
|
||||
if (!xmlScreenParser.isHomeScreen()) {
|
||||
@ -55,27 +63,45 @@ void GetProfileInfoScript::doStart() {
|
||||
}
|
||||
|
||||
// Всегда тапаем кнопку обновления — баланс на экране может быть закэширован
|
||||
// и не отражать актуальное состояние счёта. Лоадера у refresh нет,
|
||||
// поэтому ждём фиксированно.
|
||||
// и не отражать актуальное состояние счёта. Лоадера у refresh нет, но во
|
||||
// время загрузки родительский блок временно становится content-desc="TJS"
|
||||
// (без даты, без числа) — это надёжный маркер «идёт обновление». Поэтому
|
||||
// после тапа поллим экран до появления распарсиваемого баланса.
|
||||
Node refreshBtn = xmlScreenParser.findRefreshButtonOnHomeScreen();
|
||||
std::optional<double> parsedOpt;
|
||||
if (refreshBtn.x() > 0) {
|
||||
qDebug() << "[Dushanbe::GetProfileInfo] Tapping refresh to get up-to-date balance";
|
||||
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
|
||||
QThread::msleep(3000);
|
||||
|
||||
constexpr int kPollIntervalMs = 500;
|
||||
constexpr int kPollTimeoutMs = 15000;
|
||||
QElapsedTimer t;
|
||||
t.start();
|
||||
while (true) {
|
||||
QThread::msleep(kPollIntervalMs);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
} else {
|
||||
qWarning() << "[Dushanbe::GetProfileInfo] Refresh button not found, parsing as-is";
|
||||
parsedOpt = xmlScreenParser.parseBalanceFromHomeScreenOpt();
|
||||
if (parsedOpt.has_value()) break;
|
||||
if (t.elapsed() >= kPollTimeoutMs) break;
|
||||
}
|
||||
|
||||
const double parsedBalance = xmlScreenParser.parseBalanceFromHomeScreen();
|
||||
qDebug() << "[Dushanbe::GetProfileInfo] Parsed balance after refresh:" << parsedBalance;
|
||||
if (parsedBalance <= 0.0) {
|
||||
m_error = "Balance is zero after refresh: " + m_deviceId + " " + m_appCode;
|
||||
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 {
|
||||
qWarning() << "[Dushanbe::GetProfileInfo] Refresh button not found, parsing as-is";
|
||||
parsedOpt = xmlScreenParser.parseBalanceFromHomeScreenOpt();
|
||||
}
|
||||
|
||||
const double parsedBalance = parsedOpt.value_or(0.0);
|
||||
qDebug() << "[Dushanbe::GetProfileInfo] Parsed balance after refresh:" << parsedBalance;
|
||||
// Нулевой баланс — валидное состояние счёта (пустой кошелёк), не ошибка.
|
||||
// Failure-ветка выше (Balance did not load) ловит реальную проблему —
|
||||
// зависший loading, когда блок "TJS\n<дата>" так и не появился.
|
||||
BankProfileDAO::updateAmount(app.id, parsedBalance);
|
||||
// У Душанбе все карты сидят на одном счёте, а экран деталей карты
|
||||
// отдаёт placeholder "0,00 TJS" — единственный источник правды
|
||||
|
||||
@ -41,9 +41,14 @@ void LoginAndCheckAccountsScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{};
|
||||
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
|
||||
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
||||
|
||||
@ -97,6 +97,48 @@ static QString recognizeOperationId(const QByteArray &pngData) {
|
||||
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 {
|
||||
|
||||
PayByCardScript::PayByCardScript(
|
||||
@ -123,9 +165,14 @@ void PayByCardScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
|
||||
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
|
||||
if (!xmlScreenParser.isHomeScreen()) {
|
||||
@ -378,23 +425,8 @@ void PayByCardScript::makePayment(
|
||||
QString::fromUtf8("Подтверждение платежа"), true);
|
||||
if (!confirmNode.isEmpty() &&
|
||||
confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
|
||||
// Compose flatten-ит весь bottom sheet в одну accessibility-кнопку,
|
||||
// отдельного узла "Подтверждаю" в дампе нет — координаты берём 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;
|
||||
}
|
||||
// Позицию кнопки ищем: цвет → OCR → геометрия (см. locateConfirmButton).
|
||||
const QPoint tap = locateConfirmButton(deviceId, confirmNode, height);
|
||||
qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap;
|
||||
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||
confirmed = true;
|
||||
@ -482,20 +514,9 @@ void PayByCardScript::makePayment(
|
||||
++confirmRetries;
|
||||
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap"
|
||||
<< confirmRetries << "/" << maxConfirmRetries;
|
||||
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
|
||||
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||
QPoint tap = OcrUtils::findTextCenter(screen,
|
||||
QString::fromUtf8("Подтверждаю"),
|
||||
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;
|
||||
}
|
||||
// Та же логика, что и на первом тапе: цвет → OCR → геометрия.
|
||||
const QPoint tap = locateConfirmButton(deviceId, retryConfirm, height);
|
||||
qDebug() << "[Dushanbe::PayByCard] Re-tapping 'Подтверждаю' at" << tap;
|
||||
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||
QThread::msleep(4000);
|
||||
continue;
|
||||
|
||||
@ -120,9 +120,14 @@ void PayByPhoneScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
|
||||
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
|
||||
if (!xmlScreenParser.isHomeScreen()) {
|
||||
|
||||
@ -164,31 +164,54 @@ Node ScreenXmlParser::findPinCodeEditText() {
|
||||
}
|
||||
|
||||
bool ScreenXmlParser::isHomeScreen() {
|
||||
// Нижняя навигация (Главная + соседняя вкладка) присутствует на ВСЕХ
|
||||
// основных вкладках — Главная, История, Переводы, MiniApps — поэтому одного
|
||||
// таб-бара недостаточно: экран «История/Операции» иначе ошибочно
|
||||
// принимается за home, и скрипт пытается открыть боковое меню/листать не с
|
||||
// той страницы (см. «Side menu not detected»). Требуем дополнительно
|
||||
// уникальный для главной якорь и явно отсекаем экран истории.
|
||||
bool hasGlavnaya = false;
|
||||
bool hasBottomNav = false;
|
||||
bool hasHomeAnchor = false;
|
||||
bool isHistoryScreen = false;
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc.contains(QString::fromUtf8("Главная"))) {
|
||||
const QString &d = node.contentDesc;
|
||||
if (d.contains(QString::fromUtf8("Главная"))) {
|
||||
hasGlavnaya = true;
|
||||
}
|
||||
// Нижняя навигация — любая из соседних вкладок указывает, что мы на
|
||||
// основной странице приложения, а не на splash/PIN/диалоге.
|
||||
if (node.contentDesc == QString::fromUtf8("Переводы") ||
|
||||
node.contentDesc == QString::fromUtf8("Сервисы") ||
|
||||
node.contentDesc == QString::fromUtf8("MiniApps") ||
|
||||
node.contentDesc == QString::fromUtf8("История")) {
|
||||
if (d == QString::fromUtf8("Переводы") ||
|
||||
d == QString::fromUtf8("Сервисы") ||
|
||||
d == QString::fromUtf8("MiniApps") ||
|
||||
d == QString::fromUtf8("История")) {
|
||||
hasBottomNav = true;
|
||||
}
|
||||
if (hasGlavnaya && hasBottomNav) {
|
||||
return true;
|
||||
// Уникальные для главного экрана элементы (нет на Истории/Переводах):
|
||||
// заголовок «Мини приложения», плитки переводов «DC по номеру …»
|
||||
// (в 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 false;
|
||||
return hasGlavnaya && hasBottomNav && hasHomeAnchor && !isHistoryScreen;
|
||||
}
|
||||
|
||||
double ScreenXmlParser::parseBalanceFromHomeScreen() {
|
||||
std::optional<double> ScreenXmlParser::parseBalanceFromHomeScreenOpt() {
|
||||
// На домашнем экране: parent node content-desc="TJS\n03.04.26 - 21:46:15"
|
||||
// внутри него 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) {
|
||||
if (!m_nodes[i].contentDesc.startsWith("TJS")) continue;
|
||||
// Проверяем что это блок с датой (не просто "TJS" текст)
|
||||
@ -205,7 +228,11 @@ double ScreenXmlParser::parseBalanceFromHomeScreen() {
|
||||
if (ok) return val;
|
||||
}
|
||||
}
|
||||
return 0.0;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
double ScreenXmlParser::parseBalanceFromHomeScreen() {
|
||||
return parseBalanceFromHomeScreenOpt().value_or(0.0);
|
||||
}
|
||||
|
||||
QString ScreenXmlParser::parseUserNameFromHomeScreen() {
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
#include <QDomElement>
|
||||
#include "android/xml/Node.h"
|
||||
#include "AppVersionTrack.h"
|
||||
#include "db/MaterialInfo.h"
|
||||
|
||||
namespace Dushanbe {
|
||||
@ -18,6 +21,14 @@ public:
|
||||
|
||||
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;
|
||||
|
||||
Node findTextNode(const QString &text);
|
||||
@ -37,9 +48,20 @@ public:
|
||||
|
||||
bool isHomeScreen();
|
||||
|
||||
// Парсит баланс с домашнего экрана
|
||||
// Парсит баланс с домашнего экрана. Возвращает 0.0, если блок "TJS\n<дата>"
|
||||
// не найден или внутри него нет числовой ноды (это может быть как реально
|
||||
// нулевой баланс, так и loading state сразу после тапа refresh). Для
|
||||
// различения этих случаев используйте parseBalanceFromHomeScreenOpt().
|
||||
double parseBalanceFromHomeScreen();
|
||||
|
||||
// То же, что parseBalanceFromHomeScreen, но возвращает std::nullopt, когда
|
||||
// экран в состоянии загрузки (родитель имеет content-desc "TJS" без даты,
|
||||
// либо блок "TJS\n<дата>" найден, но числовая нода рядом отсутствует) или
|
||||
// когда мы вообще не на home screen. При nullopt вызывающий код может
|
||||
// подождать и попробовать снова — это безопаснее, чем интерпретировать
|
||||
// loading как реальный нулевой баланс.
|
||||
std::optional<double> parseBalanceFromHomeScreenOpt();
|
||||
|
||||
// Находит кнопку обновления данных в блоке "TJS\n<дата>" — правый clickable
|
||||
// ImageView (рядом слева сидит глазик-Button, его не трогаем).
|
||||
Node findRefreshButtonOnHomeScreen();
|
||||
@ -160,6 +182,7 @@ public:
|
||||
private:
|
||||
QList<Node> m_nodes;
|
||||
QDomElement m_xml;
|
||||
AppVersionTrack m_track = AppVersionTrack::V3_2_38;
|
||||
};
|
||||
|
||||
} // namespace Dushanbe
|
||||
|
||||
@ -202,4 +202,89 @@ QPoint findTextCenter(const QByteArray &png,
|
||||
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
|
||||
@ -19,4 +19,25 @@ QPoint findTextCenter(const QByteArray &png,
|
||||
int yMin = 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
|
||||
79
android/ozon/AppVersionTrack.h
Normal file
@ -0,0 +1,79 @@
|
||||
#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
|
||||
@ -179,6 +179,14 @@ bool CommonScript::runAppAndGoToHomeScreen(
|
||||
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) {
|
||||
const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
||||
@ -225,6 +233,12 @@ bool CommonScript::tryToCloseAllBannersOnHomePage(const QString &deviceId, const
|
||||
closedAny = true;
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
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);
|
||||
closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
|
||||
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
|
||||
@ -452,7 +466,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
|
||||
ns.postBankProfile();
|
||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||
if (afterPost.bankProfileId.isEmpty()) {
|
||||
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
|
||||
QString err;
|
||||
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;
|
||||
return err;
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
#include "dao/SettingsDAO.h"
|
||||
#include "dump/TaskDumpRecorder.h"
|
||||
#include "net/NetworkService.h"
|
||||
#include "net/MaterialPayload.h"
|
||||
#include "AppLogger.h"
|
||||
|
||||
namespace Ozon {
|
||||
@ -41,9 +42,14 @@ void GetCardInfoScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{};
|
||||
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
|
||||
@ -234,7 +240,12 @@ void GetCardInfoScript::doStart() {
|
||||
// Проверяем что bankProfileId сохранился
|
||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||
if (afterPost.bankProfileId.isEmpty()) {
|
||||
const QString err = "postBankProfile failed for " + m_deviceId;
|
||||
QString err;
|
||||
if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) {
|
||||
err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage();
|
||||
} else {
|
||||
err = "postBankProfile failed for " + m_deviceId;
|
||||
}
|
||||
qWarning() << "[GetCardInfo]" << err;
|
||||
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
|
||||
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||
@ -274,13 +285,50 @@ void GetCardInfoScript::doStart() {
|
||||
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);
|
||||
|
||||
// Скроллим WebView вверх, пока "Показать" не окажется в верхней трети экрана.
|
||||
// На устройствах с короткой высотой (например, SM-A165F, h=2340) кнопка "Показать"
|
||||
// находится за bottom navigation, и тап уходит в нав-бар.
|
||||
const int targetTopY = device.screenHeight / 3;
|
||||
for (int scrollAttempt = 0; scrollAttempt < 6; ++scrollAttempt) {
|
||||
Node cardTile;
|
||||
for (int a = 0; a < 8; ++a) {
|
||||
QThread::msleep(a == 0 ? 1500 : 700);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
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);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
@ -296,15 +344,24 @@ void GetCardInfoScript::doStart() {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (candidate.y() <= targetTopY) {
|
||||
qDebug() << "[GetCardInfo] 'Показать' already at top y=" << candidate.y();
|
||||
const int cy = candidate.y();
|
||||
if (cy >= minTapY && cy <= midY) {
|
||||
qDebug() << "[GetCardInfo] 'Показать' in tap zone (above middle) y=" << cy;
|
||||
break;
|
||||
}
|
||||
const int swipeFromY = candidate.y();
|
||||
const int swipeToY = device.screenHeight / 6;
|
||||
qDebug() << "[GetCardInfo] Scrolling 'Показать' up:" << swipeFromY << "->" << swipeToY;
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, swipeFromY,
|
||||
device.screenWidth / 2, swipeToY);
|
||||
if (cy > midY) {
|
||||
// Ниже середины — листаем вверх, целясь в верхнюю треть (медленно, без перелёта).
|
||||
const int swipeToY = device.screenHeight / 3;
|
||||
qDebug() << "[GetCardInfo] Scrolling 'Показать' up:" << cy << "->" << swipeToY;
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, cy,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Ждём экран карты и стабильной геометрии блока реквизитов.
|
||||
@ -411,7 +468,16 @@ void GetCardInfoScript::doStart() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!cardNumber.isEmpty()) {
|
||||
if (!cardNumber.isEmpty() && !cardNumber.endsWith(card.lastNumbers)) {
|
||||
// Прочитан номер ЧУЖОЙ карты (карусель отдала не ту) — НЕ сохраняем, иначе
|
||||
// материал получит неверный номер. Раньше из-за этого все карты получали
|
||||
// номер передней карты карусели.
|
||||
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;
|
||||
// Апсёртим запись только теперь — с полным номером карты.
|
||||
MaterialInfo toSave = card;
|
||||
@ -432,12 +498,16 @@ void GetCardInfoScript::doStart() {
|
||||
"FETCH_PROFILE");
|
||||
}
|
||||
|
||||
// Возвращаемся на домашний экран для следующей карты
|
||||
// Возвращаемся на домашний экран для следующей карты. В новом UI путь длиннее
|
||||
// (карта → "Основной счёт" → главная), поэтому жмём "назад" до главной (макс 3).
|
||||
if (i < cards.size() - 1) {
|
||||
for (int b = 0; b < 3; ++b) {
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(2000);
|
||||
QThread::msleep(1500);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
if (xmlScreenParser.isHomeScreen()) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -541,31 +611,30 @@ void GetCardInfoScript::doStart() {
|
||||
// }
|
||||
// }
|
||||
|
||||
// 3. Отправляем материалы (карты) — после OCR, когда номера карт уже в БД
|
||||
// 3. Отправляем материалы (карты) на сервер.
|
||||
// Инвариант: одновременно живёт максимум один материал на (deviceId, appCode) —
|
||||
// тот, что локально active. Пушим только active-карту и только если другой
|
||||
// материал ещё не синхронизирован (нет с непустым materialId).
|
||||
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||
if (!updatedApp.bankProfileId.isEmpty()) {
|
||||
const QList<MaterialInfo> dbCards = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||
bool alreadySynced = false;
|
||||
for (const MaterialInfo &m : dbCards) {
|
||||
if (!m.materialId.isEmpty()) { alreadySynced = true; break; }
|
||||
}
|
||||
if (alreadySynced) {
|
||||
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.materialId.isEmpty()) {
|
||||
qDebug() << "[GetCardInfo] material already exists for card" << dbCard.lastNumbers << ", skipping";
|
||||
continue;
|
||||
}
|
||||
|
||||
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());
|
||||
if (dbCard.status != MaterialStatus::Active) continue;
|
||||
ns.setPayload(buildMaterialPayload(dbCard, updatedApp));
|
||||
ns.addExtra("account_id", dbCard.id);
|
||||
ns.postMaterial();
|
||||
qDebug() << "[GetCardInfo] postMaterial done for card" << dbCard.lastNumbers;
|
||||
qDebug() << "[GetCardInfo] postMaterial done for active card" << dbCard.lastNumbers;
|
||||
break; // не больше одного материала на сервер
|
||||
}
|
||||
}
|
||||
} else {
|
||||
qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials";
|
||||
|
||||
@ -82,16 +82,14 @@ GetLastTransactionsScript::GetLastTransactionsScript(
|
||||
QString deviceId,
|
||||
QString appCode,
|
||||
int maxCount,
|
||||
double searchAmount,
|
||||
QDateTime searchTime,
|
||||
QList<QPair<double, QDateTime>> targets,
|
||||
QString materialId,
|
||||
QObject *parent
|
||||
) : CommonScript(parent),
|
||||
m_deviceId(std::move(deviceId)),
|
||||
m_appCode(std::move(appCode)),
|
||||
m_maxCount(maxCount),
|
||||
m_searchAmount(searchAmount),
|
||||
m_searchTime(std::move(searchTime)),
|
||||
m_targets(std::move(targets)),
|
||||
m_materialId(std::move(materialId)) {
|
||||
}
|
||||
|
||||
@ -110,9 +108,15 @@ void GetLastTransactionsScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
|
||||
const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first;
|
||||
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);
|
||||
|
||||
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
|
||||
qDebug() << "[Ozon::GetLastTransactions] appVersion=" << app.appVersion
|
||||
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
|
||||
|
||||
if (!navigateToTransactionList(device, app)) {
|
||||
emit finishedWithResult(m_error);
|
||||
return;
|
||||
@ -127,9 +131,31 @@ void GetLastTransactionsScript::doStart() {
|
||||
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
|
||||
}
|
||||
|
||||
// Выбор режима: поиск конкретной транзакции или сканирование последних
|
||||
if (qAbs(m_searchAmount) > 0.01) {
|
||||
searchTransaction(device, accountId);
|
||||
// Выбор режима: поиск списка конкретных транзакций или сканирование последних.
|
||||
if (!m_targets.isEmpty()) {
|
||||
// Ищем каждую цель из списка. Найденные отдаём сразу через
|
||||
// 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 {
|
||||
scanRecentTransactions(device, accountId);
|
||||
}
|
||||
@ -242,8 +268,9 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
|
||||
return false;
|
||||
}
|
||||
|
||||
void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
|
||||
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
|
||||
bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountId,
|
||||
double searchAmount, const QDateTime &searchTime) {
|
||||
// Скриншот списка транзакций — отправим при неудаче, удалим при успехе
|
||||
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||
|
||||
// Время в Ozon показывается в таймзоне устройства, а не в MSK
|
||||
@ -266,12 +293,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
int navBarTop = detectNavBarTop();
|
||||
|
||||
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
|
||||
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
|
||||
const QDateTime searchLocal = searchTime.toTimeZone(deviceTz);
|
||||
const QDate searchDate = searchLocal.date();
|
||||
|
||||
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
|
||||
<< "amount=" << m_searchAmount
|
||||
<< "time=" << m_searchTime.toString(Qt::ISODate)
|
||||
<< "amount=" << searchAmount
|
||||
<< "time=" << searchTime.toString(Qt::ISODate)
|
||||
<< "tz=" << tzName
|
||||
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
|
||||
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
|
||||
@ -348,7 +375,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
|
||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||
m_error = "Interrupted";
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
@ -369,12 +396,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
}
|
||||
|
||||
if (!dateFound) {
|
||||
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
|
||||
qWarning() << "[FetchTransactions]" << m_error;
|
||||
qWarning() << "[FetchTransactions] Date not found in transaction list: "
|
||||
<< searchDate.toString("dd.MM.yyyy");
|
||||
m_resultScreenshot = listScreenshot.isEmpty()
|
||||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
||||
@ -436,7 +463,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||
m_error = "Interrupted";
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
@ -503,7 +530,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
for (int bi = 0; bi < txButtons.size(); ++bi) {
|
||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||
m_error = "Interrupted";
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Пропускаем кнопки вне Y-диапазона нужной даты.
|
||||
@ -517,9 +544,9 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
|
||||
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
|
||||
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
|
||||
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
|
||||
<< "parsed amount=" << parsed.amount << "search=" << searchAmount
|
||||
<< "text=" << txButtons[bi].text.left(60);
|
||||
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
|
||||
if (qAbs(parsed.amount - searchAmount) > 0.01) continue;
|
||||
|
||||
const Node &visibleBtn = txButtons[bi];
|
||||
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
|
||||
@ -543,7 +570,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
for (int da = 0; da < 10; ++da) {
|
||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||
m_error = "Interrupted";
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
@ -596,10 +623,10 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем время (±10 минут)
|
||||
if (txTime.isValid() && m_searchTime.isValid()) {
|
||||
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
|
||||
if (diffSecs > 600) {
|
||||
// Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+10м, одностороннее окно)
|
||||
if (txTime.isValid() && searchTime.isValid()) {
|
||||
const qint64 diffSecs = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели
|
||||
if (diffSecs < 0 || diffSecs > 600) {
|
||||
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
|
||||
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
|
||||
<< "diff=" << diffSecs << "sec";
|
||||
@ -614,6 +641,20 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
<< "amount=" << parsed.amount << "time=" << dtStr
|
||||
<< "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.
|
||||
// Раньше дополнительно тапали "Документы" + OCR ради ID операции,
|
||||
// но это медленно и нестабильно. Теперь полагаемся на дату+время+
|
||||
@ -622,6 +663,16 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
|
||||
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) {
|
||||
TransactionInfo tx;
|
||||
@ -629,9 +680,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
tx.amount = parsed.amount;
|
||||
tx.bankName = m_appCode;
|
||||
tx.bankTime = dtStr;
|
||||
tx.bankTrExternalId = externalId.isEmpty()
|
||||
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
|
||||
: externalId;
|
||||
tx.bankTrExternalId = finalExternalId;
|
||||
tx.status = TransactionStatus::Complete;
|
||||
tx.timestamp = DateUtils::getUtcNow();
|
||||
if (txTime.isValid()) tx.completeTime = txTime;
|
||||
@ -649,10 +698,6 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
|
||||
// Отдаём результат в EventHandler через сигнал (как Dushanbe)
|
||||
{
|
||||
const QString finalExternalId = externalId.isEmpty()
|
||||
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
|
||||
: externalId;
|
||||
|
||||
QJsonObject txJson;
|
||||
txJson["bank_transaction_id"] = finalExternalId;
|
||||
txJson["amount"] = static_cast<int>(parsed.amount * 100);
|
||||
@ -665,7 +710,11 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
QJsonObject extraObj;
|
||||
extraObj["fee"] = 0;
|
||||
txJson["extra"] = extraObj;
|
||||
txJson["created_at"] = txTime.isValid()
|
||||
// created_at — время из задачи (поисковый target), real_created_at —
|
||||
// фактическое время транзакции, спарсенное с экрана приложения.
|
||||
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);
|
||||
|
||||
qDebug() << "[FetchTransactions] Emitting parsed tx:"
|
||||
@ -680,7 +729,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
QThread::msleep(1000);
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(1000);
|
||||
return; // успех
|
||||
return true; // успех
|
||||
}
|
||||
|
||||
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
|
||||
@ -751,14 +800,30 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
}
|
||||
}
|
||||
|
||||
m_error = "Transaction not found: device=" + m_deviceId
|
||||
+ " amount=" + QString::number(m_searchAmount, 'f', 2)
|
||||
+ " date=" + searchDate.toString("dd.MM.yyyy");
|
||||
qWarning() << "[FetchTransactions]" << m_error;
|
||||
// Эту цель не нашли. m_error не трогаем — агрегированную ошибку по всем
|
||||
// ненайденным целям соберёт оркестратор в doStart.
|
||||
qWarning() << "[FetchTransactions] Transaction not found: device=" << m_deviceId
|
||||
<< "amount=" << QString::number(searchAmount, 'f', 2)
|
||||
<< "date=" << searchDate.toString("dd.MM.yyyy");
|
||||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||||
m_resultScreenshot = listScreenshot.isEmpty()
|
||||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||
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) {
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
#include "CommonScript.h"
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QPair>
|
||||
#include <QSet>
|
||||
#include "db/DeviceInfo.h"
|
||||
#include "db/BankProfileInfo.h"
|
||||
|
||||
@ -11,15 +14,21 @@ class GetLastTransactionsScript final : public CommonScript {
|
||||
Q_OBJECT
|
||||
|
||||
signals:
|
||||
// Испускается на КАЖДУЮ найденную транзакцию из списка целей.
|
||||
void transactionParsed(const QJsonObject &tx);
|
||||
|
||||
public:
|
||||
// Снимок глобального кэша найденных транзакций (от EventHandler).
|
||||
// Ключи: "real_created_at|abs_amount_cents". Транзакции из этого набора
|
||||
// пропускаются при поиске, чтобы одна и та же TX не подтверждала два задания.
|
||||
void setFoundCache(const QSet<QString> &cache) { m_foundCache = cache; }
|
||||
|
||||
public:
|
||||
explicit GetLastTransactionsScript(
|
||||
QString deviceId,
|
||||
QString appCode,
|
||||
int maxCount = 0,
|
||||
double searchAmount = 0,
|
||||
QDateTime searchTime = {},
|
||||
QList<QPair<double, QDateTime>> targets = {},
|
||||
QString materialId = {},
|
||||
QObject *parent = nullptr
|
||||
);
|
||||
@ -36,16 +45,23 @@ private:
|
||||
// Сканирование последних транзакций (для UI: 24ч или maxCount)
|
||||
void scanRecentTransactions(const DeviceInfo &device, int accountId);
|
||||
|
||||
// Поиск конкретной транзакции по сумме и времени (для задач FETCH_TRANSACTIONS)
|
||||
void searchTransaction(const DeviceInfo &device, int accountId);
|
||||
// Поиск одной конкретной транзакции по сумме и времени (FETCH_TRANSACTIONS).
|
||||
// Возвращает true и эмитит transactionParsed, если нашли; false — если нет.
|
||||
bool searchOne(const DeviceInfo &device, int accountId,
|
||||
double searchAmount, const QDateTime &searchTime);
|
||||
|
||||
// Быстрый возврат к началу списка операций (между поисками целей)
|
||||
void scrollListToTop(const DeviceInfo &device);
|
||||
|
||||
QString m_deviceId;
|
||||
const QString m_appCode;
|
||||
const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних
|
||||
const double m_searchAmount; // сумма в рублях (0 = не искать конкретную)
|
||||
const QDateTime m_searchTime; // примерное время (UTC)
|
||||
// Список целей поиска: пары (сумма в рублях со знаком, примерное время UTC).
|
||||
// Пустой список → режим сканирования последних (scanRecentTransactions).
|
||||
const QList<QPair<double, QDateTime>> m_targets;
|
||||
const QString m_materialId; // UUID задачи (для сохранения дампов)
|
||||
QString m_error;
|
||||
QSet<QString> m_foundCache; // снимок глобального кэша (от EventHandler)
|
||||
};
|
||||
|
||||
} // namespace Ozon
|
||||
|
||||
@ -37,9 +37,14 @@ void GetProfileInfoScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{};
|
||||
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
|
||||
@ -169,7 +174,12 @@ void GetProfileInfoScript::doStart() {
|
||||
ns.postBankProfile();
|
||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||
if (afterPost.bankProfileId.isEmpty()) {
|
||||
const QString err = "postBankProfile failed for " + m_deviceId;
|
||||
QString err;
|
||||
if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) {
|
||||
err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage();
|
||||
} else {
|
||||
err = "postBankProfile failed for " + m_deviceId;
|
||||
}
|
||||
qWarning() << "[GetProfileInfo]" << err;
|
||||
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
|
||||
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||
|
||||
@ -40,9 +40,14 @@ void LoginAndCheckAccountsScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
const TaskDumpMeta dumpMeta{};
|
||||
const TaskDumpMeta dumpMeta{{}, 0, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
|
||||
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
||||
|
||||
@ -91,9 +91,14 @@ void PayByCardScript::doStart() {
|
||||
}
|
||||
|
||||
const QString &adbId = device.adbSerial;
|
||||
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
|
||||
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
|
||||
// Закрываем рекламный bottom sheet
|
||||
@ -162,6 +167,10 @@ void PayByCardScript::makePayment(
|
||||
};
|
||||
// 1. Нажимаем "Перевести" на домашнем экране
|
||||
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()) {
|
||||
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
||||
qWarning() << "[PayByCard]" << m_error;
|
||||
|
||||
@ -100,9 +100,14 @@ void PayByPhoneScript::doStart() {
|
||||
}
|
||||
|
||||
const QString &adbId = device.adbSerial;
|
||||
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
|
||||
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}, app.bankProfileId, app.phone, device.id, app.appVersion};
|
||||
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));
|
||||
|
||||
// Закрываем рекламный bottom sheet
|
||||
@ -176,6 +181,10 @@ void PayByPhoneScript::makePayment(
|
||||
// открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы
|
||||
// гарантированно попасть мимо бейджа.
|
||||
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()) {
|
||||
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
||||
qWarning() << "[PayByPhone]" << m_error;
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
#include "db/MaterialInfo.h"
|
||||
#include "db/TransactionInfo.h"
|
||||
#include "time/DateUtils.h"
|
||||
#include "BankConfigUtils.h"
|
||||
|
||||
namespace Ozon {
|
||||
|
||||
@ -256,13 +257,17 @@ Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) {
|
||||
}
|
||||
|
||||
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 hasTopUp = false;
|
||||
for (const Node &node: m_nodes) {
|
||||
const QString t = node.text.trimmed();
|
||||
if (t == "Перевести") hasTransfer = true;
|
||||
if (t == "Пополнить") hasTopUp = true;
|
||||
const QString cd = node.contentDesc.trimmed();
|
||||
if (t == "Перевести" || cd == "transfer-button-shared") hasTransfer = true;
|
||||
if (t == "Пополнить" || cd == "replenish-button") hasTopUp = true;
|
||||
if (hasTransfer && hasTopUp) return true;
|
||||
}
|
||||
return false;
|
||||
@ -323,6 +328,28 @@ Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int
|
||||
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() {
|
||||
// Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти"
|
||||
bool hasTitle = false;
|
||||
@ -427,7 +454,13 @@ Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) {
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.className != "android.widget.Button" || !node.clickable) continue;
|
||||
const QString t = node.text.trimmed();
|
||||
if (!t.endsWith(lastNumbers)) continue;
|
||||
// Старый UI: отдельная кнопка карты оканчивается на 4 цифры.
|
||||
// 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)
|
||||
if (t.contains(QString::fromUtf8("Переводы"))
|
||||
|| t.contains(QStringLiteral("**"))
|
||||
@ -449,6 +482,40 @@ Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) {
|
||||
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() {
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.className != "android.widget.Button") continue;
|
||||
@ -522,35 +589,64 @@ QList<MaterialInfo> ScreenXmlParser::parseCardsOnHomeScreen() {
|
||||
if (commonAmount == 0.0)
|
||||
qWarning() << "[parseCards] Balance button not found or amount=0";
|
||||
|
||||
// 2. Собираем все кнопки карт (текст заканчивается на 4 цифры).
|
||||
// Записи из ленты переводов выглядят как кнопки и тоже заканчиваются на 4 цифры
|
||||
// ("Евгений Дмитриевич Р. − 100 ₽ Переводы • Карта **1092") — отсеиваем по маркерам.
|
||||
QList<MaterialInfo> cards;
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.className != "android.widget.Button" || !node.clickable) continue;
|
||||
QString t = node.text.trimmed();
|
||||
if (!endsWithFourDigits.match(t).hasMatch()) continue;
|
||||
|
||||
// Маркеры записи из ленты переводов:
|
||||
// - слово "Переводы"
|
||||
// - маска "**" перед цифрами
|
||||
// - знак суммы транзакции (минус U+2212 или плюс)
|
||||
if (t.contains(QString::fromUtf8("Переводы"))
|
||||
// Маркеры записи из ленты переводов (не карта):
|
||||
// - слово "Переводы", маска "**" перед цифрами, знак суммы (минус U+2212 или плюс).
|
||||
auto isTransferFeedRow = [](const QString &t) {
|
||||
return t.contains(QString::fromUtf8("Переводы"))
|
||||
|| t.contains(QStringLiteral("**"))
|
||||
|| t.contains(QChar(0x2212)) || t.contains(QChar('+'))) {
|
||||
|| 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;
|
||||
QStringList seen;
|
||||
static const QRegularExpression bankCardRx(R"((?:Bank Card|Карта)\s+(\d{4}))");
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.className != "android.widget.Button") continue;
|
||||
const QString t = node.text;
|
||||
if (isTransferFeedRow(t)) {
|
||||
qDebug() << "[parseCards] Skipping transfer-feed row:" << t;
|
||||
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 цифрами.
|
||||
if (cards.isEmpty()) {
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.className != "android.widget.Button" || !node.clickable) continue;
|
||||
const QString t = node.text.trimmed();
|
||||
if (!endsWithFourDigits.match(t).hasMatch()) continue;
|
||||
if (isTransferFeedRow(t)) {
|
||||
qDebug() << "[parseCards] Skipping transfer-feed row:" << t;
|
||||
continue;
|
||||
}
|
||||
MaterialInfo acc;
|
||||
acc.lastNumbers = t.right(4);
|
||||
acc.amount = commonAmount;
|
||||
acc.currency = "RUB";
|
||||
acc.description = "Виртуальная карта";
|
||||
qDebug() << "[parseCards] Card found: lastNumbers=" << acc.lastNumbers
|
||||
qDebug() << "[parseCards] Card found (fallback):" << acc.lastNumbers
|
||||
<< "text=" << node.text << "amount=" << commonAmount;
|
||||
cards.append(acc);
|
||||
}
|
||||
}
|
||||
|
||||
qDebug() << "[parseCards] Total cards found:" << cards.size();
|
||||
return cards;
|
||||
@ -582,8 +678,11 @@ QList<Node> ScreenXmlParser::findPinCodeNodes(const QString &pinCode) {
|
||||
static const QRegularExpression digitRx(R"(^(?:key\s+)?(\d)$)", QRegularExpression::CaseInsensitiveOption);
|
||||
for (const Node &node : m_nodes) {
|
||||
if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue;
|
||||
const QString t = node.text.trimmed();
|
||||
if (const auto match = digitRx.match(t); match.hasMatch()) {
|
||||
// Цифра живёт в text ("1"/"key 1") в старом UI, либо — в Compose-сборках Ozon
|
||||
// (>= 19.x) — в content-desc ("key 1") при пустом text.
|
||||
auto match = digitRx.match(node.text.trimmed());
|
||||
if (!match.hasMatch()) match = digitRx.match(node.contentDesc.trimmed());
|
||||
if (match.hasMatch()) {
|
||||
digitMap[match.captured(1)] = node;
|
||||
}
|
||||
}
|
||||
@ -1196,30 +1295,45 @@ QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayReportHistory() {
|
||||
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() {
|
||||
QDate today = DateUtils::getMoscowNow().date();
|
||||
QDate yesterday = today.addDays(-1);
|
||||
QDate beforeYesterday = today.addDays(-2);
|
||||
// Окно поиска: сегодня + (N-1) предыдущих дней. Настраивается в
|
||||
// config.ini → [ozon]/transaction_search_window_days (дефолт 2 = сегодня+вчера).
|
||||
const int windowDays = BankConfigUtils::transactionSearchWindowDays("ozon");
|
||||
const QDate today = DateUtils::getMoscowNow().date();
|
||||
|
||||
|
||||
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;
|
||||
if (!yesterdayNode.isNull()) {
|
||||
parentNode = yesterdayNode.parentNode();
|
||||
} else if (!todayNode.isNull()) {
|
||||
parentNode = todayNode.parentNode();
|
||||
// Заголовки дней внутри окна [сегодня .. сегодня-(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);
|
||||
|
||||
// Родительский контейнер списка: берём первый существующий заголовок окна.
|
||||
QDomNode parentNode;
|
||||
for (const DayTitle &dt : windowTitles) {
|
||||
const QDomNode node = findNode(m_xml, dt.title);
|
||||
if (!node.isNull()) {
|
||||
parentNode = node.parentNode();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
QList<TransactionInfo> transactions;
|
||||
if (!parentNode.isNull()) {
|
||||
@ -1228,17 +1342,19 @@ QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayHistory() {
|
||||
QString transactionShortText = transactionNode.toElement().attribute("text");
|
||||
|
||||
const QString titleLowerCase = transactionShortText.toLower();
|
||||
if (titleLowerCase.startsWith(beforeYesterdayTitle)) {
|
||||
// только сегодня и вчера парсим, позавчера не трогаем
|
||||
|
||||
// День за пределами окна — дальше только старая история, прекращаем.
|
||||
if (titleLowerCase.startsWith(boundaryTitle)) {
|
||||
break;
|
||||
}
|
||||
if (titleLowerCase.startsWith(todayTitle)) {
|
||||
date = today;
|
||||
transactionShortText = transactionShortText.mid(todayTitle.length() + 1);
|
||||
|
||||
// Заголовок дня внутри окна — обновляем текущую дату и срезаем префикс.
|
||||
for (const DayTitle &dt : windowTitles) {
|
||||
if (titleLowerCase.startsWith(dt.title)) {
|
||||
date = dt.date;
|
||||
transactionShortText = transactionShortText.mid(dt.title.length() + 1);
|
||||
break;
|
||||
}
|
||||
if (titleLowerCase.startsWith(yesterdayTitle)) {
|
||||
date = yesterday;
|
||||
transactionShortText = transactionShortText.mid(yesterdayTitle.length() + 1);
|
||||
}
|
||||
|
||||
if (transactionShortText.contains("ЕСПП")) {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
#include "android/xml/Node.h"
|
||||
#include "db/MaterialInfo.h"
|
||||
#include "db/TransactionInfo.h"
|
||||
#include "AppVersionTrack.h"
|
||||
|
||||
namespace Ozon {
|
||||
|
||||
@ -16,6 +17,13 @@ public:
|
||||
|
||||
~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 tryToFindSecurityBanner();
|
||||
@ -50,6 +58,11 @@ public:
|
||||
|
||||
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
|
||||
|
||||
// Онбординг-баннер "Обновили главную" (resource-id onboarding_viewpager),
|
||||
// появляется поверх главного экрана Ozon. Возвращает кнопку "Пропустить"
|
||||
// для тапа, либо пустой Node если баннер не виден.
|
||||
Node tryToFindOnboardingBanner();
|
||||
|
||||
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android
|
||||
// (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден.
|
||||
Node tryToFindSystemPermissionDenyButton();
|
||||
@ -60,6 +73,19 @@ public:
|
||||
// Finds a card button whose text ends with the given last 4 digits
|
||||
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
|
||||
// (lastNumbers, description, amount filled; id=-1, cardNumber empty)
|
||||
QList<MaterialInfo> parseCardsOnHomeScreen();
|
||||
@ -128,6 +154,7 @@ public:
|
||||
private:
|
||||
QList<Node> m_nodes;
|
||||
QDomElement m_xml;
|
||||
AppVersionTrack m_track = AppVersionTrack::V19_19_1;
|
||||
|
||||
QList<Node> findPinCodeNodes(const QString &pinCode);
|
||||
};
|
||||
|
||||
4
apk/arc-shot/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
build/
|
||||
.gradle/
|
||||
*.keystore
|
||||
local.properties
|
||||
54
apk/arc-shot/README.md
Normal file
@ -0,0 +1,54 @@
|
||||
# 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 недоступен).
|
||||
33
apk/arc-shot/build.gradle
Normal file
@ -0,0 +1,33 @@
|
||||
// Стандартная сборка через 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
|
||||
}
|
||||
}
|
||||
61
apk/arc-shot/build_manual.sh
Executable file
@ -0,0 +1,61 @@
|
||||
#!/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
|
||||
14
apk/arc-shot/settings.gradle
Normal file
@ -0,0 +1,14 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'arc-shot'
|
||||
39
apk/arc-shot/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,39 @@
|
||||
<?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>
|
||||
207
apk/arc-shot/src/main/java/com/arc/shot/ArcShotService.java
Normal file
@ -0,0 +1,207 @@
|
||||
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apk/arc-shot/src/main/java/com/arc/shot/BootReceiver.java
Normal file
@ -0,0 +1,19 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apk/arc-shot/src/main/java/com/arc/shot/MainActivity.java
Normal file
@ -0,0 +1,25 @@
|
||||
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,80 +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="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="DON’T 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>
|
||||
BIN
assets/arc-shot.apk
Normal file
1
assets/dushanbe/3.2.38/card_transfer_confirm.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,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="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру карты) Номер/Счет 9762000104225205 Способ оплаты: 9762 **** **** 0819 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,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>
|
||||
1
assets/dushanbe/3.2.38/card_transfer_input.xml
Normal file
1
assets/dushanbe/3.2.38/card_transfer_success.xml
Normal file
1
assets/dushanbe/3.2.38/card_transfer_tx_detail.xml
Normal file
1
assets/dushanbe/3.2.38/cards_screen.xml
Normal file
1
assets/dushanbe/3.2.38/history_screen.xml
Normal file
1
assets/dushanbe/3.2.38/home_screen.xml
Normal file
1
assets/dushanbe/3.2.38/phone_transfer_confirm.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,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="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру телефона) Номер/Счет 9762000186263942 Способ оплаты: 9762 **** **** 0819 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,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>
|
||||
1
assets/dushanbe/3.2.38/transfers_screen.xml
Normal file
1
assets/dushanbe/3.2.39/card_detail.xml
Normal file
1
assets/dushanbe/3.2.39/card_transfer_confirm.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,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="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру карты) Номер/Счет 9762000186263942 Способ оплаты: 9762 **** **** 5947 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,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>
|
||||
1
assets/dushanbe/3.2.39/card_transfer_input.xml
Normal file
1
assets/dushanbe/3.2.39/card_transfer_success.xml
Normal file
1
assets/dushanbe/3.2.39/card_transfer_tx_detail.xml
Normal file
1
assets/dushanbe/3.2.39/cards_screen.xml
Normal file
1
assets/dushanbe/3.2.39/history_screen.xml
Normal file
1
assets/dushanbe/3.2.39/home_screen.xml
Normal file
BIN
assets/dushanbe/3.2.39/phone_transfer_confirm.jpg
Normal file
|
After Width: | Height: | Size: 109 KiB |
1
assets/dushanbe/3.2.39/phone_transfer_confirm.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,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="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру телефона) Номер/Счет 559411001 Способ оплаты: 9762 **** **** 5947 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,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>
|
||||
BIN
assets/dushanbe/3.2.39/phone_transfer_input.jpg
Normal file
|
After Width: | Height: | Size: 182 KiB |
1
assets/dushanbe/3.2.39/phone_transfer_input.xml
Normal file
BIN
assets/dushanbe/3.2.39/side_menu.jpg
Normal file
|
After Width: | Height: | Size: 252 KiB |
1
assets/dushanbe/3.2.39/side_menu.xml
Normal file
BIN
assets/dushanbe/3.2.39/statement_screen.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
1
assets/dushanbe/3.2.39/statement_screen.xml
Normal file
1
assets/dushanbe/3.2.41/card_detail.xml
Normal file
1
assets/dushanbe/3.2.41/card_transfer_confirm.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру карты) Номер/Счет 9762000186263942 Способ оплаты: 9762 **** **** 9536 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>
|
||||
1
assets/dushanbe/3.2.41/card_transfer_input.xml
Normal file
1
assets/dushanbe/3.2.41/card_transfer_success.xml
Normal file
1
assets/dushanbe/3.2.41/card_transfer_tx_detail.xml
Normal file
1
assets/dushanbe/3.2.41/cards_screen.xml
Normal file
1
assets/dushanbe/3.2.41/history_screen.xml
Normal file
1
assets/dushanbe/3.2.41/home_screen.xml
Normal file
BIN
assets/dushanbe/3.2.41/phone_transfer_confirm.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
1
assets/dushanbe/3.2.41/phone_transfer_confirm.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру телефона) Номер/Счет 559411001 Способ оплаты: 9762 **** **** 9536 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>
|
||||
BIN
assets/dushanbe/3.2.41/phone_transfer_input.jpg
Normal file
|
After Width: | Height: | Size: 177 KiB |
1
assets/dushanbe/3.2.41/phone_transfer_input.xml
Normal file
BIN
assets/dushanbe/3.2.41/phone_transfer_success.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
1
assets/dushanbe/3.2.41/phone_transfer_success.xml
Normal file
BIN
assets/dushanbe/3.2.41/side_menu.jpg
Normal file
|
After Width: | Height: | Size: 256 KiB |
1
assets/dushanbe/3.2.41/side_menu.xml
Normal file
BIN
assets/dushanbe/3.2.41/statement_screen.jpg
Normal file
|
After Width: | Height: | Size: 193 KiB |
1
assets/dushanbe/3.2.41/statement_screen.xml
Normal file
103
assets/ozon/19.19.1/NOTES.md
Normal file
@ -0,0 +1,103 @@
|
||||
# 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 ⚠️ (всплывают ситуативно)
|
||||
19
assets/ozon/19.19.1/capture.sh
Executable file
@ -0,0 +1,19 @@
|
||||
#!/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"
|
||||
BIN
assets/ozon/19.19.1/card_screen.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
1
assets/ozon/19.19.1/card_screen.xml
Normal file
BIN
assets/ozon/19.19.1/card_screen_hide.png
Normal file
|
After Width: | Height: | Size: 161 KiB |