Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c1db3c36c | ||
|
|
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
|
cmake_install.cmake
|
||||||
CMakeCache.txt
|
CMakeCache.txt
|
||||||
Makefile
|
Makefile
|
||||||
|
compile_commands.json
|
||||||
|
|
||||||
.qt/
|
.qt/
|
||||||
|
|
||||||
@ -23,8 +24,7 @@ Makefile
|
|||||||
/Testing/
|
/Testing/
|
||||||
/.ninja_deps
|
/.ninja_deps
|
||||||
/.ninja_log
|
/.ninja_log
|
||||||
/ARCDesktopProject_autogen/
|
/ARC
|
||||||
/ARCDesktopProject
|
|
||||||
/Contents/
|
/Contents/
|
||||||
/application.log
|
/application.log
|
||||||
/database/app_data.db
|
/database/app_data.db
|
||||||
|
|||||||
8
BUILD.md
@ -1,4 +1,4 @@
|
|||||||
# ARCDesktopProject — сборка и запуск
|
# ARC — сборка и запуск
|
||||||
|
|
||||||
## Windows (x64)
|
## Windows (x64)
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ cmake --build . --config Release
|
|||||||
### Деплой Qt DLL
|
### Деплой Qt DLL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
"C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARCDesktopProject.exe
|
"C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARC.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
### Копирование vcpkg DLL
|
### Копирование vcpkg DLL
|
||||||
@ -44,7 +44,7 @@ copy database\app_data.db .\Release\database\app_data.db
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd Release
|
cd Release
|
||||||
ARCDesktopProject.exe
|
ARC.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -77,5 +77,5 @@ cp ../database/app_data.db ./database/app_data.db
|
|||||||
### Запуск
|
### Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./ARCDesktopProject
|
./ARC
|
||||||
```
|
```
|
||||||
|
|||||||
@ -58,6 +58,7 @@ find_package(Qt6 REQUIRED COMPONENTS
|
|||||||
Widgets
|
Widgets
|
||||||
Sql
|
Sql
|
||||||
Xml
|
Xml
|
||||||
|
Concurrent
|
||||||
)
|
)
|
||||||
|
|
||||||
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
|
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
|
||||||
@ -104,6 +105,7 @@ add_executable(ARC WIN32
|
|||||||
views/log/LogDetailWindow.h
|
views/log/LogDetailWindow.h
|
||||||
database/dao/GeneralLogDAO.cpp
|
database/dao/GeneralLogDAO.cpp
|
||||||
database/dao/GeneralLogDAO.h
|
database/dao/GeneralLogDAO.h
|
||||||
|
android/dushanbe/AppVersionTrack.h
|
||||||
android/dushanbe/CommonScript.cpp
|
android/dushanbe/CommonScript.cpp
|
||||||
android/dushanbe/CommonScript.h
|
android/dushanbe/CommonScript.h
|
||||||
android/dushanbe/GetCardInfoScript.cpp
|
android/dushanbe/GetCardInfoScript.cpp
|
||||||
@ -135,6 +137,7 @@ elseif (APPLE)
|
|||||||
views/log/LogDetailWindow.h
|
views/log/LogDetailWindow.h
|
||||||
database/dao/GeneralLogDAO.cpp
|
database/dao/GeneralLogDAO.cpp
|
||||||
database/dao/GeneralLogDAO.h
|
database/dao/GeneralLogDAO.h
|
||||||
|
android/dushanbe/AppVersionTrack.h
|
||||||
android/dushanbe/CommonScript.cpp
|
android/dushanbe/CommonScript.cpp
|
||||||
android/dushanbe/CommonScript.h
|
android/dushanbe/CommonScript.h
|
||||||
android/dushanbe/GetCardInfoScript.cpp
|
android/dushanbe/GetCardInfoScript.cpp
|
||||||
@ -156,6 +159,7 @@ if (WIN32)
|
|||||||
Qt6::Xml
|
Qt6::Xml
|
||||||
Qt6::Sql
|
Qt6::Sql
|
||||||
Qt6::Network
|
Qt6::Network
|
||||||
|
Qt6::Concurrent
|
||||||
Tesseract::libtesseract
|
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}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||||
# adb копируется в post-build чтобы не падать при configure если adb.exe занят
|
# adb копируется в post-build чтобы не падать при configure если adb.exe занят
|
||||||
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
|
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
|
||||||
foreach(_adb_file ${_adb_files})
|
foreach(_adb_file ${_adb_files})
|
||||||
@ -226,6 +231,10 @@ if (WIN32)
|
|||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk"
|
"${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk"
|
||||||
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
|
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/assets/arc-shot.apk"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
|
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
|
||||||
@ -276,6 +285,7 @@ elseif (APPLE)
|
|||||||
Qt6::Sql
|
Qt6::Sql
|
||||||
Qt6::Xml
|
Qt6::Xml
|
||||||
Qt6::Network
|
Qt6::Network
|
||||||
|
Qt6::Concurrent
|
||||||
tesseract
|
tesseract
|
||||||
leptonica
|
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}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
target_include_directories(ARC PRIVATE
|
target_include_directories(ARC PRIVATE
|
||||||
|
|||||||
@ -17,7 +17,7 @@ brew install qt tesseract leptonica
|
|||||||
mkdir -p build && cd build
|
mkdir -p build && cd build
|
||||||
cmake .. -DCMAKE_BUILD_TYPE=Release
|
cmake .. -DCMAKE_BUILD_TYPE=Release
|
||||||
cmake --build .
|
cmake --build .
|
||||||
./ARCDesktopProject
|
./ARC
|
||||||
```
|
```
|
||||||
|
|
||||||
Подробнее — см. [BUILD.md](BUILD.md) (macOS + Windows).
|
Подробнее — см. [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,62 @@
|
|||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QTcpSocket>
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QHostAddress>
|
||||||
|
|
||||||
#include "dump/TaskDumpRecorder.h"
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Конфиг скриншот-стратегии (config.ini [screenshot]). Кэшируется при первом чтении.
|
||||||
|
struct ScreenshotCfg {
|
||||||
|
QString transport; // "http" | "execout"
|
||||||
|
int httpPort = 8099;
|
||||||
|
int httpTimeoutMs = 4000;
|
||||||
|
QString remotePath = "/sdcard/arc_shot.png";
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScreenshotCfg &screenshotCfg() {
|
||||||
|
static ScreenshotCfg cfg = [] {
|
||||||
|
const QSettings s("config.ini", QSettings::IniFormat);
|
||||||
|
ScreenshotCfg c;
|
||||||
|
c.transport = s.value("screenshot/transport", "http").toString().trimmed().toLower();
|
||||||
|
c.httpPort = s.value("screenshot/httpPort", 8099).toInt();
|
||||||
|
c.httpTimeoutMs = s.value("screenshot/httpTimeoutMs", 4000).toInt();
|
||||||
|
c.remotePath = s.value("screenshot/remotePath", "/sdcard/arc_shot.png").toString();
|
||||||
|
return c;
|
||||||
|
}();
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кэш IP телефона по serial (резолв дорогой — adb shell). Потокобезопасно.
|
||||||
|
// С TTL: IP редко меняется, перечитываем не чаще kIpTtlSecs (на случай смены DHCP).
|
||||||
|
constexpr qint64 kIpTtlSecs = 300;
|
||||||
|
QMutex g_ipCacheMutex;
|
||||||
|
QHash<QString, QString> g_ipCache;
|
||||||
|
QHash<QString, qint64> g_ipCacheTs;
|
||||||
|
|
||||||
|
// Негативный кэш: serial → unix ts последней неудачи HTTP. Пока не истёк бэкофф,
|
||||||
|
// идём сразу в exec-out fallback, не дёргая screencap+IP+таймаут каждый цикл.
|
||||||
|
constexpr qint64 kHttpFailBackoffSecs = 60;
|
||||||
|
QMutex g_httpFailMutex;
|
||||||
|
QHash<QString, qint64> g_httpFailAt;
|
||||||
|
|
||||||
|
// Лог смены пути скриншота (http ↔ execout) для наблюдаемости — без спама каждые 4с.
|
||||||
|
QMutex g_modeMutex;
|
||||||
|
QHash<QString, QString> g_lastMode;
|
||||||
|
|
||||||
|
void logScreenshotMode(const QString &deviceId, const QString &mode) {
|
||||||
|
QMutexLocker lock(&g_modeMutex);
|
||||||
|
if (g_lastMode.value(deviceId) != mode) {
|
||||||
|
g_lastMode.insert(deviceId, mode);
|
||||||
|
qInfo().noquote() << "[AdbUtils] screenshot path for" << deviceId << "→" << mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
|
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +123,23 @@ bool AdbUtils::startProcess(const QString &program, const QStringList &args,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ждёт завершения процесса с таймаутом. Раньше здесь стоял бесконечный
|
||||||
|
// waitForFinished() — если adb-клиент зависал на неотвечающем устройстве/USB,
|
||||||
|
// рабочий поток блокировался навсегда (накопление зависших потоков → фриз
|
||||||
|
// приложения через несколько часов). При таймауте убиваем зависший процесс и
|
||||||
|
// реапим его, чтобы и поток освободился, и деструктор QProcess не блокировался.
|
||||||
|
// Возвращает true, если процесс завершился сам в отведённое время.
|
||||||
|
static bool waitOrKill(QProcess &process, int timeoutMs, const char *tag) {
|
||||||
|
if (process.waitForFinished(timeoutMs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
qWarning() << "[AdbUtils]" << tag << "timed out after" << timeoutMs
|
||||||
|
<< "ms — killing wedged adb process";
|
||||||
|
process.kill();
|
||||||
|
process.waitForFinished(2000);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// adb shell dumpsys package ru.rshb.dbo | grep permission
|
// adb shell dumpsys package ru.rshb.dbo | grep permission
|
||||||
// TODO доделать обработку пермишинов
|
// TODO доделать обработку пермишинов
|
||||||
// adb shell pm revoke ru.rshb.dbo android.permission.WRITE_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.READ_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_COARSE_LOCATION; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_FINE_LOCATION
|
// adb shell pm revoke ru.rshb.dbo android.permission.WRITE_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.READ_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_COARSE_LOCATION; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_FINE_LOCATION
|
||||||
@ -147,7 +217,7 @@ QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb pidof:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb pidof:" << process.errorString();
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 10000, "tryToGetRunningAppPid");
|
||||||
QString pid = process.readAllStandardOutput();
|
QString pid = process.readAllStandardOutput();
|
||||||
return pid.replace("\n", "");
|
return pid.replace("\n", "");
|
||||||
}
|
}
|
||||||
@ -230,7 +300,7 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb monkey:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb monkey:" << process.errorString();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 15000, "tryToStartApplication(monkey)");
|
||||||
const QString stdOut = QString::fromUtf8(process.readAllStandardOutput());
|
const QString stdOut = QString::fromUtf8(process.readAllStandardOutput());
|
||||||
const QString stdErr = QString::fromUtf8(process.readAllStandardError());
|
const QString stdErr = QString::fromUtf8(process.readAllStandardError());
|
||||||
const int exitCode = process.exitCode();
|
const int exitCode = process.exitCode();
|
||||||
@ -243,13 +313,20 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
|
|||||||
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
|
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
|
||||||
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
|
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
|
||||||
|
|
||||||
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code,
|
// Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code,
|
||||||
// в отличие от monkey, который при пустом resolve просто молча печатает "No activities found".
|
// в отличие от monkey, который при пустом resolve просто молча печатает "No activities found".
|
||||||
|
// Также используется когда monkey дал "Events injected: 1" но приложение упало (NativeCrash).
|
||||||
if (!launcherActivity.isEmpty()) {
|
if (!launcherActivity.isEmpty()) {
|
||||||
|
if (hadNativeCrash)
|
||||||
|
qWarning().noquote() << "[AdbUtils::tryToStartApplication] NativeCrash detected, retrying via am start -n" << launcherActivity;
|
||||||
|
else
|
||||||
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
|
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
|
||||||
<< "пробую am start -n" << launcherActivity;
|
<< "пробую am start -n" << launcherActivity;
|
||||||
QProcess am;
|
QProcess am;
|
||||||
@ -287,7 +364,7 @@ bool AdbUtils::tryToKillApplication(const QString &deviceId, const QString &pack
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb force-stop:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb force-stop:" << process.errorString();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 15000, "tryToKillApplication");
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -425,6 +502,36 @@ bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
|
QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
|
||||||
|
// http-режим: пробуем разгрузку через APK, при неудаче — fallback на exec-out.
|
||||||
|
if (screenshotCfg().transport == "http") {
|
||||||
|
// Негативный бэкофф: если HTTP недавно не сработал — не дёргаем его каждый цикл.
|
||||||
|
bool skipHttp = false;
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&g_httpFailMutex);
|
||||||
|
const qint64 failAt = g_httpFailAt.value(deviceId, 0);
|
||||||
|
skipHttp = failAt != 0
|
||||||
|
&& QDateTime::currentSecsSinceEpoch() - failAt < kHttpFailBackoffSecs;
|
||||||
|
}
|
||||||
|
if (!skipHttp) {
|
||||||
|
if (const QByteArray png = captureScreenshotHttp(deviceId); !png.isEmpty()) {
|
||||||
|
QMutexLocker lock(&g_httpFailMutex);
|
||||||
|
g_httpFailAt.remove(deviceId);
|
||||||
|
logScreenshotMode(deviceId, "http");
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&g_httpFailMutex);
|
||||||
|
g_httpFailAt[deviceId] = QDateTime::currentSecsSinceEpoch();
|
||||||
|
}
|
||||||
|
// arc-shot мог умереть — пробуем поднять (раз в бэкофф-окно, не спамит).
|
||||||
|
kickArcShot(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logScreenshotMode(deviceId, "exec-out (fallback)");
|
||||||
|
return captureScreenshotExecOut(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray AdbUtils::captureScreenshotExecOut(const QString &deviceId) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.setWorkingDirectory(adbDir());
|
process.setWorkingDirectory(adbDir());
|
||||||
process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"});
|
process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"});
|
||||||
@ -440,6 +547,172 @@ QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
|
|||||||
return process.readAllStandardOutput();
|
return process.readAllStandardOutput();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QByteArray AdbUtils::captureScreenshotHttp(const QString &deviceId) {
|
||||||
|
const ScreenshotCfg &cfg = screenshotCfg();
|
||||||
|
|
||||||
|
// 1. Снимаем В ФАЙЛ на устройстве — байты НЕ идут через adb, команда быстрая.
|
||||||
|
QString stdErr;
|
||||||
|
if (!startProcess(adbPath(),
|
||||||
|
{"-s", deviceId, "shell", "screencap", "-p", cfg.remotePath},
|
||||||
|
nullptr, &stdErr)) {
|
||||||
|
qWarning() << "[AdbUtils] http screencap-to-file failed for" << deviceId << stdErr;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. IP телефона в Wi-Fi.
|
||||||
|
const QString ip = resolvePhoneIp(deviceId);
|
||||||
|
if (ip.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Качаем файл по HTTP напрямую с телефона (мимо adb).
|
||||||
|
const QString fileName = cfg.remotePath.section('/', -1);
|
||||||
|
const QByteArray png = httpGet(ip, cfg.httpPort, "/" + fileName, cfg.httpTimeoutMs);
|
||||||
|
if (png.isEmpty()) {
|
||||||
|
// Не сбрасываем кэш IP — он переживёт неудачу http (TTL сам перечитает при
|
||||||
|
// реальной смене IP). Иначе IP не доезжал бы до БД на fallback-устройствах.
|
||||||
|
qWarning() << "[AdbUtils] http screenshot GET failed for" << deviceId << "ip:" << ip;
|
||||||
|
}
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::screenshotMode(const QString &deviceId) {
|
||||||
|
QMutexLocker lock(&g_modeMutex);
|
||||||
|
return g_lastMode.value(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::resolvePhoneIp(const QString &deviceId) {
|
||||||
|
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&g_ipCacheMutex);
|
||||||
|
if (const QString cached = g_ipCache.value(deviceId);
|
||||||
|
!cached.isEmpty() && now - g_ipCacheTs.value(deviceId, 0) < kIpTtlSecs) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ip;
|
||||||
|
// Для TCP-серийника IP:port — IP прямо из серийника.
|
||||||
|
static const QRegularExpression reIpPort(R"(^(\d+\.\d+\.\d+\.\d+):\d+$)");
|
||||||
|
if (const auto m = reIpPort.match(deviceId); m.hasMatch()) {
|
||||||
|
ip = m.captured(1);
|
||||||
|
} else {
|
||||||
|
// Иначе спрашиваем устройство: ip route get 1.1.1.1 → "... src <ipv4> ..."
|
||||||
|
QString out;
|
||||||
|
if (startProcess(adbPath(),
|
||||||
|
{"-s", deviceId, "shell", "ip", "route", "get", "1.1.1.1"},
|
||||||
|
&out)) {
|
||||||
|
static const QRegularExpression reSrc(R"(src\s+(\d+\.\d+\.\d+\.\d+))");
|
||||||
|
if (const auto m = reSrc.match(out); m.hasMatch()) {
|
||||||
|
ip = m.captured(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ip.isEmpty()) {
|
||||||
|
QMutexLocker lock(&g_ipCacheMutex);
|
||||||
|
g_ipCache.insert(deviceId, ip);
|
||||||
|
g_ipCacheTs.insert(deviceId, now);
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray AdbUtils::httpGet(const QString &ip, int port, const QString &path, int timeoutMs) {
|
||||||
|
QTcpSocket sock;
|
||||||
|
sock.connectToHost(ip, static_cast<quint16>(port));
|
||||||
|
if (!sock.waitForConnected(timeoutMs)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QByteArray req = "GET " + path.toUtf8() + " HTTP/1.0\r\n"
|
||||||
|
"Host: " + ip.toUtf8() + "\r\n"
|
||||||
|
"Connection: close\r\n\r\n";
|
||||||
|
sock.write(req);
|
||||||
|
if (!sock.waitForBytesWritten(timeoutMs)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем до закрытия соединения (HTTP/1.0 + Connection: close).
|
||||||
|
QByteArray resp;
|
||||||
|
while (sock.state() == QAbstractSocket::ConnectedState || sock.bytesAvailable() > 0) {
|
||||||
|
if (!sock.waitForReadyRead(timeoutMs)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
resp += sock.readAll();
|
||||||
|
}
|
||||||
|
resp += sock.readAll();
|
||||||
|
|
||||||
|
// Разбор: статус-строка + тело после "\r\n\r\n".
|
||||||
|
const int headerEnd = resp.indexOf("\r\n\r\n");
|
||||||
|
if (headerEnd < 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QByteArray header = resp.left(headerEnd);
|
||||||
|
if (!header.startsWith("HTTP/1.") || !header.contains(" 200")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return resp.mid(headerEnd + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AdbUtils::kickArcShot(const QString &deviceId) {
|
||||||
|
const QString adb = adbPath();
|
||||||
|
|
||||||
|
// 1. Установлен ли arc-shot? (pm path вернёт "package:..." если да)
|
||||||
|
QString pmOut;
|
||||||
|
const bool installed = startProcess(adb,
|
||||||
|
{"-s", deviceId, "shell", "pm", "path", "com.arc.shot"}, &pmOut)
|
||||||
|
&& pmOut.contains("package:");
|
||||||
|
|
||||||
|
// 2. Не установлен (удалили / новое устройство) — ставим bundled APK рядом с десктопом.
|
||||||
|
if (!installed) {
|
||||||
|
const QString apk = QCoreApplication::applicationDirPath() + "/assets/arc-shot.apk";
|
||||||
|
if (!QFileInfo::exists(apk)) {
|
||||||
|
qWarning() << "[AdbUtils] arc-shot apk not found at" << apk << "— skip auto-install";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QString err;
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "install", "-r", apk}, nullptr, &err)) {
|
||||||
|
qWarning() << "[AdbUtils] arc-shot install failed for" << deviceId << ":" << err.trimmed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Доступ к /sdcard без диалога на устройстве.
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "appops", "set",
|
||||||
|
"com.arc.shot", "MANAGE_EXTERNAL_STORAGE", "allow"});
|
||||||
|
qInfo().noquote() << "[AdbUtils] arc-shot auto-installed on" << deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Защита от заморозки фоновых приложений (Doze + вендорские freezer'ы вроде
|
||||||
|
// TECNO HiOS/MIUI). adb-settable, без диалогов на устройстве. Без этого вендор
|
||||||
|
// замораживает userspace сервиса — порт принимает TCP, но HTTP не отвечает.
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "deviceidle", "whitelist",
|
||||||
|
"+com.arc.shot"});
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "cmd", "appops", "set", "com.arc.shot",
|
||||||
|
"RUN_ANY_IN_BACKGROUND", "allow"});
|
||||||
|
|
||||||
|
// 4. Поднять сервис. Свежая установка — через Activity (am start -n .../MainActivity):
|
||||||
|
// надёжно снимает stopped-state, в котором у части вендоров FGS-старт молча
|
||||||
|
// игнорируется. Activity без UI и сразу finish(), сервис стартует из onCreate.
|
||||||
|
// Уже установленный (реанимация) — start-foreground-service, без Activity поверх
|
||||||
|
// работающего банк-скрипта.
|
||||||
|
QString err;
|
||||||
|
const bool ok = !installed
|
||||||
|
? startProcess(adb, {"-s", deviceId, "shell", "am", "start",
|
||||||
|
"-n", "com.arc.shot/.MainActivity"}, nullptr, &err)
|
||||||
|
: startProcess(adb, {"-s", deviceId, "shell", "am", "start-foreground-service",
|
||||||
|
"-n", "com.arc.shot/.ArcShotService"}, nullptr, &err);
|
||||||
|
if (ok) {
|
||||||
|
qInfo().noquote() << "[AdbUtils] arc-shot" << (installed ? "revive attempt" : "started")
|
||||||
|
<< "for" << deviceId;
|
||||||
|
if (!installed) {
|
||||||
|
// Свежая установка: сбрасываем http-бэкофф, чтобы http включился на следующем
|
||||||
|
// кадре (а не ждал 60с) — устройство быстрее уходит с exec-out.
|
||||||
|
QMutexLocker lock(&g_httpFailMutex);
|
||||||
|
g_httpFailAt.remove(deviceId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qDebug().noquote() << "[AdbUtils] arc-shot kick failed for" << deviceId << ":" << err.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) {
|
bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) {
|
||||||
const QString adb = adbPath();
|
const QString adb = adbPath();
|
||||||
struct Step { QStringList args; };
|
struct Step { QStringList args; };
|
||||||
@ -473,7 +746,7 @@ bool AdbUtils::makeTap(const QString &deviceId, const int x, const int y) {
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb tap:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb tap:" << process.errorString();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 10000, "makeTap");
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -588,7 +861,7 @@ bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) {
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 10000, "inputAdbIMEText");
|
||||||
qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode()
|
qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode()
|
||||||
<< "text:" << text
|
<< "text:" << text
|
||||||
<< "output:" << process.readAllStandardOutput().trimmed()
|
<< "output:" << process.readAllStandardOutput().trimmed()
|
||||||
@ -605,7 +878,7 @@ bool AdbUtils::disableAdbIMEKeyboard(const QString &deviceId) {
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb ime disable:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb ime disable:" << process.errorString();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 10000, "disableAdbIMEKeyboard");
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -630,7 +903,7 @@ bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, co
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb swipe:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb swipe:" << process.errorString();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 10000, "makeSwipe");
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -646,7 +919,7 @@ bool AdbUtils::inputText(const QString &deviceId, const QString &text) {
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb input text:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb input text:" << process.errorString();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 10000, "inputText");
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -699,7 +972,7 @@ bool AdbUtils::goBack(const QString &deviceId) {
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 10000, "goBack");
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -715,7 +988,7 @@ QSet<QString> AdbUtils::getListApps(const QString &deviceId) {
|
|||||||
qWarning() << "[AdbUtils] Failed to start adb pm list:" << process.errorString();
|
qWarning() << "[AdbUtils] Failed to start adb pm list:" << process.errorString();
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
process.waitForFinished();
|
waitOrKill(process, 15000, "getListApps");
|
||||||
|
|
||||||
const QString output = process.readAllStandardOutput();
|
const QString output = process.readAllStandardOutput();
|
||||||
QSet<QString> apps;
|
QSet<QString> apps;
|
||||||
@ -755,6 +1028,70 @@ QString AdbUtils::getAppVersion(const QString &deviceId, const QString &packageN
|
|||||||
return m.hasMatch() ? m.captured(1).trimmed() : QString();
|
return m.hasMatch() ? m.captured(1).trimmed() : QString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Достаёт имя пакета из строки фокуса вида "... u0 com.bank.app/.MainActivity ...".
|
||||||
|
static QString packageFromFocusLine(const QString &out, const QString &marker) {
|
||||||
|
const qsizetype idx = out.indexOf(marker);
|
||||||
|
if (idx < 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const qsizetype end = out.indexOf(QChar('\n'), idx);
|
||||||
|
const QString line = out.mid(idx, end < 0 ? -1 : end - idx);
|
||||||
|
static const QRegularExpression rePkg(R"(\bu0\s+([A-Za-z0-9_.]+)/)");
|
||||||
|
const QRegularExpressionMatch m = rePkg.match(line);
|
||||||
|
return m.hasMatch() ? m.captured(1) : QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::getForegroundPackage(const QString &deviceId) {
|
||||||
|
if (deviceId.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// mResumedActivity — реально активная (foreground) активность.
|
||||||
|
QString out;
|
||||||
|
if (startProcess(adbPath(),
|
||||||
|
{"-s", deviceId, "shell", "dumpsys", "activity", "activities"}, &out)) {
|
||||||
|
const QString pkg = packageFromFocusLine(out, "mResumedActivity");
|
||||||
|
if (!pkg.isEmpty()) {
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fallback: mCurrentFocus из dumpsys window (формат отличается между версиями Android).
|
||||||
|
out.clear();
|
||||||
|
if (startProcess(adbPath(), {"-s", deviceId, "shell", "dumpsys", "window"}, &out)) {
|
||||||
|
const QString pkg = packageFromFocusLine(out, "mCurrentFocus");
|
||||||
|
if (!pkg.isEmpty()) {
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AdbUtils::ensurePortraitOrientation(const QString &deviceId) {
|
||||||
|
if (deviceId.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QString adb = adbPath();
|
||||||
|
QString out;
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "input"}, &out)) {
|
||||||
|
return false; // adb недоступен — пропускаем (best-effort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SurfaceOrientation: 0=0°(portrait) 1=90°(landscape) 2=180° 3=270°(landscape)
|
||||||
|
static const QRegularExpression re(R"(SurfaceOrientation:\s*(\d))");
|
||||||
|
const QRegularExpressionMatch m = re.match(out);
|
||||||
|
const int orient = m.hasMatch() ? m.captured(1).toInt() : 0;
|
||||||
|
|
||||||
|
if (orient == 1 || orient == 3) {
|
||||||
|
qInfo() << "[AdbUtils] device" << deviceId
|
||||||
|
<< "in landscape (orient" << orient << ") → forcing portrait";
|
||||||
|
// Автоповорот выключаем, иначе сенсор вернёт landscape.
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "settings", "put",
|
||||||
|
"system", "accelerometer_rotation", "0"});
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "settings", "put",
|
||||||
|
"system", "user_rotation", "0"});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) {
|
void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) {
|
||||||
const QString adb = adbPath();
|
const QString adb = adbPath();
|
||||||
|
|
||||||
|
|||||||
@ -27,9 +27,26 @@ public:
|
|||||||
|
|
||||||
static bool takeScreenshot(const QString &deviceId, const QString &name);
|
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);
|
static QByteArray captureScreenshotBytes(const QString &deviceId);
|
||||||
|
|
||||||
|
// Скриншот через APK arc-shot: adb screencap в файл на /sdcard (дёшево, без
|
||||||
|
// передачи байтов через adb) + HTTP-GET файла напрямую с телефона по его
|
||||||
|
// Wi-Fi IP. Разгружает adb. Пусто при недоступности (нет APK/сети) — вызывающий
|
||||||
|
// откатывается на exec-out. Возвращает PNG-байты.
|
||||||
|
static QByteArray captureScreenshotHttp(const QString &deviceId);
|
||||||
|
|
||||||
static bool takeXmlDump(const QString &deviceId, const QString &name);
|
static bool takeXmlDump(const QString &deviceId, const QString &name);
|
||||||
|
|
||||||
static bool makeTap(const QString &deviceId, int x, int y);
|
static bool makeTap(const QString &deviceId, int x, int y);
|
||||||
@ -59,8 +76,29 @@ public:
|
|||||||
// Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии
|
// Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии
|
||||||
static QString getAppVersion(const QString &deviceId, const QString &packageName);
|
static QString getAppVersion(const QString &deviceId, const QString &packageName);
|
||||||
|
|
||||||
|
// Возвращает пакет приложения на переднем плане (foreground), либо пустую строку при ошибке
|
||||||
|
static QString getForegroundPackage(const QString &deviceId);
|
||||||
|
|
||||||
|
// Проверяет реальную ориентацию экрана (dumpsys input → SurfaceOrientation).
|
||||||
|
// Если экран в горизонтальной ориентации (landscape, 90°/270°), принудительно
|
||||||
|
// ставит вертикальную: выключает автоповорот и выставляет user_rotation=0.
|
||||||
|
// Best-effort: при ошибке adb просто ничего не делает. Возвращает true, если
|
||||||
|
// экран в портрете (изначально или после форсирования).
|
||||||
|
static bool ensurePortraitOrientation(const QString &deviceId);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static QString adbDir();
|
static QString adbDir();
|
||||||
static bool startProcess(const QString &program, const QStringList &args,
|
static bool startProcess(const QString &program, const QStringList &args,
|
||||||
QString *stdOut = nullptr, QString *stdErr = nullptr);
|
QString *stdOut = nullptr, QString *stdErr = nullptr);
|
||||||
|
|
||||||
|
// Старый способ: exec-out screencap -p (стримит PNG через adb). Fallback для http.
|
||||||
|
static QByteArray captureScreenshotExecOut(const QString &deviceId);
|
||||||
|
|
||||||
|
// Синхронный HTTP/1.0 GET по QTcpSocket (без event-loop, безопасно из worker-потоков).
|
||||||
|
// Возвращает тело ответа при 200, иначе пусто.
|
||||||
|
static QByteArray httpGet(const QString &ip, int port, const QString &path, int timeoutMs);
|
||||||
|
|
||||||
|
// Поднимает foreground-сервис arc-shot (ставит APK, если удалён). Дёргается из
|
||||||
|
// http-fail ветки, т.е. не чаще раза в бэкофф-окно.
|
||||||
|
static void kickArcShot(const QString &deviceId);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -251,7 +251,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
|
|||||||
ns.postBankProfile();
|
ns.postBankProfile();
|
||||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
if (afterPost.bankProfileId.isEmpty()) {
|
if (afterPost.bankProfileId.isEmpty()) {
|
||||||
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;
|
qWarning() << "[Black] createBankProfileIfNeeded:" << err;
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
#include "dao/DeviceDAO.h"
|
#include "dao/DeviceDAO.h"
|
||||||
#include "dump/TaskDumpRecorder.h"
|
#include "dump/TaskDumpRecorder.h"
|
||||||
#include "net/NetworkService.h"
|
#include "net/NetworkService.h"
|
||||||
|
#include "net/MaterialPayload.h"
|
||||||
|
|
||||||
namespace Black {
|
namespace Black {
|
||||||
|
|
||||||
@ -36,7 +37,7 @@ void GetCardInfoScript::doStart() {
|
|||||||
return;
|
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);
|
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";
|
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 MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
|
||||||
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
if (savedAccount.id != -1 && !updatedApp.bankProfileId.isEmpty()) {
|
if (savedAccount.id != -1 && !updatedApp.bankProfileId.isEmpty()) {
|
||||||
if (savedAccount.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;
|
NetworkService ns;
|
||||||
ns.setDeviceId(m_deviceId);
|
ns.setDeviceId(m_deviceId);
|
||||||
|
ns.setPayload(buildMaterialPayload(savedAccount, updatedApp));
|
||||||
const QMap<QString, int> currencyCodes = {
|
|
||||||
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
|
|
||||||
};
|
|
||||||
QJsonObject materialBody;
|
|
||||||
materialBody["bank_profile_id"] = updatedApp.bankProfileId;
|
|
||||||
materialBody["card_number"] = savedAccount.cardNumber;
|
|
||||||
materialBody["name"] = updatedApp.fullName;
|
|
||||||
materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled";
|
|
||||||
materialBody["currency"] = currencyCodes.value(savedAccount.currency.toUpper(), 32);
|
|
||||||
ns.setPayload(QJsonDocument(materialBody).toJson());
|
|
||||||
ns.addExtra("account_id", savedAccount.id);
|
ns.addExtra("account_id", savedAccount.id);
|
||||||
ns.postMaterial();
|
ns.postMaterial();
|
||||||
qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers;
|
qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers;
|
||||||
} else {
|
}
|
||||||
qDebug() << "[Black::GetCardInfo] material already exists for CVU" << lastNumbers << ", skipping";
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material";
|
qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material";
|
||||||
|
|||||||
@ -35,7 +35,7 @@ void GetProfileInfoScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
// Читаем текущий экран
|
// Читаем текущий экран
|
||||||
|
|||||||
@ -36,7 +36,7 @@ void LoginAndCheckAccountsScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
|
#include <QSettings>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
@ -166,22 +167,39 @@ TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
|
|||||||
}
|
}
|
||||||
|
|
||||||
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
|
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
|
||||||
const QString apiToken = SettingsDAO::get("api_key");
|
|
||||||
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
|
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
|
||||||
|
|
||||||
|
// api-токен в caption больше не пишем: он общий на нескольких операторов и
|
||||||
|
// не позволяет отличить, чей профиль упал. Идентифицируем по UUID профиля,
|
||||||
|
// материалу и телефону профиля (см. m_meta).
|
||||||
QStringList head{QStringLiteral("[TASK_ERROR]")};
|
QStringList head{QStringLiteral("[TASK_ERROR]")};
|
||||||
if (!apiToken.isEmpty())
|
|
||||||
head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped());
|
|
||||||
head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped());
|
head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped());
|
||||||
head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped());
|
head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped());
|
||||||
|
|
||||||
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())
|
if (!m_meta.materialId.isEmpty())
|
||||||
body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped());
|
body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped());
|
||||||
|
if (!m_meta.profilePhone.isEmpty())
|
||||||
|
body += QStringLiteral("\nPhone: %1").arg(m_meta.profilePhone.toHtmlEscaped());
|
||||||
|
if (!m_meta.appVersion.isEmpty())
|
||||||
|
body += QStringLiteral("\nAppVersion: %1").arg(m_meta.appVersion.toHtmlEscaped());
|
||||||
if (m_meta.amount != 0)
|
if (m_meta.amount != 0)
|
||||||
body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
|
body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
|
||||||
if (!m_meta.phone.isEmpty())
|
if (!m_meta.phone.isEmpty())
|
||||||
body += QStringLiteral("\nPhone: %1").arg(m_meta.phone.toHtmlEscaped());
|
body += QStringLiteral("\nRecipient: %1").arg(m_meta.phone.toHtmlEscaped());
|
||||||
if (!m_meta.card.isEmpty())
|
if (!m_meta.card.isEmpty())
|
||||||
body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped());
|
body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped());
|
||||||
body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped());
|
body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped());
|
||||||
@ -238,9 +256,18 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
|||||||
if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
QTextStream ts(&ef);
|
QTextStream ts(&ef);
|
||||||
ts.setEncoding(QStringConverter::Utf8);
|
ts.setEncoding(QStringConverter::Utf8);
|
||||||
|
const QString desktopId = SettingsDAO::get("desktop_id");
|
||||||
|
if (!desktopId.isEmpty()) ts << "desktop_id: " << desktopId << "\n";
|
||||||
|
const QString clientVersion =
|
||||||
|
QSettings("config.ini", QSettings::IniFormat).value("common/version").toString();
|
||||||
|
if (!clientVersion.isEmpty()) ts << "desktop_version: " << clientVersion << "\n";
|
||||||
ts << "bank: " << m_bank << "\n"
|
ts << "bank: " << m_bank << "\n"
|
||||||
<< "task: " << m_taskKind << "\n"
|
<< "task: " << m_taskKind << "\n"
|
||||||
<< "device: " << m_adbDeviceIdRaw << "\n";
|
<< "device: " << m_adbDeviceIdRaw << "\n";
|
||||||
|
if (!m_meta.androidId.isEmpty()) ts << "android_id: " << m_meta.androidId << "\n";
|
||||||
|
if (!m_meta.bankProfileId.isEmpty()) ts << "profile_id: " << m_meta.bankProfileId << "\n";
|
||||||
|
if (!m_meta.profilePhone.isEmpty()) ts << "profile_phone: " << m_meta.profilePhone << "\n";
|
||||||
|
if (!m_meta.appVersion.isEmpty()) ts << "app_version: " << m_meta.appVersion << "\n";
|
||||||
if (!m_meta.materialId.isEmpty()) ts << "material_id: " << m_meta.materialId << "\n";
|
if (!m_meta.materialId.isEmpty()) ts << "material_id: " << m_meta.materialId << "\n";
|
||||||
if (m_meta.amount != 0) ts << "amount: " << static_cast<qint64>(m_meta.amount) << "\n";
|
if (m_meta.amount != 0) ts << "amount: " << static_cast<qint64>(m_meta.amount) << "\n";
|
||||||
if (!m_meta.phone.isEmpty()) ts << "phone: " << m_meta.phone << "\n";
|
if (!m_meta.phone.isEmpty()) ts << "phone: " << m_meta.phone << "\n";
|
||||||
@ -255,6 +282,12 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
|||||||
const QString archivePath = m_folder + ".zip";
|
const QString archivePath = m_folder + ".zip";
|
||||||
const QString text = buildCaption(errorMessage);
|
const QString text = buildCaption(errorMessage);
|
||||||
|
|
||||||
|
// Имя файла архива на relay: <bank>_<task>_<timestamp>.zip — чтобы дамп
|
||||||
|
// сразу читался по банку и типу задачи без открытия caption'а.
|
||||||
|
const QString stamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
|
||||||
|
const QString archiveFilename =
|
||||||
|
QStringLiteral("%1_%2_%3.zip").arg(m_bank, m_taskKind, stamp);
|
||||||
|
|
||||||
// Финальный скриншот шлём отдельным полем `image`, поэтому сразу читаем
|
// Финальный скриншот шлём отдельным полем `image`, поэтому сразу читаем
|
||||||
// его в память, пока папка ещё цела.
|
// его в память, пока папка ещё цела.
|
||||||
QByteArray finalScreenshot;
|
QByteArray finalScreenshot;
|
||||||
@ -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;
|
QByteArray archiveBytes;
|
||||||
if (zipFolder(folder, archivePath)) {
|
if (zipFolder(folder, archivePath)) {
|
||||||
archiveBytes = readFileBytes(archivePath);
|
archiveBytes = readFileBytes(archivePath);
|
||||||
@ -288,7 +326,8 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
|||||||
Qt::QueuedConnection,
|
Qt::QueuedConnection,
|
||||||
Q_ARG(QString, text),
|
Q_ARG(QString, text),
|
||||||
Q_ARG(QByteArray, archiveBytes),
|
Q_ARG(QByteArray, archiveBytes),
|
||||||
Q_ARG(QByteArray, finalScreenshot));
|
Q_ARG(QByteArray, finalScreenshot),
|
||||||
|
Q_ARG(QString, archiveFilename));
|
||||||
if (!dispatched) {
|
if (!dispatched) {
|
||||||
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
|
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
|
||||||
"slot not found or wrong signature for" << folder;
|
"slot not found or wrong signature for" << folder;
|
||||||
@ -297,11 +336,6 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
|||||||
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
|
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
|
||||||
<< "(setNetworkService was not called at app startup?)";
|
<< "(setNetworkService was not called at app startup?)";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Следом — application.log + БД (+ WAL/SHM) отдельным архивом.
|
|
||||||
// Запускаем уже ПОСЛЕ того, как task-дамп поставлен в очередь
|
|
||||||
// отправки, чтобы порядок прихода на relay был "дамп → логи".
|
|
||||||
LogArchiveSender::dispatch(QStringLiteral("TASK_ERROR"));
|
|
||||||
});
|
});
|
||||||
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
|
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
|
||||||
bg->start();
|
bg->start();
|
||||||
|
|||||||
@ -9,8 +9,12 @@ class NetworkService;
|
|||||||
struct TaskDumpMeta {
|
struct TaskDumpMeta {
|
||||||
QString materialId;
|
QString materialId;
|
||||||
double amount = 0; // 0 = нет
|
double amount = 0; // 0 = нет
|
||||||
QString phone; // пусто = нет
|
QString phone; // пусто = нет (телефон получателя в транзакции)
|
||||||
QString card; // пусто = нет
|
QString card; // пусто = нет
|
||||||
|
QString bankProfileId; // серверный UUID банковского профиля (детект в общих логах)
|
||||||
|
QString profilePhone; // телефон банковского профиля (не получателя)
|
||||||
|
QString androidId; // стабильный android_id устройства (в отличие от adbSerial не меняется)
|
||||||
|
QString appVersion; // версия банковского приложения (из pm dump versionName)
|
||||||
};
|
};
|
||||||
|
|
||||||
class TaskDumpRecorder {
|
class TaskDumpRecorder {
|
||||||
|
|||||||
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();
|
ns.postBankProfile();
|
||||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
if (afterPost.bankProfileId.isEmpty()) {
|
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;
|
qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err;
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
#include "dao/DeviceDAO.h"
|
#include "dao/DeviceDAO.h"
|
||||||
#include "dump/TaskDumpRecorder.h"
|
#include "dump/TaskDumpRecorder.h"
|
||||||
#include "net/NetworkService.h"
|
#include "net/NetworkService.h"
|
||||||
|
#include "net/MaterialPayload.h"
|
||||||
|
|
||||||
namespace Dushanbe {
|
namespace Dushanbe {
|
||||||
|
|
||||||
@ -35,9 +36,14 @@ void GetCardInfoScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Dushanbe::GetCardInfo] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
@ -263,29 +269,30 @@ void GetCardInfoScript::doStart() {
|
|||||||
qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers;
|
qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отправляем на сервер если нет material_id
|
// Отправляем на сервер: инвариант — только active-карта и только если
|
||||||
|
// другая карта в (deviceId, appCode) ещё не синхронизирована.
|
||||||
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
if (!updatedApp.bankProfileId.isEmpty()) {
|
if (!updatedApp.bankProfileId.isEmpty()) {
|
||||||
const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
|
const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
|
||||||
if (savedAccount.id != -1 && 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;
|
NetworkService ns;
|
||||||
ns.setDeviceId(m_deviceId);
|
ns.setDeviceId(m_deviceId);
|
||||||
const QMap<QString, int> currencyCodes = {
|
ns.setPayload(buildMaterialPayload(savedAccount, updatedApp));
|
||||||
{"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.addExtra("account_id", savedAccount.id);
|
ns.addExtra("account_id", savedAccount.id);
|
||||||
ns.postMaterial();
|
ns.postMaterial();
|
||||||
qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers;
|
qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Назад к списку карт
|
// Назад к списку карт
|
||||||
AdbUtils::goBack(m_deviceId);
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
|||||||
@ -15,9 +15,12 @@
|
|||||||
#include "dao/BankProfileDAO.h"
|
#include "dao/BankProfileDAO.h"
|
||||||
#include "dao/DeviceDAO.h"
|
#include "dao/DeviceDAO.h"
|
||||||
#include "AppLogger.h"
|
#include "AppLogger.h"
|
||||||
|
#include "BankConfigUtils.h"
|
||||||
|
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
// Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50",
|
// Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50",
|
||||||
// "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0,
|
// "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0,
|
||||||
// потому что требует, чтобы вся строка была валидным числом.
|
// потому что требует, чтобы вся строка была валидным числом.
|
||||||
@ -249,15 +252,13 @@ namespace Dushanbe {
|
|||||||
GetLastTransactionsScript::GetLastTransactionsScript(
|
GetLastTransactionsScript::GetLastTransactionsScript(
|
||||||
QString deviceId,
|
QString deviceId,
|
||||||
QString appCode,
|
QString appCode,
|
||||||
double searchAmount,
|
QList<QPair<double, QDateTime>> targets,
|
||||||
QDateTime searchTime,
|
|
||||||
QString materialId,
|
QString materialId,
|
||||||
QObject *parent
|
QObject *parent
|
||||||
) : CommonScript(parent),
|
) : CommonScript(parent),
|
||||||
m_deviceId(std::move(deviceId)),
|
m_deviceId(std::move(deviceId)),
|
||||||
m_appCode(std::move(appCode)),
|
m_appCode(std::move(appCode)),
|
||||||
m_searchAmount(searchAmount),
|
m_targets(std::move(targets)),
|
||||||
m_searchTime(std::move(searchTime)),
|
|
||||||
m_materialId(std::move(materialId)) {
|
m_materialId(std::move(materialId)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,9 +278,15 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
if (!xmlScreenParser.isHomeScreen()) {
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
@ -296,9 +303,9 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Тапаем "История" в нижнем меню
|
// Тапаем "История" в нижнем меню (с геометрическим fallback при [0,0][0,0]).
|
||||||
Node historyBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История"));
|
const QPoint historyPt = bottomNavPoint(QString::fromUtf8("История"), device);
|
||||||
if (historyBtn.x() == 0 && historyBtn.y() == 0) {
|
if (historyPt.x() < 0) {
|
||||||
m_error = "Cannot find History button: " + m_deviceId;
|
m_error = "Cannot find History button: " + m_deviceId;
|
||||||
qWarning() << m_error;
|
qWarning() << m_error;
|
||||||
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
@ -306,7 +313,7 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AdbUtils::makeTap(m_deviceId, historyBtn.x(), historyBtn.y());
|
AdbUtils::makeTap(m_deviceId, historyPt.x(), historyPt.y());
|
||||||
QThread::msleep(3000);
|
QThread::msleep(3000);
|
||||||
|
|
||||||
// Ждём экран истории
|
// Ждём экран истории
|
||||||
@ -378,9 +385,6 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
|
|
||||||
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
|
||||||
|
|
||||||
// Находим accountId
|
// Находим accountId
|
||||||
int accountId = -1;
|
int accountId = -1;
|
||||||
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||||
@ -390,13 +394,111 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
qWarning() << "[Dushanbe::GetLastTransactions] No account found";
|
qWarning() << "[Dushanbe::GetLastTransactions] No account found";
|
||||||
}
|
}
|
||||||
|
|
||||||
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
|
// История отсортирована сверху-вниз: новые операции выше старых. Ищем цели
|
||||||
<< "time=" << m_searchTime.toString(Qt::ISODate);
|
// в том же порядке (по времени, от новых к старым) — тогда один непрерывный
|
||||||
|
// проход вниз находит все, и возвращаться к началу между целями не нужно.
|
||||||
|
// Single-pass: один проход вниз обслуживает ВСЕ цели сразу. Список Выписки
|
||||||
|
// идёт сверху-вниз (новые→старые), сортируем цели так же и отдаём в searchAll.
|
||||||
|
// Найденные он эмитит через transactionParsed и убирает из pending; в остатке —
|
||||||
|
// ненайденные, по которым собираем агрегированную ошибку.
|
||||||
|
QList<QPair<double, QDateTime>> pending = m_targets;
|
||||||
|
std::sort(pending.begin(), pending.end(),
|
||||||
|
[](const QPair<double, QDateTime> &a, const QPair<double, QDateTime> &b) {
|
||||||
|
return a.second > b.second; // по убыванию времени (новые первыми)
|
||||||
|
});
|
||||||
|
|
||||||
|
searchAll(device, accountId, pending);
|
||||||
|
|
||||||
|
if (m_error != "Interrupted") {
|
||||||
|
QStringList notFound;
|
||||||
|
for (const auto &t : pending) {
|
||||||
|
notFound << QString("amount=%1 time=%2")
|
||||||
|
.arg(QString::number(t.first, 'f', 2), t.second.toString(Qt::ISODate));
|
||||||
|
}
|
||||||
|
m_error = notFound.isEmpty()
|
||||||
|
? QString()
|
||||||
|
: QString("Transactions not found (%1/%2): %3")
|
||||||
|
.arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаемся на главную (с геометрическим fallback при [0,0][0,0]).
|
||||||
|
{
|
||||||
|
const QPoint homePt = bottomNavPoint(QString::fromUtf8("Главная"), device);
|
||||||
|
if (homePt.x() >= 0) {
|
||||||
|
AdbUtils::makeTap(m_deviceId, homePt.x(), homePt.y());
|
||||||
|
} else {
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint GetLastTransactionsScript::bottomNavPoint(const QString &label, const DeviceInfo &device) {
|
||||||
|
// 1) Норма: у таба есть реальные bounds в нижней полосе экрана. Проверка
|
||||||
|
// «центр в нижних 80% высоты» отсекает и вырожденные [0,0][0,0], и
|
||||||
|
// НЕнулевые-но-негодные координаты (одноимённый элемент не в нав-баре).
|
||||||
|
const Node lbl = xmlScreenParser.findNodeByContentDesc(label);
|
||||||
|
if (!lbl.isEmpty() && lbl.x() > 0
|
||||||
|
&& lbl.y() >= static_cast<int>(device.screenHeight * 0.80))
|
||||||
|
return {lbl.x(), lbl.y()};
|
||||||
|
|
||||||
|
// 2) Лейбл нав-таба вырожден ([0,0][0,0] — гибридный UI, напр. Infinix XOS не
|
||||||
|
// отдаёт bounds нижнего меню). Но ИКОНКИ табов имеют реальные координаты.
|
||||||
|
// Берём самый нижний ряд небольших нод и в нём крайнюю по X: История —
|
||||||
|
// самая правая иконка, Главная — самая левая (FAB по центру никогда не
|
||||||
|
// крайний; контентные плитки выше нижнего ряда и отсекаются по y2). Это
|
||||||
|
// фактические координаты → не зависит от размера экрана, плотности и
|
||||||
|
// высоты системной навигации Android (её фикс-высота в dp ломала бы доли).
|
||||||
|
const bool rightmost = (label == QString::fromUtf8("История"));
|
||||||
|
const bool leftmost = (label == QString::fromUtf8("Главная"));
|
||||||
|
if (rightmost || leftmost) {
|
||||||
|
const int H = device.screenHeight, W = device.screenWidth;
|
||||||
|
const auto nodes = xmlScreenParser.findAllNodes();
|
||||||
|
auto isNavIcon = [&](const Node &n) {
|
||||||
|
return n.x1() >= 0 && n.width() > 0 && n.height() >= 12
|
||||||
|
&& n.width() <= W / 3 && n.y() >= H / 2; // не контейнер, нижняя половина
|
||||||
|
};
|
||||||
|
int rowBottom = -1;
|
||||||
|
for (const Node &n : nodes)
|
||||||
|
if (isNavIcon(n)) rowBottom = qMax(rowBottom, n.y2());
|
||||||
|
if (rowBottom > 0) {
|
||||||
|
const int tol = qMax(8, H / 100); // «тот же ряд» — у самой нижней кромки
|
||||||
|
Node pick;
|
||||||
|
for (const Node &n : nodes) {
|
||||||
|
if (!isNavIcon(n) || n.y2() < rowBottom - tol) continue;
|
||||||
|
if (pick.isEmpty()
|
||||||
|
|| (rightmost && n.x() > pick.x())
|
||||||
|
|| (leftmost && n.x() < pick.x()))
|
||||||
|
pick = n;
|
||||||
|
}
|
||||||
|
if (!pick.isEmpty()) {
|
||||||
|
qWarning() << "[Dushanbe::GetLastTransactions] nav tab" << label
|
||||||
|
<< "— icon-row fallback tap at" << pick.x() << pick.y();
|
||||||
|
return {pick.x(), pick.y()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {-1, -1};
|
||||||
|
}
|
||||||
|
|
||||||
|
void GetLastTransactionsScript::searchAll(
|
||||||
|
const DeviceInfo &device, int accountId,
|
||||||
|
QList<QPair<double, QDateTime>> &pending) {
|
||||||
|
// Скриншот списка транзакций — отправим при неудаче
|
||||||
|
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] searchAll: targets=" << pending.size();
|
||||||
|
|
||||||
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
|
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
|
||||||
const QTimeZone tjTz("Asia/Dushanbe");
|
const QTimeZone tjTz("Asia/Dushanbe");
|
||||||
const int maxScrolls = 10;
|
const int maxScrolls = 10;
|
||||||
|
|
||||||
|
// Окно поиска по датам: сегодня + (N-1) предыдущих дней. Настраивается в
|
||||||
|
// config.ini → [<bankCode>]/transaction_search_window_days (дефолт 2 = сегодня+вчера).
|
||||||
|
const int searchWindowDays = BankConfigUtils::transactionSearchWindowDays(m_appCode);
|
||||||
|
|
||||||
auto interrupted = []() {
|
auto interrupted = []() {
|
||||||
return QThread::currentThread()->isInterruptionRequested();
|
return QThread::currentThread()->isInterruptionRequested();
|
||||||
};
|
};
|
||||||
@ -409,10 +511,17 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
Node node;
|
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()) {
|
if (interrupted()) {
|
||||||
m_error = "Interrupted";
|
m_error = "Interrupted";
|
||||||
emit finishedWithResult(m_error);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
@ -482,7 +591,11 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
static const QRegularExpression reDateOrToday(
|
static const QRegularExpression reDateOrToday(
|
||||||
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
|
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
|
||||||
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
|
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
|
||||||
static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)");
|
// Сумма: либо с десятичной частью ("-5577.95", "109,50"), либо целая
|
||||||
|
// ("109" — P2P-зачисления показываются без копеек). Целую часть
|
||||||
|
// ограничиваем 7 цифрами, чтобы не спутать с 12–16-значным номером
|
||||||
|
// карты, если он окажется на отдельной строке без пробелов/дефисов.
|
||||||
|
static const QRegularExpression reAmountLine(R"(^-?(?:\d+[.,]\d{1,2}|\d{1,7})$)");
|
||||||
QList<VypiskaItem> items;
|
QList<VypiskaItem> items;
|
||||||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
if (n.className != "android.view.View") continue;
|
if (n.className != "android.view.View") continue;
|
||||||
@ -498,12 +611,14 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
|
|
||||||
VypiskaItem it;
|
VypiskaItem it;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
// Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному
|
// Ищем строку-сумму — устойчиво и к 5-, и к 6-строчному DCWallet,
|
||||||
// DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые
|
// и к P2P. Карты с пробелами ("9762 0002 ...") и дефисами, а также
|
||||||
// поля не подпадают под паттерн.
|
// текстовые поля не подпадают под паттерн.
|
||||||
for (const QString &line : lines) {
|
for (const QString &line : lines) {
|
||||||
if (!reAmountLine.match(line.trimmed()).hasMatch()) continue;
|
const QString t = line.trimmed();
|
||||||
it.amount = line.trimmed().toDouble(&ok);
|
if (!reAmountLine.match(t).hasMatch()) continue;
|
||||||
|
QString v = t; v.replace(',', '.');
|
||||||
|
it.amount = v.toDouble(&ok);
|
||||||
if (ok) break;
|
if (ok) break;
|
||||||
}
|
}
|
||||||
if (!ok) continue;
|
if (!ok) continue;
|
||||||
@ -596,49 +711,58 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
<< "y=" << items[i].node.y();
|
<< "y=" << items[i].node.y();
|
||||||
}
|
}
|
||||||
|
|
||||||
const double searchAbs = qAbs(m_searchAmount);
|
// Single-pass: сверяем каждую видимую транзакцию со ВСЕМ набором
|
||||||
|
// оставшихся целей. Найденную убираем из pending и пере-сканируем тот же
|
||||||
|
// экран (matchedThisScreen), скроллим только когда matchей на экране нет.
|
||||||
|
bool matchedThisScreen = false;
|
||||||
for (const auto &tx : items) {
|
for (const auto &tx : items) {
|
||||||
const double absListAmt = qAbs(tx.amount);
|
const double absListAmt = qAbs(tx.amount);
|
||||||
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
|
|
||||||
const bool amountX100 = qAbs(absListAmt * 100.0 - searchAbs) < 0.01;
|
|
||||||
if (!amountDirect && !amountX100) continue;
|
|
||||||
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 формата).
|
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
|
||||||
// DCWallet транзакции получают дату из groupDates ImageView, которая
|
// 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 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()) {
|
if (listDate.isValid() && searchLocalDate.isValid()) {
|
||||||
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
|
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
|
||||||
if (dayDiff > 3) {
|
if (dayDiff > 3) continue;
|
||||||
qDebug() << "[Dushanbe::GetLastTransactions] Date pre-filter skip:"
|
|
||||||
<< tx.date << "vs" << searchLocalDate.toString("dd.MM.yyyy")
|
|
||||||
<< "diff=" << dayDiff << "days";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи)
|
// Проверка времени по HH:MM:SS (+10 минут, одностороннее окно, с учётом wraparound полуночи)
|
||||||
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
|
if (cand.isValid() && !tx.time.isEmpty()) {
|
||||||
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
|
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
|
||||||
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
|
const QTime searchLocalTime = cand.toTimeZone(tjTz).time();
|
||||||
if (listTime.isValid() && searchLocalTime.isValid()) {
|
if (listTime.isValid() && searchLocalTime.isValid()) {
|
||||||
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
|
int diffSecs = searchLocalTime.secsTo(listTime); // >0 = listTime (tx) позже цели
|
||||||
if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m
|
if (diffSecs < -43200) diffSecs += 86400; // wraparound через полночь вперёд
|
||||||
qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:"
|
else if (diffSecs > 43200) diffSecs -= 86400; // wraparound назад
|
||||||
<< tx.time << "vs" << searchLocalTime.toString("HH:mm:ss")
|
if (diffSecs < 0 || diffSecs > 600) continue; // tx раньше цели или позже неё на >10 мин
|
||||||
<< "diff=" << diffSecs << "sec";
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
Node txNode = tx.node;
|
||||||
|
|
||||||
@ -659,13 +783,13 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
const int tapX = txNode.x1() + 80;
|
const int tapX = txNode.x1() + 80;
|
||||||
AdbUtils::makeTap(m_deviceId, tapX, txNode.y());
|
AdbUtils::makeTap(m_deviceId, tapX, txNode.y());
|
||||||
QThread::msleep(3000);
|
QThread::msleep(3000);
|
||||||
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
if (interrupted()) { m_error = "Interrupted"; return; }
|
||||||
|
|
||||||
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
|
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
|
||||||
OcrTransactionDetail detail;
|
OcrTransactionDetail detail;
|
||||||
bool detailLoaded = false;
|
bool detailLoaded = false;
|
||||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
if (interrupted()) { m_error = "Interrupted"; return; }
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
// 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml):
|
// 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml):
|
||||||
@ -793,8 +917,8 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
<< "date=" << detail.date << "time=" << detail.time
|
<< "date=" << detail.date << "time=" << detail.time
|
||||||
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
|
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
|
||||||
|
|
||||||
// Проверяем время ±10 минут
|
// Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+10м, одностороннее окно)
|
||||||
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
|
if (searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
|
||||||
QDateTime txTime = QDateTime::fromString(
|
QDateTime txTime = QDateTime::fromString(
|
||||||
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
||||||
if (!txTime.isValid()) {
|
if (!txTime.isValid()) {
|
||||||
@ -802,9 +926,9 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
}
|
}
|
||||||
if (txTime.isValid()) {
|
if (txTime.isValid()) {
|
||||||
txTime.setTimeZone(tjTz);
|
txTime.setTimeZone(tjTz);
|
||||||
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
|
const qint64 diffSecs = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели
|
||||||
if (diffSecs > 600) {
|
if (diffSecs < 0 || diffSecs > 600) {
|
||||||
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping";
|
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (out of +10min window), skipping";
|
||||||
AdbUtils::goBack(m_deviceId);
|
AdbUtils::goBack(m_deviceId);
|
||||||
QThread::msleep(1000);
|
QThread::msleep(1000);
|
||||||
continue;
|
continue;
|
||||||
@ -823,8 +947,8 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
// Формируем task_result напрямую из распарсенных данных и отдаём
|
// Формируем task_result напрямую из распарсенных данных и отдаём
|
||||||
// EventHandler через сигнал — без записи/чтения БД.
|
// EventHandler через сигнал — без записи/чтения БД.
|
||||||
{
|
{
|
||||||
const double finalAmount = matchedX100 ? detail.amount * 100.0 : detail.amount;
|
const double finalAmount = detail.amount;
|
||||||
const double finalFee = matchedX100 ? detail.fee * 100.0 : detail.fee;
|
const double finalFee = detail.fee;
|
||||||
|
|
||||||
QDateTime txTime = QDateTime::fromString(
|
QDateTime txTime = QDateTime::fromString(
|
||||||
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
||||||
@ -834,9 +958,15 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
if (txTime.isValid()) txTime.setTimeZone(tjTz);
|
if (txTime.isValid()) txTime.setTimeZone(tjTz);
|
||||||
|
|
||||||
QJsonObject txJson;
|
QJsonObject txJson;
|
||||||
txJson["bank_transaction_id"] = detail.operationId.isEmpty()
|
// Фолбэк-id (когда «Номер операции» не распарсился) строим из
|
||||||
? QString("dushanbe_") + detail.date + "_" + detail.time
|
// created_at задачи — он детерминирован и стабилен между запусками,
|
||||||
: detail.operationId;
|
// что важно для дедупликации на сервере. Раньше брали распарсенные
|
||||||
|
// с экрана дату+время, которые могли «плавать» из-за OCR.
|
||||||
|
txJson["bank_transaction_id"] = !detail.operationId.isEmpty()
|
||||||
|
? detail.operationId
|
||||||
|
: (searchTime.isValid()
|
||||||
|
? QStringLiteral("dushanbe_") + searchTime.toUTC().toString(Qt::ISODate)
|
||||||
|
: QStringLiteral("dushanbe_") + detail.date + "_" + detail.time);
|
||||||
// Списание хранится с отрицательным знаком, пополнение — положительным.
|
// Списание хранится с отрицательным знаком, пополнение — положительным.
|
||||||
// detail.amount всегда положителен (OCR), знак берём из списка Выписки.
|
// detail.amount всегда положителен (OCR), знак берём из списка Выписки.
|
||||||
const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount);
|
const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount);
|
||||||
@ -849,9 +979,27 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
QJsonObject extraObj;
|
QJsonObject extraObj;
|
||||||
extraObj["fee"] = static_cast<int>(finalFee * 100);
|
extraObj["fee"] = static_cast<int>(finalFee * 100);
|
||||||
txJson["extra"] = extraObj;
|
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);
|
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
|
||||||
|
|
||||||
|
// Проверяем глобальный кэш: если транзакция уже была найдена
|
||||||
|
// в рамках другой задачи этого материала — пропускаем её.
|
||||||
|
const QString realAt = txJson["real_created_at"].toString();
|
||||||
|
if (!realAt.isEmpty()) {
|
||||||
|
const QString ck = realAt + "|" + QString::number(qAbs(txJson["amount"].toInt()));
|
||||||
|
if (m_foundCache.contains(ck)) {
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] TX already found in another task, skip:" << ck;
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
m_foundCache.insert(ck);
|
||||||
|
}
|
||||||
|
|
||||||
qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:"
|
qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:"
|
||||||
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
|
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
|
||||||
emit transactionParsed(txJson);
|
emit transactionParsed(txJson);
|
||||||
@ -871,13 +1019,65 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
}
|
}
|
||||||
QThread::msleep(1000);
|
QThread::msleep(1000);
|
||||||
|
|
||||||
emit finishedWithResult(m_error); // m_error пустой — успех
|
// Цель найдена и отдана — убираем из набора и пере-сканируем этот же
|
||||||
return;
|
// экран (вдруг на нём есть ещё цели), прежде чем скроллить дальше.
|
||||||
|
pending.removeAt(matchIdx);
|
||||||
|
matchedThisScreen = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.isEmpty()) break;
|
||||||
|
// На экране что-то нашли — пере-сканируем его же (без скролла): другие
|
||||||
|
// цели могут быть на тех же позициях, а скролл их бы пропустил.
|
||||||
|
if (matchedThisScreen) continue;
|
||||||
|
|
||||||
|
// Окно поиска — "сегодня + (searchWindowDays-1) предыдущих дней". Список
|
||||||
|
// Выписки идёт сверху-вниз от новых к старым; группы-даты на текущем экране
|
||||||
|
// собраны в groupDates. Как только самая свежая видимая дата уже старше
|
||||||
|
// нижней границы окна — значит окно полностью проскроллено, дальше только
|
||||||
|
// старая история. Прекращаем скролл (целевой транзакции в окне нет), вместо
|
||||||
|
// того чтобы листать до упора и упереться в watchdog-таймаут.
|
||||||
|
{
|
||||||
|
const QDate cutoff = todayLocal.addDays(-(searchWindowDays - 1)); // нижняя граница окна
|
||||||
|
QDate newestVisible;
|
||||||
|
for (const QString &gd : groupDates) {
|
||||||
|
const QDate d = QDate::fromString(gd, "dd.MM.yyyy");
|
||||||
|
if (d.isValid() && (newestVisible.isNull() || d > newestVisible))
|
||||||
|
newestVisible = d;
|
||||||
|
}
|
||||||
|
if (newestVisible.isValid() && newestVisible < cutoff) {
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Past today+yesterday window"
|
||||||
|
" (newest visible=" << newestVisible.toString("dd.MM.yyyy")
|
||||||
|
<< "< cutoff=" << cutoff.toString("dd.MM.yyyy")
|
||||||
|
<< ") — stopping scroll";
|
||||||
|
return; // дальше только старая история — оставшиеся цели не найдены
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Детект отсутствия прогресса прокрутки: сравниваем сигнатуру текущего
|
||||||
|
// видимого набора с предыдущей. Совпала — свайп ничего не сдвинул.
|
||||||
|
{
|
||||||
|
QStringList sigParts;
|
||||||
|
for (const VypiskaItem &it : items) {
|
||||||
|
sigParts << QString::number(it.amount, 'f', 2) + "|" + it.date + "|" + it.time;
|
||||||
|
}
|
||||||
|
const QString viewSig = sigParts.join(';') + "##" + groupDates.join(',');
|
||||||
|
if (!prevViewSig.isEmpty() && viewSig == prevViewSig) {
|
||||||
|
if (++stallScrolls >= 2) {
|
||||||
|
qWarning() << "[Dushanbe::GetLastTransactions] No scroll progress for"
|
||||||
|
<< stallScrolls << "swipes (list stuck) — aborting,"
|
||||||
|
<< pending.size() << "target(s) still not found";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stallScrolls = 0;
|
||||||
|
}
|
||||||
|
prevViewSig = viewSig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Скроллим вниз — свайп внутри области транзакций
|
// Скроллим вниз — свайп внутри области транзакций
|
||||||
if (scroll < maxScrolls) {
|
if (scroll < maxScrolls) {
|
||||||
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
if (interrupted()) { m_error = "Interrupted"; return; }
|
||||||
const int x = device.screenWidth / 2;
|
const int x = device.screenWidth / 2;
|
||||||
// Определяем область контента из реальных Y позиций items
|
// Определяем область контента из реальных Y позиций items
|
||||||
int contentBottom = device.screenHeight * 2 / 3;
|
int contentBottom = device.screenHeight * 2 / 3;
|
||||||
@ -892,27 +1092,19 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
|
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
|
||||||
QThread::msleep(2000);
|
QThread::msleep(2000);
|
||||||
}
|
}
|
||||||
|
++scroll; // успешный match делает continue выше и сюда не доходит
|
||||||
}
|
}
|
||||||
|
|
||||||
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
|
// Прошли весь доступный диапазон/окно. В pending остались ненайденные цели —
|
||||||
+ " time=" + m_searchTime.toString(Qt::ISODate);
|
// агрегированную ошибку по ним соберёт doStart (он же вернёт на главную).
|
||||||
qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
|
if (!pending.isEmpty()) {
|
||||||
|
qWarning() << "[Dushanbe::GetLastTransactions] searchAll finished,"
|
||||||
|
<< pending.size() << "target(s) not found";
|
||||||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||||||
m_resultScreenshot = listScreenshot.isEmpty()
|
m_resultScreenshot = listScreenshot.isEmpty()
|
||||||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||||
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||||
|
|
||||||
// Возвращаемся на главную
|
|
||||||
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
|
|
||||||
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {
|
|
||||||
AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y());
|
|
||||||
} else {
|
|
||||||
AdbUtils::goBack(m_deviceId);
|
|
||||||
}
|
}
|
||||||
QThread::msleep(1000);
|
|
||||||
|
|
||||||
emit finishedWithResult(m_error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace Dushanbe
|
} // namespace Dushanbe
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
#include "CommonScript.h"
|
#include "CommonScript.h"
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QList>
|
||||||
|
#include <QPair>
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QSet>
|
||||||
#include "db/DeviceInfo.h"
|
#include "db/DeviceInfo.h"
|
||||||
#include "db/BankProfileInfo.h"
|
#include "db/BankProfileInfo.h"
|
||||||
|
|
||||||
@ -14,8 +18,7 @@ public:
|
|||||||
explicit GetLastTransactionsScript(
|
explicit GetLastTransactionsScript(
|
||||||
QString deviceId,
|
QString deviceId,
|
||||||
QString appCode,
|
QString appCode,
|
||||||
double searchAmount = 0,
|
QList<QPair<double, QDateTime>> targets = {},
|
||||||
QDateTime searchTime = {},
|
|
||||||
QString materialId = {},
|
QString materialId = {},
|
||||||
QObject *parent = nullptr
|
QObject *parent = nullptr
|
||||||
);
|
);
|
||||||
@ -23,21 +26,45 @@ public:
|
|||||||
~GetLastTransactionsScript() override;
|
~GetLastTransactionsScript() override;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
// Испускается, когда транзакция найдена и распарсена с экрана деталей.
|
// Испускается на КАЖДУЮ найденную транзакцию из списка целей.
|
||||||
// EventHandler использует эти данные напрямую для task_result вместо
|
// EventHandler использует эти данные напрямую для task_result вместо
|
||||||
// повторного чтения из БД.
|
// повторного чтения из БД.
|
||||||
void transactionParsed(const QJsonObject &tx);
|
void transactionParsed(const QJsonObject &tx);
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Снимок глобального кэша найденных транзакций (от EventHandler).
|
||||||
|
// Ключи: "real_created_at|abs_amount_cents". Транзакции из этого набора
|
||||||
|
// пропускаются при поиске, чтобы одна и та же TX не подтверждала два задания.
|
||||||
|
void setFoundCache(const QSet<QString> &cache) { m_foundCache = cache; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void doStart() override;
|
void doStart() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Single-pass поиск ВСЕХ целей за один проход вниз по "Выписке": на каждом
|
||||||
|
// экране сверяем видимые транзакции со всем набором оставшихся целей,
|
||||||
|
// найденные эмитим через transactionParsed и убираем из pending, затем
|
||||||
|
// пере-сканируем тот же экран. Это убирает N-кратный перескролл (раньше
|
||||||
|
// каждая цель искалась отдельным searchOne с возвратом к началу списка → при
|
||||||
|
// многих целях упор в 10-мин watchdog). По завершении в pending остаются
|
||||||
|
// только ненайденные цели. Прерывание выставляет m_error="Interrupted".
|
||||||
|
void searchAll(const DeviceInfo &device, int accountId,
|
||||||
|
QList<QPair<double, QDateTime>> &pending);
|
||||||
|
|
||||||
|
// Координаты тапа по табу НИЖНЕГО меню. Сначала пробуем реальные bounds из
|
||||||
|
// дерева доступности; если узел отсутствует или вырожден ([0,0][0,0] —
|
||||||
|
// гибридный UI на части устройств, напр. Infinix XOS, не отдаёт bounds
|
||||||
|
// нижнего бара, хотя он отрисован у самого низа экрана), падаем на геометрию
|
||||||
|
// (доли ширины/высоты). Возвращает {-1,-1}, если таб неизвестен.
|
||||||
|
QPoint bottomNavPoint(const QString &label, const DeviceInfo &device);
|
||||||
|
|
||||||
QString m_deviceId;
|
QString m_deviceId;
|
||||||
const QString m_appCode;
|
const QString m_appCode;
|
||||||
const double m_searchAmount; // сумма в сомони (0 = все последние)
|
// Список целей: пары (сумма в сомони со знаком, примерное время UTC).
|
||||||
const QDateTime m_searchTime; // примерное время (UTC)
|
const QList<QPair<double, QDateTime>> m_targets;
|
||||||
const QString m_materialId; // UUID задачи (для сохранения дампов)
|
const QString m_materialId; // UUID задачи (для сохранения дампов)
|
||||||
QString m_error;
|
QString m_error;
|
||||||
|
QSet<QString> m_foundCache; // снимок глобального кэша (от EventHandler)
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Dushanbe
|
} // namespace Dushanbe
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
#include "GetProfileInfoScript.h"
|
#include "GetProfileInfoScript.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <QElapsedTimer>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
@ -36,9 +39,14 @@ void GetProfileInfoScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Dushanbe::GetProfileInfo] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
if (!xmlScreenParser.isHomeScreen()) {
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
@ -55,27 +63,45 @@ void GetProfileInfoScript::doStart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Всегда тапаем кнопку обновления — баланс на экране может быть закэширован
|
// Всегда тапаем кнопку обновления — баланс на экране может быть закэширован
|
||||||
// и не отражать актуальное состояние счёта. Лоадера у refresh нет,
|
// и не отражать актуальное состояние счёта. Лоадера у refresh нет, но во
|
||||||
// поэтому ждём фиксированно.
|
// время загрузки родительский блок временно становится content-desc="TJS"
|
||||||
|
// (без даты, без числа) — это надёжный маркер «идёт обновление». Поэтому
|
||||||
|
// после тапа поллим экран до появления распарсиваемого баланса.
|
||||||
Node refreshBtn = xmlScreenParser.findRefreshButtonOnHomeScreen();
|
Node refreshBtn = xmlScreenParser.findRefreshButtonOnHomeScreen();
|
||||||
|
std::optional<double> parsedOpt;
|
||||||
if (refreshBtn.x() > 0) {
|
if (refreshBtn.x() > 0) {
|
||||||
qDebug() << "[Dushanbe::GetProfileInfo] Tapping refresh to get up-to-date balance";
|
qDebug() << "[Dushanbe::GetProfileInfo] Tapping refresh to get up-to-date balance";
|
||||||
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
|
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
|
||||||
QThread::msleep(3000);
|
|
||||||
|
constexpr int kPollIntervalMs = 500;
|
||||||
|
constexpr int kPollTimeoutMs = 15000;
|
||||||
|
QElapsedTimer t;
|
||||||
|
t.start();
|
||||||
|
while (true) {
|
||||||
|
QThread::msleep(kPollIntervalMs);
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
} else {
|
parsedOpt = xmlScreenParser.parseBalanceFromHomeScreenOpt();
|
||||||
qWarning() << "[Dushanbe::GetProfileInfo] Refresh button not found, parsing as-is";
|
if (parsedOpt.has_value()) break;
|
||||||
|
if (t.elapsed() >= kPollTimeoutMs) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const double parsedBalance = xmlScreenParser.parseBalanceFromHomeScreen();
|
if (!parsedOpt.has_value()) {
|
||||||
qDebug() << "[Dushanbe::GetProfileInfo] Parsed balance after refresh:" << parsedBalance;
|
m_error = "Balance did not load after refresh: " + m_deviceId + " " + m_appCode;
|
||||||
if (parsedBalance <= 0.0) {
|
|
||||||
m_error = "Balance is zero after refresh: " + m_deviceId + " " + m_appCode;
|
|
||||||
qWarning() << m_error;
|
qWarning() << m_error;
|
||||||
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
emit finishedWithResult(m_error);
|
emit finishedWithResult(m_error);
|
||||||
return;
|
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);
|
BankProfileDAO::updateAmount(app.id, parsedBalance);
|
||||||
// У Душанбе все карты сидят на одном счёте, а экран деталей карты
|
// У Душанбе все карты сидят на одном счёте, а экран деталей карты
|
||||||
// отдаёт placeholder "0,00 TJS" — единственный источник правды
|
// отдаёт placeholder "0,00 TJS" — единственный источник правды
|
||||||
|
|||||||
@ -41,9 +41,14 @@ void LoginAndCheckAccountsScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Dushanbe::Login] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
||||||
|
|||||||
@ -97,6 +97,48 @@ static QString recognizeOperationId(const QByteArray &pngData) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Координаты кнопки "Подтверждаю" на bottom sheet подтверждения платежа.
|
||||||
|
// Compose flatten-ит весь sheet в одну accessibility-ноду — отдельного узла
|
||||||
|
// кнопки в дампе нет, поэтому позицию ищем по скриншоту тремя способами по
|
||||||
|
// убыванию надёжности:
|
||||||
|
// 1) цвет — оранжевая sticky-кнопка RGB(255,117,40); не зависит от текста,
|
||||||
|
// контраста, языка и разрешения (самый стабильный путь);
|
||||||
|
// 2) OCR "Подтверждаю" — если вдруг тема/цвет кнопки иные;
|
||||||
|
// 3) геометрия — центр кнопки ≈ 6% высоты экрана над низом sheet'a; считаем
|
||||||
|
// относительно высоты, а не в абсолютных пикселях, чтобы работать на
|
||||||
|
// любом разрешении.
|
||||||
|
// `confirmNode` — нода sheet'a (на весь экран), `height` — высота экрана.
|
||||||
|
static QPoint locateConfirmButton(const QString &deviceId,
|
||||||
|
const Node &confirmNode,
|
||||||
|
int height) {
|
||||||
|
// Окно поиска — нижняя половина sheet'a, где живёт кнопка. Sheet-нода
|
||||||
|
// растянута на весь экран, поэтому y1/y2 сами по себе поиск не сужают.
|
||||||
|
const int yMin = (confirmNode.y1() + confirmNode.y2()) / 2;
|
||||||
|
const int yMax = confirmNode.y2();
|
||||||
|
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
|
||||||
|
// 1) Цвет.
|
||||||
|
QPoint tap = OcrUtils::findColorButtonCenter(screen, 255, 117, 40, 55, yMin, yMax);
|
||||||
|
if (tap.x() >= 0) {
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] confirm button via color at" << tap;
|
||||||
|
return tap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) OCR.
|
||||||
|
tap = OcrUtils::findTextCenter(screen, QString::fromUtf8("Подтверждаю"), yMin, yMax);
|
||||||
|
if (tap.x() >= 0) {
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] confirm button via OCR at" << tap;
|
||||||
|
return tap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Геометрия: кнопка у самого низа sheet'a. Целимся ВНИЗ к y2, а не вверх.
|
||||||
|
const int sheetH = confirmNode.y2() - confirmNode.y1();
|
||||||
|
const int offset = qMax(sheetH * 6 / 100, height / 16);
|
||||||
|
tap = QPoint(confirmNode.x(), confirmNode.y2() - offset);
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] confirm button geometry fallback at" << tap;
|
||||||
|
return tap;
|
||||||
|
}
|
||||||
|
|
||||||
namespace Dushanbe {
|
namespace Dushanbe {
|
||||||
|
|
||||||
PayByCardScript::PayByCardScript(
|
PayByCardScript::PayByCardScript(
|
||||||
@ -123,9 +165,14 @@ void PayByCardScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_CARD", device.adbSerial, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
|
||||||
|
|
||||||
if (!xmlScreenParser.isHomeScreen()) {
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
@ -378,23 +425,8 @@ void PayByCardScript::makePayment(
|
|||||||
QString::fromUtf8("Подтверждение платежа"), true);
|
QString::fromUtf8("Подтверждение платежа"), true);
|
||||||
if (!confirmNode.isEmpty() &&
|
if (!confirmNode.isEmpty() &&
|
||||||
confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
|
confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
|
||||||
// Compose flatten-ит весь bottom sheet в одну accessibility-кнопку,
|
// Позицию кнопки ищем: цвет → OCR → геометрия (см. locateConfirmButton).
|
||||||
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
|
const QPoint tap = locateConfirmButton(deviceId, confirmNode, height);
|
||||||
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
|
|
||||||
// нижней четверти sheet, выше системного nav-бара).
|
|
||||||
// Compose отдаёт sheet как одну ноду на весь экран ([0,0][720,1640]),
|
|
||||||
// поэтому confirmNode.y1/y2 не сужают поиск — OCR сканирует весь
|
|
||||||
// скриншот и ловит ложные совпадения. Ограничиваем нижней половиной.
|
|
||||||
const int ocrYMin = (confirmNode.y1() + confirmNode.y2()) / 2;
|
|
||||||
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
|
||||||
QPoint tap = OcrUtils::findTextCenter(screen,
|
|
||||||
QString::fromUtf8("Подтверждаю"),
|
|
||||||
ocrYMin, confirmNode.y2());
|
|
||||||
if (tap.x() < 0) {
|
|
||||||
const int tapY = confirmNode.y2() - qMax(150, height / 14);
|
|
||||||
tap = QPoint(confirmNode.x(), tapY);
|
|
||||||
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
|
|
||||||
}
|
|
||||||
qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap;
|
qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap;
|
||||||
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||||
confirmed = true;
|
confirmed = true;
|
||||||
@ -482,20 +514,9 @@ void PayByCardScript::makePayment(
|
|||||||
++confirmRetries;
|
++confirmRetries;
|
||||||
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap"
|
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap"
|
||||||
<< confirmRetries << "/" << maxConfirmRetries;
|
<< confirmRetries << "/" << maxConfirmRetries;
|
||||||
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
|
// Та же логика, что и на первом тапе: цвет → OCR → геометрия.
|
||||||
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
const QPoint tap = locateConfirmButton(deviceId, retryConfirm, height);
|
||||||
QPoint tap = OcrUtils::findTextCenter(screen,
|
qDebug() << "[Dushanbe::PayByCard] Re-tapping 'Подтверждаю' at" << tap;
|
||||||
QString::fromUtf8("Подтверждаю"),
|
|
||||||
ocrYMin, retryConfirm.y2());
|
|
||||||
if (tap.x() < 0) {
|
|
||||||
// Варьируем Y: первый retry — выше fallback на 100px,
|
|
||||||
// второй — ещё на 100px выше. Compose-кнопка действия
|
|
||||||
// обычно на y2 - 150..350; перебираем диапазон.
|
|
||||||
const int extra = 100 * confirmRetries;
|
|
||||||
const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra);
|
|
||||||
tap = QPoint(retryConfirm.x(), tapY);
|
|
||||||
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
|
|
||||||
}
|
|
||||||
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||||
QThread::msleep(4000);
|
QThread::msleep(4000);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -120,9 +120,14 @@ void PayByPhoneScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_PHONE", device.adbSerial, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
|
||||||
|
|
||||||
if (!xmlScreenParser.isHomeScreen()) {
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
|||||||
@ -164,31 +164,54 @@ Node ScreenXmlParser::findPinCodeEditText() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool ScreenXmlParser::isHomeScreen() {
|
bool ScreenXmlParser::isHomeScreen() {
|
||||||
|
// Нижняя навигация (Главная + соседняя вкладка) присутствует на ВСЕХ
|
||||||
|
// основных вкладках — Главная, История, Переводы, MiniApps — поэтому одного
|
||||||
|
// таб-бара недостаточно: экран «История/Операции» иначе ошибочно
|
||||||
|
// принимается за home, и скрипт пытается открыть боковое меню/листать не с
|
||||||
|
// той страницы (см. «Side menu not detected»). Требуем дополнительно
|
||||||
|
// уникальный для главной якорь и явно отсекаем экран истории.
|
||||||
bool hasGlavnaya = false;
|
bool hasGlavnaya = false;
|
||||||
bool hasBottomNav = false;
|
bool hasBottomNav = false;
|
||||||
|
bool hasHomeAnchor = false;
|
||||||
|
bool isHistoryScreen = false;
|
||||||
for (const Node &node : m_nodes) {
|
for (const Node &node : m_nodes) {
|
||||||
if (node.contentDesc.contains(QString::fromUtf8("Главная"))) {
|
const QString &d = node.contentDesc;
|
||||||
|
if (d.contains(QString::fromUtf8("Главная"))) {
|
||||||
hasGlavnaya = true;
|
hasGlavnaya = true;
|
||||||
}
|
}
|
||||||
// Нижняя навигация — любая из соседних вкладок указывает, что мы на
|
// Нижняя навигация — любая из соседних вкладок указывает, что мы на
|
||||||
// основной странице приложения, а не на splash/PIN/диалоге.
|
// основной странице приложения, а не на splash/PIN/диалоге.
|
||||||
if (node.contentDesc == QString::fromUtf8("Переводы") ||
|
if (d == QString::fromUtf8("Переводы") ||
|
||||||
node.contentDesc == QString::fromUtf8("Сервисы") ||
|
d == QString::fromUtf8("Сервисы") ||
|
||||||
node.contentDesc == QString::fromUtf8("MiniApps") ||
|
d == QString::fromUtf8("MiniApps") ||
|
||||||
node.contentDesc == QString::fromUtf8("История")) {
|
d == QString::fromUtf8("История")) {
|
||||||
hasBottomNav = true;
|
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"
|
// На домашнем экране: parent node content-desc="TJS\n03.04.26 - 21:46:15"
|
||||||
// внутри него child node content-desc="0.00" (или "10.00")
|
// внутри него child node content-desc="0.00" (или "10.00")
|
||||||
// Стратегия: находим индекс parent, затем child сразу после него с числовым content-desc
|
// Стратегия: находим индекс parent, затем child сразу после него с числовым content-desc.
|
||||||
|
// Во время refresh-загрузки parent временно превращается в content-desc="TJS" (без даты)
|
||||||
|
// и числового child нет — в этом случае возвращаем nullopt, чтобы вызывающий
|
||||||
|
// код мог отличить состояние загрузки от реального нулевого баланса.
|
||||||
for (int i = 0; i < m_nodes.size(); ++i) {
|
for (int i = 0; i < m_nodes.size(); ++i) {
|
||||||
if (!m_nodes[i].contentDesc.startsWith("TJS")) continue;
|
if (!m_nodes[i].contentDesc.startsWith("TJS")) continue;
|
||||||
// Проверяем что это блок с датой (не просто "TJS" текст)
|
// Проверяем что это блок с датой (не просто "TJS" текст)
|
||||||
@ -205,7 +228,11 @@ double ScreenXmlParser::parseBalanceFromHomeScreen() {
|
|||||||
if (ok) return val;
|
if (ok) return val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0.0;
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
double ScreenXmlParser::parseBalanceFromHomeScreen() {
|
||||||
|
return parseBalanceFromHomeScreenOpt().value_or(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ScreenXmlParser::parseUserNameFromHomeScreen() {
|
QString ScreenXmlParser::parseUserNameFromHomeScreen() {
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QDomElement>
|
#include <QDomElement>
|
||||||
#include "android/xml/Node.h"
|
#include "android/xml/Node.h"
|
||||||
|
#include "AppVersionTrack.h"
|
||||||
#include "db/MaterialInfo.h"
|
#include "db/MaterialInfo.h"
|
||||||
|
|
||||||
namespace Dushanbe {
|
namespace Dushanbe {
|
||||||
@ -18,6 +21,14 @@ public:
|
|||||||
|
|
||||||
void parseAndSaveXml(const QString &xml);
|
void parseAndSaveXml(const QString &xml);
|
||||||
|
|
||||||
|
// Версионный трек приложения, под которое сейчас идёт парсинг. Скрипт
|
||||||
|
// выставляет его один раз после загрузки BankProfileInfo:
|
||||||
|
// parser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
|
||||||
|
// Версионно-зависимые методы парсинга используют m_track как явный шов
|
||||||
|
// (см. AppVersionTrack.h). По умолчанию — новейший известный формат.
|
||||||
|
void setVersionTrack(AppVersionTrack track) { m_track = track; }
|
||||||
|
AppVersionTrack versionTrack() const { return m_track; }
|
||||||
|
|
||||||
const QList<Node> &findAllNodes() const;
|
const QList<Node> &findAllNodes() const;
|
||||||
|
|
||||||
Node findTextNode(const QString &text);
|
Node findTextNode(const QString &text);
|
||||||
@ -37,9 +48,20 @@ public:
|
|||||||
|
|
||||||
bool isHomeScreen();
|
bool isHomeScreen();
|
||||||
|
|
||||||
// Парсит баланс с домашнего экрана
|
// Парсит баланс с домашнего экрана. Возвращает 0.0, если блок "TJS\n<дата>"
|
||||||
|
// не найден или внутри него нет числовой ноды (это может быть как реально
|
||||||
|
// нулевой баланс, так и loading state сразу после тапа refresh). Для
|
||||||
|
// различения этих случаев используйте parseBalanceFromHomeScreenOpt().
|
||||||
double parseBalanceFromHomeScreen();
|
double parseBalanceFromHomeScreen();
|
||||||
|
|
||||||
|
// То же, что parseBalanceFromHomeScreen, но возвращает std::nullopt, когда
|
||||||
|
// экран в состоянии загрузки (родитель имеет content-desc "TJS" без даты,
|
||||||
|
// либо блок "TJS\n<дата>" найден, но числовая нода рядом отсутствует) или
|
||||||
|
// когда мы вообще не на home screen. При nullopt вызывающий код может
|
||||||
|
// подождать и попробовать снова — это безопаснее, чем интерпретировать
|
||||||
|
// loading как реальный нулевой баланс.
|
||||||
|
std::optional<double> parseBalanceFromHomeScreenOpt();
|
||||||
|
|
||||||
// Находит кнопку обновления данных в блоке "TJS\n<дата>" — правый clickable
|
// Находит кнопку обновления данных в блоке "TJS\n<дата>" — правый clickable
|
||||||
// ImageView (рядом слева сидит глазик-Button, его не трогаем).
|
// ImageView (рядом слева сидит глазик-Button, его не трогаем).
|
||||||
Node findRefreshButtonOnHomeScreen();
|
Node findRefreshButtonOnHomeScreen();
|
||||||
@ -160,6 +182,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
QList<Node> m_nodes;
|
QList<Node> m_nodes;
|
||||||
QDomElement m_xml;
|
QDomElement m_xml;
|
||||||
|
AppVersionTrack m_track = AppVersionTrack::V3_2_38;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Dushanbe
|
} // namespace Dushanbe
|
||||||
|
|||||||
@ -202,4 +202,89 @@ QPoint findTextCenter(const QByteArray &png,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QPoint findColorButtonCenter(const QByteArray &png,
|
||||||
|
int targetR,
|
||||||
|
int targetG,
|
||||||
|
int targetB,
|
||||||
|
int tolerance,
|
||||||
|
int yMin,
|
||||||
|
int yMax) {
|
||||||
|
if (png.isEmpty()) return {-1, -1};
|
||||||
|
|
||||||
|
QElapsedTimer timer;
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
Pix *src = pixReadMem(reinterpret_cast<const l_uint8 *>(png.constData()),
|
||||||
|
static_cast<size_t>(png.size()));
|
||||||
|
if (!src) {
|
||||||
|
qWarning() << "[OcrUtils] findColorButtonCenter: pixReadMem failed";
|
||||||
|
return {-1, -1};
|
||||||
|
}
|
||||||
|
// Приводим к 32bpp, чтобы pixGetRGBPixel работал независимо от исходной
|
||||||
|
// глубины (PNG со скриншота бывает 24/32 bpp).
|
||||||
|
Pix *pix = pixConvertTo32(src);
|
||||||
|
pixDestroy(&src);
|
||||||
|
if (!pix) return {-1, -1};
|
||||||
|
|
||||||
|
const int W = pixGetWidth(pix);
|
||||||
|
const int H = pixGetHeight(pix);
|
||||||
|
const int y0 = qBound(0, yMin, H);
|
||||||
|
const int y1 = (yMax > 0 && yMax <= H) ? yMax : H;
|
||||||
|
|
||||||
|
// Для каждой строки считаем число пикселей целевого цвета и их X-диапазон.
|
||||||
|
QVector<int> rowCount(H, 0), rowMinX(H, -1), rowMaxX(H, -1);
|
||||||
|
for (int y = y0; y < y1; ++y) {
|
||||||
|
int cnt = 0, mn = W, mx = -1;
|
||||||
|
for (int x = 0; x < W; ++x) {
|
||||||
|
l_int32 r, g, b;
|
||||||
|
pixGetRGBPixel(pix, x, y, &r, &g, &b);
|
||||||
|
if (qAbs(r - targetR) <= tolerance &&
|
||||||
|
qAbs(g - targetG) <= tolerance &&
|
||||||
|
qAbs(b - targetB) <= tolerance) {
|
||||||
|
++cnt;
|
||||||
|
if (x < mn) mn = x;
|
||||||
|
if (x > mx) mx = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowCount[y] = cnt;
|
||||||
|
rowMinX[y] = mn;
|
||||||
|
rowMaxX[y] = mx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка — широкая сплошная полоса: ищем самую длинную серию строк, где
|
||||||
|
// заполнение целевым цветом ≥ 25% ширины. Мелкие цветные иконки в статус-
|
||||||
|
// баре такой порог не проходят.
|
||||||
|
const int minRowFill = qMax(1, W / 4);
|
||||||
|
int bestStart = -1, bestLen = 0, curStart = -1;
|
||||||
|
for (int y = y0; y <= y1; ++y) {
|
||||||
|
const bool filled = (y < y1) && (rowCount[y] >= minRowFill);
|
||||||
|
if (filled) {
|
||||||
|
if (curStart < 0) curStart = y;
|
||||||
|
} else if (curStart >= 0) {
|
||||||
|
const int len = y - curStart;
|
||||||
|
if (len > bestLen) { bestLen = len; bestStart = curStart; }
|
||||||
|
curStart = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полоса должна быть не тоньше ~1% высоты — отсекаем шум/тонкие разделители.
|
||||||
|
if (bestStart < 0 || bestLen < qMax(8, H / 100)) {
|
||||||
|
pixDestroy(&pix);
|
||||||
|
qDebug() << "[OcrUtils] findColorButtonCenter: no button band in"
|
||||||
|
<< timer.elapsed() << "ms";
|
||||||
|
return {-1, -1};
|
||||||
|
}
|
||||||
|
|
||||||
|
int xmin = W, xmax = -1;
|
||||||
|
for (int y = bestStart; y < bestStart + bestLen; ++y) {
|
||||||
|
if (rowMinX[y] >= 0) { xmin = qMin(xmin, rowMinX[y]); xmax = qMax(xmax, rowMaxX[y]); }
|
||||||
|
}
|
||||||
|
pixDestroy(&pix);
|
||||||
|
|
||||||
|
const QPoint center((xmin + xmax) / 2, bestStart + bestLen / 2);
|
||||||
|
qDebug() << "[OcrUtils] findColorButtonCenter → (" << center.x() << ","
|
||||||
|
<< center.y() << ") band h=" << bestLen << "in" << timer.elapsed() << "ms";
|
||||||
|
return center;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace OcrUtils
|
} // namespace OcrUtils
|
||||||
@ -19,4 +19,25 @@ QPoint findTextCenter(const QByteArray &png,
|
|||||||
int yMin = 0,
|
int yMin = 0,
|
||||||
int yMax = 0);
|
int yMax = 0);
|
||||||
|
|
||||||
|
// Находит крупную сплошную кнопку заданного цвета (по умолчанию — оранжевая
|
||||||
|
// sticky-action кнопка Dushanbe "Подтверждаю"/"Оплатить", RGB(255,117,40)) и
|
||||||
|
// возвращает центр её bounding box в координатах экрана (= пиксели скриншота).
|
||||||
|
//
|
||||||
|
// В отличие от findTextCenter не зависит от текста, контраста и языка: OCR на
|
||||||
|
// белом-по-оранжевому ненадёжен, а сплошной цветной прямоугольник ловится
|
||||||
|
// стабильно. Алгоритм — поиск самой длинной вертикальной полосы строк, где
|
||||||
|
// доля пикселей целевого цвета превышает порог по ширине; это отсекает мелкие
|
||||||
|
// цветные иконки в статус-баре и ловит именно широкую кнопку. Координаты
|
||||||
|
// относительные по построению, поэтому работает на любом разрешении.
|
||||||
|
//
|
||||||
|
// `yMin`/`yMax` — необязательное окно по Y (координаты экрана), 0/0 — весь
|
||||||
|
// кадр. Возвращает QPoint(-1, -1), если подходящей полосы не найдено.
|
||||||
|
QPoint findColorButtonCenter(const QByteArray &png,
|
||||||
|
int targetR = 255,
|
||||||
|
int targetG = 117,
|
||||||
|
int targetB = 40,
|
||||||
|
int tolerance = 55,
|
||||||
|
int yMin = 0,
|
||||||
|
int yMax = 0);
|
||||||
|
|
||||||
} // namespace OcrUtils
|
} // namespace OcrUtils
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем на онбординг-баннер "Обновили главную" поверх главной
|
||||||
|
if (Node onboardingNode = xmlScreenParser.tryToFindOnboardingBanner(); !onboardingNode.isEmpty()) {
|
||||||
|
qDebug() << "[runAppAndGoToHomeScreen] Onboarding banner detected, tapping 'Пропустить'...";
|
||||||
|
AdbUtils::makeTap(deviceId, onboardingNode.x(), onboardingNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем, что это экран ввода пин-кода
|
// Проверяем, что это экран ввода пин-кода
|
||||||
if (!findAndInputPincode) {
|
if (!findAndInputPincode) {
|
||||||
const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
||||||
@ -225,6 +233,12 @@ bool CommonScript::tryToCloseAllBannersOnHomePage(const QString &deviceId, const
|
|||||||
closedAny = true;
|
closedAny = true;
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
continue;
|
continue;
|
||||||
|
} else if (Node onboardingNode = xmlScreenParser.tryToFindOnboardingBanner(); !onboardingNode.isEmpty()) {
|
||||||
|
// Онбординг "Обновили главную" поверх главной — жмём "Пропустить"
|
||||||
|
qDebug() << "[tryToCloseAllBannersOnHomePage] Onboarding banner detected, tapping 'Пропустить'...";
|
||||||
|
AdbUtils::makeTap(deviceId, onboardingNode.x(), onboardingNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
closedThis = true;
|
||||||
} else if (Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
|
} else if (Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
|
||||||
closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
|
closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
|
||||||
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
|
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
|
||||||
@ -452,7 +466,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
|
|||||||
ns.postBankProfile();
|
ns.postBankProfile();
|
||||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
if (afterPost.bankProfileId.isEmpty()) {
|
if (afterPost.bankProfileId.isEmpty()) {
|
||||||
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;
|
qWarning() << "[Ozon] createBankProfileIfNeeded:" << err;
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
#include "dao/SettingsDAO.h"
|
#include "dao/SettingsDAO.h"
|
||||||
#include "dump/TaskDumpRecorder.h"
|
#include "dump/TaskDumpRecorder.h"
|
||||||
#include "net/NetworkService.h"
|
#include "net/NetworkService.h"
|
||||||
|
#include "net/MaterialPayload.h"
|
||||||
#include "AppLogger.h"
|
#include "AppLogger.h"
|
||||||
|
|
||||||
namespace Ozon {
|
namespace Ozon {
|
||||||
@ -41,9 +42,14 @@ void GetCardInfoScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Ozon::GetCardInfo] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
// Читаем текущий экран
|
// Читаем текущий экран
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
@ -234,7 +240,12 @@ void GetCardInfoScript::doStart() {
|
|||||||
// Проверяем что bankProfileId сохранился
|
// Проверяем что bankProfileId сохранился
|
||||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
if (afterPost.bankProfileId.isEmpty()) {
|
if (afterPost.bankProfileId.isEmpty()) {
|
||||||
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;
|
qWarning() << "[GetCardInfo]" << err;
|
||||||
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
|
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
|
||||||
AdbUtils::captureScreenshotBytes(m_deviceId),
|
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||||
@ -274,13 +285,50 @@ void GetCardInfoScript::doStart() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
|
// Открываем экран нужной карты.
|
||||||
|
if (xmlScreenParser.versionTrack() >= AppVersionTrack::V19_19_1) {
|
||||||
|
// Новый UI: тап по НИЖНЕЙ (чиповой) части тайла "Основной счёт" открывает экран
|
||||||
|
// счёта с РАЗДЕЛЬНЫМИ кнопками карт ("Bank Card NNNN"). Тапаем кнопку нужной
|
||||||
|
// карты — открывается именно она. (Тап в центр тайла открывал бы переднюю карту
|
||||||
|
// карусели, и для всех материалов читался номер одной карты.)
|
||||||
|
const int accY = cardBtn.y2() - cardBtn.height() / 5; // зона чипов карт внизу тайла
|
||||||
|
qDebug() << "[GetCardInfo] Opening account screen via tile bottom at" << cardBtn.x() << accY;
|
||||||
|
AdbUtils::makeTap(m_deviceId, cardBtn.x(), accY);
|
||||||
|
|
||||||
// Скроллим WebView вверх, пока "Показать" не окажется в верхней трети экрана.
|
Node cardTile;
|
||||||
// На устройствах с короткой высотой (например, SM-A165F, h=2340) кнопка "Показать"
|
for (int a = 0; a < 8; ++a) {
|
||||||
// находится за bottom navigation, и тап уходит в нав-бар.
|
QThread::msleep(a == 0 ? 1500 : 700);
|
||||||
const int targetTopY = device.screenHeight / 3;
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
for (int scrollAttempt = 0; scrollAttempt < 6; ++scrollAttempt) {
|
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);
|
QThread::msleep(scrollAttempt == 0 ? 2000 : 600);
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
@ -296,15 +344,24 @@ void GetCardInfoScript::doStart() {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (candidate.y() <= targetTopY) {
|
const int cy = candidate.y();
|
||||||
qDebug() << "[GetCardInfo] 'Показать' already at top y=" << candidate.y();
|
if (cy >= minTapY && cy <= midY) {
|
||||||
|
qDebug() << "[GetCardInfo] 'Показать' in tap zone (above middle) y=" << cy;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const int swipeFromY = candidate.y();
|
if (cy > midY) {
|
||||||
const int swipeToY = device.screenHeight / 6;
|
// Ниже середины — листаем вверх, целясь в верхнюю треть (медленно, без перелёта).
|
||||||
qDebug() << "[GetCardInfo] Scrolling 'Показать' up:" << swipeFromY << "->" << swipeToY;
|
const int swipeToY = device.screenHeight / 3;
|
||||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, swipeFromY,
|
qDebug() << "[GetCardInfo] Scrolling 'Показать' up:" << cy << "->" << swipeToY;
|
||||||
device.screenWidth / 2, 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;
|
qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "full number:" << cardNumber;
|
||||||
// Апсёртим запись только теперь — с полным номером карты.
|
// Апсёртим запись только теперь — с полным номером карты.
|
||||||
MaterialInfo toSave = card;
|
MaterialInfo toSave = card;
|
||||||
@ -432,12 +498,16 @@ void GetCardInfoScript::doStart() {
|
|||||||
"FETCH_PROFILE");
|
"FETCH_PROFILE");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Возвращаемся на домашний экран для следующей карты
|
// Возвращаемся на домашний экран для следующей карты. В новом UI путь длиннее
|
||||||
|
// (карта → "Основной счёт" → главная), поэтому жмём "назад" до главной (макс 3).
|
||||||
if (i < cards.size() - 1) {
|
if (i < cards.size() - 1) {
|
||||||
|
for (int b = 0; b < 3; ++b) {
|
||||||
AdbUtils::goBack(m_deviceId);
|
AdbUtils::goBack(m_deviceId);
|
||||||
QThread::msleep(2000);
|
QThread::msleep(1500);
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isHomeScreen()) break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -541,31 +611,30 @@ void GetCardInfoScript::doStart() {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// 3. Отправляем материалы (карты) — после OCR, когда номера карт уже в БД
|
// 3. Отправляем материалы (карты) на сервер.
|
||||||
|
// Инвариант: одновременно живёт максимум один материал на (deviceId, appCode) —
|
||||||
|
// тот, что локально active. Пушим только active-карту и только если другой
|
||||||
|
// материал ещё не синхронизирован (нет с непустым materialId).
|
||||||
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
if (!updatedApp.bankProfileId.isEmpty()) {
|
if (!updatedApp.bankProfileId.isEmpty()) {
|
||||||
|
const QList<MaterialInfo> dbCards = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||||
|
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) {
|
for (const MaterialInfo &card : cards) {
|
||||||
const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
|
const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
|
||||||
if (dbCard.cardNumber.isEmpty()) continue;
|
if (dbCard.cardNumber.isEmpty()) continue;
|
||||||
if (!dbCard.materialId.isEmpty()) {
|
if (dbCard.status != MaterialStatus::Active) continue;
|
||||||
qDebug() << "[GetCardInfo] material already exists for card" << dbCard.lastNumbers << ", skipping";
|
ns.setPayload(buildMaterialPayload(dbCard, updatedApp));
|
||||||
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());
|
|
||||||
ns.addExtra("account_id", dbCard.id);
|
ns.addExtra("account_id", dbCard.id);
|
||||||
ns.postMaterial();
|
ns.postMaterial();
|
||||||
qDebug() << "[GetCardInfo] postMaterial done for card" << dbCard.lastNumbers;
|
qDebug() << "[GetCardInfo] postMaterial done for active card" << dbCard.lastNumbers;
|
||||||
|
break; // не больше одного материала на сервер
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials";
|
qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials";
|
||||||
|
|||||||
@ -82,16 +82,14 @@ GetLastTransactionsScript::GetLastTransactionsScript(
|
|||||||
QString deviceId,
|
QString deviceId,
|
||||||
QString appCode,
|
QString appCode,
|
||||||
int maxCount,
|
int maxCount,
|
||||||
double searchAmount,
|
QList<QPair<double, QDateTime>> targets,
|
||||||
QDateTime searchTime,
|
|
||||||
QString materialId,
|
QString materialId,
|
||||||
QObject *parent
|
QObject *parent
|
||||||
) : CommonScript(parent),
|
) : CommonScript(parent),
|
||||||
m_deviceId(std::move(deviceId)),
|
m_deviceId(std::move(deviceId)),
|
||||||
m_appCode(std::move(appCode)),
|
m_appCode(std::move(appCode)),
|
||||||
m_maxCount(maxCount),
|
m_maxCount(maxCount),
|
||||||
m_searchAmount(searchAmount),
|
m_targets(std::move(targets)),
|
||||||
m_searchTime(std::move(searchTime)),
|
|
||||||
m_materialId(std::move(materialId)) {
|
m_materialId(std::move(materialId)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,9 +108,15 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Ozon::GetLastTransactions] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
if (!navigateToTransactionList(device, app)) {
|
if (!navigateToTransactionList(device, app)) {
|
||||||
emit finishedWithResult(m_error);
|
emit finishedWithResult(m_error);
|
||||||
return;
|
return;
|
||||||
@ -127,9 +131,31 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
|
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выбор режима: поиск конкретной транзакции или сканирование последних
|
// Выбор режима: поиск списка конкретных транзакций или сканирование последних.
|
||||||
if (qAbs(m_searchAmount) > 0.01) {
|
if (!m_targets.isEmpty()) {
|
||||||
searchTransaction(device, accountId);
|
// Ищем каждую цель из списка. Найденные отдаём сразу через
|
||||||
|
// transactionParsed; ненайденные копим и репортим одной ошибкой в конце.
|
||||||
|
QStringList notFound;
|
||||||
|
for (int i = 0; i < m_targets.size(); ++i) {
|
||||||
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
|
m_error = "Interrupted";
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const double amount = m_targets[i].first;
|
||||||
|
const QDateTime time = m_targets[i].second;
|
||||||
|
// Перед каждой целью (кроме первой) возвращаемся к началу списка,
|
||||||
|
// т.к. предыдущий поиск мог проскроллить вниз к другой дате.
|
||||||
|
if (i > 0) scrollListToTop(device);
|
||||||
|
if (!searchOne(device, accountId, amount, time)) {
|
||||||
|
notFound << QString("amount=%1 time=%2")
|
||||||
|
.arg(QString::number(amount, 'f', 2), time.toString(Qt::ISODate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_error = notFound.isEmpty()
|
||||||
|
? QString()
|
||||||
|
: QString("Transactions not found (%1/%2): %3")
|
||||||
|
.arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; "));
|
||||||
} else {
|
} else {
|
||||||
scanRecentTransactions(device, accountId);
|
scanRecentTransactions(device, accountId);
|
||||||
}
|
}
|
||||||
@ -242,8 +268,9 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
|
|||||||
return false;
|
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);
|
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
|
||||||
// Время в Ozon показывается в таймзоне устройства, а не в MSK
|
// Время в Ozon показывается в таймзоне устройства, а не в MSK
|
||||||
@ -266,12 +293,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
int navBarTop = detectNavBarTop();
|
int navBarTop = detectNavBarTop();
|
||||||
|
|
||||||
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
|
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
|
||||||
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
|
const QDateTime searchLocal = searchTime.toTimeZone(deviceTz);
|
||||||
const QDate searchDate = searchLocal.date();
|
const QDate searchDate = searchLocal.date();
|
||||||
|
|
||||||
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
|
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
|
||||||
<< "amount=" << m_searchAmount
|
<< "amount=" << searchAmount
|
||||||
<< "time=" << m_searchTime.toString(Qt::ISODate)
|
<< "time=" << searchTime.toString(Qt::ISODate)
|
||||||
<< "tz=" << tzName
|
<< "tz=" << tzName
|
||||||
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
|
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
|
||||||
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
|
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
|
||||||
@ -348,7 +375,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
|
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
|
||||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
m_error = "Interrupted";
|
m_error = "Interrupted";
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
@ -369,12 +396,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!dateFound) {
|
if (!dateFound) {
|
||||||
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
|
qWarning() << "[FetchTransactions] Date not found in transaction list: "
|
||||||
qWarning() << "[FetchTransactions]" << m_error;
|
<< searchDate.toString("dd.MM.yyyy");
|
||||||
m_resultScreenshot = listScreenshot.isEmpty()
|
m_resultScreenshot = listScreenshot.isEmpty()
|
||||||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||||
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
||||||
@ -436,7 +463,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
for (int attempt = 0; attempt < 15; ++attempt) {
|
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
m_error = "Interrupted";
|
m_error = "Interrupted";
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
@ -503,7 +530,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
for (int bi = 0; bi < txButtons.size(); ++bi) {
|
for (int bi = 0; bi < txButtons.size(); ++bi) {
|
||||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
m_error = "Interrupted";
|
m_error = "Interrupted";
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пропускаем кнопки вне Y-диапазона нужной даты.
|
// Пропускаем кнопки вне Y-диапазона нужной даты.
|
||||||
@ -517,9 +544,9 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
|
|
||||||
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
|
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
|
||||||
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
|
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
|
||||||
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
|
<< "parsed amount=" << parsed.amount << "search=" << searchAmount
|
||||||
<< "text=" << txButtons[bi].text.left(60);
|
<< "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];
|
const Node &visibleBtn = txButtons[bi];
|
||||||
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
|
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
|
||||||
@ -543,7 +570,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
for (int da = 0; da < 10; ++da) {
|
for (int da = 0; da < 10; ++da) {
|
||||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
m_error = "Interrupted";
|
m_error = "Interrupted";
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
@ -596,10 +623,10 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем время (±10 минут)
|
// Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+10м, одностороннее окно)
|
||||||
if (txTime.isValid() && m_searchTime.isValid()) {
|
if (txTime.isValid() && searchTime.isValid()) {
|
||||||
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
|
const qint64 diffSecs = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели
|
||||||
if (diffSecs > 600) {
|
if (diffSecs < 0 || diffSecs > 600) {
|
||||||
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
|
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
|
||||||
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
|
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
|
||||||
<< "diff=" << diffSecs << "sec";
|
<< "diff=" << diffSecs << "sec";
|
||||||
@ -614,6 +641,20 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
<< "amount=" << parsed.amount << "time=" << dtStr
|
<< "amount=" << parsed.amount << "time=" << dtStr
|
||||||
<< "name=" << detail.name;
|
<< "name=" << detail.name;
|
||||||
|
|
||||||
|
// Проверяем глобальный кэш: если транзакция уже была найдена
|
||||||
|
// в рамках другой задачи этого материала — пропускаем её.
|
||||||
|
if (txTime.isValid()) {
|
||||||
|
const QString ck = txTime.toUTC().toString(Qt::ISODate)
|
||||||
|
+ "|" + QString::number(qAbs(static_cast<int>(parsed.amount * 100)));
|
||||||
|
if (m_foundCache.contains(ck)) {
|
||||||
|
qDebug() << "[FetchTransactions] TX already found in another task, skip:" << ck;
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(500);
|
||||||
|
continue; // следующая кнопка в for(bi) — ищем другую транзакцию
|
||||||
|
}
|
||||||
|
m_foundCache.insert(ck);
|
||||||
|
}
|
||||||
|
|
||||||
// Скриншот экрана деталей транзакции — отдаём в EventHandler.
|
// Скриншот экрана деталей транзакции — отдаём в EventHandler.
|
||||||
// Раньше дополнительно тапали "Документы" + OCR ради ID операции,
|
// Раньше дополнительно тапали "Документы" + OCR ради ID операции,
|
||||||
// но это медленно и нестабильно. Теперь полагаемся на дату+время+
|
// но это медленно и нестабильно. Теперь полагаемся на дату+время+
|
||||||
@ -622,6 +663,16 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
|
|
||||||
QString externalId = detail.bankTrExternalId;
|
QString externalId = detail.bankTrExternalId;
|
||||||
|
|
||||||
|
// Фолбэк-id (когда реальный ID банка не распарсился) строим из
|
||||||
|
// created_at задачи — он детерминирован и стабилен между запусками,
|
||||||
|
// что важно для дедупликации на сервере. Раньше брали распарсенное
|
||||||
|
// с экрана время (dtStr), которое могло «плавать» из-за OCR.
|
||||||
|
const QString finalExternalId = externalId.isEmpty()
|
||||||
|
? QStringLiteral("ozon_") + (searchTime.isValid()
|
||||||
|
? searchTime.toUTC().toString(Qt::ISODate)
|
||||||
|
: QString(dtStr).remove(',').trimmed().replace(' ', '_'))
|
||||||
|
: externalId;
|
||||||
|
|
||||||
// Сохраняем
|
// Сохраняем
|
||||||
if (accountId != -1) {
|
if (accountId != -1) {
|
||||||
TransactionInfo tx;
|
TransactionInfo tx;
|
||||||
@ -629,9 +680,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
tx.amount = parsed.amount;
|
tx.amount = parsed.amount;
|
||||||
tx.bankName = m_appCode;
|
tx.bankName = m_appCode;
|
||||||
tx.bankTime = dtStr;
|
tx.bankTime = dtStr;
|
||||||
tx.bankTrExternalId = externalId.isEmpty()
|
tx.bankTrExternalId = finalExternalId;
|
||||||
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
|
|
||||||
: externalId;
|
|
||||||
tx.status = TransactionStatus::Complete;
|
tx.status = TransactionStatus::Complete;
|
||||||
tx.timestamp = DateUtils::getUtcNow();
|
tx.timestamp = DateUtils::getUtcNow();
|
||||||
if (txTime.isValid()) tx.completeTime = txTime;
|
if (txTime.isValid()) tx.completeTime = txTime;
|
||||||
@ -649,10 +698,6 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
|
|
||||||
// Отдаём результат в EventHandler через сигнал (как Dushanbe)
|
// Отдаём результат в EventHandler через сигнал (как Dushanbe)
|
||||||
{
|
{
|
||||||
const QString finalExternalId = externalId.isEmpty()
|
|
||||||
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
|
|
||||||
: externalId;
|
|
||||||
|
|
||||||
QJsonObject txJson;
|
QJsonObject txJson;
|
||||||
txJson["bank_transaction_id"] = finalExternalId;
|
txJson["bank_transaction_id"] = finalExternalId;
|
||||||
txJson["amount"] = static_cast<int>(parsed.amount * 100);
|
txJson["amount"] = static_cast<int>(parsed.amount * 100);
|
||||||
@ -665,7 +710,11 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
QJsonObject extraObj;
|
QJsonObject extraObj;
|
||||||
extraObj["fee"] = 0;
|
extraObj["fee"] = 0;
|
||||||
txJson["extra"] = extraObj;
|
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);
|
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
|
||||||
|
|
||||||
qDebug() << "[FetchTransactions] Emitting parsed tx:"
|
qDebug() << "[FetchTransactions] Emitting parsed tx:"
|
||||||
@ -680,7 +729,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
QThread::msleep(1000);
|
QThread::msleep(1000);
|
||||||
AdbUtils::goBack(m_deviceId);
|
AdbUtils::goBack(m_deviceId);
|
||||||
QThread::msleep(1000);
|
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
|
// Эту цель не нашли. m_error не трогаем — агрегированную ошибку по всем
|
||||||
+ " amount=" + QString::number(m_searchAmount, 'f', 2)
|
// ненайденным целям соберёт оркестратор в doStart.
|
||||||
+ " date=" + searchDate.toString("dd.MM.yyyy");
|
qWarning() << "[FetchTransactions] Transaction not found: device=" << m_deviceId
|
||||||
qWarning() << "[FetchTransactions]" << m_error;
|
<< "amount=" << QString::number(searchAmount, 'f', 2)
|
||||||
|
<< "date=" << searchDate.toString("dd.MM.yyyy");
|
||||||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||||||
m_resultScreenshot = listScreenshot.isEmpty()
|
m_resultScreenshot = listScreenshot.isEmpty()
|
||||||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||||
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GetLastTransactionsScript::scrollListToTop(const DeviceInfo &device) {
|
||||||
|
// Несколько быстрых свайпов вверх, чтобы вернуться к началу списка операций.
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
if (QThread::currentThread()->isInterruptionRequested()) return;
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||||
|
device.screenHeight / 3,
|
||||||
|
device.screenWidth / 2,
|
||||||
|
device.screenHeight * 2 / 3, 200);
|
||||||
|
QThread::msleep(400);
|
||||||
|
}
|
||||||
|
QThread::msleep(800);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
}
|
}
|
||||||
|
|
||||||
void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) {
|
void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) {
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
#include "CommonScript.h"
|
#include "CommonScript.h"
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QList>
|
||||||
|
#include <QPair>
|
||||||
|
#include <QSet>
|
||||||
#include "db/DeviceInfo.h"
|
#include "db/DeviceInfo.h"
|
||||||
#include "db/BankProfileInfo.h"
|
#include "db/BankProfileInfo.h"
|
||||||
|
|
||||||
@ -11,15 +14,21 @@ class GetLastTransactionsScript final : public CommonScript {
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
|
// Испускается на КАЖДУЮ найденную транзакцию из списка целей.
|
||||||
void transactionParsed(const QJsonObject &tx);
|
void transactionParsed(const QJsonObject &tx);
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Снимок глобального кэша найденных транзакций (от EventHandler).
|
||||||
|
// Ключи: "real_created_at|abs_amount_cents". Транзакции из этого набора
|
||||||
|
// пропускаются при поиске, чтобы одна и та же TX не подтверждала два задания.
|
||||||
|
void setFoundCache(const QSet<QString> &cache) { m_foundCache = cache; }
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit GetLastTransactionsScript(
|
explicit GetLastTransactionsScript(
|
||||||
QString deviceId,
|
QString deviceId,
|
||||||
QString appCode,
|
QString appCode,
|
||||||
int maxCount = 0,
|
int maxCount = 0,
|
||||||
double searchAmount = 0,
|
QList<QPair<double, QDateTime>> targets = {},
|
||||||
QDateTime searchTime = {},
|
|
||||||
QString materialId = {},
|
QString materialId = {},
|
||||||
QObject *parent = nullptr
|
QObject *parent = nullptr
|
||||||
);
|
);
|
||||||
@ -36,16 +45,23 @@ private:
|
|||||||
// Сканирование последних транзакций (для UI: 24ч или maxCount)
|
// Сканирование последних транзакций (для UI: 24ч или maxCount)
|
||||||
void scanRecentTransactions(const DeviceInfo &device, int accountId);
|
void scanRecentTransactions(const DeviceInfo &device, int accountId);
|
||||||
|
|
||||||
// Поиск конкретной транзакции по сумме и времени (для задач FETCH_TRANSACTIONS)
|
// Поиск одной конкретной транзакции по сумме и времени (FETCH_TRANSACTIONS).
|
||||||
void searchTransaction(const DeviceInfo &device, int accountId);
|
// Возвращает true и эмитит transactionParsed, если нашли; false — если нет.
|
||||||
|
bool searchOne(const DeviceInfo &device, int accountId,
|
||||||
|
double searchAmount, const QDateTime &searchTime);
|
||||||
|
|
||||||
|
// Быстрый возврат к началу списка операций (между поисками целей)
|
||||||
|
void scrollListToTop(const DeviceInfo &device);
|
||||||
|
|
||||||
QString m_deviceId;
|
QString m_deviceId;
|
||||||
const QString m_appCode;
|
const QString m_appCode;
|
||||||
const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних
|
const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних
|
||||||
const double m_searchAmount; // сумма в рублях (0 = не искать конкретную)
|
// Список целей поиска: пары (сумма в рублях со знаком, примерное время UTC).
|
||||||
const QDateTime m_searchTime; // примерное время (UTC)
|
// Пустой список → режим сканирования последних (scanRecentTransactions).
|
||||||
|
const QList<QPair<double, QDateTime>> m_targets;
|
||||||
const QString m_materialId; // UUID задачи (для сохранения дампов)
|
const QString m_materialId; // UUID задачи (для сохранения дампов)
|
||||||
QString m_error;
|
QString m_error;
|
||||||
|
QSet<QString> m_foundCache; // снимок глобального кэша (от EventHandler)
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Ozon
|
} // namespace Ozon
|
||||||
|
|||||||
@ -37,9 +37,14 @@ void GetProfileInfoScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Ozon::GetProfileInfo] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
// Читаем текущий экран
|
// Читаем текущий экран
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
@ -169,7 +174,12 @@ void GetProfileInfoScript::doStart() {
|
|||||||
ns.postBankProfile();
|
ns.postBankProfile();
|
||||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
if (afterPost.bankProfileId.isEmpty()) {
|
if (afterPost.bankProfileId.isEmpty()) {
|
||||||
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;
|
qWarning() << "[GetProfileInfo]" << err;
|
||||||
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
|
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
|
||||||
AdbUtils::captureScreenshotBytes(m_deviceId),
|
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||||
|
|||||||
@ -40,9 +40,14 @@ void LoginAndCheckAccountsScript::doStart() {
|
|||||||
return;
|
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);
|
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Ozon::LoginAndCheckAccounts] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
||||||
|
|||||||
@ -91,9 +91,14 @@ void PayByCardScript::doStart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QString &adbId = device.adbSerial;
|
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);
|
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_CARD", adbId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Ozon::PayByCard] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
|
||||||
// Закрываем рекламный bottom sheet
|
// Закрываем рекламный bottom sheet
|
||||||
@ -162,6 +167,10 @@ void PayByCardScript::makePayment(
|
|||||||
};
|
};
|
||||||
// 1. Нажимаем "Перевести" на домашнем экране
|
// 1. Нажимаем "Перевести" на домашнем экране
|
||||||
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
|
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
|
||||||
|
if (transferBtn.isEmpty()) {
|
||||||
|
// Compose-UI Ozon (>= 19.x): у кнопки пустой text, тест-id в content-desc.
|
||||||
|
transferBtn = xmlScreenParser.findNodeByContentDesc("transfer-button-shared");
|
||||||
|
}
|
||||||
if (transferBtn.isEmpty()) {
|
if (transferBtn.isEmpty()) {
|
||||||
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
||||||
qWarning() << "[PayByCard]" << m_error;
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
|||||||
@ -100,9 +100,14 @@ void PayByPhoneScript::doStart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QString &adbId = device.adbSerial;
|
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);
|
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_PHONE", adbId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
|
||||||
|
xmlScreenParser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
|
||||||
|
qDebug() << "[Ozon::PayByPhone] appVersion=" << app.appVersion
|
||||||
|
<< "→ track" << Ozon::trackName(xmlScreenParser.versionTrack());
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
|
||||||
// Закрываем рекламный bottom sheet
|
// Закрываем рекламный bottom sheet
|
||||||
@ -176,6 +181,10 @@ void PayByPhoneScript::makePayment(
|
|||||||
// открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы
|
// открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы
|
||||||
// гарантированно попасть мимо бейджа.
|
// гарантированно попасть мимо бейджа.
|
||||||
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
|
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
|
||||||
|
if (transferBtn.isEmpty()) {
|
||||||
|
// Compose-UI Ozon (>= 19.x): у кнопки пустой text, тест-id в content-desc.
|
||||||
|
transferBtn = xmlScreenParser.findNodeByContentDesc("transfer-button-shared");
|
||||||
|
}
|
||||||
if (transferBtn.isEmpty()) {
|
if (transferBtn.isEmpty()) {
|
||||||
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
||||||
qWarning() << "[PayByPhone]" << m_error;
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
#include "db/MaterialInfo.h"
|
#include "db/MaterialInfo.h"
|
||||||
#include "db/TransactionInfo.h"
|
#include "db/TransactionInfo.h"
|
||||||
#include "time/DateUtils.h"
|
#include "time/DateUtils.h"
|
||||||
|
#include "BankConfigUtils.h"
|
||||||
|
|
||||||
namespace Ozon {
|
namespace Ozon {
|
||||||
|
|
||||||
@ -256,13 +257,17 @@ Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool ScreenXmlParser::isHomeScreen() {
|
bool ScreenXmlParser::isHomeScreen() {
|
||||||
// Домашний экран Ozon Банка: одновременно присутствуют кнопки "Перевести" и "Пополнить"
|
// Домашний экран Ozon Банка: одновременно присутствуют кнопки "Перевести" и "Пополнить".
|
||||||
|
// Старый UI отдавал их видимым text. Compose-сборки Ozon (>= 19.x) text не отдают —
|
||||||
|
// у кнопок пустой text, а тест-id лежит в content-desc (transfer-button-shared /
|
||||||
|
// replenish-button). Поддерживаем оба варианта.
|
||||||
bool hasTransfer = false;
|
bool hasTransfer = false;
|
||||||
bool hasTopUp = false;
|
bool hasTopUp = false;
|
||||||
for (const Node &node: m_nodes) {
|
for (const Node &node: m_nodes) {
|
||||||
const QString t = node.text.trimmed();
|
const QString t = node.text.trimmed();
|
||||||
if (t == "Перевести") hasTransfer = true;
|
const QString cd = node.contentDesc.trimmed();
|
||||||
if (t == "Пополнить") hasTopUp = true;
|
if (t == "Перевести" || cd == "transfer-button-shared") hasTransfer = true;
|
||||||
|
if (t == "Пополнить" || cd == "replenish-button") hasTopUp = true;
|
||||||
if (hasTransfer && hasTopUp) return true;
|
if (hasTransfer && hasTopUp) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -323,6 +328,28 @@ Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::tryToFindOnboardingBanner() {
|
||||||
|
// Онбординг "Обновили главную" рендерится во ViewPager с этим resource-id.
|
||||||
|
// Кнопка "Пропустить" — TextView (text_atom) внутри кликабельного контейнера,
|
||||||
|
// занимающего всю ширину верхней полоски. Сам TextView некликабельный, но его
|
||||||
|
// bounds совпадают с кликабельным родителем, поэтому тап по его центру попадает.
|
||||||
|
bool hasOnboarding = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.resourceId == "ru.ozon.fintech.finance:id/onboarding_viewpager") {
|
||||||
|
hasOnboarding = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasOnboarding) return {};
|
||||||
|
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.text.trimmed() == QString::fromUtf8("Пропустить")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
bool ScreenXmlParser::isProfileScreen() {
|
bool ScreenXmlParser::isProfileScreen() {
|
||||||
// Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти"
|
// Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти"
|
||||||
bool hasTitle = false;
|
bool hasTitle = false;
|
||||||
@ -427,7 +454,13 @@ Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) {
|
|||||||
for (const Node &node : m_nodes) {
|
for (const Node &node : m_nodes) {
|
||||||
if (node.className != "android.widget.Button" || !node.clickable) continue;
|
if (node.className != "android.widget.Button" || !node.clickable) continue;
|
||||||
const QString t = node.text.trimmed();
|
const QString t = node.text.trimmed();
|
||||||
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)
|
// Отсекаем строки из ленты переводов (см. parseCardsOnHomeScreen)
|
||||||
if (t.contains(QString::fromUtf8("Переводы"))
|
if (t.contains(QString::fromUtf8("Переводы"))
|
||||||
|| t.contains(QStringLiteral("**"))
|
|| t.contains(QStringLiteral("**"))
|
||||||
@ -449,6 +482,40 @@ Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseActiveCardLast4() {
|
||||||
|
// Маска реквизитов активной карты: "•••• •••• •••• 9865". Отличаем от плиток
|
||||||
|
// карусели ("…•• 9865") по наличию группы из 4 точек "••••".
|
||||||
|
static const QRegularExpression tail(R"((\d{4})\s*$)");
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
const QString t = node.text.trimmed();
|
||||||
|
if (!t.contains(QString::fromUtf8("••••"))) continue;
|
||||||
|
if (const auto m = tail.match(t); m.hasMatch())
|
||||||
|
return m.captured(1);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findCardCarouselTile(const QString &lastNumbers) {
|
||||||
|
// Плитка карты на экране "Карта": Button "Bank Card Виртуальная карта •• NNNN".
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.Button" || !node.clickable) continue;
|
||||||
|
const QString t = node.text.trimmed();
|
||||||
|
if (t.contains(QString::fromUtf8("Виртуальная карта")) && t.endsWith(lastNumbers))
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findAccountCardButton(const QString &lastNumbers) {
|
||||||
|
const QString needle = "Bank Card " + lastNumbers;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (!node.clickable) continue;
|
||||||
|
if (node.text.trimmed() == needle || node.contentDesc.trimmed() == needle)
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
Node ScreenXmlParser::findMainAccountButton() {
|
Node ScreenXmlParser::findMainAccountButton() {
|
||||||
for (const Node &node : m_nodes) {
|
for (const Node &node : m_nodes) {
|
||||||
if (node.className != "android.widget.Button") continue;
|
if (node.className != "android.widget.Button") continue;
|
||||||
@ -522,35 +589,64 @@ QList<MaterialInfo> ScreenXmlParser::parseCardsOnHomeScreen() {
|
|||||||
if (commonAmount == 0.0)
|
if (commonAmount == 0.0)
|
||||||
qWarning() << "[parseCards] Balance button not found or amount=0";
|
qWarning() << "[parseCards] Balance button not found or amount=0";
|
||||||
|
|
||||||
// 2. Собираем все кнопки карт (текст заканчивается на 4 цифры).
|
// Маркеры записи из ленты переводов (не карта):
|
||||||
// Записи из ленты переводов выглядят как кнопки и тоже заканчиваются на 4 цифры
|
// - слово "Переводы", маска "**" перед цифрами, знак суммы (минус U+2212 или плюс).
|
||||||
// ("Евгений Дмитриевич Р. − 100 ₽ Переводы • Карта **1092") — отсеиваем по маркерам.
|
auto isTransferFeedRow = [](const QString &t) {
|
||||||
QList<MaterialInfo> cards;
|
return t.contains(QString::fromUtf8("Переводы"))
|
||||||
for (const Node &node : m_nodes) {
|
|
||||||
if (node.className != "android.widget.Button" || !node.clickable) continue;
|
|
||||||
QString t = node.text.trimmed();
|
|
||||||
if (!endsWithFourDigits.match(t).hasMatch()) continue;
|
|
||||||
|
|
||||||
// Маркеры записи из ленты переводов:
|
|
||||||
// - слово "Переводы"
|
|
||||||
// - маска "**" перед цифрами
|
|
||||||
// - знак суммы транзакции (минус U+2212 или плюс)
|
|
||||||
if (t.contains(QString::fromUtf8("Переводы"))
|
|
||||||
|| t.contains(QStringLiteral("**"))
|
|| t.contains(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;
|
qDebug() << "[parseCards] Skipping transfer-feed row:" << t;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
auto it = bankCardRx.globalMatch(t);
|
||||||
|
while (it.hasNext()) {
|
||||||
|
const QString ln = it.next().captured(1);
|
||||||
|
if (seen.contains(ln)) continue;
|
||||||
|
seen.append(ln);
|
||||||
|
MaterialInfo acc;
|
||||||
|
acc.lastNumbers = ln;
|
||||||
|
acc.amount = commonAmount;
|
||||||
|
acc.currency = "RUB";
|
||||||
|
acc.description = "Виртуальная карта";
|
||||||
|
cards.append(acc);
|
||||||
|
qDebug() << "[parseCards] Card found (Bank Card):" << ln << "from:" << t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback (старый UI): отдельные кнопки, текст которых оканчивается 4 цифрами.
|
||||||
|
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;
|
MaterialInfo acc;
|
||||||
acc.lastNumbers = t.right(4);
|
acc.lastNumbers = t.right(4);
|
||||||
acc.amount = commonAmount;
|
acc.amount = commonAmount;
|
||||||
acc.currency = "RUB";
|
acc.currency = "RUB";
|
||||||
acc.description = "Виртуальная карта";
|
acc.description = "Виртуальная карта";
|
||||||
qDebug() << "[parseCards] Card found: lastNumbers=" << acc.lastNumbers
|
qDebug() << "[parseCards] Card found (fallback):" << acc.lastNumbers
|
||||||
<< "text=" << node.text << "amount=" << commonAmount;
|
<< "text=" << node.text << "amount=" << commonAmount;
|
||||||
cards.append(acc);
|
cards.append(acc);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
qDebug() << "[parseCards] Total cards found:" << cards.size();
|
qDebug() << "[parseCards] Total cards found:" << cards.size();
|
||||||
return cards;
|
return cards;
|
||||||
@ -582,8 +678,11 @@ QList<Node> ScreenXmlParser::findPinCodeNodes(const QString &pinCode) {
|
|||||||
static const QRegularExpression digitRx(R"(^(?:key\s+)?(\d)$)", QRegularExpression::CaseInsensitiveOption);
|
static const QRegularExpression digitRx(R"(^(?:key\s+)?(\d)$)", QRegularExpression::CaseInsensitiveOption);
|
||||||
for (const Node &node : m_nodes) {
|
for (const Node &node : m_nodes) {
|
||||||
if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue;
|
if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue;
|
||||||
const QString t = node.text.trimmed();
|
// Цифра живёт в text ("1"/"key 1") в старом UI, либо — в Compose-сборках Ozon
|
||||||
if (const auto match = digitRx.match(t); match.hasMatch()) {
|
// (>= 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;
|
digitMap[match.captured(1)] = node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -743,8 +842,11 @@ QList<Node> ScreenXmlParser::tryToFindPinCode(const QString &pinCode, const QStr
|
|||||||
if (node.text.trimmed() == "Отложить" && node.className == "android.widget.Button") {
|
if (node.text.trimmed() == "Отложить" && node.className == "android.widget.Button") {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
// В Ozon PIN экран определяется по ссылке "Не помню код-пароль"
|
// В Ozon PIN экран определяется по ссылке "Не помню код-пароль".
|
||||||
if (node.text.trimmed() == "Не помню код-пароль") {
|
// Матчим только по префиксу до тире: символ тире в тексте варьируется между
|
||||||
|
// сборками (дефис U+002D / em-dash U+2014, который отдаёт текущий WebView-экран),
|
||||||
|
// и точный == по полной строке тогда не срабатывает — экран пин-кода не распознаётся.
|
||||||
|
if (node.text.trimmed().startsWith("Не помню код")) {
|
||||||
isPinScreen = true;
|
isPinScreen = true;
|
||||||
}
|
}
|
||||||
// Fallback: другие банки используют title текст
|
// Fallback: другие банки используют title текст
|
||||||
@ -1196,30 +1298,45 @@ QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayReportHistory() {
|
|||||||
return transactions;
|
return transactions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Строит заголовок дня в формате Ozon-Выписки для переданной даты.
|
||||||
|
// offset 0 → "сегодня, ...", 1 → "вчера, ...", иначе "<день> <месяц>, <дн.недели>".
|
||||||
|
static QString ozonDayTitle(const QDate &day, int offset) {
|
||||||
|
if (offset == 0)
|
||||||
|
return QString("сегодня, %1").arg(getDayOfWeekAsString(day.dayOfWeek()));
|
||||||
|
if (offset == 1)
|
||||||
|
return QString("вчера, %1").arg(getDayOfWeekAsString(day.dayOfWeek()));
|
||||||
|
return QString("%1 %2, %3").arg(
|
||||||
|
QString::number(day.day()),
|
||||||
|
getMonthAsString(day.month()),
|
||||||
|
getDayOfWeekAsString(day.dayOfWeek()));
|
||||||
|
}
|
||||||
|
|
||||||
QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayHistory() {
|
QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayHistory() {
|
||||||
QDate today = DateUtils::getMoscowNow().date();
|
// Окно поиска: сегодня + (N-1) предыдущих дней. Настраивается в
|
||||||
QDate yesterday = today.addDays(-1);
|
// config.ini → [ozon]/transaction_search_window_days (дефолт 2 = сегодня+вчера).
|
||||||
QDate beforeYesterday = today.addDays(-2);
|
const int windowDays = BankConfigUtils::transactionSearchWindowDays("ozon");
|
||||||
|
const QDate today = DateUtils::getMoscowNow().date();
|
||||||
|
|
||||||
|
// Заголовки дней внутри окна [сегодня .. сегодня-(windowDays-1)] → дата дня.
|
||||||
QString todayTitle = QString("сегодня, %1").arg(getDayOfWeekAsString(today.dayOfWeek()));
|
struct DayTitle { QString title; QDate date; };
|
||||||
QString yesterdayTitle = QString("вчера, %1").arg(getDayOfWeekAsString(yesterday.dayOfWeek()));
|
QList<DayTitle> windowTitles;
|
||||||
QString beforeYesterdayTitle = QString("%1 %2, %3").arg(
|
for (int i = 0; i < windowDays; ++i) {
|
||||||
QString::number(beforeYesterday.day()),
|
const QDate d = today.addDays(-i);
|
||||||
getMonthAsString(beforeYesterday.month()),
|
windowTitles.append({ozonDayTitle(d, i), d});
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
// Заголовок первого дня ЗА окном — сигнал "дальше старая история, стоп".
|
||||||
|
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;
|
QList<TransactionInfo> transactions;
|
||||||
if (!parentNode.isNull()) {
|
if (!parentNode.isNull()) {
|
||||||
@ -1228,17 +1345,19 @@ QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayHistory() {
|
|||||||
QString transactionShortText = transactionNode.toElement().attribute("text");
|
QString transactionShortText = transactionNode.toElement().attribute("text");
|
||||||
|
|
||||||
const QString titleLowerCase = transactionShortText.toLower();
|
const QString titleLowerCase = transactionShortText.toLower();
|
||||||
if (titleLowerCase.startsWith(beforeYesterdayTitle)) {
|
|
||||||
// только сегодня и вчера парсим, позавчера не трогаем
|
// День за пределами окна — дальше только старая история, прекращаем.
|
||||||
|
if (titleLowerCase.startsWith(boundaryTitle)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (titleLowerCase.startsWith(todayTitle)) {
|
|
||||||
date = today;
|
// Заголовок дня внутри окна — обновляем текущую дату и срезаем префикс.
|
||||||
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("ЕСПП")) {
|
if (transactionShortText.contains("ЕСПП")) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
#include "android/xml/Node.h"
|
#include "android/xml/Node.h"
|
||||||
#include "db/MaterialInfo.h"
|
#include "db/MaterialInfo.h"
|
||||||
#include "db/TransactionInfo.h"
|
#include "db/TransactionInfo.h"
|
||||||
|
#include "AppVersionTrack.h"
|
||||||
|
|
||||||
namespace Ozon {
|
namespace Ozon {
|
||||||
|
|
||||||
@ -16,6 +17,13 @@ public:
|
|||||||
|
|
||||||
~ScreenXmlParser() override;
|
~ScreenXmlParser() override;
|
||||||
|
|
||||||
|
// Версионный трек приложения проставляется один раз в начале скрипта:
|
||||||
|
// parser.setVersionTrack(Ozon::trackForVersion(app.appVersion));
|
||||||
|
// Версионно-зависимые методы парсинга используют m_track как явный шов
|
||||||
|
// (см. AppVersionTrack.h). По умолчанию — новейший известный формат.
|
||||||
|
void setVersionTrack(AppVersionTrack track) { m_track = track; }
|
||||||
|
AppVersionTrack versionTrack() const { return m_track; }
|
||||||
|
|
||||||
Node tryToFindBannerOrDialog(int width, int height);
|
Node tryToFindBannerOrDialog(int width, int height);
|
||||||
|
|
||||||
Node tryToFindSecurityBanner();
|
Node tryToFindSecurityBanner();
|
||||||
@ -50,6 +58,11 @@ public:
|
|||||||
|
|
||||||
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
|
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
|
||||||
|
|
||||||
|
// Онбординг-баннер "Обновили главную" (resource-id onboarding_viewpager),
|
||||||
|
// появляется поверх главного экрана Ozon. Возвращает кнопку "Пропустить"
|
||||||
|
// для тапа, либо пустой Node если баннер не виден.
|
||||||
|
Node tryToFindOnboardingBanner();
|
||||||
|
|
||||||
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android
|
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android
|
||||||
// (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден.
|
// (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден.
|
||||||
Node tryToFindSystemPermissionDenyButton();
|
Node tryToFindSystemPermissionDenyButton();
|
||||||
@ -60,6 +73,19 @@ public:
|
|||||||
// Finds a card button whose text ends with the given last 4 digits
|
// Finds a card button whose text ends with the given last 4 digits
|
||||||
Node findCardButtonByLastNumbers(const QString &lastNumbers);
|
Node findCardButtonByLastNumbers(const QString &lastNumbers);
|
||||||
|
|
||||||
|
// Экран "Карта": last4 активной карты из маски "•••• •••• •••• NNNN".
|
||||||
|
// Пусто, если маскированный номер не найден.
|
||||||
|
QString parseActiveCardLast4();
|
||||||
|
|
||||||
|
// Экран "Карта": плитка карты в карусели по последним 4 цифрам
|
||||||
|
// ("Bank Card Виртуальная карта •• NNNN"). Тап по ней делает карту активной.
|
||||||
|
Node findCardCarouselTile(const QString &lastNumbers);
|
||||||
|
|
||||||
|
// Экран "Основной счёт": отдельная кнопка карты "Bank Card NNNN" (text или
|
||||||
|
// content-desc). Тап по ней открывает экран именно этой карты (а не передней
|
||||||
|
// карты карусели). NNNN — последние 4 цифры.
|
||||||
|
Node findAccountCardButton(const QString &lastNumbers);
|
||||||
|
|
||||||
// Returns all cards visible on the home screen as MaterialInfo stubs
|
// Returns all cards visible on the home screen as MaterialInfo stubs
|
||||||
// (lastNumbers, description, amount filled; id=-1, cardNumber empty)
|
// (lastNumbers, description, amount filled; id=-1, cardNumber empty)
|
||||||
QList<MaterialInfo> parseCardsOnHomeScreen();
|
QList<MaterialInfo> parseCardsOnHomeScreen();
|
||||||
@ -128,6 +154,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
QList<Node> m_nodes;
|
QList<Node> m_nodes;
|
||||||
QDomElement m_xml;
|
QDomElement m_xml;
|
||||||
|
AppVersionTrack m_track = AppVersionTrack::V19_19_1;
|
||||||
|
|
||||||
QList<Node> findPinCodeNodes(const QString &pinCode);
|
QList<Node> findPinCodeNodes(const QString &pinCode);
|
||||||
};
|
};
|
||||||
|
|||||||
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 |