1
0
forked from BRT/arc

Compare commits

..

60 Commits
main ... dev

Author SHA1 Message Date
slava
3c1db3c36c Fix Ozon 2026-06-19 16:54:45 +07:00
slava
da72c4eda6 Изменил работу со скриншотами 2026-06-17 18:30:08 +07:00
slava
7257a5207e Added android id to post bank profile 2026-06-17 14:12:39 +07:00
slava
7929f7c351 Add delete profile functionality with UI integration, local cleanup, and server synchronization. 2026-06-09 00:18:57 +05:00
slava
2fb6146fd9 Force portrait orientation on connected devices, remove x100 heuristic from transaction matching, and add secondary text search filter in monitoring app. 2026-06-08 21:37:46 +05:00
slava
2c2d58021f Force portrait orientation on connected devices, remove x100 heuristic from transaction matching, and add secondary text search filter in monitoring app. 2026-06-08 21:06:00 +05:00
slava
e8ddde7e36 Add screen dumps and app version track for Ozon Bank 19.19.1, including Compose-specific parsing logic and new XML assets. 2026-06-06 18:52:02 +05:00
slava
ba476bae50 Update Ozon screen dump checklist for version 19.19.1: add versioning details, revise instructions, and integrate Compose-specific parsing notes. Include new capture script for automated screenshot and UI dump generation. 2026-06-06 18:48:17 +05:00
slava
ce3bc99ca8 Update Ozon screen dump checklist for version 19.19.1: add versioning details, revise instructions, and integrate Compose-specific parsing notes. Include new capture script for automated screenshot and UI dump generation. 2026-06-06 16:15:02 +05:00
slava
fac49447bf Refactor network requests: consolidate retry logic with backoff, handle transient errors, and improve timeout handling across NetworkService methods. Emit result signals for monitoring dumps. 2026-06-05 23:59:42 +05:00
slava
cef3515790 Add screen dump checklists for Dushanbe and Ozon, introduce download stall timeouts, and refine update process with watchdog to prevent hangs during downloads. 2026-06-05 21:39:23 +05:00
slava
9f23991c61 Refactor payment confirmation logic: add locateConfirmButton with prioritized search (color → OCR → geometry), enhance tap precision 2026-06-05 14:56:41 +05:00
slava
e760f2bb5f Optimize database logging, improve thread performance, and implement periodic maintenance to avoid GUI blocking. Replace infinite waits in AdbUtils with safe timeouts to prevent thread starvation. 2026-06-05 14:39:29 +05:00
slava
cfc562eae6 Support datetime input for filters, improve date parsing logic, and update UI labels and button states in monitoring app. 2026-06-05 13:10:58 +05:00
slava
39e07ddb15 Save and upload previous session logs before truncation; extend LogArchiveSender for specific log file handling. 2026-06-05 13:10:50 +05:00
slava
49f1323706 Revise log entry processing in LogArchiveSender, disable screen history uploads in DeviceScreener, and replace rowid with id in database queries for consistency. 2026-06-05 11:01:42 +05:00
slava
d7fc838d1a Remove database file handling from LogArchiveSender, revise emergency purge logic in monitoring, and enhance error reporting in BankSetupWizard. 2026-06-04 23:28:26 +05:00
slava
521101463b Handle edge cases in log processing, enhance JSON rendering in monitoring logs, and improve logging in AdbUtils. 2026-06-04 22:07:36 +05:00
slava
a0583c4966 Add tests for monitoring log format: validate task_success/task_error JSON structure and improve database documentation with schema and migration details. 2026-06-04 13:36:18 +05:00
slava
a72a00cc3f Add monitoring/app.py to project and add logs download button
- Import arc-monitoring server code (app.py) into the repo under monitoring/
  so it can be version-controlled and edited locally
- Add "Скачать архив" button to admin header: GET /admin/logs/export
  downloads a ZIP with entries.json (all log entries) + attached log archives
  (file_path ZIPs from entries table); excludes screenshots and screen_dumps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:31:34 +05:00
slava
c2994d9041 Refactor monitoring logs handling: use concise JSON formatting for transaction logs, add /monitoring_logs admin endpoint for testing, increase screen monitoring interval to 120s, and simplify network retry logic with shorter monitoring timeouts. 2026-06-04 13:01:47 +05:00
slava
2b4bd5a9d1 Add database section to documentation: include SQLite schema, migration patterns, and update processes. 2026-06-03 17:41:00 +05:00
slava
1f20c9437c Streamline transaction logging: remove redundant batch success reporting in FETCH_TRANSACTIONS and log individual transactions directly to arc-monitoring. 2026-06-03 16:49:38 +05:00
slava
615febc8b5 Introduce global transaction cache to prevent duplicate confirmations across tasks and enhance workflow consistency. Optimize cache cleanup and integrate validation in "Get 2026-06-03 15:29:04 +05:00
slava
cd98644f32 Add taskJson parameter to sendTaskError, introduce sendTaskSuccess, enhance error and success logging with detailed JSON data, and refine time mismatch handling. 2026-06-03 15:17:22 +05:00
slava
66279d4767 Add clientVersion retrieval and include in task dump reports for improved debugging. 2026-06-02 23:16:47 +05:00
slava
3391591e33 Include appVersion and clientVersion in error reporting for task failures. Add fallback logic for bottom navigation taps in GetLastTransactionsScript using icon-based detection. 2026-06-02 21:56:42 +05:00
slava
b486e922b3 Optimize "GetLastTransactionsScript" with single-pass search for all targets, add geometric fallback for bottom navigation taps, and improve error handling for unmatched transactions. 2026-06-02 20:06:57 +05:00
slava
fb9fa279a6 Refine error handling for "Interrupted" events, optimize screenshot throttling, and reduce redundant logs and heavy operations in ADB workflows. 2026-06-02 18:25:25 +05:00
slava
0168cf7610 Disable server-side material DELETE operations across multiple workflows; restrict DELETE to card toggling in AccountSettingsWindow. 2026-06-02 14:38:18 +05:00
slava
569eefb3e8 Update error reporting functions to include event ID and improve scroll detection in transaction scripts. Adjust network ping interval to 15 seconds. 2026-06-02 14:07:23 +05:00
slava
6d60cecef4 Add configurable date search window for transaction history parsing. 2026-06-01 22:27:34 +05:00
slava
e5a11b0a57 Disable date-based scrolling limit in GetLastTransactionsScript temporarily. 2026-06-01 21:52:25 +05:00
slava
7d953add94 Expand AppVersionTrack documentation with guidelines for adding new app versions. 2026-06-01 19:55:45 +05:00
slava
9744fbf990 Expand AppVersionTrack documentation with guidelines for adding new app versions. 2026-06-01 18:23:06 +05:00
slava
c6c35cff2c Add AppVersionTrack enum and related helpers to support version-based parsing. Include XML assets for Dushanbe 3.2.38 version. 2026-06-01 18:17:38 +05:00
slava
e08514ea2e Add parallel ADB scanning with QtConcurrent, optimize device sync logic, and replace blocking API calls with fire-and-forget dispatch for improved performance. 2026-06-01 14:54:17 +05:00
slava
29ae105c64 Add appVersion to TaskDumpMeta structure and update associated scripts for enhanced application version tracking in tasks and monitoring outputs. 2026-06-01 14:29:05 +05:00
slava
f6d895ab34 Refactor LogArchiveSender to add collectInto for file bundling; update TaskDumpRecorder to unify archive creation for tasks and logs. 2026-06-01 12:45:10 +05:00
slava
c5f0c035fe Adjust watchdog timer for FETCH_TRANSACTIONS to 10 minutes; optimize scroll behavior in GetLastTransactionsScript. 2026-06-01 00:27:43 +05:00
slava
5c2aba0505 Include androidId in TaskDumpMeta and update related scripts for improved device identification and tracking. 2026-05-31 21:23:50 +05:00
slava
35b2b3bcaa Include archive size in Telegram notification for x64 build. Adjust build_x64.bat to calculate and append file size to update messages. 2026-05-30 15:01:23 +05:00
slava
8d86d5f835 Add bank profile and phone metadata for improved task tracking and error reporting. Update TaskDumpMeta structure and associated scripts to include bankProfileId and profilePhone. Enhance LogArchiveSender and monitoring outputs for better operator identification and debugging. 2026-05-30 14:54:32 +05:00
slava
65d3b00bb9 Remove unused minVersion and mandatory fields from version.json generation in build_x64.bat. 2026-05-30 14:19:37 +05:00
slava
63fa25a087 Remove unused "notes" field from update mechanism and refactor version availability prompts for improved clarity. 2026-05-30 14:14:55 +05:00
slava
92e754ef6b Add support for -prod configuration in build_x64.bat, including API and manifest switching. Replace SCP with CURL for update uploads. 2026-05-30 14:05:32 +05:00
slava
f179c581d0 Add flag parsing for -up-version and -prod in build_x64.bat. Update archive naming to include a _prod suffix when -prod is specified. 2026-05-30 13:47:22 +05:00
slava
ce61986035 Add update mechanism for ARC: implement UpdateService for version checks, downloads, and staging; add ApplyUpdate for file copying and relaunch; integrate update actions into MainWindow and workflows. 2026-05-30 13:36:50 +05:00
slava
992073d43d Add support for database wipe on exit, including delayed deletion handling and user-triggered wipe option in GUI. Update DatabaseManager with sentinel logic and file path utilities. 2026-05-30 12:29:23 +05:00
slava
709c7d0490 Streamline FETCH_TRANSACTIONS to support per-transaction streaming, handle partial successes, and prevent duplicate retries. Update scripts, task lifecycles, and error reporting with multi-target processing. 2026-05-29 21:01:09 +05:00
slava
3832a658ce Add retry logic to EventHandler for FETCH_TRANSACTIONS script errors and timeouts, with a maximum of two retries per event. Update related event and task handling to ensure retry tracking and cleanup. 2026-05-28 22:21:11 +05:00
slava
b8da23fa6c Add DesktopSelectionWindow with UI logic for selecting or creating desktops. Update NetworkService to handle desktop selection flow. Improve desktop synchronization and settings management. Enhance mock server to support desktop device data in tests. 2026-05-28 19:12:27 +05:00
slava
942d6137e9 Add getOnlineDeviceApiIds and getActiveBankProfileIds to DAO layers, refactor NetworkService::doPingProfile to include device and bank profile details. Comment out unused debug statement in EventHandler. Update mock server profile data for testing. 2026-05-27 15:09:59 +05:00
slava
07399670c0 Introduce MaterialPayload utility for consistent material data serialization and API operations. Refactor profile and material handling logic across scripts and UI components to ensure invariant enforcement and improved server synchronization. Add deleteMaterial operation to NetworkService. 2026-05-27 13:29:07 +05:00
slava
a7840b640d Remove error handling for zero balance in GetProfileInfoScript as it is now considered a valid state. 2026-05-26 20:48:17 +05:00
slava
34e80f04bb Improve GetProfileInfoScript with polling mechanism for balance loading. Add parseBalanceFromHomeScreenOpt to differentiate loading state from zero balance. Update ScreenXmlParser logic accordingly. 2026-05-26 20:30:54 +05:00
slava
c5a6524e16 Merge branch 'main' into dev 2026-05-26 19:46:09 +05:00
slava
50a7b20f0a Add transactionId support to EventHandler, EventDAO, and related components for improved event tracking and error handling. Update database queries, error reporting, and UI logic accordingly.
(cherry picked from commit 067b16f700)
2026-05-26 19:39:35 +05:00
slava
d3b109d87f Update build documentation to reflect executable name change from ARCDesktopProject to ARC 2026-05-25 16:48:13 +05:00
slava
54edae7600 Add missing build resources: app.rc (Windows resource script referencing arc.ico for the executable icon) and arc.icns (macOS app icon). Both are referenced by the build but were not tracked in the repository.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:44:32 +05:00
190 changed files with 8654 additions and 1308 deletions

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View 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
View 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

View File

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

View File

@ -27,9 +27,26 @@ public:
static bool takeScreenshot(const QString &deviceId, const QString &name);
// Захватывает скриншот напрямую в память (PNG-байты), без временного файла
// IP телефона в Wi-Fi (кэш по serial с TTL). Для IP:port-серийника берётся из
// серийника, иначе `adb shell ip route get 1.1.1.1` → src. Пусто, если не в Wi-Fi.
// Используется и для http-скриншота, и для сохранения IP в БД (wifi_connections).
static QString resolvePhoneIp(const QString &deviceId);
// Текущий режим скриншотов устройства: "http" | "exec-out (fallback)" | "" (ещё не
// определялся). Для индикации в UI. Ключ — adb-серийник.
static QString screenshotMode(const QString &deviceId);
// Захватывает скриншот напрямую в память (PNG-байты), без временного файла.
// Выбирает стратегию по config.ini [screenshot] transport: http (через APK
// arc-shot, мимо adb) с fallback на exec-out, либо сразу exec-out.
static QByteArray captureScreenshotBytes(const QString &deviceId);
// Скриншот через APK arc-shot: adb screencap в файл на /sdcard (дёшево, без
// передачи байтов через adb) + HTTP-GET файла напрямую с телефона по его
// Wi-Fi IP. Разгружает adb. Пусто при недоступности (нет APK/сети) — вызывающий
// откатывается на exec-out. Возвращает PNG-байты.
static QByteArray captureScreenshotHttp(const QString &deviceId);
static bool takeXmlDump(const QString &deviceId, const QString &name);
static bool makeTap(const QString &deviceId, int x, int y);
@ -59,8 +76,29 @@ public:
// Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии
static QString getAppVersion(const QString &deviceId, const QString &packageName);
// Возвращает пакет приложения на переднем плане (foreground), либо пустую строку при ошибке
static QString getForegroundPackage(const QString &deviceId);
// Проверяет реальную ориентацию экрана (dumpsys input → SurfaceOrientation).
// Если экран в горизонтальной ориентации (landscape, 90°/270°), принудительно
// ставит вертикальную: выключает автоповорот и выставляет user_rotation=0.
// Best-effort: при ошибке adb просто ничего не делает. Возвращает true, если
// экран в портрете (изначально или после форсирования).
static bool ensurePortraitOrientation(const QString &deviceId);
private:
static QString adbDir();
static bool startProcess(const QString &program, const QStringList &args,
QString *stdOut = nullptr, QString *stdErr = nullptr);
// Старый способ: exec-out screencap -p (стримит PNG через adb). Fallback для http.
static QByteArray captureScreenshotExecOut(const QString &deviceId);
// Синхронный HTTP/1.0 GET по QTcpSocket (без event-loop, безопасно из worker-потоков).
// Возвращает тело ответа при 200, иначе пусто.
static QByteArray httpGet(const QString &ip, int port, const QString &path, int timeoutMs);
// Поднимает foreground-сервис arc-shot (ставит APK, если удалён). Дёргается из
// http-fail ветки, т.е. не чаще раза в бэкофф-окно.
static void kickArcShot(const QString &deviceId);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
apk/arc-shot/.gitignore vendored Normal file
View File

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

54
apk/arc-shot/README.md Normal file
View 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
View 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
View 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

View File

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

View 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>

View 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) {
}
}
}
}

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

View 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
app.rc Normal file
View File

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

BIN
arc.icns Normal file

Binary file not shown.

View File

@ -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="DONT ALLOW"
resource-id="com.android.permissioncontroller:id/permission_deny_button"
class="android.widget.Button" package="com.google.android.permissioncontroller"
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[48,925][672,1030]"/>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>

BIN
assets/arc-shot.apk Normal file

Binary file not shown.

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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="Подтверждение платежа&#10;1.00 TJS&#10;Поставщик:&#10;DC (по номеру телефона) &#10;Номер/Счет&#10;9762000186263942&#10;Способ оплаты:&#10;9762 **** **** 0819&#10;Сумма зачисления:&#10;1.00 TJS&#10;Комиссия:&#10;0.00 TJS&#10;Сумма списания:&#10;1.00 TJS&#10;Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1444]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1444]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1444]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][0,0]" /></node></hierarchy>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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="Подтверждение платежа&#10;1.00 TJS&#10;Поставщик:&#10;DC (по номеру карты) &#10;Номер/Счет&#10;9762000186263942&#10;Способ оплаты:&#10;9762 **** **** 5947&#10;Сумма зачисления:&#10;1.00 TJS&#10;Комиссия:&#10;0.00 TJS&#10;Сумма списания:&#10;1.00 TJS&#10;Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1504]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1504]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1504]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1504][720,1600]" /></node></hierarchy>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

File diff suppressed because one or more lines are too long

View 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
View 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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

File diff suppressed because one or more lines are too long

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