Compare commits
177 Commits
scrcpy-ser
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9d3eebd0 | ||
|
|
9fb209378f | ||
|
|
cff6ec7216 | ||
|
|
82ac22f635 | ||
|
|
36567e78a4 | ||
|
|
8abe5a915f | ||
|
|
3670e34938 | ||
|
|
9da2a6fc52 | ||
|
|
0045c37d64 | ||
|
|
4ab46b8b49 | ||
|
|
07871231a3 | ||
|
|
56ba89522c | ||
|
|
14e46cac84 | ||
|
|
feb839daf2 | ||
|
|
6b3f275781 | ||
|
|
f65840f28b | ||
|
|
e7cf9a311e | ||
|
|
6c5b5a91c2 | ||
|
|
1457064423 | ||
|
|
542fabf880 | ||
|
|
078aae0d2f | ||
|
|
7bf49ba749 | ||
|
|
0adb57d87e | ||
|
|
01190bfa69 | ||
|
|
f696e816e2 | ||
|
|
373d103212 | ||
|
|
53becd0183 | ||
|
|
ae04874c48 | ||
|
|
a8d41baab7 | ||
|
|
7e5f90a450 | ||
|
|
ff4a468b35 | ||
|
|
98f1d0f9d6 | ||
|
|
72d582f0ab | ||
|
|
abcc3cadb2 | ||
|
|
a7cefa7fcf | ||
|
|
3751406c36 | ||
|
|
52d261a060 | ||
|
|
afe83e17c2 | ||
|
|
377844e94b | ||
|
|
9a7a718d52 | ||
|
|
daa0123f7f | ||
|
|
46aca63341 | ||
|
|
1ab023b45e | ||
|
|
9c2ec74f76 | ||
|
|
558e881c75 | ||
|
|
c144305240 | ||
|
|
d58100083f | ||
|
|
f611639532 | ||
|
|
11e8f00e42 | ||
|
|
6de3751a60 | ||
|
|
f19137280e | ||
|
|
d37acd5347 | ||
|
|
f7a187ec88 | ||
|
|
052f9051e1 | ||
|
|
44e60fc09f | ||
|
|
cd1828e2b5 | ||
|
|
a92610f9e5 | ||
|
|
af47dc5888 | ||
|
|
f5be380292 | ||
|
|
cc5a4ed15b | ||
|
|
ac8b5922ad | ||
|
|
f200eae2bd | ||
|
|
d480cc51b0 | ||
|
|
8c2be04787 | ||
|
|
0a460d10a0 | ||
|
|
efc2da5cd0 | ||
|
|
d854da3e8f | ||
|
|
a2c9118c82 | ||
|
|
248fae6061 | ||
|
|
bac36aa5d2 | ||
|
|
779e3cd74c | ||
|
|
ad4d87abc3 | ||
|
|
fc0b3f9e6e | ||
|
|
d59bf51416 | ||
|
|
d0330a509a | ||
|
|
d9479cd3ea | ||
|
|
312e0b37dd | ||
|
|
899f4ff6d6 | ||
|
|
628323184f | ||
|
|
a97bdea977 | ||
|
|
e5e8500534 | ||
|
|
db88cf373e | ||
|
|
16cde75220 | ||
|
|
967397b5d5 | ||
|
|
dfbe1cbf72 | ||
|
|
0ab71db506 | ||
|
|
c2fc0104c6 | ||
|
|
23c3f366b1 | ||
|
|
d235508c70 | ||
|
|
33c8420910 | ||
|
|
4407f999c7 | ||
|
|
4f3e2e472f | ||
|
|
d79dd93514 | ||
|
|
ffb206303e | ||
|
|
fd99281dfb | ||
|
|
76ceaed0eb | ||
|
|
14ca8348c3 | ||
|
|
0c1d2675b3 | ||
|
|
87d74e7747 | ||
|
|
4c1ff0af0f | ||
|
|
d27f4b6b22 | ||
|
|
7931a10b88 | ||
|
|
3e22f9ea93 | ||
|
|
6d3f76feea | ||
|
|
73461b93bf | ||
|
|
baa60d008b | ||
|
|
c5e8fc4ed5 | ||
|
|
a046a27569 | ||
|
|
8ec9f37160 | ||
|
|
d7c296ba42 | ||
|
|
013a00c972 | ||
|
|
b2bbb2207f | ||
|
|
6647a91c88 | ||
|
|
41bb5e33fd | ||
|
|
58513eb15a | ||
|
|
3ed2d4da20 | ||
|
|
37b72938e8 | ||
|
|
f28311b5cf | ||
|
|
8d0eae8ec0 | ||
|
|
a6509b5653 | ||
|
|
de8c2e84d0 | ||
|
|
bceceb516b | ||
|
|
b395ed41a5 | ||
|
|
e1b977becc | ||
|
|
8f416b3303 | ||
|
|
b42fb5eea9 | ||
|
|
01f8801a73 | ||
|
|
cefad834c7 | ||
|
|
712b73316c | ||
|
|
10897164e9 | ||
|
|
8ed25698e8 | ||
|
|
eb14339623 | ||
|
|
55fef77c43 | ||
|
|
23d404b9c5 | ||
|
|
f95dabca77 | ||
|
|
1ed7cbe6f7 | ||
|
|
e9aaa71c56 | ||
|
|
277f94c15a | ||
|
|
9f0b547100 | ||
|
|
3d3ed9cbf6 | ||
|
|
8eedd07ffd | ||
|
|
832fb03aac | ||
|
|
73e23f437d | ||
|
|
afbf3a3afb | ||
|
|
91f8e96fa6 | ||
|
|
f5810acf6c | ||
|
|
c9e266225a | ||
|
|
6ded374ee8 | ||
|
|
ee6de4fc5a | ||
|
|
9e67063946 | ||
|
|
93bf00352a | ||
|
|
9c6d1bd4e5 | ||
|
|
e7db943a04 | ||
|
|
b2179a8acf | ||
|
|
2552ca49be | ||
|
|
ab16b8962f | ||
|
|
748d515fbf | ||
|
|
5ba4cc8dcb | ||
|
|
6823676e14 | ||
|
|
5f94bd84d9 | ||
|
|
0d80a4c11e | ||
|
|
29ac3c473f | ||
|
|
6988794657 | ||
|
|
b8b2b38f8a | ||
|
|
f70b46ce44 | ||
|
|
6e9b7c2b6e | ||
|
|
4fe6e399bc | ||
|
|
01aa8bca5e | ||
|
|
0cebf8f1ff | ||
|
|
dd66adf455 | ||
|
|
c3fed927fc | ||
|
|
675fa7ca5f | ||
|
|
cf543917b4 | ||
|
|
b125c509b3 | ||
|
|
bf976f04c7 | ||
|
|
d52574c50f | ||
|
|
327b206fe8 |
16
.gitignore
vendored
@ -6,6 +6,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Mock server state
|
||||||
|
tests/mock_server/.mock_state.json
|
||||||
|
tests/mock_server/.mock_screenshots/
|
||||||
|
|
||||||
cmake_install.cmake
|
cmake_install.cmake
|
||||||
CMakeCache.txt
|
CMakeCache.txt
|
||||||
Makefile
|
Makefile
|
||||||
@ -21,3 +25,15 @@ Makefile
|
|||||||
/.ninja_log
|
/.ninja_log
|
||||||
/ARCDesktopProject_autogen/
|
/ARCDesktopProject_autogen/
|
||||||
/ARCDesktopProject
|
/ARCDesktopProject
|
||||||
|
/Contents/
|
||||||
|
/application.log
|
||||||
|
/database/app_data.db
|
||||||
|
/build_x64/
|
||||||
|
/tests/mock_server/scenarios/pool/
|
||||||
|
/.cache/
|
||||||
|
/.claude/
|
||||||
|
/dumps/
|
||||||
|
/ARC_autogen/
|
||||||
|
/build_test/
|
||||||
|
/build_x64/
|
||||||
|
/tests/mock_server/scenarios/
|
||||||
|
|||||||
81
BUILD.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# ARCDesktopProject — сборка и запуск
|
||||||
|
|
||||||
|
## Windows (x64)
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- Visual Studio 2022
|
||||||
|
- Qt 6.9.0 (msvc2022_64)
|
||||||
|
- vcpkg с пакетом `tesseract:x64-windows`
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir build_x64
|
||||||
|
cd build_x64
|
||||||
|
cmake .. -G "Visual Studio 17 2022" -A x64 -DCMAKE_PREFIX_PATH="C:/Qt/6.9.0/msvc2022_64" -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build . --config Release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Деплой Qt DLL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARCDesktopProject.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Копирование vcpkg DLL
|
||||||
|
|
||||||
|
Скопировать все DLL из vcpkg в папку с exe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy C:\Users\User\.vcpkg-clion\vcpkg\installed\x64-windows\bin\*.dll .\Release\
|
||||||
|
```
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
|
||||||
|
БД не включена в сборку. Перед первым запуском скопировать файл БД:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy database\app_data.db .\Release\database\app_data.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Папка `database/` создаётся автоматически при сборке. Путь к БД настраивается в `config.ini` (ключ `db/dbPath`), по умолчанию `./database/app_data.db`.
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Release
|
||||||
|
ARCDesktopProject.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
> TODO: инструкция для macOS будет добавлена позже.
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- Qt 6 (`brew install qt`)
|
||||||
|
- Tesseract + Leptonica (`brew install tesseract leptonica`)
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir build && cd build
|
||||||
|
cmake .. -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build .
|
||||||
|
```
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
|
||||||
|
Перед первым запуском скопировать файл БД:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p database
|
||||||
|
cp ../database/app_data.db ./database/app_data.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ARCDesktopProject
|
||||||
|
```
|
||||||
236
CMakeLists.txt
@ -1,5 +1,12 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
project(ARCDesktopProject LANGUAGES CXX)
|
project(ARC LANGUAGES CXX)
|
||||||
|
|
||||||
|
#mkdir build
|
||||||
|
#cd build
|
||||||
|
#cmake .. -A x64 -DCMAKE_PREFIX_PATH="C:/Qt/6.9.0/msvc2022_64" -DCMAKE_BUILD_TYPE=Release
|
||||||
|
#cmake .. -G "Visual Studio 17 2022" -A x64 -DCMAKE_PREFIX_PATH="C:/Qt/6.9.0/msvc2022_64" -DCMAKE_BUILD_TYPE=Release
|
||||||
|
#cmake --build . --config Release
|
||||||
|
#"C:\Qt\6.9.0\msvc2022_64\bin\windeployqt.exe" .\Release\ARC.exe
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_AUTOMOC ON)
|
set(CMAKE_AUTOMOC ON)
|
||||||
@ -9,18 +16,41 @@ set(CMAKE_AUTOUIC ON)
|
|||||||
add_compile_definitions(QT_MESSAGELOGCONTEXT)
|
add_compile_definitions(QT_MESSAGELOGCONTEXT)
|
||||||
|
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
# Укажи путь к Qt6
|
# Определяем целевую архитектуру.
|
||||||
|
# CMAKE_GENERATOR_PLATFORM (-A flag) — самый надёжный способ определения при кросс-компиляции.
|
||||||
|
# На ARM64 хосте CMAKE_SYSTEM_PROCESSOR может быть "ARM64" даже при сборке под x64,
|
||||||
|
# поэтому сначала проверяем явные x64-признаки.
|
||||||
|
if (CMAKE_GENERATOR_PLATFORM STREQUAL "x64"
|
||||||
|
OR CMAKE_GENERATOR_PLATFORM STREQUAL "Win64"
|
||||||
|
OR VCPKG_TARGET_TRIPLET MATCHES "x64")
|
||||||
|
message(STATUS "Detected x64 target architecture")
|
||||||
|
set(CMAKE_PREFIX_PATH "C:/Qt/6.9.0/msvc2022_64")
|
||||||
|
list(APPEND CMAKE_PREFIX_PATH "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows")
|
||||||
|
set(Tesseract_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows/share/tesseract")
|
||||||
|
set(VCPKG_BIN_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows/bin")
|
||||||
|
elseif (VCPKG_TARGET_TRIPLET MATCHES "arm64"
|
||||||
|
OR CMAKE_SYSTEM_PROCESSOR STREQUAL "ARM64"
|
||||||
|
OR CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64"
|
||||||
|
OR CMAKE_CXX_COMPILER MATCHES "arm64")
|
||||||
|
message(STATUS "Detected ARM64 target architecture")
|
||||||
set(CMAKE_PREFIX_PATH "C:/Qt/6.9.0/msvc2022_arm64")
|
set(CMAKE_PREFIX_PATH "C:/Qt/6.9.0/msvc2022_arm64")
|
||||||
list(APPEND CMAKE_PREFIX_PATH "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows")
|
list(APPEND CMAKE_PREFIX_PATH "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows")
|
||||||
#vcpkg install tesseract:arm64-windows
|
|
||||||
set(Tesseract_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows/share/tesseract")
|
set(Tesseract_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows/share/tesseract")
|
||||||
|
set(VCPKG_BIN_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/arm64-windows/bin")
|
||||||
|
else ()
|
||||||
|
message(STATUS "Detected x64 target architecture (default)")
|
||||||
|
set(CMAKE_PREFIX_PATH "C:/Qt/6.9.0/msvc2022_64")
|
||||||
|
list(APPEND CMAKE_PREFIX_PATH "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows")
|
||||||
|
set(Tesseract_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows/share/tesseract")
|
||||||
|
set(VCPKG_BIN_DIR "C:/Users/User/.vcpkg-clion/vcpkg/installed/x64-windows/bin")
|
||||||
|
endif ()
|
||||||
|
|
||||||
find_package(Tesseract CONFIG REQUIRED)
|
find_package(Tesseract CONFIG REQUIRED)
|
||||||
elseif (APPLE)
|
elseif (APPLE)
|
||||||
# Укажи путь к Qt6
|
# Укажи путь к Qt6
|
||||||
set(CMAKE_PREFIX_PATH "/opt/homebrew/opt/qt")
|
set(CMAKE_PREFIX_PATH "/opt/homebrew/opt/qt")
|
||||||
include_directories("/opt/homebrew/opt/tesseract/include")
|
include_directories("/opt/homebrew/include")
|
||||||
link_directories("/opt/homebrew/opt/tesseract/lib")
|
link_directories("/opt/homebrew/lib")
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS
|
find_package(Qt6 REQUIRED COMPONENTS
|
||||||
@ -30,42 +60,98 @@ find_package(Qt6 REQUIRED COMPONENTS
|
|||||||
Xml
|
Xml
|
||||||
)
|
)
|
||||||
|
|
||||||
file(GLOB_RECURSE DB_SOURCES
|
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/database/*.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/database/*.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/database/*.h"
|
"${CMAKE_CURRENT_SOURCE_DIR}/database/*.h"
|
||||||
)
|
)
|
||||||
|
|
||||||
file(GLOB_RECURSE VIEW_SOURCES
|
file(GLOB_RECURSE VIEW_SOURCES CONFIGURE_DEPENDS
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/views/*.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/views/*.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/views/*.h"
|
"${CMAKE_CURRENT_SOURCE_DIR}/views/*.h"
|
||||||
)
|
)
|
||||||
|
|
||||||
file(GLOB_RECURSE SERVICES_SOURCES
|
file(GLOB_RECURSE SERVICES_SOURCES CONFIGURE_DEPENDS
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/services/*.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/services/*.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/services/*.h"
|
"${CMAKE_CURRENT_SOURCE_DIR}/services/*.h"
|
||||||
)
|
)
|
||||||
|
|
||||||
file(GLOB_RECURSE MODELS_SOURCES
|
file(GLOB_RECURSE MODELS_SOURCES CONFIGURE_DEPENDS
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/models/*.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/models/*.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/models/*.h"
|
"${CMAKE_CURRENT_SOURCE_DIR}/models/*.h"
|
||||||
)
|
)
|
||||||
|
|
||||||
file(GLOB_RECURSE ANDROID_SOURCES
|
file(GLOB_RECURSE ANDROID_SOURCES CONFIGURE_DEPENDS
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/android/*.cpp"
|
"${CMAKE_CURRENT_SOURCE_DIR}/android/*.cpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/android/*.h"
|
"${CMAKE_CURRENT_SOURCE_DIR}/android/*.h"
|
||||||
)
|
)
|
||||||
|
|
||||||
add_executable(ARCDesktopProject
|
if (WIN32)
|
||||||
|
add_executable(ARC WIN32
|
||||||
|
main.cpp
|
||||||
|
app.rc
|
||||||
|
${VIEW_SOURCES}
|
||||||
|
${SERVICES_SOURCES}
|
||||||
|
${DB_SOURCES}
|
||||||
|
${MODELS_SOURCES}
|
||||||
|
${ANDROID_SOURCES}
|
||||||
|
views/bank/BankSettingsWindow.cpp
|
||||||
|
views/bank/BankSettingsWindow.h
|
||||||
|
views/bank/BankSetupWizard.cpp
|
||||||
|
views/bank/BankSetupWizard.h
|
||||||
|
views/log/FileLogWindow.cpp
|
||||||
|
views/log/FileLogWindow.h
|
||||||
|
views/log/LogDetailWindow.cpp
|
||||||
|
views/log/LogDetailWindow.h
|
||||||
|
database/dao/GeneralLogDAO.cpp
|
||||||
|
database/dao/GeneralLogDAO.h
|
||||||
|
android/dushanbe/CommonScript.cpp
|
||||||
|
android/dushanbe/CommonScript.h
|
||||||
|
android/dushanbe/GetCardInfoScript.cpp
|
||||||
|
android/dushanbe/GetCardInfoScript.h
|
||||||
|
android/dushanbe/GetLastTransactionsScript.cpp
|
||||||
|
android/dushanbe/GetLastTransactionsScript.h
|
||||||
|
android/dushanbe/GetProfileInfoScript.cpp
|
||||||
|
android/dushanbe/GetProfileInfoScript.h
|
||||||
|
android/dushanbe/LoginAndCheckAccountsScript.cpp
|
||||||
|
android/dushanbe/LoginAndCheckAccountsScript.h
|
||||||
|
android/dushanbe/ScreenXmlParser.cpp
|
||||||
|
android/dushanbe/ScreenXmlParser.h
|
||||||
|
)
|
||||||
|
elseif (APPLE)
|
||||||
|
add_executable(ARC
|
||||||
main.cpp
|
main.cpp
|
||||||
${VIEW_SOURCES}
|
${VIEW_SOURCES}
|
||||||
${SERVICES_SOURCES}
|
${SERVICES_SOURCES}
|
||||||
${DB_SOURCES}
|
${DB_SOURCES}
|
||||||
${MODELS_SOURCES}
|
${MODELS_SOURCES}
|
||||||
${ANDROID_SOURCES}
|
${ANDROID_SOURCES}
|
||||||
)
|
views/bank/BankSettingsWindow.cpp
|
||||||
|
views/bank/BankSettingsWindow.h
|
||||||
|
views/bank/BankSetupWizard.cpp
|
||||||
|
views/bank/BankSetupWizard.h
|
||||||
|
views/log/FileLogWindow.cpp
|
||||||
|
views/log/FileLogWindow.h
|
||||||
|
views/log/LogDetailWindow.cpp
|
||||||
|
views/log/LogDetailWindow.h
|
||||||
|
database/dao/GeneralLogDAO.cpp
|
||||||
|
database/dao/GeneralLogDAO.h
|
||||||
|
android/dushanbe/CommonScript.cpp
|
||||||
|
android/dushanbe/CommonScript.h
|
||||||
|
android/dushanbe/GetCardInfoScript.cpp
|
||||||
|
android/dushanbe/GetCardInfoScript.h
|
||||||
|
android/dushanbe/GetLastTransactionsScript.cpp
|
||||||
|
android/dushanbe/GetLastTransactionsScript.h
|
||||||
|
android/dushanbe/GetProfileInfoScript.cpp
|
||||||
|
android/dushanbe/GetProfileInfoScript.h
|
||||||
|
android/dushanbe/LoginAndCheckAccountsScript.cpp
|
||||||
|
android/dushanbe/LoginAndCheckAccountsScript.h
|
||||||
|
android/dushanbe/ScreenXmlParser.cpp
|
||||||
|
android/dushanbe/ScreenXmlParser.h
|
||||||
|
)
|
||||||
|
endif ()
|
||||||
|
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
target_link_libraries(ARCDesktopProject
|
target_link_libraries(ARC
|
||||||
Qt6::Widgets
|
Qt6::Widgets
|
||||||
Qt6::Xml
|
Qt6::Xml
|
||||||
Qt6::Sql
|
Qt6::Sql
|
||||||
@ -74,22 +160,140 @@ if (WIN32)
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Копирование изображений в директорию сборки
|
# Копирование изображений в директорию сборки
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/database/app_data.db" DESTINATION "${CMAKE_BINARY_DIR}")
|
# file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/database/app_data.db" DESTINATION "${CMAKE_BINARY_DIR}/database")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/tutorials" DESTINATION "${CMAKE_BINARY_DIR}")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/tutorials" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/images" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata" DESTINATION "${CMAKE_BINARY_DIR}")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
|
# tessdata/ — канонический путь, ожидаемый Tesseract 5+ через TESSDATA_PREFIX/datapath
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata" DESTINATION "${CMAKE_BINARY_DIR}/tessdata")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata" DESTINATION "${CMAKE_BINARY_DIR}/tessdata")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.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})
|
||||||
|
get_filename_component(_adb_fname "${_adb_file}" NAME)
|
||||||
|
add_custom_command(TARGET ARC POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ARC>/tools/adb/windows"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_adb_file}" "$<TARGET_FILE_DIR:ARC>/tools/adb/windows/${_adb_fname}"
|
||||||
|
)
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
add_custom_command(TARGET ARC POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/database"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/tutorials"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/tutorials"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/images"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/images"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/rus.traineddata"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/eng.traineddata"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/tessdata"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/tessdata/rus.traineddata"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/tessdata/eng.traineddata"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/config.ini"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/config.ini"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/no_devices.png"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/assets"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
|
||||||
|
file(GLOB _openssl_dlls "${VCPKG_BIN_DIR}/libssl*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/libcrypto*.dll")
|
||||||
|
foreach(_dll ${_openssl_dlls})
|
||||||
|
add_custom_command(TARGET ARC POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_dll}" "$<TARGET_FILE_DIR:ARC>"
|
||||||
|
)
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
# Copy mock-server test scripts into the build so they ship with the archive
|
||||||
|
add_custom_command(TARGET ARC POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/tests/mock_server"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/tests/mock_server"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy Tesseract, Leptonica, and their transitive dependency DLLs
|
||||||
|
file(GLOB _tesseract_dlls "${VCPKG_BIN_DIR}/tesseract*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/leptonica*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/libpng*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/jpeg*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/tiff*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/zlib*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/gif*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/libwebp*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/libsharpyuv*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/openjp2*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/archive*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/libcurl*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/lz4*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/zstd*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/liblzma*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/bz2*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/iconv*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/charset*.dll"
|
||||||
|
"${VCPKG_BIN_DIR}/libxml2*.dll")
|
||||||
|
foreach(_dll ${_tesseract_dlls})
|
||||||
|
add_custom_command(TARGET ARC POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_dll}" "$<TARGET_FILE_DIR:ARC>"
|
||||||
|
)
|
||||||
|
endforeach()
|
||||||
|
|
||||||
elseif (APPLE)
|
elseif (APPLE)
|
||||||
target_link_libraries(ARCDesktopProject
|
target_link_libraries(ARC
|
||||||
Qt6::Widgets
|
Qt6::Widgets
|
||||||
Qt6::Sql
|
Qt6::Sql
|
||||||
Qt6::Xml
|
Qt6::Xml
|
||||||
Qt6::Network
|
Qt6::Network
|
||||||
tesseract
|
tesseract
|
||||||
|
leptonica
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Копирование изображений в директорию сборки
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/tutorials" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/images" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
|
# tessdata/ — канонический путь, ожидаемый Tesseract 5+ через TESSDATA_PREFIX/datapath
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/rus.traineddata" DESTINATION "${CMAKE_BINARY_DIR}/tessdata")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/eng.traineddata" DESTINATION "${CMAKE_BINARY_DIR}/tessdata")
|
||||||
|
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")
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
target_include_directories(ARCDesktopProject PRIVATE
|
target_include_directories(ARC PRIVATE
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/views
|
${CMAKE_CURRENT_SOURCE_DIR}/views
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/database
|
${CMAKE_CURRENT_SOURCE_DIR}/database
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/services
|
${CMAKE_CURRENT_SOURCE_DIR}/services
|
||||||
|
|||||||
136
README.md
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
# ARC Desktop
|
||||||
|
|
||||||
|
Десктопное Qt6/C++ приложение для мониторинга и автоматизации Android-приложений мобильных банков через ADB. Принимает задачи с бэкенда (получение профиля/балансов, выписка по транзакциям, создание платежей), исполняет их в реальном приложении банка на подключённом устройстве и возвращает результат.
|
||||||
|
|
||||||
|
> **Репозиторий:** `git@git.brtservice.io:BRT/arc.git`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.brtservice.io:BRT/arc.git
|
||||||
|
cd arc
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install qt tesseract leptonica
|
||||||
|
mkdir -p build && cd build
|
||||||
|
cmake .. -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build .
|
||||||
|
./ARCDesktopProject
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробнее — см. [BUILD.md](BUILD.md) (macOS + Windows).
|
||||||
|
|
||||||
|
При первом запуске потребуется токен авторизации; настройки лежат в [`config.ini`](config.ini).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
Все актуальные материалы в каталоге [`docs/`](docs/). Точка входа — [docs/index.md](docs/index.md).
|
||||||
|
|
||||||
|
### Архитектура и фичи
|
||||||
|
|
||||||
|
| Документ | О чём |
|
||||||
|
|---|---|
|
||||||
|
| [docs/index.md](docs/index.md) | Общая схема приложения, потоки, технологии |
|
||||||
|
| [docs/events.md](docs/events.md) | Жизненный цикл задачи: API → БД → выполнение скриптом → отчёт |
|
||||||
|
| [docs/bank-setup-wizard.md](docs/bank-setup-wizard.md) | `BankSetupWizard` — мастер заведения банковского профиля (Ozon, Black, Dushanbe) |
|
||||||
|
| [docs/status-sync.md](docs/status-sync.md) | Синхронизация online/offline статусов устройств и сервиса |
|
||||||
|
| [docs/black_tasks.md](docs/black_tasks.md) | Скрипты для банка Black (Birbank) |
|
||||||
|
| [docs/ozon_tasks.md](docs/ozon_tasks.md) | Скрипты для Ozon |
|
||||||
|
|
||||||
|
### Backend API
|
||||||
|
|
||||||
|
| Документ | О чём |
|
||||||
|
|---|---|
|
||||||
|
| [docs/api/auth.md](docs/api/auth.md) | Логин, refresh, хранение токенов |
|
||||||
|
| [docs/api/profile.md](docs/api/profile.md) | `bank_profile` и `material` (карты) |
|
||||||
|
| [docs/api/device.md](docs/api/device.md) | Регистрация десктопов и Android-устройств, отправка скриншотов |
|
||||||
|
| [docs/api/task.md](docs/api/task.md) | Получение задач, отправка результатов |
|
||||||
|
| [docs/api/network.md](docs/api/network.md) | Полная карта HTTP-вызовов приложения |
|
||||||
|
| [docs/api/openapi.json](docs/api/openapi.json) | OpenAPI спецификация бэкенда |
|
||||||
|
|
||||||
|
### Сборка и тесты
|
||||||
|
|
||||||
|
| Документ | О чём |
|
||||||
|
|---|---|
|
||||||
|
| [BUILD.md](BUILD.md) | Сборка под macOS и Windows |
|
||||||
|
| [tests/mock_server/README.md](tests/mock_server/README.md) | Локальный mock-бэкенд для интеграционных тестов |
|
||||||
|
| [tests/mock_server/WINDOWS.md](tests/mock_server/WINDOWS.md) | Особенности запуска моков на Windows |
|
||||||
|
| [tutorials/ui_info.md](tutorials/ui_info.md) | Туториал по UI приложения |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
MainWindow (Qt6 GUI)
|
||||||
|
├── DeviceScreener (QThread) — опрос ADB-устройств, скриншоты
|
||||||
|
├── EventHandler (QThread) — оркестратор задач, запуск bank-скриптов
|
||||||
|
└── NetworkService (QThread) — REST API + monitoring relay
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
android/<bank>/ — автоматизация конкретного банка (ozon, black, dushanbe, ...)
|
||||||
|
├── *Script.{h,cpp} — CommonScript, LoginAndCheckAccountsScript, PayByCardScript, GetLastTransactionsScript, ...
|
||||||
|
└── ScreenXmlParser — парсинг ADB UI dump
|
||||||
|
|
||||||
|
android/adb/ — обёртка над adb (скриншот, tap, swipe, input, uiautomator dump)
|
||||||
|
android/ocr/ — Tesseract + утилиты поиска текста на скриншоте
|
||||||
|
android/dump/ — захват UI-дампов
|
||||||
|
|
||||||
|
database/ — SQLite через DatabaseManager (singleton) + DAO-слой
|
||||||
|
Таблицы: devices → applications → accounts → transactions/events
|
||||||
|
|
||||||
|
models/ — POCO: DeviceInfo, AccountInfo, EventInfo, TransactionInfo, ...
|
||||||
|
services/ — фоновые потоки (DeviceScreener, EventHandler, NetworkService, AppLogger, LogArchiveSender)
|
||||||
|
views/ — Qt-виджеты: MainWindow, DeviceWidget, BankSetupWizard, AccountWindow, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Полная диаграмма потоков и сигналов — [docs/index.md](docs/index.md), детально по задачам — [docs/events.md](docs/events.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
- **Qt 6** (Widgets, Network, Sql, Xml)
|
||||||
|
- **C++17**, CMake (опционально Ninja)
|
||||||
|
- **SQLite** (через QtSql)
|
||||||
|
- **ADB** (платформенные бинари в `tools/adb/`)
|
||||||
|
- **Tesseract OCR** + Leptonica (`eng.traineddata`, `rus.traineddata`)
|
||||||
|
|
||||||
|
Поддерживаемые банки (в текущем `config.ini`): **Ozon**, **Black** (Birbank), **Dushanbe City Bank**. Список расширяется через `[apps] list=` в [`config.ini`](config.ini).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Главный файл — [`config.ini`](config.ini):
|
||||||
|
|
||||||
|
| Секция | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `[common]` | Версия, валюты, токен |
|
||||||
|
| `[apps] list=` | Активные банки |
|
||||||
|
| `[db] dbPath=` | Путь к SQLite (`./database/app_data.db`) |
|
||||||
|
| `[net]` | `apiBase`, пути ручек, мониторинг-релей |
|
||||||
|
| `[<bank>]` | `android_package_name`, `currency`, `country`, `app_version`, ... |
|
||||||
|
|
||||||
|
Схема БД — [`migrations.sql`](migrations.sql) (5 таблиц).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Полезные пути
|
||||||
|
|
||||||
|
- Логи приложения: `application.log` (в корне), архив отправляется через `LogArchiveSender` на `monitoringBase`
|
||||||
|
- ADB-бинари для Windows: `tools/adb/windows/`
|
||||||
|
- Тестовые скриншоты/XML: `assets/<bank>/`
|
||||||
|
- Иконки и ресурсы: `images/`, `arc.icns`, `arc.ico`, `app.rc`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Скрипты бэкенда и операционные ссылки
|
||||||
|
|
||||||
|
- Backend API (prod): см. `apiBase` в [`config.ini`](config.ini)
|
||||||
|
- Monitoring relay: `142.93.102.40:8080` — релеит `/monitoring/log` и `/monitoring/dump` в Telegram (systemd `arc-monitoring.service`)
|
||||||
@ -2,10 +2,74 @@
|
|||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
#include <qDebug>
|
#include <qDebug>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
|
||||||
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
|
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::adbPath() {
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
QString path = QCoreApplication::applicationDirPath() + "/tools/adb/windows/adb.exe";
|
||||||
|
#else
|
||||||
|
QString path = QCoreApplication::applicationDirPath() + "/tools/adb/macos/adb";
|
||||||
|
#endif
|
||||||
|
static bool checked = false;
|
||||||
|
if (!checked) {
|
||||||
|
checked = true;
|
||||||
|
QFileInfo fi(path);
|
||||||
|
if (!fi.exists()) {
|
||||||
|
qWarning() << "[AdbUtils] adb not found at:" << path;
|
||||||
|
} else if (!fi.isExecutable()) {
|
||||||
|
qWarning() << "[AdbUtils] adb is not executable:" << path;
|
||||||
|
} else {
|
||||||
|
qDebug() << "[AdbUtils] adb path:" << path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::adbDir() {
|
||||||
|
return QFileInfo(adbPath()).absolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AdbUtils::startProcess(const QString &program, const QStringList &args,
|
||||||
|
QString *stdOut, QString *stdErr) {
|
||||||
|
QProcess process;
|
||||||
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(program, args);
|
||||||
|
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start process:" << program << args
|
||||||
|
<< "error:" << process.errorString();
|
||||||
|
if (stdErr) {
|
||||||
|
*stdErr = process.errorString();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.waitForFinished(30000) ||
|
||||||
|
process.exitStatus() != QProcess::NormalExit ||
|
||||||
|
process.exitCode() != 0) {
|
||||||
|
if (stdErr) {
|
||||||
|
*stdErr = QString::fromUtf8(process.readAllStandardError());
|
||||||
|
}
|
||||||
|
qWarning() << "[AdbUtils] Process failed:" << program << args
|
||||||
|
<< "exitCode:" << process.exitCode()
|
||||||
|
<< "stderr:" << (stdErr ? *stdErr : QString::fromUtf8(process.readAllStandardError()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdOut) {
|
||||||
|
*stdOut = QString::fromUtf8(process.readAllStandardOutput());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// adb shell dumpsys package ru.rshb.dbo | grep permission
|
// adb shell dumpsys package ru.rshb.dbo | grep permission
|
||||||
// TODO доделать обработку пермишинов
|
// TODO доделать обработку пермишинов
|
||||||
// adb shell pm revoke ru.rshb.dbo android.permission.WRITE_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.READ_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_COARSE_LOCATION; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_FINE_LOCATION
|
// adb shell pm revoke ru.rshb.dbo android.permission.WRITE_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.READ_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_COARSE_LOCATION; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_FINE_LOCATION
|
||||||
@ -16,9 +80,36 @@ AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
|
|||||||
* @return [(id, status), ...]
|
* @return [(id, status), ...]
|
||||||
*/
|
*/
|
||||||
QList<QPair<QString, QString> > AdbUtils::getListDevices() {
|
QList<QPair<QString, QString> > AdbUtils::getListDevices() {
|
||||||
|
const QString adb = adbPath();
|
||||||
|
const QString dir = adbDir();
|
||||||
|
|
||||||
|
// Убиваем чужой adb-сервер (возможно другой версии) и запускаем свой,
|
||||||
|
// чтобы избежать конфликта версий (например, scrcpy adb 35 vs bundled adb 36)
|
||||||
|
static bool serverRestarted = false;
|
||||||
|
if (!serverRestarted) {
|
||||||
|
serverRestarted = true;
|
||||||
|
QProcess kill;
|
||||||
|
kill.setWorkingDirectory(dir);
|
||||||
|
kill.start(adb, {"kill-server"});
|
||||||
|
kill.waitForFinished(5000);
|
||||||
|
qDebug() << "[AdbUtils] Killed existing adb server";
|
||||||
|
|
||||||
|
QProcess start;
|
||||||
|
start.setWorkingDirectory(dir);
|
||||||
|
start.start(adb, {"start-server"});
|
||||||
|
start.waitForFinished(10000);
|
||||||
|
qDebug() << "[AdbUtils] Started own adb server";
|
||||||
|
}
|
||||||
|
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("adb", {"devices"});
|
process.setWorkingDirectory(dir);
|
||||||
process.waitForFinished();
|
process.start(adb, {"devices"});
|
||||||
|
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb devices:" << process.errorString();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
process.waitForFinished(10000);
|
||||||
|
|
||||||
const QString output = process.readAllStandardOutput();
|
const QString output = process.readAllStandardOutput();
|
||||||
QList<QPair<QString, QString> > devices;
|
QList<QPair<QString, QString> > devices;
|
||||||
@ -47,177 +138,649 @@ QList<QPair<QString, QString> > AdbUtils::getListDevices() {
|
|||||||
|
|
||||||
QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &packageName) {
|
QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &packageName) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("adb", {
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), {
|
||||||
"-s", deviceId,
|
"-s", deviceId,
|
||||||
"shell", "pidof", packageName
|
"shell", "pidof", packageName
|
||||||
});
|
});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb pidof:" << process.errorString();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
QString pid = process.readAllStandardOutput();
|
QString pid = process.readAllStandardOutput();
|
||||||
return pid.replace("\n", "");
|
return pid.replace("\n", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Логирует длинный вывод построчно с префиксом, иначе Qt-логгер режет строку.
|
||||||
|
static void logMultiline(const char *tag, const QString &text) {
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
qDebug().noquote() << tag << "(empty)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QStringList lines = text.split(QRegularExpression("\r?\n"));
|
||||||
|
for (const QString &line : lines) {
|
||||||
|
if (!line.isEmpty()) qDebug().noquote() << tag << line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прогоняет adb-команду и возвращает stdout (stderr игнорируется — этим функциям важен только stdout).
|
||||||
|
static QString adbShellStdout(const QStringList &args, int timeoutMs = 10000) {
|
||||||
|
QProcess p;
|
||||||
|
p.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
|
||||||
|
p.start(AdbUtils::adbPath(), args);
|
||||||
|
if (!p.waitForStarted(5000)) return {};
|
||||||
|
p.waitForFinished(timeoutMs);
|
||||||
|
return QString::fromUtf8(p.readAllStandardOutput());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращает {launcherActivity, summary}. launcherActivity вида "pkg/.MainActivity" или пустая строка.
|
||||||
|
static QPair<QString, QString> probePackageState(const QString &deviceId, const QString &packageName) {
|
||||||
|
const QString resolveOut = adbShellStdout(
|
||||||
|
{"-s", deviceId, "shell", "cmd", "package", "resolve-activity", "--brief", packageName});
|
||||||
|
QString launcher;
|
||||||
|
// Формат: первая строка — приоритет, вторая — "pkg/activity".
|
||||||
|
for (const QString &raw : resolveOut.split(QRegularExpression("\r?\n"))) {
|
||||||
|
const QString line = raw.trimmed();
|
||||||
|
if (line.contains('/') && line.startsWith(packageName + "/")) {
|
||||||
|
launcher = line;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString dump = adbShellStdout(
|
||||||
|
{"-s", deviceId, "shell", "dumpsys", "package", packageName}, 15000);
|
||||||
|
|
||||||
|
auto pickFlag = [&dump](const QString &key) -> QString {
|
||||||
|
const QRegularExpression re(key + "=(\\S+)");
|
||||||
|
const auto m = re.match(dump);
|
||||||
|
return m.hasMatch() ? m.captured(1) : QStringLiteral("?");
|
||||||
|
};
|
||||||
|
const bool installed = dump.contains("Package [" + packageName + "]");
|
||||||
|
const QString enabled = pickFlag("enabled");
|
||||||
|
const QString stopped = pickFlag("stopped");
|
||||||
|
const QString hidden = pickFlag("hidden");
|
||||||
|
const bool hasLauncherCategory = dump.contains("android.intent.category.LAUNCHER");
|
||||||
|
|
||||||
|
const QString summary = QString(
|
||||||
|
"installed=%1 enabled=%2 stopped=%3 hidden=%4 hasLauncherCategory=%5 resolveActivity=%6")
|
||||||
|
.arg(installed ? "true" : "false", enabled, stopped, hidden,
|
||||||
|
hasLauncherCategory ? "true" : "false",
|
||||||
|
launcher.isEmpty() ? "(none)" : launcher);
|
||||||
|
return {launcher, summary};
|
||||||
|
}
|
||||||
|
|
||||||
bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &packageName) {
|
bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &packageName) {
|
||||||
|
// Pre-flight: состояние пакета. Без этого по логу нельзя отличить
|
||||||
|
// "нет иконки" от "disabled" от "hidden" от "не установлен".
|
||||||
|
const auto [launcherActivity, stateSummary] = probePackageState(deviceId, packageName);
|
||||||
|
qDebug().noquote() << "[AdbUtils::tryToStartApplication] device=" << deviceId
|
||||||
|
<< "pkg=" << packageName << "state:" << stateSummary;
|
||||||
|
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("adb", {
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), {
|
||||||
"-s", deviceId,
|
"-s", deviceId,
|
||||||
"shell", "monkey",
|
"shell", "monkey",
|
||||||
"-p", packageName,
|
"-p", packageName,
|
||||||
"-c", "android.intent.category.LAUNCHER",
|
"-c", "android.intent.category.LAUNCHER",
|
||||||
"1"
|
"1"
|
||||||
});
|
});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb monkey:" << process.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
const QString output = process.readAllStandardOutput();
|
const QString stdOut = QString::fromUtf8(process.readAllStandardOutput());
|
||||||
|
const QString stdErr = QString::fromUtf8(process.readAllStandardError());
|
||||||
|
const int exitCode = process.exitCode();
|
||||||
|
const QProcess::ExitStatus exitStatus = process.exitStatus();
|
||||||
|
|
||||||
// Если процесс запустился, там будет такая строка.
|
// Полный вывод monkey пишем построчно — Qt-логгер режет длинные QString,
|
||||||
// Если нет, то: "** No activities found to run, monkey aborted."
|
// из-за чего хвост (`Events injected` / `No activities found`) терялся.
|
||||||
if (output.contains("Events injected: 1")) {
|
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey exitStatus="
|
||||||
|
<< exitStatus << "exitCode=" << exitCode;
|
||||||
|
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
|
||||||
|
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
|
||||||
|
|
||||||
|
if (stdOut.contains("Events injected: 1")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Тут надо в логи писать, почему не стартует
|
// Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code,
|
||||||
qDebug() << "Start app output:" << output;
|
// в отличие от monkey, который при пустом resolve просто молча печатает "No activities found".
|
||||||
|
if (!launcherActivity.isEmpty()) {
|
||||||
|
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
|
||||||
|
<< "пробую am start -n" << launcherActivity;
|
||||||
|
QProcess am;
|
||||||
|
am.setWorkingDirectory(adbDir());
|
||||||
|
am.start(adbPath(),
|
||||||
|
{"-s", deviceId, "shell", "am", "start", "-W", "-n", launcherActivity});
|
||||||
|
am.waitForStarted(5000);
|
||||||
|
am.waitForFinished(15000);
|
||||||
|
const QString amOut = QString::fromUtf8(am.readAllStandardOutput());
|
||||||
|
const QString amErr = QString::fromUtf8(am.readAllStandardError());
|
||||||
|
qDebug().noquote() << "[AdbUtils::tryToStartApplication] am exitCode=" << am.exitCode();
|
||||||
|
logMultiline("[AdbUtils::tryToStartApplication] am stdout:", amOut);
|
||||||
|
logMultiline("[AdbUtils::tryToStartApplication] am stderr:", amErr);
|
||||||
|
// `am start -W` печатает "Status: ok" при успехе.
|
||||||
|
if (am.exitCode() == 0 && amOut.contains("Status: ok")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning() << "[AdbUtils::tryToStartApplication] нет launcher-активити для" << packageName
|
||||||
|
<< "— приложение либо не установлено, либо disabled/hidden, либо без LAUNCHER intent-filter";
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AdbUtils::tryToKillApplication(const QString &deviceId, const QString &packageName) {
|
bool AdbUtils::tryToKillApplication(const QString &deviceId, const QString &packageName) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("adb", {
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), {
|
||||||
"-s", deviceId,
|
"-s", deviceId,
|
||||||
"shell", "am",
|
"shell", "am",
|
||||||
"force-stop", packageName
|
"force-stop", packageName
|
||||||
});
|
});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb force-stop:" << process.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// idx - временный лог
|
// idx - временный лог
|
||||||
// adb -s 6edc4a47 shell 'uiautomator dump /sdcard/uidump.xml' && adb -s 6edc4a47 pull /sdcard/uidump.xml step_1.xml && adb -s 6edc4a47 shell 'rm /sdcard/uidump.xml'
|
// adb -s 6edc4a47 shell 'uiautomator dump /sdcard/uidump.xml' && adb -s 6edc4a47 pull /sdcard/uidump.xml step_1.xml && adb -s 6edc4a47 shell 'rm /sdcard/uidump.xml'
|
||||||
QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, const int idx) {
|
QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot) {
|
||||||
// TODO убрать логирование в xml файл
|
const QString adb = adbPath();
|
||||||
// "adb -s %1 pull /sdcard/uidump.xml step_%2.xml && "
|
|
||||||
QProcess process;
|
|
||||||
QString xml = QString(
|
|
||||||
"adb -s %1 shell 'uiautomator dump /sdcard/uidump.xml' && "
|
|
||||||
|
|
||||||
"adb -s %1 shell 'cat /sdcard/uidump.xml' && "
|
for (int retry = 0; retry < 3; ++retry) {
|
||||||
"adb -s %1 shell 'rm /sdcard/uidump.xml'"
|
// 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android)
|
||||||
// ).arg(deviceId, QString::number(idx));
|
bool dumpOk = false;
|
||||||
).arg(deviceId);
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||||
process.start("sh", {"-c", xml});
|
if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) {
|
||||||
process.waitForFinished();
|
dumpOk = true;
|
||||||
|
break;
|
||||||
if (false) {
|
|
||||||
// TODO делаем скрин, убрать потом
|
|
||||||
// adb -s 6edc4a47 shell 'screencap -p /sdcard/screen.png' && adb -s 6edc4a47 pull /sdcard/screen.png screen_step_1.png && adb -s 6edc4a47 shell 'rm /sdcard/screen.png'
|
|
||||||
QProcess processScreenshot;
|
|
||||||
QString screenshot = QString(
|
|
||||||
"adb -s %1 shell 'screencap -p /sdcard/screen.png' && "
|
|
||||||
"adb -s %1 pull /sdcard/screen.png screen_step_%2.png && "
|
|
||||||
"adb -s %1 shell 'rm /sdcard/screen.png'"
|
|
||||||
).arg(deviceId, QString::number(idx));
|
|
||||||
processScreenshot.start("sh", {"-c", screenshot});
|
|
||||||
processScreenshot.waitForFinished();
|
|
||||||
}
|
}
|
||||||
|
qWarning() << "[AdbUtils] uiautomator dump failed, attempt" << (attempt + 1) << "of 3";
|
||||||
// чтобы убедиться, что мы прочитали без ошибок
|
QThread::msleep(1000);
|
||||||
if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) {
|
}
|
||||||
|
if (!dumpOk) {
|
||||||
|
qWarning() << "Failed to dump UI hierarchy after 3 attempts.";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// 2. Optionally pull and save the XML file
|
||||||
|
if (screenshot) {
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "pull", "/sdcard/uidump.xml",
|
||||||
|
QString("step_%1.xml").arg(idx)})) {
|
||||||
|
qWarning() << "Failed to pull UIDump XML.";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Read the XML content
|
||||||
|
QString xmlOut;
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) {
|
||||||
|
qWarning() << "Failed to read UIDump XML.";
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
QByteArray xmlBytes = process.readAllStandardOutput();
|
// 4. Remove temp XML file
|
||||||
// UI hierchary dumped to: /sdcard/uidump.xml\n
|
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"});
|
||||||
if (const int startIndex = xmlBytes.indexOf("<?xml"); startIndex != -1) {
|
|
||||||
xmlBytes = xmlBytes.mid(startIndex); // Обрезаем всё до "<?xml"
|
// 5. Минимальная санитарная проверка: дамп не пустой, достаточного размера
|
||||||
|
// и содержит хотя бы одну ноду с непустым text или content-desc.
|
||||||
|
// Этого хватает, чтобы отсеять WebView-shell-only состояния Ozon
|
||||||
|
// (там в шелле labeled нод вообще нет), и при этом не отбрасывать
|
||||||
|
// экраны RN/Compose (Dushanbe, Black), где интерактив строится на
|
||||||
|
// LinearLayout/View с content-desc, без Button/text/EditText.
|
||||||
|
static const QRegularExpression reNonEmpty(R"((text|content-desc)="[^"]+")");
|
||||||
|
const bool hasContent = reNonEmpty.match(xmlOut).hasMatch();
|
||||||
|
if (xmlOut.trimmed().isEmpty() || !xmlOut.contains("<?xml")
|
||||||
|
|| xmlOut.size() < 500 || !hasContent) {
|
||||||
|
qWarning() << "[AdbUtils] getScreenDumpAsXml: bad dump (size=" << xmlOut.size()
|
||||||
|
<< "hasContent=" << hasContent
|
||||||
|
<< "), retry" << (retry + 1) << "of 3";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Screenshot (if needed)
|
||||||
|
if (screenshot) {
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"});
|
||||||
|
startProcess(adb, {"-s", deviceId, "pull", "/sdcard/screen.png",
|
||||||
|
QString("screen_step_%1.png").arg(idx)});
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/screen.png"});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Обрезка к началу XML
|
||||||
|
QByteArray xmlBytes = xmlOut.toUtf8();
|
||||||
|
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
|
||||||
|
xmlBytes = xmlBytes.mid(start);
|
||||||
|
}
|
||||||
|
const QString cleanXml = QString::fromUtf8(xmlBytes);
|
||||||
|
if (auto *rec = TaskDumpRecorder::current()) {
|
||||||
|
rec->saveXml(idx, cleanXml);
|
||||||
|
}
|
||||||
|
return cleanXml;
|
||||||
|
}
|
||||||
|
|
||||||
|
qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::getRawScreenDumpAsXml(const QString &deviceId) {
|
||||||
|
const QString adb = adbPath();
|
||||||
|
|
||||||
|
// 1. Дампим иерархию (uiautomator может умирать — до 3 попыток)
|
||||||
|
bool dumpOk = false;
|
||||||
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||||
|
if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) {
|
||||||
|
dumpOk = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(500);
|
||||||
|
}
|
||||||
|
if (!dumpOk) {
|
||||||
|
qWarning() << "[AdbUtils] getRawScreenDumpAsXml: uiautomator dump failed";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Читаем
|
||||||
|
QString xmlOut;
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) {
|
||||||
|
qWarning() << "[AdbUtils] getRawScreenDumpAsXml: cat failed";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"});
|
||||||
|
|
||||||
|
// 3. Обрезка к началу XML; в отличие от getScreenDumpAsXml — без фильтра по контенту,
|
||||||
|
// оператору важно видеть РЕАЛЬНОЕ состояние экрана (PIN, сплэш, WebView и т.д.).
|
||||||
|
QByteArray xmlBytes = xmlOut.toUtf8();
|
||||||
|
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
|
||||||
|
xmlBytes = xmlBytes.mid(start);
|
||||||
}
|
}
|
||||||
return QString::fromUtf8(xmlBytes);
|
return QString::fromUtf8(xmlBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
|
bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
|
||||||
|
const QString adb = adbPath();
|
||||||
|
struct Step { QStringList args; };
|
||||||
|
Step steps[] = {
|
||||||
|
{{"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"}},
|
||||||
|
{{"-s", deviceId, "pull", "/sdcard/screen.png", name + ".png"}},
|
||||||
|
{{"-s", deviceId, "shell", "rm", "/sdcard/screen.png"}}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto &step : steps) {
|
||||||
|
QString stdErr;
|
||||||
|
if (!startProcess(adb, step.args, nullptr, &stdErr)) {
|
||||||
|
qWarning() << "ADB command failed:" << step.args.join(' ');
|
||||||
|
qWarning() << "Error:" << stdErr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
QString screenshot = QString(
|
process.setWorkingDirectory(adbDir());
|
||||||
"adb -s %1 shell 'screencap -p /sdcard/screen.png' && "
|
process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"});
|
||||||
"adb -s %1 pull /sdcard/screen.png screen_%2.png && "
|
if (!process.waitForStarted(5000)) {
|
||||||
"adb -s %1 shell 'rm /sdcard/screen.png'"
|
qWarning() << "[AdbUtils] captureScreenshotBytes failed to start for" << deviceId
|
||||||
).arg(deviceId, name);
|
<< "error:" << process.errorString();
|
||||||
process.start("sh", {"-c", screenshot});
|
return {};
|
||||||
process.waitForFinished();
|
}
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
if (!process.waitForFinished(10000) || process.exitCode() != 0) {
|
||||||
|
qWarning() << "[AdbUtils] captureScreenshotBytes failed for" << deviceId;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return process.readAllStandardOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) {
|
||||||
|
const QString adb = adbPath();
|
||||||
|
struct Step { QStringList args; };
|
||||||
|
Step steps[] = {
|
||||||
|
{{"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"}},
|
||||||
|
{{"-s", deviceId, "pull", "/sdcard/uidump.xml", name + ".xml"}},
|
||||||
|
{{"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}},
|
||||||
|
{{"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"}}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto &step : steps) {
|
||||||
|
QString stdErr;
|
||||||
|
if (!startProcess(adb, step.args, nullptr, &stdErr)) {
|
||||||
|
qWarning() << "ADB command failed:" << step.args.join(' ');
|
||||||
|
qWarning() << "Error:" << stdErr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AdbUtils::makeTap(const QString &deviceId, const int x, const int y) {
|
bool AdbUtils::makeTap(const QString &deviceId, const int x, const int y) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("adb", {
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), {
|
||||||
"-s", deviceId,
|
"-s", deviceId,
|
||||||
"shell", "input",
|
"shell", "input",
|
||||||
"tap", QString::number(x), QString::number(y)
|
"tap", QString::number(x), QString::number(y)
|
||||||
});
|
});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb tap:" << process.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AdbUtils::enableAdbIMEKeyboard(const QString &deviceId) {
|
bool AdbUtils::enableAdbIMEKeyboard(const QString &deviceId) {
|
||||||
QProcess process;
|
const QString adb = adbPath();
|
||||||
QString command = QString(
|
const QString imeId = QStringLiteral("com.android.adbkeyboard/.AdbIME");
|
||||||
"adb -s %1 shell ime enable com.android.adbkeyboard/.AdbIME "
|
|
||||||
"&& adb -s %1 shell ime set com.android.adbkeyboard/.AdbIME"
|
// Проверяем, установлен ли ADB Keyboard
|
||||||
).arg(deviceId);
|
QString installedList;
|
||||||
process.start(command);
|
startProcess(adb, {"-s", deviceId, "shell", "pm", "list", "packages", "com.android.adbkeyboard"}, &installedList);
|
||||||
process.waitForFinished();
|
bool freshlyInstalled = false;
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
if (!installedList.contains("com.android.adbkeyboard")) {
|
||||||
|
const QString apkPath = QCoreApplication::applicationDirPath() + "/assets/ADBKeyboard.apk";
|
||||||
|
qDebug() << "[AdbUtils] ADB Keyboard not installed, installing from:" << apkPath;
|
||||||
|
QString installErr;
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "install", apkPath}, nullptr, &installErr)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to install ADB Keyboard:" << installErr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
qDebug() << "[AdbUtils] ADB Keyboard installed successfully";
|
||||||
|
freshlyInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пакет мог быть отключён производителем (pm disable-user).
|
||||||
|
// Пытаемся включить; ошибку не считаем фатальной (на некоторых прошивках команда
|
||||||
|
// отвергается, но сам IME при этом включается).
|
||||||
|
{
|
||||||
|
QString pmOut, pmErr;
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "shell", "pm", "enable", "com.android.adbkeyboard"}, &pmOut, &pmErr)) {
|
||||||
|
qWarning() << "[AdbUtils] pm enable com.android.adbkeyboard failed:" << pmErr.trimmed()
|
||||||
|
<< "(продолжаем)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ime enable с retry: сразу после 'pm install' InputMethodManagerService
|
||||||
|
// может ещё не подхватить новый IME и возвращать "Unknown input method ... cannot be enabled".
|
||||||
|
// Ретраим до появления IME в списке всех зарегистрированных (ime list -a).
|
||||||
|
const int enableAttempts = freshlyInstalled ? 10 : 3;
|
||||||
|
bool imeEnabled = false;
|
||||||
|
QString lastErr;
|
||||||
|
for (int attempt = 0; attempt < enableAttempts; ++attempt) {
|
||||||
|
QString out, err;
|
||||||
|
if (startProcess(adb, {"-s", deviceId, "shell", "ime", "enable", imeId}, &out, &err)) {
|
||||||
|
qDebug() << "[AdbUtils] ime enable:" << out.trimmed();
|
||||||
|
imeEnabled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastErr = err.trimmed();
|
||||||
|
qDebug() << "[AdbUtils] ime enable attempt" << attempt + 1 << "/" << enableAttempts
|
||||||
|
<< "failed:" << lastErr << "— ждём, пока IMMS перечитает IME";
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
if (!imeEnabled) {
|
||||||
|
qWarning() << "[AdbUtils] ime enable failed after" << enableAttempts
|
||||||
|
<< "attempts. Last error:" << lastErr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Верификация: IME должен появиться в списке разрешённых
|
||||||
|
{
|
||||||
|
QString enabledList;
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "ime", "list", "-s"}, &enabledList);
|
||||||
|
if (!enabledList.contains(imeId)) {
|
||||||
|
qWarning() << "[AdbUtils] ADB Keyboard отсутствует в 'ime list -s' после 'ime enable'."
|
||||||
|
<< "Вывод:" << enabledList.trimmed()
|
||||||
|
<< "(прошивка/OEM может блокировать сторонние IME)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ime set
|
||||||
|
{
|
||||||
|
QString out, err;
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "shell", "ime", "set", imeId}, &out, &err)) {
|
||||||
|
qWarning() << "[AdbUtils] ime set failed:" << err.trimmed();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
qDebug() << "[AdbUtils] ime set:" << out.trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Верификация: активный IME должен совпасть с нашим
|
||||||
|
{
|
||||||
|
QString defaultIme;
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "settings", "get", "secure", "default_input_method"}, &defaultIme);
|
||||||
|
const QString trimmed = defaultIme.trimmed();
|
||||||
|
if (!trimmed.contains(imeId)) {
|
||||||
|
qWarning() << "[AdbUtils] default_input_method =" << trimmed
|
||||||
|
<< "— ожидали" << imeId
|
||||||
|
<< "(прошивка откатывает смену IME)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
qDebug() << "[AdbUtils] Active IME verified:" << trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) {
|
bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
QString command = QString(
|
process.setWorkingDirectory(adbDir());
|
||||||
"adb -s %1 shell am broadcast -a ADB_INPUT_TEXT --es msg '%2'"
|
|
||||||
).arg(deviceId, text);
|
// Кодируем UTF-8 → base64 и шлём через ADB_INPUT_B64. Это исключает любые проблемы
|
||||||
process.start(command);
|
// shell-кавычек на устройстве с пробелами, скобками и не-ASCII символами
|
||||||
|
// (типа "Озон Банк (Ozon)"). ADBKeyboard декодирует base64 и вызывает commitText.
|
||||||
|
const QString b64 = QString::fromLatin1(text.toUtf8().toBase64());
|
||||||
|
|
||||||
|
process.start(adbPath(), {
|
||||||
|
"-s", deviceId,
|
||||||
|
"shell", "am broadcast -a ADB_INPUT_B64 --es msg " + b64
|
||||||
|
});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
|
qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode()
|
||||||
|
<< "text:" << text
|
||||||
|
<< "output:" << process.readAllStandardOutput().trimmed()
|
||||||
|
<< "error:" << process.readAllStandardError().trimmed();
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AdbUtils::disableAdbIMEKeyboard(const QString &deviceId) {
|
bool AdbUtils::disableAdbIMEKeyboard(const QString &deviceId) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
QString command = QString(
|
process.setWorkingDirectory(adbDir());
|
||||||
"adb -s %1 shell ime disable com.android.adbkeyboard/.AdbIME"
|
process.start(adbPath(), {"-s", deviceId, "shell", "ime", "disable",
|
||||||
).arg(deviceId);
|
"com.android.adbkeyboard/.AdbIME"});
|
||||||
process.start(command);
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb ime disable:" << process.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, const int x2, const int y2) {
|
bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, const int x2, const int y2) {
|
||||||
QProcess process;
|
return makeSwipe(deviceId, x1, y1, x2, y2, -1);
|
||||||
process.start(
|
}
|
||||||
"adb",
|
|
||||||
{
|
bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, const int x2, const int y2, int durationMs) {
|
||||||
|
QStringList args = {
|
||||||
"-s", deviceId,
|
"-s", deviceId,
|
||||||
"shell", "input",
|
"shell", "input",
|
||||||
"swipe", QString("%1 %2 %3 %4").arg(x1).arg(y1).arg(x2).arg(y2)
|
"swipe",
|
||||||
|
QString::number(x1), QString::number(y1),
|
||||||
|
QString::number(x2), QString::number(y2)
|
||||||
|
};
|
||||||
|
if (durationMs > 0) args << QString::number(durationMs);
|
||||||
|
|
||||||
|
QProcess process;
|
||||||
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), args);
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb swipe:" << process.errorString();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AdbUtils::inputText(const QString &deviceId, const QString &text) {
|
bool AdbUtils::inputText(const QString &deviceId, const QString &text) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("adb", {
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), {
|
||||||
"-s", deviceId,
|
"-s", deviceId,
|
||||||
"shell", "input",
|
"shell", "input",
|
||||||
"text", text
|
"text", text
|
||||||
});
|
});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb input text:" << process.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool AdbUtils::hideKeyboard(const QString &deviceId) {
|
||||||
|
// Метод 1: KEYCODE_ESCAPE (111) — работает на большинстве Android-устройств,
|
||||||
|
// но игнорируется на некоторых прошивках (например, TECNO/HiOS).
|
||||||
|
{
|
||||||
|
QProcess p;
|
||||||
|
p.setWorkingDirectory(adbDir());
|
||||||
|
p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "111"});
|
||||||
|
if (p.waitForStarted(5000)) p.waitForFinished(3000);
|
||||||
|
}
|
||||||
|
// Метод 2: KEYCODE_BACK (4) — Android посылает BACK сначала в IME; если
|
||||||
|
// IME (клавиатура) видим — он его потребляет и закрывает, не уходя
|
||||||
|
// на предыдущий экран. На TECNO/HiOS это часто единственный рабочий
|
||||||
|
// вариант. Если клавиатура уже скрыта (после метода 1), BACK уйдёт
|
||||||
|
// в Activity — нежелательно. Поэтому сначала проверим через dumpsys
|
||||||
|
// input_method, видна ли всё ещё клавиатура.
|
||||||
|
bool imeVisible = false;
|
||||||
|
{
|
||||||
|
QProcess p;
|
||||||
|
p.setWorkingDirectory(adbDir());
|
||||||
|
p.start(adbPath(), {"-s", deviceId, "shell", "dumpsys", "input_method"});
|
||||||
|
if (p.waitForStarted(5000) && p.waitForFinished(5000)) {
|
||||||
|
const QString out = QString::fromUtf8(p.readAllStandardOutput());
|
||||||
|
imeVisible = out.contains(QStringLiteral("mInputShown=true"))
|
||||||
|
|| out.contains(QStringLiteral("mServedInputConnection=")) // fallback heuristic
|
||||||
|
&& !out.contains(QStringLiteral("mInputShown=false"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imeVisible) {
|
||||||
|
qDebug() << "[AdbUtils] hideKeyboard: ESC didn't dismiss IME, trying BACK";
|
||||||
|
QProcess p;
|
||||||
|
p.setWorkingDirectory(adbDir());
|
||||||
|
p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "4"});
|
||||||
|
if (p.waitForStarted(5000)) p.waitForFinished(3000);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool AdbUtils::goBack(const QString &deviceId) {
|
bool AdbUtils::goBack(const QString &deviceId) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("adb", {
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), {
|
||||||
"-s", deviceId,
|
"-s", deviceId,
|
||||||
"shell", "input",
|
"shell", "input",
|
||||||
"keyevent", "4"
|
"keyevent", "4"
|
||||||
});
|
});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QSet<QString> AdbUtils::getListApps(const QString &deviceId) {
|
||||||
|
QProcess process;
|
||||||
|
process.setWorkingDirectory(adbDir());
|
||||||
|
process.start(adbPath(), {
|
||||||
|
"-s", deviceId,
|
||||||
|
"shell", "pm list",
|
||||||
|
"packages", "-3"
|
||||||
|
});
|
||||||
|
if (!process.waitForStarted(5000)) {
|
||||||
|
qWarning() << "[AdbUtils] Failed to start adb pm list:" << process.errorString();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
process.waitForFinished();
|
||||||
|
|
||||||
|
const QString output = process.readAllStandardOutput();
|
||||||
|
QSet<QString> apps;
|
||||||
|
|
||||||
|
const QStringList lines = output.split("\n");
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
QString line = lines.at(i).trimmed().replace("package:", "");
|
||||||
|
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
apps.insert(line);
|
||||||
|
}
|
||||||
|
return apps;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::getDeviceTimezone(const QString &deviceId) {
|
||||||
|
QString out;
|
||||||
|
if (!startProcess(adbPath(), {"-s", deviceId, "shell", "getprop", "persist.sys.timezone"}, &out)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return out.trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::getAppVersion(const QString &deviceId, const QString &packageName) {
|
||||||
|
if (deviceId.isEmpty() || packageName.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
QString out;
|
||||||
|
if (!startProcess(adbPath(),
|
||||||
|
{"-s", deviceId, "shell", "dumpsys", "package", packageName},
|
||||||
|
&out)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
static const QRegularExpression re(R"(versionName=([^\s]+))");
|
||||||
|
const QRegularExpressionMatch m = re.match(out);
|
||||||
|
return m.hasMatch() ? m.captured(1).trimmed() : QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) {
|
||||||
|
const QString adb = adbPath();
|
||||||
|
|
||||||
|
// Проверяем, включён ли экран (mWakefulness=Awake)
|
||||||
|
QString powerState;
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "power"}, &powerState);
|
||||||
|
const bool isAwake = powerState.contains("mWakefulness=Awake");
|
||||||
|
|
||||||
|
if (!isAwake) {
|
||||||
|
// Включаем экран
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "input", "keyevent", "KEYCODE_WAKEUP"});
|
||||||
|
QThread::msleep(500);
|
||||||
|
qDebug() << "[AdbUtils] unlockScreen: woke up device" << deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, залочен ли экран (mDreamingSleepToken / mShowingLockscreen)
|
||||||
|
QString wmState;
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "window"}, &wmState);
|
||||||
|
const bool isLocked = wmState.contains("mDreamingSleepToken") || wmState.contains("mShowingLockscreen=true")
|
||||||
|
|| wmState.contains("StatusBar");
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
// Свайп вверх для разблокировки
|
||||||
|
const int x = screenWidth / 2;
|
||||||
|
makeSwipe(deviceId, x, screenHeight * 3 / 4, x, screenHeight / 4);
|
||||||
|
QThread::msleep(500);
|
||||||
|
qDebug() << "[AdbUtils] unlockScreen: swiped to unlock" << deviceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@ class AdbUtils final : public QObject {
|
|||||||
public:
|
public:
|
||||||
explicit AdbUtils(QObject *parent = nullptr);
|
explicit AdbUtils(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
static QString adbPath();
|
||||||
|
|
||||||
static QList<QPair<QString, QString> > getListDevices();
|
static QList<QPair<QString, QString> > getListDevices();
|
||||||
|
|
||||||
static QString tryToGetRunningAppPid(const QString &deviceId, const QString &packageName);
|
static QString tryToGetRunningAppPid(const QString &deviceId, const QString &packageName);
|
||||||
@ -17,10 +19,19 @@ public:
|
|||||||
|
|
||||||
static bool tryToKillApplication(const QString &deviceId, const QString &packageName);
|
static bool tryToKillApplication(const QString &deviceId, const QString &packageName);
|
||||||
|
|
||||||
static QString getScreenDumpAsXml(const QString &deviceId, int idx);
|
static QString getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot = false);
|
||||||
|
|
||||||
|
// Сырой дамп без фильтра "useful UI" — для ручного превью оператора:
|
||||||
|
// отдаёт что есть на экране, даже если это PIN-пад / сплэш / WebView-shell.
|
||||||
|
static QString getRawScreenDumpAsXml(const QString &deviceId);
|
||||||
|
|
||||||
static bool takeScreenshot(const QString &deviceId, const QString &name);
|
static bool takeScreenshot(const QString &deviceId, const QString &name);
|
||||||
|
|
||||||
|
// Захватывает скриншот напрямую в память (PNG-байты), без временного файла
|
||||||
|
static QByteArray captureScreenshotBytes(const QString &deviceId);
|
||||||
|
|
||||||
|
static bool takeXmlDump(const QString &deviceId, const QString &name);
|
||||||
|
|
||||||
static bool makeTap(const QString &deviceId, int x, int y);
|
static bool makeTap(const QString &deviceId, int x, int y);
|
||||||
|
|
||||||
static bool enableAdbIMEKeyboard(const QString &deviceId);
|
static bool enableAdbIMEKeyboard(const QString &deviceId);
|
||||||
@ -30,8 +41,26 @@ public:
|
|||||||
static bool disableAdbIMEKeyboard(const QString &deviceId);
|
static bool disableAdbIMEKeyboard(const QString &deviceId);
|
||||||
|
|
||||||
static bool makeSwipe(const QString &deviceId, int x1, int y1, int x2, int y2);
|
static bool makeSwipe(const QString &deviceId, int x1, int y1, int x2, int y2);
|
||||||
|
static bool makeSwipe(const QString &deviceId, int x1, int y1, int x2, int y2, int durationMs);
|
||||||
|
|
||||||
static bool inputText(const QString &deviceId, const QString &text);
|
static bool inputText(const QString &deviceId, const QString &text);
|
||||||
|
|
||||||
|
static bool hideKeyboard(const QString &deviceId);
|
||||||
|
|
||||||
static bool goBack(const QString &deviceId);
|
static bool goBack(const QString &deviceId);
|
||||||
|
|
||||||
|
static void unlockScreen(const QString &deviceId, int screenWidth, int screenHeight);
|
||||||
|
|
||||||
|
static QSet<QString> getListApps(const QString &deviceId);
|
||||||
|
|
||||||
|
// Возвращает таймзону устройства (напр. "Asia/Bangkok"), либо пустую строку при ошибке
|
||||||
|
static QString getDeviceTimezone(const QString &deviceId);
|
||||||
|
|
||||||
|
// Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии
|
||||||
|
static QString getAppVersion(const QString &deviceId, const QString &packageName);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static QString adbDir();
|
||||||
|
static bool startProcess(const QString &program, const QStringList &args,
|
||||||
|
QString *stdOut = nullptr, QString *stdErr = nullptr);
|
||||||
};
|
};
|
||||||
|
|||||||
293
android/black/CommonScript.cpp
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QRandomGenerator>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
CommonScript::CommonScript(QObject *parent)
|
||||||
|
: QObject(parent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
CommonScript::~CommonScript() = default;
|
||||||
|
|
||||||
|
void CommonScript::start() {
|
||||||
|
doStart();
|
||||||
|
emit finished();
|
||||||
|
}
|
||||||
|
|
||||||
|
Node CommonScript::swipeToButton(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &text, int &counter, const int width, const int height
|
||||||
|
) {
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
if (Node button = xmlScreenParser.findButtonNode(text, true); button.x() > 0 && button.y() > 0) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
const int x = width / 2;
|
||||||
|
const int offset = height / 4;
|
||||||
|
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode) {
|
||||||
|
// BlackBanx использует EditText с password=true, а не кнопочную сетку
|
||||||
|
Node editText = xmlScreenParser.findPinCodeEditText();
|
||||||
|
if (editText.isEmpty()) {
|
||||||
|
qWarning() << "[Black::inputPinCode] PIN EditText not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тапаем по полю для фокуса
|
||||||
|
AdbUtils::makeTap(deviceId, editText.x(), editText.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
|
||||||
|
// Вводим PIN через текстовый ввод
|
||||||
|
AdbUtils::inputText(deviceId, pinCode);
|
||||||
|
QThread::msleep(500);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::runAppAndGoToHomeScreen(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &packageName,
|
||||||
|
const QString &pinCode,
|
||||||
|
const int width,
|
||||||
|
const int height
|
||||||
|
) {
|
||||||
|
// Разблокируем экран перед запуском
|
||||||
|
AdbUtils::unlockScreen(deviceId, width, height);
|
||||||
|
|
||||||
|
// Убить приложение если запущено
|
||||||
|
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
|
||||||
|
qDebug() << "[Black] Process already running, pid:" << pid;
|
||||||
|
AdbUtils::getScreenDumpAsXml(deviceId, 1000);
|
||||||
|
AdbUtils::tryToKillApplication(deviceId, packageName);
|
||||||
|
}
|
||||||
|
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
|
||||||
|
AdbUtils::takeScreenshot(deviceId, "run_app_error");
|
||||||
|
qWarning() << "[Black] Process has not started";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Даем время на запуск
|
||||||
|
QThread::msleep(4500);
|
||||||
|
int counter = 0;
|
||||||
|
bool findAndInputPincode = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
counter++;
|
||||||
|
|
||||||
|
if (counter > 10) {
|
||||||
|
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем экран ввода PIN-кода
|
||||||
|
if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) {
|
||||||
|
inputPinCode(deviceId, pinCode);
|
||||||
|
findAndInputPincode = true;
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::postAccountsData(const QList<QPair<MaterialInfo, Node>> &accounts) {
|
||||||
|
if (accounts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonArray list;
|
||||||
|
for (const QPair<MaterialInfo, Node> &pair : accounts) {
|
||||||
|
QJsonObject obj;
|
||||||
|
MaterialInfo account = pair.first;
|
||||||
|
obj["appName"] = account.appCode;
|
||||||
|
obj["lastNumbers"] = account.lastNumbers;
|
||||||
|
obj["amount"] = account.amount;
|
||||||
|
obj["description"] = account.description;
|
||||||
|
obj["currency"] = account.currency;
|
||||||
|
obj["status"] = materialStatusToString(account.status);
|
||||||
|
list.append(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
worker->setDeviceId(accounts.first().first.deviceId);
|
||||||
|
worker->setPayload(QJsonDocument(list).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
|
||||||
|
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction) {
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonObject obj;
|
||||||
|
obj["id"] = transaction.id;
|
||||||
|
obj["amount"] = transaction.amount;
|
||||||
|
obj["name"] = transaction.name;
|
||||||
|
obj["fee"] = transaction.fee;
|
||||||
|
obj["status"] = transactionStatusToString(transaction.status);
|
||||||
|
obj["type"] = transactionTypeToString(transaction.type);
|
||||||
|
obj["phone"] = transaction.phone;
|
||||||
|
obj["description"] = transaction.description;
|
||||||
|
obj["updatedDescription"] = transaction.updatedDescription;
|
||||||
|
obj["bankName"] = transaction.bankName;
|
||||||
|
obj["bankTime"] = transaction.bankTime;
|
||||||
|
obj["bankTransactionId"] = transaction.bankTrExternalId;
|
||||||
|
|
||||||
|
worker->setDeviceId(account.deviceId);
|
||||||
|
worker->addExtra("lastNumbers", account.lastNumbers);
|
||||||
|
worker->addExtra("transactionId", transaction.id);
|
||||||
|
worker->setPayload(QJsonDocument(obj).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::started, worker, &NetworkService::insertTransactionData);
|
||||||
|
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::updateTransactionData(
|
||||||
|
const MaterialInfo &account,
|
||||||
|
const int transactionId,
|
||||||
|
const TransactionStatus status,
|
||||||
|
const QString &oldDesc,
|
||||||
|
const QString &newDesc
|
||||||
|
) {
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonObject obj;
|
||||||
|
obj["id"] = transactionId;
|
||||||
|
obj["status"] = transactionStatusToString(status);
|
||||||
|
obj["description"] = newDesc;
|
||||||
|
obj["updatedDescription"] = oldDesc;
|
||||||
|
|
||||||
|
worker->setDeviceId(account.deviceId);
|
||||||
|
worker->addExtra("lastNumbers", account.lastNumbers);
|
||||||
|
worker->setPayload(QJsonDocument(obj).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::started, worker, &NetworkService::updateTransactionData);
|
||||||
|
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
|
|
||||||
|
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
|
||||||
|
qWarning() << "[Black] createBankProfileIfNeeded: skipping — fullName or phone is empty";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QMap<QString, int> currencyCodes = {
|
||||||
|
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
|
||||||
|
};
|
||||||
|
const int currencyCode = currencyCodes.value(app.currency.toUpper(), 840);
|
||||||
|
const QStringList parts = app.fullName.split(' ', Qt::SkipEmptyParts);
|
||||||
|
|
||||||
|
// Нормализуем телефон: оставляем только цифры, добавляем код страны и "+"
|
||||||
|
const QSettings settings("config.ini", QSettings::IniFormat);
|
||||||
|
const QString phoneCode = settings.value(appCode + "/phone_code").toString();
|
||||||
|
QString phone = app.phone;
|
||||||
|
phone.remove(QRegularExpression("[^0-9]"));
|
||||||
|
if (!phoneCode.isEmpty() && !phone.startsWith(phoneCode)) {
|
||||||
|
if (phone.startsWith('8') && phoneCode != "8") {
|
||||||
|
phone.replace(0, 1, phoneCode);
|
||||||
|
} else {
|
||||||
|
phone.prepend(phoneCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phone.prepend('+');
|
||||||
|
|
||||||
|
const DeviceInfo device = DeviceDAO::getDeviceById(deviceId);
|
||||||
|
|
||||||
|
QJsonObject profileBody;
|
||||||
|
profileBody["first_name"] = parts.value(0);
|
||||||
|
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : QString("");
|
||||||
|
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
|
||||||
|
profileBody["bank_name"] = appCode;
|
||||||
|
profileBody["phone_number"] = phone;
|
||||||
|
const double balance = (parsedBalance >= 0.0) ? parsedBalance : app.amount;
|
||||||
|
profileBody["balance"] = QString::number(qRound64(balance * 100));
|
||||||
|
profileBody["state"] = (app.status == "active") ? "enabled" : "disabled";
|
||||||
|
profileBody["device_id"] = device.apiId;
|
||||||
|
profileBody["currency"] = currencyCode;
|
||||||
|
|
||||||
|
NetworkService ns;
|
||||||
|
ns.setDeviceId(deviceId);
|
||||||
|
ns.setPayload(QJsonDocument(profileBody).toJson());
|
||||||
|
|
||||||
|
if (app.bankProfileId.isEmpty()) {
|
||||||
|
ns.addExtra("app_id", app.id);
|
||||||
|
ns.postBankProfile();
|
||||||
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
|
if (afterPost.bankProfileId.isEmpty()) {
|
||||||
|
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
|
||||||
|
qWarning() << "[Black] createBankProfileIfNeeded:" << err;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
qDebug() << "[Black] createBankProfileIfNeeded: postBankProfile done";
|
||||||
|
} else {
|
||||||
|
ns.addExtra("bank_profile_id", app.bankProfileId);
|
||||||
|
ns.patchBankProfile();
|
||||||
|
qDebug() << "[Black] createBankProfileIfNeeded: patchBankProfile done";
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::stop() {
|
||||||
|
m_running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::closeGooglePlayBannerIfVisible(
|
||||||
|
const QString &deviceId, const int width, const int height
|
||||||
|
) {
|
||||||
|
// 1. Системный диалог запроса разрешения Android (permissioncontroller) — тапаем "ЗАПРЕТИТЬ"
|
||||||
|
if (Node denyBtn = xmlScreenParser.tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
|
||||||
|
qDebug() << "[Black] System permission dialog detected mid-task, tapping 'ЗАПРЕТИТЬ'...";
|
||||||
|
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Google Play in-app update bottom sheet
|
||||||
|
Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height);
|
||||||
|
if (gpNode.isEmpty()) return false;
|
||||||
|
qDebug() << "[Black] Google Play update banner detected mid-task, closing...";
|
||||||
|
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
78
android/black/CommonScript.h
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include "ScreenXmlParser.h"
|
||||||
|
#include "db/TransactionInfo.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
class CommonScript : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit CommonScript(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
~CommonScript() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void doStart() = 0;
|
||||||
|
|
||||||
|
// Ввод PIN-кода через EditText (BlackBanx использует текстовое поле, а не кнопки)
|
||||||
|
bool inputPinCode(const QString &deviceId, const QString &pinCode);
|
||||||
|
|
||||||
|
bool runAppAndGoToHomeScreen(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &packageName,
|
||||||
|
const QString &pinCode,
|
||||||
|
int width,
|
||||||
|
int height
|
||||||
|
);
|
||||||
|
|
||||||
|
Node swipeToButton(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &text,
|
||||||
|
int &counter, int width, int height
|
||||||
|
);
|
||||||
|
|
||||||
|
void postAccountsData(const QList<QPair<MaterialInfo, Node>> &accounts);
|
||||||
|
|
||||||
|
void postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction);
|
||||||
|
|
||||||
|
void updateTransactionData(
|
||||||
|
const MaterialInfo &account,
|
||||||
|
int transactionId,
|
||||||
|
TransactionStatus status,
|
||||||
|
const QString &oldDesc,
|
||||||
|
const QString &newDesc
|
||||||
|
);
|
||||||
|
|
||||||
|
// Создаёт bank_profile на сервере если ещё не создан.
|
||||||
|
// Возвращает пустую строку при успехе, текст ошибки при провале.
|
||||||
|
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
|
||||||
|
|
||||||
|
// Если виден баннер Google Play "Доступно обновление" или системный
|
||||||
|
// диалог разрешений — закрывает его и перепарсивает XML. Безопасно
|
||||||
|
// вызывать на любом экране; возвращает true если что-то было закрыто.
|
||||||
|
bool closeGooglePlayBannerIfVisible(
|
||||||
|
const QString &deviceId, int width, int height
|
||||||
|
);
|
||||||
|
|
||||||
|
bool m_running = true;
|
||||||
|
ScreenXmlParser xmlScreenParser;
|
||||||
|
int m_counter = 0;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void finished();
|
||||||
|
|
||||||
|
void finishedWithResult(const QString &result);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
virtual void start() final;
|
||||||
|
|
||||||
|
virtual void stop() final;
|
||||||
|
|
||||||
|
private:
|
||||||
|
Q_DISABLE_COPY(CommonScript);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
164
android/black/GetCardInfoScript.cpp
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
#include "GetCardInfoScript.h"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
GetCardInfoScript::GetCardInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)),
|
||||||
|
m_appCode(std::move(appCode)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
GetCardInfoScript::~GetCardInfoScript() = default;
|
||||||
|
|
||||||
|
void GetCardInfoScript::doStart() {
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("black", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Читаем текущий экран
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
// Если не на домашнем экране — перезапускаем
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Black::GetCardInfo] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Парсим баланс с домашнего экрана
|
||||||
|
const double balance = xmlScreenParser.parseBalanceFromHomeScreen();
|
||||||
|
qDebug() << "[Black::GetCardInfo] balance from home:" << balance;
|
||||||
|
BankProfileDAO::updateAmount(app.id, balance);
|
||||||
|
|
||||||
|
// 2. Нажимаем "Tu CVU" на домашнем экране
|
||||||
|
Node cvuButton = xmlScreenParser.findButtonNode("Tu CVU", false);
|
||||||
|
if (cvuButton.isEmpty()) {
|
||||||
|
m_error = "Cannot find Tu CVU button: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, cvuButton.x(), cvuButton.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 3. Ждём экран CVU
|
||||||
|
bool cvuScreenFound = false;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isCVUScreen()) {
|
||||||
|
cvuScreenFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cvuScreenFound) {
|
||||||
|
m_error = "CVU screen not detected: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Парсим номер CVU
|
||||||
|
const QString cvuNumber = xmlScreenParser.parseCVUNumber();
|
||||||
|
qDebug() << "[Black::GetCardInfo] CVU number:" << cvuNumber;
|
||||||
|
|
||||||
|
if (cvuNumber.isEmpty()) {
|
||||||
|
m_error = "Cannot parse CVU number: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Сохраняем аккаунт в БД (lastNumbers = последние 4 цифры CVU)
|
||||||
|
const QString lastNumbers = cvuNumber.right(4);
|
||||||
|
|
||||||
|
MaterialInfo account;
|
||||||
|
account.deviceId = device.id;
|
||||||
|
account.appCode = m_appCode;
|
||||||
|
account.lastNumbers = lastNumbers;
|
||||||
|
account.cardNumber = cvuNumber;
|
||||||
|
account.amount = balance;
|
||||||
|
account.currency = "ARS";
|
||||||
|
account.description = "CVU";
|
||||||
|
account.status = MaterialStatus::Off;
|
||||||
|
account.updateTime = QDateTime::currentDateTimeUtc();
|
||||||
|
|
||||||
|
if (!MaterialDAO::upsertAccount(account)) {
|
||||||
|
qWarning() << "[Black::GetCardInfo] Failed to upsert account";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Отправляем материал на сервер (если ещё нет material_id)
|
||||||
|
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;
|
||||||
|
} else {
|
||||||
|
qDebug() << "[Black::GetCardInfo] material already exists for CVU" << lastNumbers << ", skipping";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material";
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::GetCardInfo] Done. CVU:" << cvuNumber << "balance:" << balance;
|
||||||
|
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
27
android/black/GetCardInfoScript.h
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
class GetCardInfoScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit GetCardInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
~GetCardInfoScript() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
166
android/black/GetLastTransactionsScript.cpp
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
#include "GetLastTransactionsScript.h"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
#include <QTimeZone>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/TransactionDAO.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
GetLastTransactionsScript::GetLastTransactionsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
GetLastTransactionsScript::~GetLastTransactionsScript() = default;
|
||||||
|
|
||||||
|
void GetLastTransactionsScript::doStart() {
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Если не на домашнем экране — перезапускаем приложение
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Black::GetLastTransactions] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("black/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тапаем "Ver más" рядом с "Tu actividad" для перехода на экран транзакций
|
||||||
|
Node verMasBtn = xmlScreenParser.findVerMasAfterTuActividad();
|
||||||
|
if (verMasBtn.isEmpty()) {
|
||||||
|
// Попробуем проскроллить вниз чтобы увидеть "Tu actividad" + "Ver más"
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
const int x = device.screenWidth / 2;
|
||||||
|
const int offset = device.screenHeight / 4;
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
verMasBtn = xmlScreenParser.findVerMasAfterTuActividad();
|
||||||
|
if (!verMasBtn.isEmpty()) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verMasBtn.isEmpty()) {
|
||||||
|
m_error = "Button 'Ver más' not found: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::GetLastTransactions] Tapping 'Ver más' at" << verMasBtn.x() << verMasBtn.y();
|
||||||
|
AdbUtils::makeTap(m_deviceId, verMasBtn.x(), verMasBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// Ждём загрузки экрана транзакций
|
||||||
|
// Сперва появляется экран загрузки (Tu Actividad без транзакций),
|
||||||
|
// затем подгружаются транзакции
|
||||||
|
bool loaded = false;
|
||||||
|
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isTransactionListScreen()) {
|
||||||
|
loaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (xmlScreenParser.isTransactionLoadingScreen()) {
|
||||||
|
qDebug() << "[Black::GetLastTransactions] Loading screen detected, waiting for transactions, attempt" << attempt + 1 << "/ 15";
|
||||||
|
} else {
|
||||||
|
qDebug() << "[Black::GetLastTransactions] Waiting for transaction screen, attempt" << attempt + 1 << "/ 15";
|
||||||
|
}
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
// Проверяем: если экран загрузки есть, но транзакций нет — это нормально (пустой аккаунт)
|
||||||
|
if (xmlScreenParser.isTransactionLoadingScreen()) {
|
||||||
|
qDebug() << "[Black::GetLastTransactions] No transactions found (empty account)";
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_error = "Transaction list screen not loaded: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим accountId для сохранения транзакций
|
||||||
|
int accountId = -1;
|
||||||
|
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||||
|
if (!accounts.isEmpty()) {
|
||||||
|
accountId = accounts.first().id;
|
||||||
|
qDebug() << "[Black::GetLastTransactions] Using accountId:" << accountId;
|
||||||
|
} else {
|
||||||
|
qWarning() << "[Black::GetLastTransactions] No account found for" << m_deviceId << m_appCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Берём все транзакции что видны на экране
|
||||||
|
const QTimeZone argTz("America/Argentina/Buenos_Aires");
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
QList<TransactionInfo> pageItems = xmlScreenParser.parseTransactionItems();
|
||||||
|
|
||||||
|
qDebug() << "[Black::GetLastTransactions] Found" << pageItems.size() << "transactions on screen";
|
||||||
|
|
||||||
|
for (const TransactionInfo &tx : pageItems) {
|
||||||
|
TransactionInfo saveTx = tx;
|
||||||
|
saveTx.bankName = "black";
|
||||||
|
saveTx.status = TransactionStatus::Complete;
|
||||||
|
saveTx.timestamp = QDateTime::currentDateTimeUtc();
|
||||||
|
|
||||||
|
// Парсим дату из bankTime: "21 de febrero de 2026 - 09:39"
|
||||||
|
QDateTime parsed = xmlScreenParser.parseSpanishDate(saveTx.bankTime);
|
||||||
|
if (parsed.isValid()) {
|
||||||
|
parsed.setTimeZone(argTz);
|
||||||
|
saveTx.completeTime = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::GetLastTransactions]"
|
||||||
|
<< "date:" << saveTx.bankTime
|
||||||
|
<< "desc:" << saveTx.description
|
||||||
|
<< "name:" << saveTx.name
|
||||||
|
<< "amount:" << (saveTx.amount >= 0 ? "+" : "") + QString::number(saveTx.amount, 'f', 2);
|
||||||
|
|
||||||
|
// Сохраняем в БД
|
||||||
|
if (accountId != -1) {
|
||||||
|
saveTx.accountId = accountId;
|
||||||
|
if (TransactionDAO::insertTransactionIfNotExists(saveTx)) {
|
||||||
|
qDebug() << "[Black::GetLastTransactions] Saved transaction to DB";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::GetLastTransactions] Total transactions:" << pageItems.size();
|
||||||
|
|
||||||
|
// Возвращаемся на домашний экран
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
27
android/black/GetLastTransactionsScript.h
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
class GetLastTransactionsScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit GetLastTransactionsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
~GetLastTransactionsScript() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
188
android/black/GetProfileInfoScript.cpp
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
#include "GetProfileInfoScript.h"
|
||||||
|
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
GetProfileInfoScript::GetProfileInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)),
|
||||||
|
m_appCode(std::move(appCode)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
GetProfileInfoScript::~GetProfileInfoScript() = default;
|
||||||
|
|
||||||
|
void GetProfileInfoScript::doStart() {
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Читаем текущий экран
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Если не на домашнем экране — перезапускаем
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Black::GetProfileInfo] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Тапаем по кнопке "¡Hola!" (аватар с именем) на домашнем экране
|
||||||
|
Node holaNode = xmlScreenParser.findHolaButton();
|
||||||
|
if (holaNode.isEmpty()) {
|
||||||
|
m_error = "Cannot find Hola button on home screen: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим имя и баланс с домашнего экрана
|
||||||
|
const QString fullName = xmlScreenParser.parseUserNameFromHomeScreen();
|
||||||
|
const double balance = xmlScreenParser.parseBalanceFromHomeScreen();
|
||||||
|
qDebug() << "[Black::GetProfileInfo] fullName from home:" << fullName << "balance:" << balance;
|
||||||
|
|
||||||
|
// Сохраняем баланс
|
||||||
|
BankProfileDAO::updateAmount(app.id, balance);
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, holaNode.x(), holaNode.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 2. Ждём боковое меню
|
||||||
|
bool sideMenuFound = false;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isSideMenu()) {
|
||||||
|
sideMenuFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sideMenuFound) {
|
||||||
|
m_error = "Side menu not detected: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Тапаем "Tu Información"
|
||||||
|
Node tuInfoNode = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Tu Información"));
|
||||||
|
if (tuInfoNode.isEmpty()) {
|
||||||
|
m_error = "Cannot find Tu Información button: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, tuInfoNode.x(), tuInfoNode.y());
|
||||||
|
QThread::msleep(2500);
|
||||||
|
|
||||||
|
// 4. Ждём страницу профиля "Tu información"
|
||||||
|
bool profileFound = false;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isProfilePage()) {
|
||||||
|
profileFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profileFound) {
|
||||||
|
m_error = "Profile page not detected: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Парсим данные профиля (первый экран: nombre, apellido, email)
|
||||||
|
const QString nombre = xmlScreenParser.parseProfileNombre();
|
||||||
|
const QString apellido = xmlScreenParser.parseProfileApellido();
|
||||||
|
const QString email = xmlScreenParser.parseProfileEmail();
|
||||||
|
|
||||||
|
// 6. Скроллим вниз чтобы увидеть телефон
|
||||||
|
const int x = device.screenWidth / 2;
|
||||||
|
const int fromY = device.screenHeight * 3 / 4;
|
||||||
|
const int toY = device.screenHeight / 4;
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
const QString phone = xmlScreenParser.parseProfilePhone();
|
||||||
|
|
||||||
|
// Собираем полное имя: "имя фамилия"
|
||||||
|
QString parsedFullName;
|
||||||
|
if (!nombre.isEmpty() && !apellido.isEmpty()) {
|
||||||
|
parsedFullName = nombre + " " + apellido;
|
||||||
|
} else if (!nombre.isEmpty()) {
|
||||||
|
parsedFullName = nombre;
|
||||||
|
} else {
|
||||||
|
parsedFullName = fullName; // fallback с домашнего экрана
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::GetProfileInfo] nombre:" << nombre
|
||||||
|
<< "apellido:" << apellido
|
||||||
|
<< "email:" << email
|
||||||
|
<< "phone:" << phone
|
||||||
|
<< "fullName:" << parsedFullName;
|
||||||
|
|
||||||
|
// 7. Сохраняем в БД
|
||||||
|
if (!BankProfileDAO::updateProfileInfo(app.id, parsedFullName, phone, email)) {
|
||||||
|
qWarning() << "[Black::GetProfileInfo] Failed to save profile info to DB";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Создаём bank profile если нет
|
||||||
|
if (const QString err = createBankProfileIfNeeded(m_deviceId, m_appCode); !err.isEmpty()) {
|
||||||
|
m_error = err;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Отправляем данные аккаунтов на сервер если нет material_id
|
||||||
|
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||||
|
QList<QPair<MaterialInfo, Node>> accountsToPost;
|
||||||
|
for (const auto &acc : allAccounts) {
|
||||||
|
if (acc.materialId.isEmpty()) {
|
||||||
|
accountsToPost.append({acc, Node()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!accountsToPost.isEmpty()) {
|
||||||
|
postAccountsData(accountsToPost);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
27
android/black/GetProfileInfoScript.h
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
class GetProfileInfoScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
~GetProfileInfoScript() override;
|
||||||
|
|
||||||
|
explicit GetProfileInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
68
android/black/LoginAndCheckAccountsScript.cpp
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
#include "LoginAndCheckAccountsScript.h"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
#include "GetProfileInfoScript.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
LoginAndCheckAccountsScript::LoginAndCheckAccountsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent,
|
||||||
|
bool pinCheckOnly
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)),
|
||||||
|
m_appCode(std::move(appCode)),
|
||||||
|
m_pinCheckOnly(pinCheckOnly) {
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
|
||||||
|
|
||||||
|
void LoginAndCheckAccountsScript::doStart() {
|
||||||
|
// m_deviceId = ADB serial, device.id = android_id (для БД)
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("black", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cant open the home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("black/LoginAndCheck", m_deviceId, m_error, {}, "FETCH_PROFILE");
|
||||||
|
BankProfileDAO::updateComment(app.id,
|
||||||
|
"Не прошла проверка " + QDateTime::currentDateTime().toString("dd.MM.yyyy HH:mm:ss"));
|
||||||
|
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_pinCheckOnly) {
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем профиль и аккаунты (приложение уже на home screen)
|
||||||
|
GetProfileInfoScript profileScript(m_deviceId, m_appCode);
|
||||||
|
profileScript.start();
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
29
android/black/LoginAndCheckAccountsScript.h
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
class LoginAndCheckAccountsScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
~LoginAndCheckAccountsScript() override;
|
||||||
|
|
||||||
|
explicit LoginAndCheckAccountsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent = nullptr,
|
||||||
|
bool pinCheckOnly = false
|
||||||
|
);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
const bool m_pinCheckOnly;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
384
android/black/PayByCardScript.cpp
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
#include "PayByCardScript.h"
|
||||||
|
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/TransactionDAO.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
PayByCardScript::PayByCardScript(
|
||||||
|
MaterialInfo account,
|
||||||
|
QString cardNumber,
|
||||||
|
double amount,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_account(std::move(account)),
|
||||||
|
m_cardNumber(std::move(cardNumber)),
|
||||||
|
m_amount(amount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
PayByCardScript::~PayByCardScript() = default;
|
||||||
|
|
||||||
|
void PayByCardScript::doStart() {
|
||||||
|
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_account.deviceId;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
makePayment(device.adbSerial, app.pinCode, device.screenWidth, device.screenHeight);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PayByCardScript::makePayment(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &pinCode,
|
||||||
|
const int width,
|
||||||
|
const int height
|
||||||
|
) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// 0. Убеждаемся что на домашнем экране
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Black::PayByCard] Not on home screen, restarting app...";
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
|
||||||
|
if (!runAppAndGoToHomeScreen(deviceId, app.package, pinCode, width, height)) {
|
||||||
|
m_error = "Cannot reach home screen: " + DeviceDAO::getDeviceById(m_account.deviceId).name + " (" + deviceId + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("black/PayByCard", deviceId, m_error, {}, "CREATE_TRANSACTION");
|
||||||
|
AdbUtils::tryToKillApplication(deviceId, app.package);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Тапаем "Enviar a\nCBU o alias" на домашнем экране
|
||||||
|
Node enviarBtn = xmlScreenParser.findNodeByContentDesc("Enviar a\nCBU o alias", true);
|
||||||
|
if (enviarBtn.isEmpty()) {
|
||||||
|
// Попробуем проскроллить
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
const int x = width / 2;
|
||||||
|
const int offset = height / 4;
|
||||||
|
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
enviarBtn = xmlScreenParser.findNodeByContentDesc("Enviar a\nCBU o alias", true);
|
||||||
|
if (!enviarBtn.isEmpty()) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enviarBtn.isEmpty()) {
|
||||||
|
m_error = "Button 'Enviar a CBU o alias' not found: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("black/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::PayByCard] Tapping 'Enviar a CBU o alias'";
|
||||||
|
AdbUtils::makeTap(deviceId, enviarBtn.x(), enviarBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 2. Ждём экран ввода номера карты (CBU/CVU/Alias)
|
||||||
|
bool onInputScreen = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isPayInputCardNumberScreen()) {
|
||||||
|
onInputScreen = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Black::PayByCard] Waiting for input card screen, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onInputScreen) {
|
||||||
|
m_error = "Input card number screen not loaded: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("black/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вводим номер карты в EditText
|
||||||
|
Node editText = xmlScreenParser.findEditTextByHint("Ingresar CBU, CVU o Alias");
|
||||||
|
if (editText.isEmpty()) {
|
||||||
|
editText = xmlScreenParser.findFirstEditText();
|
||||||
|
}
|
||||||
|
if (editText.isEmpty()) {
|
||||||
|
m_error = "EditText for card number not found: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, editText.x(), editText.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
AdbUtils::inputText(deviceId, m_cardNumber);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
// Нажимаем "Continuar"
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
Node continuarBtn = xmlScreenParser.findNodeByContentDesc("Continuar");
|
||||||
|
if (continuarBtn.isEmpty()) {
|
||||||
|
continuarBtn = xmlScreenParser.findButtonNode("Continuar", false);
|
||||||
|
}
|
||||||
|
if (continuarBtn.isEmpty()) {
|
||||||
|
m_error = "Button 'Continuar' not found on input card screen: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::PayByCard] Tapping 'Continuar' after card number input";
|
||||||
|
AdbUtils::makeTap(deviceId, continuarBtn.x(), continuarBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 3. Ждём экран подтверждения получателя (pay_transfer_confirm)
|
||||||
|
bool onTransferConfirm = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isPayTransferConfirmScreen()) {
|
||||||
|
onTransferConfirm = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Black::PayByCard] Waiting for transfer confirm screen, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onTransferConfirm) {
|
||||||
|
m_error = "Transfer confirm screen not loaded: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("black/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нажимаем "Transferir"
|
||||||
|
Node transferirBtn = xmlScreenParser.findNodeByContentDesc("Transferir");
|
||||||
|
if (transferirBtn.isEmpty()) {
|
||||||
|
transferirBtn = xmlScreenParser.findButtonNode("Transferir", false);
|
||||||
|
}
|
||||||
|
if (transferirBtn.isEmpty()) {
|
||||||
|
m_error = "Button 'Transferir' not found on transfer confirm screen: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::PayByCard] Tapping 'Transferir' on confirm screen";
|
||||||
|
AdbUtils::makeTap(deviceId, transferirBtn.x(), transferirBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 4. Ждём экран ввода суммы (pay_input_sum)
|
||||||
|
bool onInputSum = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isPayInputSumScreen()) {
|
||||||
|
onInputSum = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Black::PayByCard] Waiting for input sum screen, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onInputSum) {
|
||||||
|
m_error = "Input sum screen not loaded: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("black/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вводим сумму в EditText
|
||||||
|
Node sumEditText = xmlScreenParser.findFirstEditText();
|
||||||
|
if (sumEditText.isEmpty()) {
|
||||||
|
m_error = "EditText for sum not found: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
|
||||||
|
// Удаляем содержимое поля (там "0,00") — удаляем пока не станет "0,00" или пусто
|
||||||
|
for (int d = 0; d < 20; ++d) {
|
||||||
|
QProcess proc;
|
||||||
|
proc.start(AdbUtils::adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "67"});
|
||||||
|
proc.waitForFinished(2000);
|
||||||
|
}
|
||||||
|
QThread::msleep(500);
|
||||||
|
|
||||||
|
// Проверяем что осталось в поле после очистки
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
Node afterClear = xmlScreenParser.findFirstEditText();
|
||||||
|
qDebug() << "[Black::PayByCard] After clear, field text:" << afterClear.text;
|
||||||
|
|
||||||
|
// Поле работает как ввод в центах: каждая цифра сдвигает значение влево
|
||||||
|
// Для 500,00 нужно ввести "50000"
|
||||||
|
const QString amountStr = QString::number(static_cast<int>(m_amount * 100));
|
||||||
|
AdbUtils::inputText(deviceId, amountStr);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
// Проверяем что сумма ввелась верно
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
Node currentEdit = xmlScreenParser.findFirstEditText();
|
||||||
|
qDebug() << "[Black::PayByCard] Sum field text:" << currentEdit.text;
|
||||||
|
|
||||||
|
// Если сумма неверная — пробуем ещё раз: удаляем всё и вводим заново
|
||||||
|
const QString expectedAmount = QString::number(static_cast<int>(m_amount));
|
||||||
|
QString cleanText = currentEdit.text.trimmed();
|
||||||
|
cleanText.remove('.');
|
||||||
|
if (cleanText.endsWith(",00")) cleanText.chop(3);
|
||||||
|
if (cleanText != expectedAmount) {
|
||||||
|
qDebug() << "[Black::PayByCard] Sum mismatch (" << cleanText << "!=" << expectedAmount << "), retrying...";
|
||||||
|
AdbUtils::makeTap(deviceId, currentEdit.x(), currentEdit.y());
|
||||||
|
QThread::msleep(300);
|
||||||
|
for (int d = 0; d < 20; ++d) {
|
||||||
|
QProcess proc;
|
||||||
|
proc.start(AdbUtils::adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "67"});
|
||||||
|
proc.waitForFinished(2000);
|
||||||
|
}
|
||||||
|
QThread::msleep(500);
|
||||||
|
AdbUtils::inputText(deviceId, amountStr);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
currentEdit = xmlScreenParser.findFirstEditText();
|
||||||
|
qDebug() << "[Black::PayByCard] Sum field text (retry):" << currentEdit.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нажимаем "Continuar"
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
Node continuarSumBtn = xmlScreenParser.findNodeByContentDesc("Continuar");
|
||||||
|
if (continuarSumBtn.isEmpty()) {
|
||||||
|
continuarSumBtn = xmlScreenParser.findButtonNode("Continuar", false);
|
||||||
|
}
|
||||||
|
if (continuarSumBtn.isEmpty()) {
|
||||||
|
m_error = "Button 'Continuar' not found on input sum screen: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::PayByCard] Tapping 'Continuar' after sum input";
|
||||||
|
AdbUtils::makeTap(deviceId, continuarSumBtn.x(), continuarSumBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 5. Ждём экран финального подтверждения (pay_confirm)
|
||||||
|
bool onPayConfirm = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isPayConfirmScreen()) {
|
||||||
|
onPayConfirm = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Black::PayByCard] Waiting for pay confirm screen, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onPayConfirm) {
|
||||||
|
m_error = "Pay confirm screen not loaded: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("black/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим имя получателя из "Vas a transferir \na ИМЯ"
|
||||||
|
const QString recipientName = xmlScreenParser.parseRecipientNameFromConfirm();
|
||||||
|
qDebug() << "[Black::PayByCard] Recipient name:" << recipientName;
|
||||||
|
|
||||||
|
// Нажимаем "Transferir" для финального подтверждения
|
||||||
|
Node finalTransferirBtn = xmlScreenParser.findNodeByContentDesc("Transferir");
|
||||||
|
if (finalTransferirBtn.isEmpty()) {
|
||||||
|
finalTransferirBtn = xmlScreenParser.findButtonNode("Transferir", false);
|
||||||
|
}
|
||||||
|
if (finalTransferirBtn.isEmpty()) {
|
||||||
|
m_error = "Button 'Transferir' not found on pay confirm screen: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::PayByCard] Tapping final 'Transferir'";
|
||||||
|
AdbUtils::makeTap(deviceId, finalTransferirBtn.x(), finalTransferirBtn.y());
|
||||||
|
QThread::msleep(3000);
|
||||||
|
|
||||||
|
// 5.5. Проверяем экран ввода PIN-кода
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isPinCodeScreen()) {
|
||||||
|
qDebug() << "[Black::PayByCard] PIN code screen detected, entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinCode);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Ждём экран успеха (pay_success) — "Dinero enviado!"
|
||||||
|
bool onSuccess = false;
|
||||||
|
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isPaySuccessScreen()) {
|
||||||
|
onSuccess = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Если PIN-код появился позже — вводим
|
||||||
|
if (xmlScreenParser.isPinCodeScreen()) {
|
||||||
|
qDebug() << "[Black::PayByCard] PIN code screen detected (retry), entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinCode);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
qDebug() << "[Black::PayByCard] Waiting for success screen, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onSuccess) {
|
||||||
|
m_error = "Pay success screen not loaded: " + deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("black/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Black::PayByCard] Payment successful!";
|
||||||
|
|
||||||
|
// Парсим данные с экрана успеха
|
||||||
|
const QString bankTime = xmlScreenParser.parsePaySuccessDateTime();
|
||||||
|
const QString coelsaId = xmlScreenParser.parsePaySuccessCoelsaId();
|
||||||
|
qDebug() << "[Black::PayByCard] DateTime:" << bankTime << "Id Coelsa:" << coelsaId;
|
||||||
|
|
||||||
|
// Делаем скриншот экрана успеха как receipt
|
||||||
|
const QByteArray receiptImage = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
|
||||||
|
// Сохраняем транзакцию
|
||||||
|
TransactionInfo tx;
|
||||||
|
tx.accountId = m_account.id;
|
||||||
|
tx.amount = -m_amount; // исходящий перевод
|
||||||
|
tx.fee = 0;
|
||||||
|
tx.status = TransactionStatus::Complete;
|
||||||
|
tx.type = TransactionType::Card;
|
||||||
|
tx.name = recipientName;
|
||||||
|
tx.description = "Transferencia a " + recipientName;
|
||||||
|
tx.phone = m_cardNumber;
|
||||||
|
tx.bankName = "black";
|
||||||
|
tx.bankTime = bankTime;
|
||||||
|
tx.bankTrExternalId = coelsaId;
|
||||||
|
tx.timestamp = QDateTime::currentDateTimeUtc();
|
||||||
|
tx.completeTime = QDateTime::currentDateTimeUtc();
|
||||||
|
tx.receiptImage = receiptImage;
|
||||||
|
|
||||||
|
const int txId = TransactionDAO::insertTransaction(tx);
|
||||||
|
if (txId != -1) {
|
||||||
|
qDebug() << "[Black::PayByCard] Transaction saved to DB, id:" << txId;
|
||||||
|
if (!receiptImage.isEmpty()) {
|
||||||
|
TransactionDAO::updateReceiptImage(txId, receiptImage);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning() << "[Black::PayByCard] Failed to save transaction to DB";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
36
android/black/PayByCardScript.h
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
class PayByCardScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
PayByCardScript(
|
||||||
|
MaterialInfo account,
|
||||||
|
QString cardNumber,
|
||||||
|
double amount,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
~PayByCardScript() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const MaterialInfo m_account;
|
||||||
|
const QString m_cardNumber;
|
||||||
|
const double m_amount;
|
||||||
|
QString m_error;
|
||||||
|
|
||||||
|
void makePayment(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &pinCode,
|
||||||
|
int width,
|
||||||
|
int height
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
578
android/black/ScreenXmlParser.cpp
Normal file
@ -0,0 +1,578 @@
|
|||||||
|
#include "ScreenXmlParser.h"
|
||||||
|
|
||||||
|
#include <QMap>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QXmlStreamReader>
|
||||||
|
#include <QDomDocument>
|
||||||
|
|
||||||
|
#include "android/xml/Node.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
static const QRegularExpression boundsRegex(R"(\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\])");
|
||||||
|
|
||||||
|
ScreenXmlParser::ScreenXmlParser(QObject *parent) : QObject(parent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenXmlParser::~ScreenXmlParser() = default;
|
||||||
|
|
||||||
|
void ScreenXmlParser::parseAndSaveXml(const QString &xml) {
|
||||||
|
QDomDocument doc;
|
||||||
|
doc.setContent(xml);
|
||||||
|
m_xml = doc.documentElement();
|
||||||
|
m_nodes.clear();
|
||||||
|
|
||||||
|
QXmlStreamReader reader(xml);
|
||||||
|
while (!reader.atEnd()) {
|
||||||
|
reader.readNext();
|
||||||
|
if (reader.isStartElement() && reader.name() == u"node") {
|
||||||
|
Node node;
|
||||||
|
if (reader.attributes().hasAttribute("bounds")) {
|
||||||
|
QString bounds = reader.attributes().value("bounds").toString();
|
||||||
|
if (QRegularExpressionMatch match = boundsRegex.match(bounds); match.hasMatch()) {
|
||||||
|
node.setBounds(
|
||||||
|
match.captured(1).toInt(),
|
||||||
|
match.captured(2).toInt(),
|
||||||
|
match.captured(3).toInt(),
|
||||||
|
match.captured(4).toInt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("text")) {
|
||||||
|
node.text = reader.attributes().value("text").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("content-desc")) {
|
||||||
|
node.contentDesc = reader.attributes().value("content-desc").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("class")) {
|
||||||
|
node.className = reader.attributes().value("class").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("clickable")) {
|
||||||
|
node.clickable = reader.attributes().value("clickable").toString() == "true";
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("focusable")) {
|
||||||
|
node.focusable = reader.attributes().value("focusable").toString() == "true";
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("resource-id")) {
|
||||||
|
node.resourceId = reader.attributes().value("resource-id").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("hint")) {
|
||||||
|
node.hint = reader.attributes().value("hint").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("password")) {
|
||||||
|
node.password = reader.attributes().value("password").toString() == "true";
|
||||||
|
}
|
||||||
|
m_nodes.append(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QList<Node> &ScreenXmlParser::findAllNodes() const {
|
||||||
|
return m_nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findTextNode(const QString &text) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.text.trimmed() == text) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == contentDesc) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc, bool contains) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (contains) {
|
||||||
|
if (node.contentDesc.contains(contentDesc)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (node.contentDesc == contentDesc) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findButtonNode(const QString &text, bool contains) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (!node.clickable) continue;
|
||||||
|
if (contains) {
|
||||||
|
if (node.text.contains(text) || node.contentDesc.contains(text)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (node.text.trimmed() == text || node.contentDesc == text) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findFirstEditText() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.EditText") {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isPinCodeScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains(QString::fromUtf8("¿Olvidaste tu PIN?"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (node.contentDesc.contains(QString::fromUtf8("Ingresá tu PIN"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findPinCodeEditText() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.EditText" && node.password) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isHomeScreen() {
|
||||||
|
bool hasSaldo = false;
|
||||||
|
bool hasInicio = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "Saldo disponible") {
|
||||||
|
hasSaldo = true;
|
||||||
|
}
|
||||||
|
if (node.contentDesc == "Inicio") {
|
||||||
|
hasInicio = true;
|
||||||
|
}
|
||||||
|
if (hasSaldo && hasInicio) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isSideMenu() {
|
||||||
|
bool hasTuInfo = false;
|
||||||
|
bool hasCerrar = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Tu Información")) {
|
||||||
|
hasTuInfo = true;
|
||||||
|
}
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Cerrar sesión")) {
|
||||||
|
hasCerrar = true;
|
||||||
|
}
|
||||||
|
if (hasTuInfo && hasCerrar) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findHolaButton() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.clickable && node.contentDesc.startsWith(QString::fromUtf8("¡Hola!"))) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseUserNameFromHomeScreen() {
|
||||||
|
// content-desc: "¡Hola!\nveronica cintia heredia \nvc"
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.startsWith(QString::fromUtf8("¡Hola!"))) {
|
||||||
|
QStringList lines = node.contentDesc.split('\n');
|
||||||
|
if (lines.size() >= 2) {
|
||||||
|
return lines[1].trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
double ScreenXmlParser::parseBalanceFromHomeScreen() {
|
||||||
|
// Баланс идёт после "Saldo disponible", формат: "$1.857,00" или "$0,00"
|
||||||
|
bool afterSaldo = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "Saldo disponible") {
|
||||||
|
afterSaldo = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (afterSaldo && node.contentDesc.startsWith("$")) {
|
||||||
|
// "$1.857,00" → "1857.00"
|
||||||
|
QString raw = node.contentDesc.mid(1); // убираем "$"
|
||||||
|
raw.remove('.'); // убираем разделитель тысяч
|
||||||
|
raw.replace(',', '.'); // запятая → точка
|
||||||
|
bool ok = false;
|
||||||
|
double val = raw.toDouble(&ok);
|
||||||
|
if (ok) return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isProfilePage() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Tu información")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseProfileNombre() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.hint == "Nombre" && !node.text.isEmpty()) {
|
||||||
|
return node.text.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseProfileApellido() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.hint == "Apellido" && !node.text.isEmpty()) {
|
||||||
|
return node.text.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseProfileEmail() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.hint == "E-mail" && !node.text.isEmpty()) {
|
||||||
|
return node.text.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseProfilePhone() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.hint == QString::fromUtf8("Teléfono celular") && !node.text.isEmpty()) {
|
||||||
|
return node.text.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isCVUScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "CVU") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseCVUNumber() {
|
||||||
|
// CVU номер идёт после content-desc="CVU", это 22-значный номер
|
||||||
|
bool afterCVU = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "CVU") {
|
||||||
|
afterCVU = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (afterCVU && !node.contentDesc.isEmpty()) {
|
||||||
|
// Проверяем что это числовая строка (CVU — 22 цифры)
|
||||||
|
QString cvu = node.contentDesc.trimmed();
|
||||||
|
bool allDigits = true;
|
||||||
|
for (const QChar &c : cvu) {
|
||||||
|
if (!c.isDigit()) {
|
||||||
|
allDigits = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allDigits && !cvu.isEmpty()) {
|
||||||
|
return cvu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findVerMasAfterTuActividad() {
|
||||||
|
// "Ver más" идёт сразу после "Tu actividad" на домашнем экране
|
||||||
|
bool afterTuActividad = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "Tu actividad") {
|
||||||
|
afterTuActividad = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (afterTuActividad && node.contentDesc == QString::fromUtf8("Ver más") && node.clickable) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
// Если встретили другую секцию — прекращаем поиск
|
||||||
|
if (afterTuActividad && node.contentDesc == "Tarjetas") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isTransactionLoadingScreen() {
|
||||||
|
// Экран загрузки: заголовок "Tu Actividad" есть, но транзакций нет
|
||||||
|
// Может содержать "Aún no tienes actividad"
|
||||||
|
bool hasTuActividad = false;
|
||||||
|
bool hasTransferencia = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "Tu Actividad") {
|
||||||
|
hasTuActividad = true;
|
||||||
|
}
|
||||||
|
if (node.contentDesc.startsWith("Transferencia")) {
|
||||||
|
hasTransferencia = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasTuActividad && !hasTransferencia;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isTransactionListScreen() {
|
||||||
|
// Экран транзакций определяется по заголовку "Tu Actividad" (с большой A)
|
||||||
|
// + наличие транзакций (ImageView с "Transferencia")
|
||||||
|
// На домашнем экране — "Tu actividad" (с маленькой a)
|
||||||
|
bool hasTuActividad = false;
|
||||||
|
bool hasTransferencia = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "Tu Actividad") {
|
||||||
|
hasTuActividad = true;
|
||||||
|
}
|
||||||
|
if (node.contentDesc.startsWith("Transferencia")) {
|
||||||
|
hasTransferencia = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasTuActividad && hasTransferencia;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<TransactionInfo> ScreenXmlParser::parseTransactionItems() {
|
||||||
|
// Транзакции — ImageView с content-desc формата:
|
||||||
|
// "Transferencia\n21 de febrero de 2026 - 09:39\n$-1.857,\n00"
|
||||||
|
// "Transferencia externa de NOELIA ISABEL LEZCANO\n21 de febrero de 2026 - 09:35\n$123,\n45"
|
||||||
|
QList<TransactionInfo> result;
|
||||||
|
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.ImageView") continue;
|
||||||
|
if (!node.contentDesc.startsWith("Transferencia")) continue;
|
||||||
|
|
||||||
|
QStringList lines = node.contentDesc.split('\n');
|
||||||
|
if (lines.size() < 3) continue;
|
||||||
|
|
||||||
|
TransactionInfo tx;
|
||||||
|
|
||||||
|
// Строка 1: описание, может содержать имя отправителя
|
||||||
|
// "Transferencia" или "Transferencia externa de NOELIA ISABEL LEZCANO"
|
||||||
|
const QString descLine = lines[0].trimmed();
|
||||||
|
tx.description = descLine;
|
||||||
|
if (descLine.contains("externa de ")) {
|
||||||
|
tx.name = descLine.mid(descLine.indexOf("externa de ") + 11).trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка 2: дата "21 de febrero de 2026 - 09:39"
|
||||||
|
tx.bankTime = lines[1].trimmed();
|
||||||
|
|
||||||
|
// Строки 3+: сумма "$-1.857,\n00" → объединяем
|
||||||
|
QString amountStr;
|
||||||
|
for (int i = 2; i < lines.size(); ++i) {
|
||||||
|
amountStr += lines[i].trimmed();
|
||||||
|
}
|
||||||
|
// "$-1.857,00" → убираем "$", "." (тысячный разделитель), заменяем "," на "."
|
||||||
|
amountStr.remove('$');
|
||||||
|
amountStr.remove(' ');
|
||||||
|
bool isNegative = amountStr.contains('-');
|
||||||
|
amountStr.remove('-');
|
||||||
|
amountStr.remove('.'); // тысячный разделитель
|
||||||
|
amountStr.replace(',', '.');
|
||||||
|
bool ok = false;
|
||||||
|
tx.amount = amountStr.toDouble(&ok);
|
||||||
|
if (ok && isNegative) tx.amount = -tx.amount;
|
||||||
|
|
||||||
|
tx.type = TransactionType::Card;
|
||||||
|
|
||||||
|
result.append(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QDateTime ScreenXmlParser::parseSpanishDate(const QString &dateStr) {
|
||||||
|
// Формат: "21 de febrero de 2026 - 09:39"
|
||||||
|
static const QMap<QString, int> spanishMonths = {
|
||||||
|
{"enero", 1}, {"febrero", 2}, {"marzo", 3}, {"abril", 4},
|
||||||
|
{"mayo", 5}, {"junio", 6}, {"julio", 7}, {"agosto", 8},
|
||||||
|
{"septiembre", 9}, {"octubre", 10}, {"noviembre", 11}, {"diciembre", 12}
|
||||||
|
};
|
||||||
|
|
||||||
|
QRegularExpression re(R"((\d{1,2})\s+de\s+(\w+)\s+de\s+(\d{4})\s*-\s*(\d{2}):(\d{2}))");
|
||||||
|
QRegularExpressionMatch match = re.match(dateStr);
|
||||||
|
if (!match.hasMatch()) return {};
|
||||||
|
|
||||||
|
const int day = match.captured(1).toInt();
|
||||||
|
const QString monthName = match.captured(2).toLower();
|
||||||
|
const int year = match.captured(3).toInt();
|
||||||
|
const int hour = match.captured(4).toInt();
|
||||||
|
const int minute = match.captured(5).toInt();
|
||||||
|
|
||||||
|
const int month = spanishMonths.value(monthName, 0);
|
||||||
|
if (month == 0) return {};
|
||||||
|
|
||||||
|
return QDateTime(QDate(year, month, day), QTime(hour, minute));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isPayInputCardNumberScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.hint == "Ingresar CBU, CVU o Alias") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isPayTransferConfirmScreen() {
|
||||||
|
// Экран с именем получателя, Alias/CBU и кнопкой "Transferir"
|
||||||
|
// Но без "Vas a transferir" (это уже финальный экран)
|
||||||
|
bool hasTransferir = false;
|
||||||
|
bool hasVasATransferir = false;
|
||||||
|
bool hasAlias = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "Transferir" && node.clickable) {
|
||||||
|
hasTransferir = true;
|
||||||
|
}
|
||||||
|
if (node.contentDesc.startsWith("Vas a transferir")) {
|
||||||
|
hasVasATransferir = true;
|
||||||
|
}
|
||||||
|
if (node.contentDesc.startsWith("Alias:")) {
|
||||||
|
hasAlias = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasTransferir && hasAlias && !hasVasATransferir;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isPayInputSumScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains(QString::fromUtf8("Cuánto vas a transferír"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isPayConfirmScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.startsWith("Vas a transferir")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isPaySuccessScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "Dinero enviado!") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseRecipientNameFromConfirm() {
|
||||||
|
// "Vas a transferir \na JOEL ARCHENTI"
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.startsWith("Vas a transferir")) {
|
||||||
|
QStringList lines = node.contentDesc.split('\n');
|
||||||
|
if (lines.size() >= 2) {
|
||||||
|
QString nameLine = lines[1].trimmed();
|
||||||
|
// Убираем "a " в начале
|
||||||
|
if (nameLine.startsWith("a ")) {
|
||||||
|
nameLine = nameLine.mid(2);
|
||||||
|
}
|
||||||
|
return nameLine.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parsePaySuccessDateTime() {
|
||||||
|
// Дата/время: "12/03/26 04:01" — содержит "/" и ":"
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
const QString desc = node.contentDesc.trimmed();
|
||||||
|
if (desc.contains('/') && desc.contains(':') && desc.length() <= 20) {
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parsePaySuccessCoelsaId() {
|
||||||
|
// "Id Coelsa" → следующий node содержит ID
|
||||||
|
bool afterIdCoelsa = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == "Id Coelsa") {
|
||||||
|
afterIdCoelsa = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (afterIdCoelsa && !node.contentDesc.isEmpty()) {
|
||||||
|
return node.contentDesc.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.EditText" && node.hint == hint) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int screenHeight) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains("Доступно обновление")) {
|
||||||
|
Node tapNode;
|
||||||
|
const int cx = screenWidth / 2;
|
||||||
|
const int cy = screenHeight / 20;
|
||||||
|
tapNode.setBounds(cx, cy, cx + 1, cy + 1);
|
||||||
|
return tapNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button"
|
||||||
|
&& node.clickable) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.clickable && node.text.trimmed() == QString::fromUtf8("ЗАПРЕТИТЬ")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
125
android/black/ScreenXmlParser.h
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QDomElement>
|
||||||
|
#include "android/xml/Node.h"
|
||||||
|
#include "db/MaterialInfo.h"
|
||||||
|
#include "db/TransactionInfo.h"
|
||||||
|
|
||||||
|
namespace Black {
|
||||||
|
|
||||||
|
class ScreenXmlParser final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ScreenXmlParser(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
~ScreenXmlParser() override;
|
||||||
|
|
||||||
|
void parseAndSaveXml(const QString &xml);
|
||||||
|
|
||||||
|
const QList<Node> &findAllNodes() const;
|
||||||
|
|
||||||
|
Node findTextNode(const QString &text);
|
||||||
|
|
||||||
|
Node findNodeByContentDesc(const QString &contentDesc);
|
||||||
|
|
||||||
|
Node findNodeByContentDesc(const QString &contentDesc, bool contains);
|
||||||
|
|
||||||
|
Node findButtonNode(const QString &text, bool contains);
|
||||||
|
|
||||||
|
Node findFirstEditText();
|
||||||
|
|
||||||
|
// Определяет экран ввода PIN-кода по content-desc "¿Olvidaste tu PIN?"
|
||||||
|
bool isPinCodeScreen();
|
||||||
|
|
||||||
|
// Находит EditText с password=true для ввода PIN
|
||||||
|
Node findPinCodeEditText();
|
||||||
|
|
||||||
|
bool isHomeScreen();
|
||||||
|
|
||||||
|
// Определяет боковое меню по content-desc "Tu Información" + "Cerrar sesión"
|
||||||
|
bool isSideMenu();
|
||||||
|
|
||||||
|
// Находит кнопку "¡Hola!" (аватар с именем) на домашнем экране
|
||||||
|
Node findHolaButton();
|
||||||
|
|
||||||
|
// Парсит имя пользователя из content-desc "¡Hola!\nимя\nинициалы"
|
||||||
|
QString parseUserNameFromHomeScreen();
|
||||||
|
|
||||||
|
// Парсит баланс с домашнего экрана (content-desc "$0,00" после "Saldo disponible")
|
||||||
|
double parseBalanceFromHomeScreen();
|
||||||
|
|
||||||
|
// Определяет страницу профиля "Tu información"
|
||||||
|
bool isProfilePage();
|
||||||
|
|
||||||
|
// Парсит данные профиля по hint полей
|
||||||
|
QString parseProfileNombre(); // hint="Nombre"
|
||||||
|
QString parseProfileApellido(); // hint="Apellido"
|
||||||
|
QString parseProfileEmail(); // hint="E-mail"
|
||||||
|
QString parseProfilePhone(); // hint="Teléfono celular"
|
||||||
|
|
||||||
|
// Определяет экран CVU (окно поверх домашнего экрана)
|
||||||
|
bool isCVUScreen();
|
||||||
|
|
||||||
|
// Парсит номер CVU (22-значный номер после content-desc="CVU")
|
||||||
|
QString parseCVUNumber();
|
||||||
|
|
||||||
|
// Находит кнопку "Ver más" рядом с "Tu actividad" (для перехода на экран транзакций)
|
||||||
|
Node findVerMasAfterTuActividad();
|
||||||
|
|
||||||
|
// Определяет экран загрузки транзакций (заголовок "Tu Actividad", но транзакций ещё нет)
|
||||||
|
bool isTransactionLoadingScreen();
|
||||||
|
|
||||||
|
// Определяет экран списка транзакций (заголовок "Tu Actividad" + есть транзакции)
|
||||||
|
bool isTransactionListScreen();
|
||||||
|
|
||||||
|
// Парсит все транзакции из content-desc ImageView элементов
|
||||||
|
// Формат: "Transferencia\nDD de MES de YYYY - HH:MM\n$-1.857,\n00"
|
||||||
|
QList<TransactionInfo> parseTransactionItems();
|
||||||
|
|
||||||
|
// Парсит дату на испанском: "21 de febrero de 2026 - 09:39" → QDateTime
|
||||||
|
static QDateTime parseSpanishDate(const QString &dateStr);
|
||||||
|
|
||||||
|
// --- Экраны оплаты ---
|
||||||
|
|
||||||
|
// Экран ввода CBU/CVU/Alias (hint="Ingresar CBU, CVU o Alias")
|
||||||
|
bool isPayInputCardNumberScreen();
|
||||||
|
|
||||||
|
// Экран подтверждения получателя (кнопка "Transferir" + имя получателя)
|
||||||
|
bool isPayTransferConfirmScreen();
|
||||||
|
|
||||||
|
// Экран ввода суммы ("¿Cuánto vas a transferír" + EditText + "Continuar")
|
||||||
|
bool isPayInputSumScreen();
|
||||||
|
|
||||||
|
// Экран финального подтверждения ("Vas a transferir" + "Transferir")
|
||||||
|
bool isPayConfirmScreen();
|
||||||
|
|
||||||
|
// Экран успешного перевода ("Dinero enviado!")
|
||||||
|
bool isPaySuccessScreen();
|
||||||
|
|
||||||
|
// Парсит имя получателя из "Vas a transferir \na ИМЯ"
|
||||||
|
QString parseRecipientNameFromConfirm();
|
||||||
|
|
||||||
|
// Парсит дату/время с экрана успеха: "12/03/26 04:01"
|
||||||
|
QString parsePaySuccessDateTime();
|
||||||
|
|
||||||
|
// Парсит Id Coelsa с экрана успеха
|
||||||
|
QString parsePaySuccessCoelsaId();
|
||||||
|
|
||||||
|
// Находит EditText по hint
|
||||||
|
Node findEditTextByHint(const QString &hint);
|
||||||
|
|
||||||
|
// Находит кнопку закрытия Google Play in-app update bottom sheet
|
||||||
|
// ("com.android.vending" оверлей "Доступно обновление"), если он показан
|
||||||
|
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
|
||||||
|
|
||||||
|
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге разрешений Android
|
||||||
|
Node tryToFindSystemPermissionDenyButton();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QList<Node> m_nodes;
|
||||||
|
QDomElement m_xml;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Black
|
||||||
327
android/dump/TaskDumpRecorder.cpp
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
#include "TaskDumpRecorder.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QMetaObject>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QTextStream>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QThreadStorage>
|
||||||
|
#include <qDebug>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/SettingsDAO.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
#include "LogArchiveSender.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QThreadStorage<TaskDumpRecorder *> &tlsRecorder() {
|
||||||
|
static QThreadStorage<TaskDumpRecorder *> tls;
|
||||||
|
return tls;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPointer<NetworkService> &networkServiceRef() {
|
||||||
|
static QPointer<NetworkService> ref;
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString dumpsRoot() {
|
||||||
|
// Пишем в пользовательский temp — UAC/Program Files не мешают.
|
||||||
|
QString base = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
|
||||||
|
if (base.isEmpty()) base = QDir::tempPath();
|
||||||
|
return base + "/arc_dumps";
|
||||||
|
}
|
||||||
|
|
||||||
|
QString sanitize(const QString &raw, int maxLen = 40) {
|
||||||
|
static const QRegularExpression re(QStringLiteral("[^\\p{L}\\p{N}_-]+"));
|
||||||
|
QString s = raw;
|
||||||
|
s.replace(re, "_");
|
||||||
|
if (s.size() > maxLen) s = s.left(maxLen);
|
||||||
|
while (s.endsWith('_')) s.chop(1);
|
||||||
|
while (s.startsWith('_')) s = s.mid(1);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString extractScreenTag(const QString &xml) {
|
||||||
|
static const QRegularExpression re(QStringLiteral("text=\"([^\"]{4,40})\""));
|
||||||
|
static const QRegularExpression noiseRe(QStringLiteral("^[0-9\\s,\\.\\-+₽$€%]+$"));
|
||||||
|
auto it = re.globalMatch(xml);
|
||||||
|
while (it.hasNext()) {
|
||||||
|
QString text = it.next().captured(1).trimmed();
|
||||||
|
if (text.isEmpty() || noiseRe.match(text).hasMatch()) continue;
|
||||||
|
QString tag = sanitize(text);
|
||||||
|
if (!tag.isEmpty()) return tag;
|
||||||
|
}
|
||||||
|
return QStringLiteral("screen");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool zipFolder(const QString &folder, const QString &archivePath) {
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
const QString cmd = QString(
|
||||||
|
"Compress-Archive -Path '%1\\*' -DestinationPath '%2' -Force"
|
||||||
|
).arg(QDir::toNativeSeparators(folder), QDir::toNativeSeparators(archivePath));
|
||||||
|
QProcess p;
|
||||||
|
p.start("powershell", {"-NoProfile", "-Command", cmd});
|
||||||
|
if (!p.waitForStarted(5000) || !p.waitForFinished(180000)) {
|
||||||
|
qWarning() << "[TaskDumpRecorder] zip failed:" << p.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (p.exitCode() != 0) {
|
||||||
|
qWarning() << "[TaskDumpRecorder] zip exit=" << p.exitCode()
|
||||||
|
<< "stderr:" << QString::fromUtf8(p.readAllStandardError()).trimmed();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
const QFileInfo fi(folder);
|
||||||
|
QProcess p;
|
||||||
|
p.setWorkingDirectory(fi.absolutePath());
|
||||||
|
p.start("zip", {"-rq", archivePath, fi.fileName()});
|
||||||
|
if (!p.waitForStarted(5000) || !p.waitForFinished(180000)) {
|
||||||
|
qWarning() << "[TaskDumpRecorder] zip failed:" << p.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return p.exitCode() == 0;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray readFileBytes(const QString &path) {
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly)) return {};
|
||||||
|
QByteArray bytes = f.readAll();
|
||||||
|
f.close();
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void TaskDumpRecorder::setNetworkService(NetworkService *service) {
|
||||||
|
networkServiceRef() = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkService *TaskDumpRecorder::networkService() {
|
||||||
|
return networkServiceRef().data();
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskDumpRecorder *TaskDumpRecorder::current() {
|
||||||
|
return tlsRecorder().hasLocalData() ? tlsRecorder().localData() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskDumpRecorder::begin(const QString &bank, const QString &taskKind,
|
||||||
|
const QString &adbDeviceId, const TaskDumpMeta &meta) {
|
||||||
|
if (auto *prev = current()) {
|
||||||
|
prev->finishInternal(QStringLiteral("interrupted (new task started before previous finished)"));
|
||||||
|
// Не вызываем `delete prev` — QThreadStorage<T*> сам удалит предыдущий
|
||||||
|
// указатель при замене через setLocalData.
|
||||||
|
}
|
||||||
|
auto *rec = new TaskDumpRecorder(bank, taskKind, adbDeviceId, meta);
|
||||||
|
tlsRecorder().setLocalData(rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskDumpRecorder::finish(const QString &errorMessage) {
|
||||||
|
auto *rec = current();
|
||||||
|
if (!rec) return;
|
||||||
|
rec->finishInternal(errorMessage);
|
||||||
|
// setLocalData(nullptr) сам вызовет delete на текущем указателе.
|
||||||
|
tlsRecorder().setLocalData(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
|
||||||
|
const QString &adbDeviceId, const TaskDumpMeta &meta)
|
||||||
|
: m_bank(sanitize(bank)),
|
||||||
|
m_taskKind(sanitize(taskKind)),
|
||||||
|
m_adbDeviceIdRaw(adbDeviceId),
|
||||||
|
m_adbDeviceIdSafe(sanitize(adbDeviceId)),
|
||||||
|
m_meta(meta) {
|
||||||
|
// Короткий суффикс для имени папки — чтобы визуально отличать задачи,
|
||||||
|
// но не раздувать путь. Берём materialId (первые 8 символов — UUID segment)
|
||||||
|
// + сумму, если есть.
|
||||||
|
QString folderTail;
|
||||||
|
if (!m_meta.materialId.isEmpty()) {
|
||||||
|
folderTail = sanitize(m_meta.materialId.left(8));
|
||||||
|
}
|
||||||
|
if (m_meta.amount != 0) {
|
||||||
|
const QString amt = QString::number(static_cast<qint64>(m_meta.amount));
|
||||||
|
folderTail = folderTail.isEmpty() ? amt : folderTail + "_" + amt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString ts = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
|
||||||
|
const QString sub = folderTail.isEmpty()
|
||||||
|
? QString("%1_%2").arg(ts, m_adbDeviceIdSafe)
|
||||||
|
: QString("%1_%2_%3").arg(ts, m_adbDeviceIdSafe, folderTail);
|
||||||
|
m_folder = QString("%1/%2_%3/%4")
|
||||||
|
.arg(dumpsRoot(), m_bank, m_taskKind, sub);
|
||||||
|
if (!QDir().mkpath(m_folder)) {
|
||||||
|
qWarning() << "[TaskDumpRecorder] failed to mkpath" << m_folder;
|
||||||
|
m_folder.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qDebug() << "[TaskDumpRecorder] begin:" << m_folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
|
||||||
|
const QString apiToken = SettingsDAO::get("api_key");
|
||||||
|
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
|
||||||
|
|
||||||
|
QStringList head{QStringLiteral("[TASK_ERROR]")};
|
||||||
|
if (!apiToken.isEmpty())
|
||||||
|
head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped());
|
||||||
|
head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped());
|
||||||
|
head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped());
|
||||||
|
|
||||||
|
QString body = QStringLiteral("Device: %1").arg(m_adbDeviceIdRaw.toHtmlEscaped());
|
||||||
|
if (!m_meta.materialId.isEmpty())
|
||||||
|
body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped());
|
||||||
|
if (m_meta.amount != 0)
|
||||||
|
body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
|
||||||
|
if (!m_meta.phone.isEmpty())
|
||||||
|
body += QStringLiteral("\nPhone: %1").arg(m_meta.phone.toHtmlEscaped());
|
||||||
|
if (!m_meta.card.isEmpty())
|
||||||
|
body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped());
|
||||||
|
body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped());
|
||||||
|
|
||||||
|
return QStringLiteral("<b>%1</b>\n%2\n%3")
|
||||||
|
.arg(head.join(QStringLiteral(" ")), timestamp.toHtmlEscaped(), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskDumpRecorder::saveXml(int counter, const QString &xml) {
|
||||||
|
if (m_folder.isEmpty() || xml.isEmpty()) return;
|
||||||
|
const QString tag = extractScreenTag(xml);
|
||||||
|
const QString fname = QString("%1/%2_%3.xml")
|
||||||
|
.arg(m_folder,
|
||||||
|
QString("%1").arg(counter, 4, 10, QChar('0')),
|
||||||
|
tag);
|
||||||
|
QFile f(fname);
|
||||||
|
if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
f.write(xml.toUtf8());
|
||||||
|
f.close();
|
||||||
|
} else {
|
||||||
|
qWarning() << "[TaskDumpRecorder] failed to write" << fname << f.errorString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
||||||
|
if (m_finished) return;
|
||||||
|
m_finished = true;
|
||||||
|
if (m_folder.isEmpty()) return;
|
||||||
|
|
||||||
|
if (errorMessage.isEmpty()) {
|
||||||
|
QDir(m_folder).removeRecursively();
|
||||||
|
qDebug() << "[TaskDumpRecorder] success — removed" << m_folder;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qWarning() << "[TaskDumpRecorder] task failed:" << errorMessage
|
||||||
|
<< "folder:" << m_folder;
|
||||||
|
|
||||||
|
// Финальный скриншот текущего экрана
|
||||||
|
if (!m_adbDeviceIdRaw.isEmpty()) {
|
||||||
|
const QByteArray png = AdbUtils::captureScreenshotBytes(m_adbDeviceIdRaw);
|
||||||
|
if (!png.isEmpty()) {
|
||||||
|
QFile f(m_folder + "/_final_screen.png");
|
||||||
|
if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
f.write(png);
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метаданные
|
||||||
|
{
|
||||||
|
QFile ef(m_folder + "/_error.txt");
|
||||||
|
if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
QTextStream ts(&ef);
|
||||||
|
ts.setEncoding(QStringConverter::Utf8);
|
||||||
|
ts << "bank: " << m_bank << "\n"
|
||||||
|
<< "task: " << m_taskKind << "\n"
|
||||||
|
<< "device: " << m_adbDeviceIdRaw << "\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";
|
||||||
|
if (!m_meta.card.isEmpty()) ts << "card: " << m_meta.card << "\n";
|
||||||
|
ts << "time: " << QDateTime::currentDateTime().toString(Qt::ISODate) << "\n"
|
||||||
|
<< "error: " << errorMessage << "\n";
|
||||||
|
ef.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString folder = m_folder;
|
||||||
|
const QString archivePath = m_folder + ".zip";
|
||||||
|
const QString text = buildCaption(errorMessage);
|
||||||
|
|
||||||
|
// Финальный скриншот шлём отдельным полем `image`, поэтому сразу читаем
|
||||||
|
// его в память, пока папка ещё цела.
|
||||||
|
QByteArray finalScreenshot;
|
||||||
|
{
|
||||||
|
const QString screenPath = folder + "/_final_screen.png";
|
||||||
|
if (QFile::exists(screenPath)) {
|
||||||
|
finalScreenshot = readFileBytes(screenPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QThread *bg = QThread::create([folder, archivePath, text, finalScreenshot]() {
|
||||||
|
QByteArray archiveBytes;
|
||||||
|
if (zipFolder(folder, archivePath)) {
|
||||||
|
archiveBytes = readFileBytes(archivePath);
|
||||||
|
if (archiveBytes.isEmpty()) {
|
||||||
|
qWarning() << "[TaskDumpRecorder] zip is empty, sending text-only dump for" << folder;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning() << "[TaskDumpRecorder] zip failed, sending text-only dump for" << folder;
|
||||||
|
}
|
||||||
|
QDir(folder).removeRecursively();
|
||||||
|
QFile::remove(archivePath);
|
||||||
|
|
||||||
|
NetworkService *svc = networkServiceRef().data();
|
||||||
|
if (svc) {
|
||||||
|
qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService"
|
||||||
|
<< "archive.size=" << archiveBytes.size()
|
||||||
|
<< "image.size=" << finalScreenshot.size()
|
||||||
|
<< "text.size=" << text.size();
|
||||||
|
const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump",
|
||||||
|
Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, text),
|
||||||
|
Q_ARG(QByteArray, archiveBytes),
|
||||||
|
Q_ARG(QByteArray, finalScreenshot));
|
||||||
|
if (!dispatched) {
|
||||||
|
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
|
||||||
|
"slot not found or wrong signature for" << folder;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
|
||||||
|
<< "(setNetworkService was not called at app startup?)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Следом — application.log + БД (+ WAL/SHM) отдельным архивом.
|
||||||
|
// Запускаем уже ПОСЛЕ того, как task-дамп поставлен в очередь
|
||||||
|
// отправки, чтобы порядок прихода на relay был "дамп → логи".
|
||||||
|
LogArchiveSender::dispatch(QStringLiteral("TASK_ERROR"));
|
||||||
|
});
|
||||||
|
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
|
||||||
|
bg->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskDumpRecorder::Scope::Scope(const QString &bank, const QString &taskKind,
|
||||||
|
const QString &adbDeviceId, const TaskDumpMeta &meta,
|
||||||
|
const QString *errorPtr)
|
||||||
|
: m_errorPtr(errorPtr), m_owned(false) {
|
||||||
|
// Вложенные скоупы не перетирают рекордер родителя — иначе внутренний
|
||||||
|
// скрипт прервал бы запись внешней задачи и сгенерировал ложный дамп
|
||||||
|
// с пометкой "interrupted".
|
||||||
|
if (TaskDumpRecorder::current() == nullptr) {
|
||||||
|
TaskDumpRecorder::begin(bank, taskKind, adbDeviceId, meta);
|
||||||
|
m_owned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskDumpRecorder::Scope::~Scope() {
|
||||||
|
if (m_owned) {
|
||||||
|
TaskDumpRecorder::finish(m_errorPtr ? *m_errorPtr : QString());
|
||||||
|
}
|
||||||
|
}
|
||||||
69
android/dump/TaskDumpRecorder.h
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class NetworkService;
|
||||||
|
|
||||||
|
// Мета-поля задачи. Пустые/нулевые значения не попадают в text дампа
|
||||||
|
// и в имя папки — выводятся только те, что релевантны конкретному типу задачи.
|
||||||
|
struct TaskDumpMeta {
|
||||||
|
QString materialId;
|
||||||
|
double amount = 0; // 0 = нет
|
||||||
|
QString phone; // пусто = нет
|
||||||
|
QString card; // пусто = нет
|
||||||
|
};
|
||||||
|
|
||||||
|
class TaskDumpRecorder {
|
||||||
|
public:
|
||||||
|
// NetworkService, через который дамп отправляется на бэкенд (/monitoring/dump).
|
||||||
|
// Вызывать один раз при старте приложения. Без него рекордер собирает дамп,
|
||||||
|
// но не отправляет его — только пишет warning в лог.
|
||||||
|
static void setNetworkService(NetworkService *service);
|
||||||
|
|
||||||
|
// Доступ к зарегистрированному NetworkService для других потребителей,
|
||||||
|
// которые тоже хотят дернуть /monitoring/dump (отправка ручных логов).
|
||||||
|
static NetworkService* networkService();
|
||||||
|
|
||||||
|
// Возвращает рекордер текущего потока (nullptr если задача не начата).
|
||||||
|
static TaskDumpRecorder* current();
|
||||||
|
|
||||||
|
// RAII-скоуп: при создании регистрирует рекордер на текущий поток,
|
||||||
|
// при разрушении — финализирует (успех/ошибка определяются по errorPtr).
|
||||||
|
// Если в текущем потоке уже есть активный рекордер (вложенный вызов из
|
||||||
|
// другого скрипта), новый скоуп становится no-op — родитель остаётся
|
||||||
|
// владельцем дампа, а внутренние скрины пишутся в его папку.
|
||||||
|
class Scope {
|
||||||
|
public:
|
||||||
|
Scope(const QString &bank, const QString &taskKind,
|
||||||
|
const QString &adbDeviceId, const TaskDumpMeta &meta,
|
||||||
|
const QString *errorPtr);
|
||||||
|
~Scope();
|
||||||
|
Scope(const Scope &) = delete;
|
||||||
|
Scope &operator=(const Scope &) = delete;
|
||||||
|
private:
|
||||||
|
const QString *m_errorPtr;
|
||||||
|
bool m_owned;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сохраняет XML-дамп в папку задачи. Имя файла: NNNN_<screenTag>.xml.
|
||||||
|
void saveXml(int counter, const QString &xml);
|
||||||
|
|
||||||
|
private:
|
||||||
|
TaskDumpRecorder(const QString &bank, const QString &taskKind,
|
||||||
|
const QString &adbDeviceId, const TaskDumpMeta &meta);
|
||||||
|
|
||||||
|
static void begin(const QString &bank, const QString &taskKind,
|
||||||
|
const QString &adbDeviceId, const TaskDumpMeta &meta);
|
||||||
|
static void finish(const QString &errorMessage);
|
||||||
|
|
||||||
|
void finishInternal(const QString &errorMessage);
|
||||||
|
QString buildCaption(const QString &errorMessage) const;
|
||||||
|
|
||||||
|
QString m_folder;
|
||||||
|
QString m_bank;
|
||||||
|
QString m_taskKind;
|
||||||
|
QString m_adbDeviceIdRaw;
|
||||||
|
QString m_adbDeviceIdSafe;
|
||||||
|
TaskDumpMeta m_meta;
|
||||||
|
bool m_finished = false;
|
||||||
|
};
|
||||||
359
android/dushanbe/CommonScript.cpp
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QRandomGenerator>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
CommonScript::CommonScript(QObject *parent)
|
||||||
|
: QObject(parent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
CommonScript::~CommonScript() = default;
|
||||||
|
|
||||||
|
void CommonScript::start() {
|
||||||
|
doStart();
|
||||||
|
emit finished();
|
||||||
|
}
|
||||||
|
|
||||||
|
Node CommonScript::swipeToButton(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &text, int &counter, const int width, const int height
|
||||||
|
) {
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
if (Node button = xmlScreenParser.findButtonNode(text, true); button.x() > 0 && button.y() > 0) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
const int x = width / 2;
|
||||||
|
const int offset = height / 4;
|
||||||
|
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode) {
|
||||||
|
if (pinCode.isEmpty()) {
|
||||||
|
qWarning() << "[Dushanbe::inputPinCode] pinCode is EMPTY — nothing to enter!";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждём, пока на экране появятся все уникальные цифры из PIN.
|
||||||
|
// DC Next на некоторых устройствах успевает показать заголовок
|
||||||
|
// "Введите код доступа" раньше, чем дорисует кнопочную сетку, —
|
||||||
|
// если начать тапать в этот момент, часть цифр не найдётся и PIN
|
||||||
|
// не введётся целиком.
|
||||||
|
bool seenInPin[10] = {false};
|
||||||
|
for (const QChar &d : pinCode) {
|
||||||
|
if (!d.isDigit()) continue;
|
||||||
|
const int idx = d.digitValue();
|
||||||
|
if (idx >= 0 && idx <= 9) seenInPin[idx] = true;
|
||||||
|
}
|
||||||
|
auto allDigitsPresent = [this, &seenInPin]() {
|
||||||
|
for (int d = 0; d < 10; ++d) {
|
||||||
|
if (!seenInPin[d]) continue;
|
||||||
|
if (xmlScreenParser.findNodeByContentDesc(QString::number(d)).isEmpty()) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
for (int i = 0; i < 6 && !allDigitsPresent(); ++i) {
|
||||||
|
QThread::msleep(500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
if (!allDigitsPresent()) {
|
||||||
|
qWarning() << "[Dushanbe::inputPinCode] keypad incomplete after wait; aborting";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::inputPinCode] entering" << pinCode.length() << "digits";
|
||||||
|
// DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9")
|
||||||
|
for (const QChar &digit : pinCode) {
|
||||||
|
Node button = xmlScreenParser.findNodeByContentDesc(QString(digit));
|
||||||
|
if (button.isEmpty()) {
|
||||||
|
qWarning() << "[Dushanbe::inputPinCode] Button not found for digit:" << digit;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
AdbUtils::makeTap(deviceId, button.x(), button.y());
|
||||||
|
QThread::msleep(300);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::runAppAndGoToHomeScreen(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &packageName,
|
||||||
|
const QString &pinCode,
|
||||||
|
const int width,
|
||||||
|
const int height
|
||||||
|
) {
|
||||||
|
AdbUtils::unlockScreen(deviceId, width, height);
|
||||||
|
|
||||||
|
// Быстрый path: если приложение уже запущено — не перезапускаем сразу,
|
||||||
|
// а смотрим текущий экран. Если это Home — готово. Если PIN — вводим
|
||||||
|
// и ждём Home. Только если не смогли — убиваем и рестартим.
|
||||||
|
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe] Process already running, pid:" << pid;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe] Fast path: already on Home screen";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (xmlScreenParser.isPinCodeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe] Fast path: PIN screen, entering PIN";
|
||||||
|
if (inputPinCode(deviceId, pinCode)) {
|
||||||
|
QThread::msleep(3000);
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(
|
||||||
|
AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe] Fast path: Home reached after PIN";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe] Fast path failed, restarting app";
|
||||||
|
AdbUtils::tryToKillApplication(deviceId, packageName);
|
||||||
|
}
|
||||||
|
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
|
||||||
|
AdbUtils::takeScreenshot(deviceId, "run_app_error");
|
||||||
|
qWarning() << "[Dushanbe] Process has not started";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QThread::msleep(4500);
|
||||||
|
int counter = 0;
|
||||||
|
bool findAndInputPincode = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
counter++;
|
||||||
|
|
||||||
|
if (counter > 20) {
|
||||||
|
qWarning() << "[Dushanbe] runAppAndGoToHomeScreen: too many attempts, giving up";
|
||||||
|
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe] Home reached on attempt" << counter;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe] PIN screen detected on attempt" << counter << "entering PIN";
|
||||||
|
if (inputPinCode(deviceId, pinCode)) {
|
||||||
|
findAndInputPincode = true;
|
||||||
|
} else {
|
||||||
|
// Клавиатура могла не дорисоваться или PIN ввёлся частично —
|
||||||
|
// оставляем флаг false, следующая итерация повторит ввод,
|
||||||
|
// когда кнопки появятся.
|
||||||
|
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter << "— will retry";
|
||||||
|
}
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диагностика: что реально на экране?
|
||||||
|
QStringList sample;
|
||||||
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (n.contentDesc.isEmpty() && n.text.isEmpty()) continue;
|
||||||
|
QString s = !n.contentDesc.isEmpty() ? n.contentDesc : n.text;
|
||||||
|
s.replace('\n', ' ');
|
||||||
|
if (s.size() > 60) s = s.left(60) + "...";
|
||||||
|
sample.append(s);
|
||||||
|
if (sample.size() >= 8) break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe] attempt" << counter << "not home, not PIN. Sample:" << sample;
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::postAccountsData(const QList<QPair<MaterialInfo, Node>> &accounts) {
|
||||||
|
if (accounts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonArray list;
|
||||||
|
for (const QPair<MaterialInfo, Node> &pair : accounts) {
|
||||||
|
QJsonObject obj;
|
||||||
|
MaterialInfo account = pair.first;
|
||||||
|
obj["appName"] = account.appCode;
|
||||||
|
obj["lastNumbers"] = account.lastNumbers;
|
||||||
|
obj["amount"] = account.amount;
|
||||||
|
obj["description"] = account.description;
|
||||||
|
obj["currency"] = account.currency;
|
||||||
|
obj["status"] = materialStatusToString(account.status);
|
||||||
|
list.append(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
worker->setDeviceId(accounts.first().first.deviceId);
|
||||||
|
worker->setPayload(QJsonDocument(list).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
|
||||||
|
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction) {
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonObject obj;
|
||||||
|
obj["id"] = transaction.id;
|
||||||
|
obj["amount"] = transaction.amount;
|
||||||
|
obj["name"] = transaction.name;
|
||||||
|
obj["fee"] = transaction.fee;
|
||||||
|
obj["status"] = transactionStatusToString(transaction.status);
|
||||||
|
obj["type"] = transactionTypeToString(transaction.type);
|
||||||
|
obj["phone"] = transaction.phone;
|
||||||
|
obj["description"] = transaction.description;
|
||||||
|
obj["updatedDescription"] = transaction.updatedDescription;
|
||||||
|
obj["bankName"] = transaction.bankName;
|
||||||
|
obj["bankTime"] = transaction.bankTime;
|
||||||
|
obj["bankTransactionId"] = transaction.bankTrExternalId;
|
||||||
|
|
||||||
|
worker->setDeviceId(account.deviceId);
|
||||||
|
worker->addExtra("lastNumbers", account.lastNumbers);
|
||||||
|
worker->addExtra("transactionId", transaction.id);
|
||||||
|
worker->setPayload(QJsonDocument(obj).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::started, worker, &NetworkService::insertTransactionData);
|
||||||
|
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::updateTransactionData(
|
||||||
|
const MaterialInfo &account,
|
||||||
|
const int transactionId,
|
||||||
|
const TransactionStatus status,
|
||||||
|
const QString &oldDesc,
|
||||||
|
const QString &newDesc
|
||||||
|
) {
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonObject obj;
|
||||||
|
obj["id"] = transactionId;
|
||||||
|
obj["status"] = transactionStatusToString(status);
|
||||||
|
obj["description"] = newDesc;
|
||||||
|
obj["updatedDescription"] = oldDesc;
|
||||||
|
|
||||||
|
worker->setDeviceId(account.deviceId);
|
||||||
|
worker->addExtra("lastNumbers", account.lastNumbers);
|
||||||
|
worker->setPayload(QJsonDocument(obj).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::started, worker, &NetworkService::updateTransactionData);
|
||||||
|
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
|
|
||||||
|
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
|
||||||
|
qWarning() << "[Dushanbe] createBankProfileIfNeeded: skipping — fullName or phone is empty";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QMap<QString, int> currencyCodes = {
|
||||||
|
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
|
||||||
|
};
|
||||||
|
const int currencyCode = currencyCodes.value(app.currency.toUpper(), 840);
|
||||||
|
const QStringList parts = app.fullName.split(' ', Qt::SkipEmptyParts);
|
||||||
|
|
||||||
|
const QSettings settings("config.ini", QSettings::IniFormat);
|
||||||
|
const QString phoneCode = settings.value(appCode + "/phone_code").toString();
|
||||||
|
QString phone = app.phone;
|
||||||
|
phone.remove(QRegularExpression("[^0-9]"));
|
||||||
|
if (!phoneCode.isEmpty() && !phone.startsWith(phoneCode)) {
|
||||||
|
if (phone.startsWith('8') && phoneCode != "8") {
|
||||||
|
phone.replace(0, 1, phoneCode);
|
||||||
|
} else {
|
||||||
|
phone.prepend(phoneCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phone.prepend('+');
|
||||||
|
|
||||||
|
const DeviceInfo device = DeviceDAO::getDeviceById(deviceId);
|
||||||
|
|
||||||
|
QJsonObject profileBody;
|
||||||
|
profileBody["first_name"] = parts.value(0);
|
||||||
|
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : QString("");
|
||||||
|
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
|
||||||
|
profileBody["bank_name"] = appCode;
|
||||||
|
profileBody["phone_number"] = phone;
|
||||||
|
const double balance = (parsedBalance >= 0.0) ? parsedBalance : app.amount;
|
||||||
|
profileBody["balance"] = QString::number(qRound64(balance * 100));
|
||||||
|
profileBody["state"] = (app.status == "active") ? "enabled" : "disabled";
|
||||||
|
profileBody["device_id"] = device.apiId;
|
||||||
|
profileBody["currency"] = currencyCode;
|
||||||
|
|
||||||
|
NetworkService ns;
|
||||||
|
ns.setDeviceId(deviceId);
|
||||||
|
ns.setPayload(QJsonDocument(profileBody).toJson());
|
||||||
|
|
||||||
|
if (app.bankProfileId.isEmpty()) {
|
||||||
|
ns.addExtra("app_id", app.id);
|
||||||
|
ns.postBankProfile();
|
||||||
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
|
if (afterPost.bankProfileId.isEmpty()) {
|
||||||
|
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
|
||||||
|
qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe] createBankProfileIfNeeded: postBankProfile done";
|
||||||
|
} else {
|
||||||
|
ns.addExtra("bank_profile_id", app.bankProfileId);
|
||||||
|
ns.patchBankProfile();
|
||||||
|
qDebug() << "[Dushanbe] createBankProfileIfNeeded: patchBankProfile done";
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::stop() {
|
||||||
|
m_running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::closeGooglePlayBannerIfVisible(
|
||||||
|
const QString &deviceId, const int width, const int height
|
||||||
|
) {
|
||||||
|
// 1. Системный диалог запроса разрешения Android (permissioncontroller) — тапаем "ЗАПРЕТИТЬ"
|
||||||
|
if (Node denyBtn = xmlScreenParser.tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe] System permission dialog detected mid-task, tapping 'ЗАПРЕТИТЬ'...";
|
||||||
|
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Google Play in-app update bottom sheet
|
||||||
|
Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height);
|
||||||
|
if (gpNode.isEmpty()) return false;
|
||||||
|
qDebug() << "[Dushanbe] Google Play update banner detected mid-task, closing...";
|
||||||
|
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include "ScreenXmlParser.h"
|
#include "ScreenXmlParser.h"
|
||||||
|
#include "db/TransactionInfo.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
class CommonScript : public QObject {
|
class CommonScript : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@ -14,34 +17,7 @@ public:
|
|||||||
protected:
|
protected:
|
||||||
virtual void doStart() = 0;
|
virtual void doStart() = 0;
|
||||||
|
|
||||||
// static method
|
bool inputPinCode(const QString &deviceId, const QString &pinCode);
|
||||||
static bool inputPinCode(const QString &deviceId, const QList<Node> &pincode);
|
|
||||||
|
|
||||||
bool goToAllProducts(
|
|
||||||
const QString &deviceId,
|
|
||||||
int width,
|
|
||||||
int height
|
|
||||||
);
|
|
||||||
|
|
||||||
bool findAndTapOnAccount(const QString &deviceId, const QString &accountNumbers);
|
|
||||||
|
|
||||||
void postAccountsData(const QList<QPair<AccountInfo, Node> > &accounts);
|
|
||||||
|
|
||||||
void postNewTransactionData(const AccountInfo &account, const TransactionInfo &transaction);
|
|
||||||
|
|
||||||
void updateTransactionData(
|
|
||||||
const AccountInfo &account,
|
|
||||||
int transactionId,
|
|
||||||
TransactionStatus status,
|
|
||||||
const QString &oldDesc,
|
|
||||||
const QString &newDesc
|
|
||||||
);
|
|
||||||
|
|
||||||
Node swipeToButton(
|
|
||||||
const QString &deviceId,
|
|
||||||
const QString &text,
|
|
||||||
int &counter, int width, int height
|
|
||||||
);
|
|
||||||
|
|
||||||
bool runAppAndGoToHomeScreen(
|
bool runAppAndGoToHomeScreen(
|
||||||
const QString &deviceId,
|
const QString &deviceId,
|
||||||
@ -51,7 +27,29 @@ protected:
|
|||||||
int height
|
int height
|
||||||
);
|
);
|
||||||
|
|
||||||
bool tryToCloseAllBannersOnHomePage(
|
Node swipeToButton(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &text,
|
||||||
|
int &counter, int width, int height
|
||||||
|
);
|
||||||
|
|
||||||
|
void postAccountsData(const QList<QPair<MaterialInfo, Node>> &accounts);
|
||||||
|
|
||||||
|
void postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction);
|
||||||
|
|
||||||
|
void updateTransactionData(
|
||||||
|
const MaterialInfo &account,
|
||||||
|
int transactionId,
|
||||||
|
TransactionStatus status,
|
||||||
|
const QString &oldDesc,
|
||||||
|
const QString &newDesc
|
||||||
|
);
|
||||||
|
|
||||||
|
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
|
||||||
|
|
||||||
|
// Если виден баннер Google Play "Доступно обновление" — закрывает его и перепарсивает XML.
|
||||||
|
// Безопасно вызывать на любом экране; возвращает true если баннер был закрыт.
|
||||||
|
bool closeGooglePlayBannerIfVisible(
|
||||||
const QString &deviceId, int width, int height
|
const QString &deviceId, int width, int height
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -59,11 +57,15 @@ protected:
|
|||||||
ScreenXmlParser xmlScreenParser;
|
ScreenXmlParser xmlScreenParser;
|
||||||
int m_counter = 0;
|
int m_counter = 0;
|
||||||
|
|
||||||
|
QByteArray m_resultScreenshot;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void finished();
|
void finished();
|
||||||
|
|
||||||
void finishedWithResult(const QString &result);
|
void finishedWithResult(const QString &result);
|
||||||
|
|
||||||
|
void screenshotReady(const QByteArray &screenshot);
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
virtual void start() final;
|
virtual void start() final;
|
||||||
|
|
||||||
@ -72,3 +74,5 @@ public slots:
|
|||||||
private:
|
private:
|
||||||
Q_DISABLE_COPY(CommonScript);
|
Q_DISABLE_COPY(CommonScript);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
307
android/dushanbe/GetCardInfoScript.cpp
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
#include "GetCardInfoScript.h"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
GetCardInfoScript::GetCardInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)),
|
||||||
|
m_appCode(std::move(appCode)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
GetCardInfoScript::~GetCardInfoScript() = default;
|
||||||
|
|
||||||
|
void GetCardInfoScript::doStart() {
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe::GetCardInfo] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим баланс с домашнего экрана
|
||||||
|
const double balance = xmlScreenParser.parseBalanceFromHomeScreen();
|
||||||
|
qDebug() << "[Dushanbe::GetCardInfo] balance:" << balance;
|
||||||
|
if (!BankProfileDAO::updateAmount(app.id, balance)) {
|
||||||
|
qWarning() << "[Dushanbe::GetCardInfo] Failed to update balance for app" << app.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тапаем "Карты" на домашнем экране
|
||||||
|
Node cardsButton = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Карты"));
|
||||||
|
if (cardsButton.x() == 0 && cardsButton.y() == 0) {
|
||||||
|
m_error = "Cannot find Cards button: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, cardsButton.x(), cardsButton.y());
|
||||||
|
QThread::msleep(3000);
|
||||||
|
|
||||||
|
// Ждём экран "Мои карты"
|
||||||
|
bool cardsScreenFound = false;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isCardsScreen()) {
|
||||||
|
cardsScreenFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardsScreenFound) {
|
||||||
|
m_error = "Cards screen not detected: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим карты из списка (lastNumbers, баланс)
|
||||||
|
const QList<ScreenXmlParser::CardInfo> cards = xmlScreenParser.parseCards();
|
||||||
|
qDebug() << "[Dushanbe::GetCardInfo] Found" << cards.size() << "cards in list";
|
||||||
|
|
||||||
|
if (cards.isEmpty()) {
|
||||||
|
m_error = "No cards found: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим все clickable ImageView с "****" — это карточки в списке
|
||||||
|
// Пропускаем кредитные карты
|
||||||
|
QList<Node> cardNodes;
|
||||||
|
for (const Node &node : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (node.className == "android.widget.ImageView" && node.clickable &&
|
||||||
|
node.contentDesc.contains("****") &&
|
||||||
|
!node.contentDesc.contains(QString::fromUtf8("Кредитная карта"))) {
|
||||||
|
cardNodes.append(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кликаем по каждой карте для получения полного номера
|
||||||
|
for (int i = 0; i < cardNodes.size(); ++i) {
|
||||||
|
if (i >= cards.size()) break;
|
||||||
|
const Node &cardNode = cardNodes[i];
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, cardNode.x(), cardNode.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// Ждём экран детали карты
|
||||||
|
bool detailFound = false;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isCardDetailScreen()) {
|
||||||
|
detailFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detailFound) {
|
||||||
|
qWarning() << "[Dushanbe::GetCardInfo] Card detail screen not found for card" << i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждём загрузки баланса (может быть 0 пока данные подтягиваются)
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
QString fullNumber = xmlScreenParser.parseFullCardNumber();
|
||||||
|
// Баланс берём с домашнего экрана (parseBalanceFromHomeScreen выше):
|
||||||
|
// в Душанбе все карты привязаны к одному счёту, и на экране детали
|
||||||
|
// карты баланс часто отображается как placeholder "0,00 TJS" пока
|
||||||
|
// данные подгружаются с сервера.
|
||||||
|
const double cardBalance = balance;
|
||||||
|
const QString holder = xmlScreenParser.parseCardDetailHolder();
|
||||||
|
const QString fullNumberClean = QString(fullNumber).remove(' ');
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::GetCardInfo] Card" << i << ":" << fullNumber
|
||||||
|
<< "balance:" << cardBalance << "holder:" << holder;
|
||||||
|
|
||||||
|
// Без полного номера карты материал не создаём и на сервер не отправляем —
|
||||||
|
// защита от записей с пустым card_number (см. Ozon::GetCardInfo).
|
||||||
|
if (fullNumberClean.isEmpty()) {
|
||||||
|
qWarning() << "[Dushanbe::GetCardInfo] Full card number not parsed for card" << i
|
||||||
|
<< "(home-screen lastNumbers=" << cards[i].lastNumbers << ") — skipping";
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString lastNumbers = fullNumberClean.right(4);
|
||||||
|
|
||||||
|
// Достаём номер счёта: Настройки карты → Реквизиты карты → таб "Реквизиты счета".
|
||||||
|
// ВРЕМЕННО ОТКЛЮЧЕНО
|
||||||
|
QString accountNumber;
|
||||||
|
// const Node settingsBtn = xmlScreenParser.findCardSettingsButton();
|
||||||
|
// if (settingsBtn.x() > 0) {
|
||||||
|
// AdbUtils::makeTap(m_deviceId, settingsBtn.x(), settingsBtn.y());
|
||||||
|
// QThread::msleep(1500);
|
||||||
|
//
|
||||||
|
// bool sheetFound = false;
|
||||||
|
// for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
// if (xmlScreenParser.isCardSettingsBottomSheet()) {
|
||||||
|
// sheetFound = true;
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// QThread::msleep(1000);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (sheetFound) {
|
||||||
|
// const Node requisitesItem = xmlScreenParser.findCardRequisitesMenuItem();
|
||||||
|
// if (requisitesItem.x() > 0) {
|
||||||
|
// AdbUtils::makeTap(m_deviceId, requisitesItem.x(), requisitesItem.y());
|
||||||
|
// QThread::msleep(1500);
|
||||||
|
//
|
||||||
|
// bool reqScreenFound = false;
|
||||||
|
// for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
// if (xmlScreenParser.isCardRequisitesScreen()) {
|
||||||
|
// reqScreenFound = true;
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// QThread::msleep(1000);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (reqScreenFound) {
|
||||||
|
// const Node accountTab = xmlScreenParser.findAccountRequisitesTab();
|
||||||
|
// if (accountTab.x() > 0) {
|
||||||
|
// AdbUtils::makeTap(m_deviceId, accountTab.x(), accountTab.y());
|
||||||
|
// QThread::msleep(1500);
|
||||||
|
//
|
||||||
|
// // Несколько попыток — данные могут подгружаться
|
||||||
|
// for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
// accountNumber = xmlScreenParser.parseAccountNumber();
|
||||||
|
// if (!accountNumber.isEmpty()) break;
|
||||||
|
// QThread::msleep(1000);
|
||||||
|
// }
|
||||||
|
// qDebug() << "[Dushanbe::GetCardInfo] accountNumber for card" << i
|
||||||
|
// << ":" << accountNumber;
|
||||||
|
// } else {
|
||||||
|
// qWarning() << "[Dushanbe::GetCardInfo] Account tab not found for card" << i;
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// qWarning() << "[Dushanbe::GetCardInfo] Requisites screen not detected for card" << i;
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// qWarning() << "[Dushanbe::GetCardInfo] 'Реквизиты карты' menu item not found for card" << i;
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// qWarning() << "[Dushanbe::GetCardInfo] Card settings bottom sheet not detected for card" << i;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Возврат на экран детали карты — bottom sheet и/или экран Реквизитов
|
||||||
|
// for (int attempt = 0; attempt < 4; ++attempt) {
|
||||||
|
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
// if (xmlScreenParser.isCardDetailScreen()) break;
|
||||||
|
// AdbUtils::goBack(m_deviceId);
|
||||||
|
// QThread::msleep(1200);
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// qWarning() << "[Dushanbe::GetCardInfo] 'Настройки карты' button not found for card" << i;
|
||||||
|
// }
|
||||||
|
|
||||||
|
MaterialInfo account;
|
||||||
|
account.deviceId = device.id;
|
||||||
|
account.appCode = m_appCode;
|
||||||
|
account.lastNumbers = lastNumbers;
|
||||||
|
account.cardNumber = QString(fullNumber).remove(' ');
|
||||||
|
account.accountNumber = accountNumber;
|
||||||
|
account.amount = cardBalance;
|
||||||
|
account.currency = "TJS";
|
||||||
|
account.description = cards[i].description.isEmpty() ? holder : cards[i].description;
|
||||||
|
account.status = MaterialStatus::Off;
|
||||||
|
account.updateTime = QDateTime::currentDateTimeUtc();
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::GetCardInfo] Saving card:" << "lastNumbers=" << lastNumbers
|
||||||
|
<< "cardNumber=" << account.cardNumber << "amount=" << cardBalance;
|
||||||
|
if (!MaterialDAO::upsertAccount(account)) {
|
||||||
|
qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем на сервер если нет material_id
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Назад к списку карт
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
|
||||||
|
// Ждём возврата на экран "Мои карты"
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isCardsScreen()) break;
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
27
android/dushanbe/GetCardInfoScript.h
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
class GetCardInfoScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit GetCardInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
~GetCardInfoScript() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
918
android/dushanbe/GetLastTransactionsScript.cpp
Normal file
@ -0,0 +1,918 @@
|
|||||||
|
#include "GetLastTransactionsScript.h"
|
||||||
|
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QTimeZone>
|
||||||
|
|
||||||
|
#include <tesseract/baseapi.h>
|
||||||
|
#include <leptonica/allheaders.h>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
|
||||||
|
// Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50",
|
||||||
|
// "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0,
|
||||||
|
// потому что требует, чтобы вся строка была валидным числом.
|
||||||
|
static double parseDushanbeAmount(const QString &raw) {
|
||||||
|
QString s = raw;
|
||||||
|
s.remove(QRegularExpression(R"([^\d,.\-])"));
|
||||||
|
s.replace(',', '.');
|
||||||
|
const int firstDot = s.indexOf('.');
|
||||||
|
if (firstDot >= 0) {
|
||||||
|
QString head = s.left(firstDot);
|
||||||
|
QString tail = s.mid(firstDot + 1);
|
||||||
|
tail.remove('.');
|
||||||
|
s = head + '.' + tail;
|
||||||
|
}
|
||||||
|
return s.toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
static QString recognizeTextFromImage(const QByteArray &pngData) {
|
||||||
|
auto *api = new tesseract::TessBaseAPI();
|
||||||
|
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||||||
|
qWarning() << "[Dushanbe::OCR] Failed to init Tesseract";
|
||||||
|
delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
|
||||||
|
static_cast<size_t>(pngData.size()));
|
||||||
|
if (!pix) {
|
||||||
|
api->End(); delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
api->SetImage(pix);
|
||||||
|
char *text = api->GetUTF8Text();
|
||||||
|
QString result = QString::fromUtf8(text).trimmed();
|
||||||
|
delete[] text;
|
||||||
|
pixDestroy(&pix);
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Точечный OCR: находит "Номер операции" через общий OCR, затем кропает строку правее
|
||||||
|
// и распознаёт только цифры. Работает с любым разрешением экрана.
|
||||||
|
static QString recognizeOperationId(const QByteArray &pngData) {
|
||||||
|
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
|
||||||
|
static_cast<size_t>(pngData.size()));
|
||||||
|
if (!pix) return {};
|
||||||
|
|
||||||
|
const int w = pixGetWidth(pix);
|
||||||
|
const int h = pixGetHeight(pix);
|
||||||
|
|
||||||
|
// 1. Полный OCR с координатами слов — ищем "операции"
|
||||||
|
auto *api = new tesseract::TessBaseAPI();
|
||||||
|
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||||||
|
pixDestroy(&pix); delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
api->SetImage(pix);
|
||||||
|
api->Recognize(nullptr);
|
||||||
|
|
||||||
|
int opLineY = -1;
|
||||||
|
int opLineH = 0;
|
||||||
|
bool prevWasNomer = false;
|
||||||
|
tesseract::ResultIterator *ri = api->GetIterator();
|
||||||
|
if (ri) {
|
||||||
|
do {
|
||||||
|
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
|
||||||
|
if (word) {
|
||||||
|
const QString w_str = QString::fromUtf8(word);
|
||||||
|
delete[] word;
|
||||||
|
if (w_str.contains(QString::fromUtf8("омер")) ||
|
||||||
|
w_str.contains(QString::fromUtf8("omep"))) {
|
||||||
|
prevWasNomer = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (prevWasNomer &&
|
||||||
|
(w_str.contains(QString::fromUtf8("операции")) ||
|
||||||
|
w_str.contains(QString::fromUtf8("onepaции")))) {
|
||||||
|
int x1, y1, x2, y2;
|
||||||
|
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
|
||||||
|
opLineY = y1;
|
||||||
|
opLineH = y2 - y1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prevWasNomer = false;
|
||||||
|
}
|
||||||
|
} while (ri->Next(tesseract::RIL_WORD));
|
||||||
|
}
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
|
||||||
|
if (opLineY < 0) {
|
||||||
|
pixDestroy(&pix);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Кропаем правую половину этой строки и OCR только цифры
|
||||||
|
const int cropY = qMax(0, opLineY - opLineH / 2);
|
||||||
|
const int cropH = opLineH * 2;
|
||||||
|
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
|
||||||
|
Pix *cropped = pixClipRectangle(pix, box, nullptr);
|
||||||
|
boxDestroy(&box);
|
||||||
|
pixDestroy(&pix);
|
||||||
|
|
||||||
|
if (!cropped) return {};
|
||||||
|
|
||||||
|
auto *api2 = new tesseract::TessBaseAPI();
|
||||||
|
if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) {
|
||||||
|
pixDestroy(&cropped); delete api2;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
api2->SetVariable("tessedit_char_whitelist", "0123456789");
|
||||||
|
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
|
||||||
|
api2->SetImage(cropped);
|
||||||
|
char *text = api2->GetUTF8Text();
|
||||||
|
QString result = QString::fromUtf8(text).trimmed();
|
||||||
|
delete[] text;
|
||||||
|
pixDestroy(&cropped);
|
||||||
|
api2->End();
|
||||||
|
delete api2;
|
||||||
|
|
||||||
|
result.remove(QRegularExpression("[^0-9]"));
|
||||||
|
if (result.length() >= 8) {
|
||||||
|
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OcrTransactionDetail {
|
||||||
|
QString date;
|
||||||
|
QString time;
|
||||||
|
QString operationId;
|
||||||
|
QString provider;
|
||||||
|
QString senderAccount;
|
||||||
|
QString receiverAccount;
|
||||||
|
double amount = 0.0;
|
||||||
|
double fee = 0.0;
|
||||||
|
QString status;
|
||||||
|
};
|
||||||
|
|
||||||
|
static OcrTransactionDetail parseDetailFromOcr(const QString &ocrText) {
|
||||||
|
OcrTransactionDetail d;
|
||||||
|
const QStringList lines = ocrText.split('\n');
|
||||||
|
|
||||||
|
for (const QString &rawLine : lines) {
|
||||||
|
const QString line = rawLine.trimmed();
|
||||||
|
|
||||||
|
// "Дата операции: 05.04.2026" или "Дата операции:" на отдельной строке
|
||||||
|
if (line.contains(QString::fromUtf8("Дата операции"))) {
|
||||||
|
QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))");
|
||||||
|
if (auto m = re.match(line); m.hasMatch()) d.date = m.captured(1);
|
||||||
|
}
|
||||||
|
// "Время операции: 18:19:54"
|
||||||
|
else if (line.contains(QString::fromUtf8("Время операции"))) {
|
||||||
|
QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))");
|
||||||
|
if (auto m = re.match(line); m.hasMatch()) d.time = m.captured(1);
|
||||||
|
}
|
||||||
|
// "Номер операции: 1598279763"
|
||||||
|
else if (line.contains(QString::fromUtf8("омер операции"))) {
|
||||||
|
QRegularExpression re(R"((\d{5,}))");
|
||||||
|
if (auto m = re.match(line); m.hasMatch()) d.operationId = m.captured(1);
|
||||||
|
}
|
||||||
|
// "Поставщик: DC (по номеру карты)"
|
||||||
|
else if (line.contains(QString::fromUtf8("Поставщик"))) {
|
||||||
|
QRegularExpression re(R"(Поставщик[:\s]*(.+))");
|
||||||
|
if (auto m = re.match(line); m.hasMatch()) d.provider = m.captured(1).trimmed();
|
||||||
|
}
|
||||||
|
// "Счет отправителя: 9762***3258"
|
||||||
|
else if (line.contains(QString::fromUtf8("Счет отправителя")) ||
|
||||||
|
line.contains(QString::fromUtf8("чет отправителя"))) {
|
||||||
|
QRegularExpression re(R"((\d[\d*]+))");
|
||||||
|
if (auto m = re.match(line); m.hasMatch()) d.senderAccount = m.captured(1);
|
||||||
|
}
|
||||||
|
// "Счет получателя: 9762000186263942" или "992111100664"
|
||||||
|
else if (line.contains(QString::fromUtf8("Счет получателя")) ||
|
||||||
|
line.contains(QString::fromUtf8("чет получателя"))) {
|
||||||
|
QRegularExpression re(R"((\d{6,}))");
|
||||||
|
if (auto m = re.match(line); m.hasMatch()) d.receiverAccount = m.captured(1);
|
||||||
|
}
|
||||||
|
// "Сумма операции: 1.00"
|
||||||
|
else if (line.contains(QString::fromUtf8("Сумма операции")) ||
|
||||||
|
line.contains(QString::fromUtf8("умма операции"))) {
|
||||||
|
QRegularExpression re(R"((\d+[\.,]\d{2}))");
|
||||||
|
if (auto m = re.match(line); m.hasMatch()) {
|
||||||
|
QString val = m.captured(1);
|
||||||
|
val.replace(',', '.');
|
||||||
|
d.amount = val.toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Комиссия: 0.00"
|
||||||
|
else if (line.contains(QString::fromUtf8("Комиссия")) ||
|
||||||
|
line.contains(QString::fromUtf8("омиссия"))) {
|
||||||
|
QRegularExpression re(R"((\d+[\.,]\d{2}))");
|
||||||
|
if (auto m = re.match(line); m.hasMatch()) {
|
||||||
|
QString val = m.captured(1);
|
||||||
|
val.replace(',', '.');
|
||||||
|
d.fee = val.toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Статус: Успешный"
|
||||||
|
else if (line.contains(QString::fromUtf8("Статус"))) {
|
||||||
|
if (line.contains(QString::fromUtf8("Успешн"))) {
|
||||||
|
d.status = QString::fromUtf8("Успешный");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: если дата/время не найдены в "ключ: значение", ищем паттерном
|
||||||
|
if (d.date.isEmpty()) {
|
||||||
|
QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))");
|
||||||
|
if (auto m = re.match(ocrText); m.hasMatch()) d.date = m.captured(1);
|
||||||
|
}
|
||||||
|
if (d.time.isEmpty()) {
|
||||||
|
QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))");
|
||||||
|
auto it = re.globalMatch(ocrText);
|
||||||
|
QStringList times;
|
||||||
|
while (it.hasNext()) times.append(it.next().captured(1));
|
||||||
|
if (times.size() >= 2) d.time = times[1];
|
||||||
|
else if (times.size() == 1) d.time = times[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ocrText.contains(QString::fromUtf8("Успешн"))) d.status = QString::fromUtf8("Успешный");
|
||||||
|
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
GetLastTransactionsScript::GetLastTransactionsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
double searchAmount,
|
||||||
|
QDateTime searchTime,
|
||||||
|
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_materialId(std::move(materialId)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
GetLastTransactionsScript::~GetLastTransactionsScript() = default;
|
||||||
|
|
||||||
|
void GetLastTransactionsScript::doStart() {
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] device.id=" << device.id << "adbSerial=" << device.adbSerial
|
||||||
|
<< "appCode=" << m_appCode << "app.id=" << app.id
|
||||||
|
<< "pinCode.length=" << app.pinCode.length() << "package=" << app.package;
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("dushanbe/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тапаем "История" в нижнем меню
|
||||||
|
Node historyBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История"));
|
||||||
|
if (historyBtn.x() == 0 && historyBtn.y() == 0) {
|
||||||
|
m_error = "Cannot find History button: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, historyBtn.x(), historyBtn.y());
|
||||||
|
QThread::msleep(3000);
|
||||||
|
|
||||||
|
// Ждём экран истории
|
||||||
|
bool historyLoaded = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
// Проверяем диалог "Ошибка" — нажимаем "ОК" и обновляем свайпом вниз
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) {
|
||||||
|
Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК"));
|
||||||
|
if (!okBtn.isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Error dialog detected, tapping OK";
|
||||||
|
AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xmlScreenParser.isHistoryScreen()) {
|
||||||
|
historyLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!historyLoaded) {
|
||||||
|
m_error = "History screen not loaded: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключаемся на вкладку "Выписка" (по умолчанию открыта "Операции")
|
||||||
|
Node vypiskaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Выписка"));
|
||||||
|
if (vypiskaBtn.isEmpty()) {
|
||||||
|
m_error = "Cannot find 'Выписка' tab: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Switching to 'Выписка' tab";
|
||||||
|
AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y());
|
||||||
|
QThread::msleep(3000);
|
||||||
|
|
||||||
|
// Pull-to-refresh: свайп сверху вниз для обновления списка
|
||||||
|
{
|
||||||
|
const int x = device.screenWidth / 2;
|
||||||
|
const int fromY = device.screenHeight / 3;
|
||||||
|
const int toY = device.screenHeight * 2 / 3;
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем дамп Выписки для анализа формата
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Закрываем возможный диалог "Ошибка" на Выписке
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) {
|
||||||
|
Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК"));
|
||||||
|
if (!okBtn.isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Error dialog on Выписка, tapping OK";
|
||||||
|
AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
|
||||||
|
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
|
||||||
|
// Находим accountId
|
||||||
|
int accountId = -1;
|
||||||
|
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||||
|
if (!accounts.isEmpty()) {
|
||||||
|
accountId = accounts.first().id;
|
||||||
|
} else {
|
||||||
|
qWarning() << "[Dushanbe::GetLastTransactions] No account found";
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
|
||||||
|
<< "time=" << m_searchTime.toString(Qt::ISODate);
|
||||||
|
|
||||||
|
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
|
||||||
|
const QTimeZone tjTz("Asia/Dushanbe");
|
||||||
|
const int maxScrolls = 10;
|
||||||
|
|
||||||
|
auto interrupted = []() {
|
||||||
|
return QThread::currentThread()->isInterruptionRequested();
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VypiskaItem {
|
||||||
|
double amount = 0.0;
|
||||||
|
QString time; // "21:31:21"
|
||||||
|
QString date; // "05.04.2026"
|
||||||
|
bool dateReliable = false; // true если дата из content-desc (P2P), false если из groupDates
|
||||||
|
Node node;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
|
||||||
|
if (interrupted()) {
|
||||||
|
m_error = "Interrupted";
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
// Верх нижнего меню: минимальный y1() среди табов и FAB "Сканировать".
|
||||||
|
// Тап по строке с y >= navTopY - navMargin промахивается и попадает в FAB/таб.
|
||||||
|
int navTopY = device.screenHeight;
|
||||||
|
for (const QString &lbl : {
|
||||||
|
QString::fromUtf8("Главная"), QString::fromUtf8("История"),
|
||||||
|
QString::fromUtf8("Переводы"), QString::fromUtf8("Сканировать")}) {
|
||||||
|
Node nav = xmlScreenParser.findNodeByContentDesc(lbl);
|
||||||
|
if (!nav.isEmpty() && nav.y1() > 0 && nav.y1() < navTopY) navTopY = nav.y1();
|
||||||
|
}
|
||||||
|
const int navMargin = 20;
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] navTopY=" << navTopY;
|
||||||
|
|
||||||
|
// 1. Собираем список дат-групп + извлекаем "встроенную" верхнюю транзакцию,
|
||||||
|
// которая впечатана в content-desc родительского ImageView "Обновлено: ..."
|
||||||
|
// вместо отдельного android.view.View (самая свежая запись в Выписке).
|
||||||
|
const QDate todayLocal = QDateTime::currentDateTimeUtc().toTimeZone(tjTz).date();
|
||||||
|
QStringList groupDates;
|
||||||
|
static const QRegularExpression reDate(R"(^\d{2}\.\d{2}\.\d{4}$)");
|
||||||
|
static const QRegularExpression reEmbTime(R"(^\s*(\d{2}:\d{2}:\d{2})\s*$)");
|
||||||
|
Node updatedImgNode;
|
||||||
|
VypiskaItem embedded;
|
||||||
|
bool hasEmbedded = false;
|
||||||
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (n.className != "android.widget.ImageView") continue;
|
||||||
|
if (!n.contentDesc.startsWith(QString::fromUtf8("Обновлено"))) continue;
|
||||||
|
updatedImgNode = n;
|
||||||
|
const QStringList descLines = n.contentDesc.split('\n');
|
||||||
|
for (const QString &line : descLines) {
|
||||||
|
const QString t = line.trimmed();
|
||||||
|
if (reDate.match(t).hasMatch()) {
|
||||||
|
groupDates.append(t);
|
||||||
|
} else if (t.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0) {
|
||||||
|
groupDates.append(todayLocal.toString("dd.MM.yyyy"));
|
||||||
|
} else if (t.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0) {
|
||||||
|
groupDates.append(todayLocal.addDays(-1).toString("dd.MM.yyyy"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Паттерн встроенной: ...\ncard\ncard\namount\nописание\n HH:MM:SS\n...
|
||||||
|
for (int i = 4; i < descLines.size(); ++i) {
|
||||||
|
const auto tm = reEmbTime.match(descLines[i]);
|
||||||
|
if (!tm.hasMatch()) continue;
|
||||||
|
bool ok = false;
|
||||||
|
const double amt = descLines[i - 2].trimmed().toDouble(&ok);
|
||||||
|
if (!ok) continue;
|
||||||
|
embedded.amount = amt;
|
||||||
|
embedded.time = tm.captured(1);
|
||||||
|
if (!groupDates.isEmpty()) embedded.date = groupDates.first();
|
||||||
|
hasEmbedded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Собираем View-транзакции (формат Выписки)
|
||||||
|
// Известные форматы content-desc (всё через '\n'):
|
||||||
|
// a) DCWallet (старый, 5 строк): DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
|
||||||
|
// b) DCWallet (новый, 6 строк): DCWallet*WDC...\nphone-card-\ncard_owner\namount\nописание\n HH:MM:SS
|
||||||
|
// c) P2P: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
|
||||||
|
// Позиция amount уехала между версиями приложения, поэтому ищем по
|
||||||
|
// паттерну "[-]?digits.dd" среди всех строк — устойчиво к перестановке полей.
|
||||||
|
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
|
||||||
|
static const QRegularExpression reDateOrToday(
|
||||||
|
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
|
||||||
|
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
|
||||||
|
static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)");
|
||||||
|
QList<VypiskaItem> items;
|
||||||
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (n.className != "android.view.View") continue;
|
||||||
|
const QStringList lines = n.contentDesc.split('\n');
|
||||||
|
if (lines.size() < 5) continue;
|
||||||
|
const QString last = lines.last().trimmed();
|
||||||
|
if (!reTime.match(last).hasMatch()) continue;
|
||||||
|
|
||||||
|
// Определяем формат: если первая строка — дата/Сегодня/Вчера, это P2P формат
|
||||||
|
const QString firstLine = lines.first().trimmed();
|
||||||
|
const bool isP2P = reDateOrToday.match(firstLine).hasMatch();
|
||||||
|
if (!isP2P && !n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue;
|
||||||
|
|
||||||
|
VypiskaItem it;
|
||||||
|
bool ok = false;
|
||||||
|
// Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному
|
||||||
|
// DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые
|
||||||
|
// поля не подпадают под паттерн.
|
||||||
|
for (const QString &line : lines) {
|
||||||
|
if (!reAmountLine.match(line.trimmed()).hasMatch()) continue;
|
||||||
|
it.amount = line.trimmed().toDouble(&ok);
|
||||||
|
if (ok) break;
|
||||||
|
}
|
||||||
|
if (!ok) continue;
|
||||||
|
it.time = last;
|
||||||
|
it.node = n;
|
||||||
|
|
||||||
|
// В P2P формате дата встроена в первую строку — надёжная
|
||||||
|
if (isP2P) {
|
||||||
|
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
|
||||||
|
it.date = todayLocal.toString("dd.MM.yyyy");
|
||||||
|
else if (firstLine.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0)
|
||||||
|
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
|
||||||
|
else
|
||||||
|
it.date = firstLine;
|
||||||
|
it.dateReliable = true;
|
||||||
|
}
|
||||||
|
items.append(it);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2a. Если нашли встроенную транзакцию — создаём синтетический Node с
|
||||||
|
// координатами тапа над первой "реальной" View-записью и добавляем
|
||||||
|
// как первый элемент списка.
|
||||||
|
if (hasEmbedded && !updatedImgNode.isEmpty()) {
|
||||||
|
int firstViewY1 = -1;
|
||||||
|
int firstViewRowH = 0;
|
||||||
|
for (const VypiskaItem &v : items) {
|
||||||
|
if (firstViewY1 < 0 || v.node.y1() < firstViewY1) {
|
||||||
|
firstViewY1 = v.node.y1();
|
||||||
|
firstViewRowH = v.node.height();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int topY = updatedImgNode.y1();
|
||||||
|
// Если первая реальная View-запись уже находится у верха ImageView
|
||||||
|
// (значит скролл поднял контент и встроенная запись ушла за экран),
|
||||||
|
// пропускаем embedded — её визуально нет.
|
||||||
|
const int minEmbeddedSpace = 120;
|
||||||
|
const bool embeddedVisible = firstViewY1 < 0 || (firstViewY1 - topY) >= minEmbeddedSpace;
|
||||||
|
if (embeddedVisible) {
|
||||||
|
int tapY;
|
||||||
|
if (firstViewY1 > 0 && firstViewRowH > 0) {
|
||||||
|
// Между встроенной записью и первой "реальной" View лежит
|
||||||
|
// заголовок даты (~0.4 * rowH). Ставим тап по центру
|
||||||
|
// строки: firstViewY1 - headerH - rowH/2 ≈ firstViewY1 - rowH * 0.9.
|
||||||
|
// Дополнительно ограничиваем диапазоном [topY + rowH/2 .. firstViewY1 - 40].
|
||||||
|
tapY = firstViewY1 - (firstViewRowH * 9 / 10);
|
||||||
|
const int minY = topY + firstViewRowH / 2;
|
||||||
|
const int maxY = firstViewY1 - 40;
|
||||||
|
if (tapY < minY) tapY = minY;
|
||||||
|
if (tapY > maxY) tapY = maxY;
|
||||||
|
} else {
|
||||||
|
tapY = topY + 200;
|
||||||
|
}
|
||||||
|
Node synth;
|
||||||
|
synth.setBounds(updatedImgNode.x1(), tapY - 10, updatedImgNode.x2(), tapY + 10);
|
||||||
|
synth.className = "android.view.View";
|
||||||
|
synth.contentDesc = updatedImgNode.contentDesc;
|
||||||
|
synth.clickable = true;
|
||||||
|
embedded.node = synth;
|
||||||
|
items.prepend(embedded);
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx added:"
|
||||||
|
<< "amount=" << embedded.amount << "time=" << embedded.time
|
||||||
|
<< "date=" << embedded.date << "tapY=" << tapY;
|
||||||
|
} else {
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx skipped "
|
||||||
|
"(scrolled off): firstViewY1=" << firstViewY1 << "topY=" << topY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Сортируем по Y и группируем по разрывам (> 30px → новая группа-день)
|
||||||
|
std::sort(items.begin(), items.end(), [](const VypiskaItem &a, const VypiskaItem &b) {
|
||||||
|
return a.node.y1() < b.node.y1();
|
||||||
|
});
|
||||||
|
int groupIdx = 0;
|
||||||
|
int prevY2 = -1;
|
||||||
|
for (VypiskaItem &it : items) {
|
||||||
|
if (prevY2 >= 0 && it.node.y1() - prevY2 > 30) ++groupIdx;
|
||||||
|
// Не перезаписываем дату если она уже установлена (P2P формат)
|
||||||
|
if (it.date.isEmpty() && groupIdx < groupDates.size())
|
||||||
|
it.date = groupDates[groupIdx];
|
||||||
|
prevY2 = it.node.y2();
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] scroll=" << scroll
|
||||||
|
<< "items:" << items.size() << "groups:" << groupDates
|
||||||
|
<< "hasEmbedded:" << hasEmbedded;
|
||||||
|
// Логируем все items для отладки
|
||||||
|
for (int i = 0; i < items.size(); ++i) {
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] item[" << i << "] amount=" << items[i].amount
|
||||||
|
<< "date=" << items[i].date << "time=" << items[i].time
|
||||||
|
<< "y=" << items[i].node.y();
|
||||||
|
}
|
||||||
|
|
||||||
|
const double searchAbs = qAbs(m_searchAmount);
|
||||||
|
for (const auto &tx : items) {
|
||||||
|
const double absListAmt = qAbs(tx.amount);
|
||||||
|
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
|
||||||
|
const bool amountX100 = qAbs(absListAmt * 100.0 - searchAbs) < 0.01;
|
||||||
|
if (!amountDirect && !amountX100) continue;
|
||||||
|
const bool matchedX100 = amountX100 && !amountDirect;
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount
|
||||||
|
<< (matchedX100 ? "(scaled x100)" : "(direct)")
|
||||||
|
<< "date:" << tx.date << "time:" << tx.time;
|
||||||
|
|
||||||
|
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
|
||||||
|
// DCWallet транзакции получают дату из groupDates ImageView, которая
|
||||||
|
// неправильно присваивается при скролле — не фильтруем по ней.
|
||||||
|
if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) {
|
||||||
|
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
|
||||||
|
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
|
||||||
|
if (listDate.isValid() && searchLocalDate.isValid()) {
|
||||||
|
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
|
||||||
|
if (dayDiff > 3) {
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Date pre-filter skip:"
|
||||||
|
<< tx.date << "vs" << searchLocalDate.toString("dd.MM.yyyy")
|
||||||
|
<< "diff=" << dayDiff << "days";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи)
|
||||||
|
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
|
||||||
|
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
|
||||||
|
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
|
||||||
|
if (listTime.isValid() && searchLocalTime.isValid()) {
|
||||||
|
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
|
||||||
|
if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:"
|
||||||
|
<< tx.time << "vs" << searchLocalTime.toString("HH:mm:ss")
|
||||||
|
<< "diff=" << diffSecs << "sec";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Node txNode = tx.node;
|
||||||
|
|
||||||
|
if (txNode.x() == 0 && txNode.y() == 0) {
|
||||||
|
qWarning() << "[Dushanbe::GetLastTransactions] Cannot find node for transaction";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка под нижним меню — тап попадёт в FAB "Сканировать" или таб.
|
||||||
|
// Пропускаем кандидата, ищем совпадение выше; если таких нет — скролл.
|
||||||
|
if (txNode.y() >= navTopY - navMargin) {
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Candidate y=" << txNode.y()
|
||||||
|
<< "behind nav (navTopY=" << navTopY << "), skipping, will scroll";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тап по левому краю ряда — уходим от центра экрана, где FAB "Сканировать".
|
||||||
|
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; }
|
||||||
|
|
||||||
|
// Ждём экран деталей Выписки — 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; }
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
// 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml):
|
||||||
|
// одна нода со всем content-desc через \n. Лейблы без двоеточий, сумма —
|
||||||
|
// отдельной строкой со знаком сразу после даты+времени.
|
||||||
|
// "Успешный платёж\n21.05.2026 16:58:32\n-10\nКарта\n...\nПолучатель\n...\nЗакрыть"
|
||||||
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
const QString cd = n.contentDesc;
|
||||||
|
if (!cd.contains(QString::fromUtf8("Карта"))) continue;
|
||||||
|
if (!cd.contains(QString::fromUtf8("Получатель"))) continue;
|
||||||
|
if (!cd.contains(QString::fromUtf8("Операция"))) continue;
|
||||||
|
const QStringList lines = cd.split('\n', Qt::SkipEmptyParts);
|
||||||
|
if (lines.size() < 6) continue;
|
||||||
|
|
||||||
|
// lines[0] — заголовок: "Успешный платёж" / "Поступление" / ...
|
||||||
|
if (lines[0].contains(QString::fromUtf8("Успешн"))
|
||||||
|
|| lines[0].contains(QString::fromUtf8("Поступление"))
|
||||||
|
|| lines[0].contains(QString::fromUtf8("выполнен"))) {
|
||||||
|
detail.status = QString::fromUtf8("Успешный");
|
||||||
|
}
|
||||||
|
// lines[1] — "dd.MM.yyyy HH:mm:ss"
|
||||||
|
{
|
||||||
|
static const QRegularExpression reDt(
|
||||||
|
R"((\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2}))");
|
||||||
|
const auto m = reDt.match(lines[1]);
|
||||||
|
if (m.hasMatch()) {
|
||||||
|
detail.date = m.captured(1);
|
||||||
|
detail.time = m.captured(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// lines[2] — сумма со знаком: "-10" / "30" / "30.50" / "1 000,50".
|
||||||
|
detail.amount = parseDushanbeAmount(lines[2]);
|
||||||
|
|
||||||
|
// Дальше — пары label/value; значение может быть multi-line (Получатель).
|
||||||
|
// На сервер шлём первую строку получателя — таков сложившийся контракт.
|
||||||
|
static const QSet<QString> labels{
|
||||||
|
QString::fromUtf8("Карта"),
|
||||||
|
QString::fromUtf8("Получатель"),
|
||||||
|
QString::fromUtf8("Операция"),
|
||||||
|
QString::fromUtf8("Дата"),
|
||||||
|
QString::fromUtf8("Время"),
|
||||||
|
QString::fromUtf8("Закрыть"),
|
||||||
|
QString::fromUtf8("Назад"),
|
||||||
|
};
|
||||||
|
for (int i = 3; i < lines.size(); ++i) {
|
||||||
|
const QString lbl = lines[i].trimmed();
|
||||||
|
if (!labels.contains(lbl)) continue;
|
||||||
|
QStringList vals;
|
||||||
|
for (int j = i + 1; j < lines.size(); ++j) {
|
||||||
|
const QString cand = lines[j].trimmed();
|
||||||
|
if (labels.contains(cand)) break;
|
||||||
|
vals << cand;
|
||||||
|
}
|
||||||
|
if (vals.isEmpty()) continue;
|
||||||
|
if (lbl == QString::fromUtf8("Получатель")) {
|
||||||
|
detail.receiverAccount = vals.first();
|
||||||
|
} else if (lbl == QString::fromUtf8("Дата") && detail.date.isEmpty()) {
|
||||||
|
detail.date = vals.first();
|
||||||
|
} else if (lbl == QString::fromUtf8("Время") && detail.time.isEmpty()) {
|
||||||
|
detail.time = vals.first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detailLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 2) Старый blob-формат (до 3.2.33): "Успешный платеж" / "Поступление" —
|
||||||
|
// одна нода с ключами и значениями через \n.
|
||||||
|
if (!detailLoaded) for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (!n.contentDesc.contains(QString::fromUtf8("Дата"))) continue;
|
||||||
|
if (!n.contentDesc.contains(QString::fromUtf8("Время"))) continue;
|
||||||
|
const QStringList lines = n.contentDesc.split('\n');
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
QString key = lines[i].trimmed();
|
||||||
|
if (key.endsWith(':')) key.chop(1); // нормализуем "Дата:" → "Дата"
|
||||||
|
if (i + 1 >= lines.size()) continue;
|
||||||
|
const QString val = lines[i + 1].trimmed();
|
||||||
|
if (key == QString::fromUtf8("Получатель")) {
|
||||||
|
detail.receiverAccount = val;
|
||||||
|
} else if (key == QString::fromUtf8("Сумма")) {
|
||||||
|
detail.amount = parseDushanbeAmount(val);
|
||||||
|
} else if (key == QString::fromUtf8("Дата")) {
|
||||||
|
detail.date = val;
|
||||||
|
} else if (key == QString::fromUtf8("Время")) {
|
||||||
|
detail.time = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detailLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 3) Split-формат: лейблы (с ':') и значения в отдельных View-нодах
|
||||||
|
// на одной Y-полосе. Промежуточная версия между blob и bottom-sheet.
|
||||||
|
if (!detailLoaded) {
|
||||||
|
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
|
||||||
|
const QString d = pairs.value(QString::fromUtf8("Дата"));
|
||||||
|
const QString t = pairs.value(QString::fromUtf8("Время"));
|
||||||
|
if (!d.isEmpty() && !t.isEmpty()) {
|
||||||
|
detail.date = d;
|
||||||
|
detail.time = t;
|
||||||
|
detail.receiverAccount = pairs.value(QString::fromUtf8("Получатель"));
|
||||||
|
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("Сумма")));
|
||||||
|
if (detail.amount == 0.0) {
|
||||||
|
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("К зачислению")));
|
||||||
|
}
|
||||||
|
detail.fee = parseDushanbeAmount(pairs.value(QString::fromUtf8("Комиссия")));
|
||||||
|
detail.operationId = pairs.value(QString::fromUtf8("Номер операции"));
|
||||||
|
detail.status = pairs.value(QString::fromUtf8("Статус"));
|
||||||
|
detailLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (detailLoaded) break;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detailLoaded) {
|
||||||
|
qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded "
|
||||||
|
"(tap missed or detail format unknown); staying on Выписка";
|
||||||
|
// Не вызываем goBack — после промаха тапа мы всё ещё на Выписке.
|
||||||
|
// goBack тут закрывает Выписку и ломает дальнейший скролл.
|
||||||
|
// Если же тап сработал и открылся НЕ-распознанный экран — следующая
|
||||||
|
// итерация parse обнаружит это, и мы обработаем штатно.
|
||||||
|
QThread::msleep(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Detail parsed:"
|
||||||
|
<< "date=" << detail.date << "time=" << detail.time
|
||||||
|
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
|
||||||
|
|
||||||
|
// Проверяем время ±10 минут
|
||||||
|
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
|
||||||
|
QDateTime txTime = QDateTime::fromString(
|
||||||
|
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
||||||
|
if (!txTime.isValid()) {
|
||||||
|
txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm");
|
||||||
|
}
|
||||||
|
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";
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нашли! Сохраняем
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] FOUND:"
|
||||||
|
<< "amount=" << detail.amount
|
||||||
|
<< "date=" << detail.date << detail.time
|
||||||
|
<< "opId=" << detail.operationId
|
||||||
|
<< "receiver=" << detail.receiverAccount
|
||||||
|
<< "status=" << detail.status;
|
||||||
|
|
||||||
|
// Формируем task_result напрямую из распарсенных данных и отдаём
|
||||||
|
// EventHandler через сигнал — без записи/чтения БД.
|
||||||
|
{
|
||||||
|
const double finalAmount = matchedX100 ? detail.amount * 100.0 : detail.amount;
|
||||||
|
const double finalFee = matchedX100 ? detail.fee * 100.0 : detail.fee;
|
||||||
|
|
||||||
|
QDateTime txTime = QDateTime::fromString(
|
||||||
|
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
||||||
|
if (!txTime.isValid()) {
|
||||||
|
txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm");
|
||||||
|
}
|
||||||
|
if (txTime.isValid()) txTime.setTimeZone(tjTz);
|
||||||
|
|
||||||
|
QJsonObject txJson;
|
||||||
|
txJson["bank_transaction_id"] = detail.operationId.isEmpty()
|
||||||
|
? QString("dushanbe_") + detail.date + "_" + detail.time
|
||||||
|
: detail.operationId;
|
||||||
|
// Списание хранится с отрицательным знаком, пополнение — положительным.
|
||||||
|
// detail.amount всегда положителен (OCR), знак берём из списка Выписки.
|
||||||
|
const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount);
|
||||||
|
txJson["amount"] = static_cast<int>(signedAmount * 100);
|
||||||
|
txJson["currency"] = 972; // TJS
|
||||||
|
txJson["transaction_type"] = "bank";
|
||||||
|
txJson["bank_participator_id"] = detail.receiverAccount;
|
||||||
|
txJson["status"] = detail.status.contains(QString::fromUtf8("Успешн"))
|
||||||
|
? QStringLiteral("completed") : QStringLiteral("progress");
|
||||||
|
QJsonObject extraObj;
|
||||||
|
extraObj["fee"] = static_cast<int>(finalFee * 100);
|
||||||
|
txJson["extra"] = extraObj;
|
||||||
|
txJson["created_at"] = txTime.isValid()
|
||||||
|
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:"
|
||||||
|
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
|
||||||
|
emit transactionParsed(txJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скриншот экрана деталей транзакции — отправляем в мониторинг
|
||||||
|
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
if (!m_resultScreenshot.isEmpty())
|
||||||
|
emit screenshotReady(m_resultScreenshot);
|
||||||
|
|
||||||
|
// Назад к истории
|
||||||
|
Node nazadBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Назад"));
|
||||||
|
if (nazadBtn.x() > 0 && nazadBtn.y() > 0) {
|
||||||
|
AdbUtils::makeTap(m_deviceId, nazadBtn.x(), nazadBtn.y());
|
||||||
|
} else {
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error); // m_error пустой — успех
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скроллим вниз — свайп внутри области транзакций
|
||||||
|
if (scroll < maxScrolls) {
|
||||||
|
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
||||||
|
const int x = device.screenWidth / 2;
|
||||||
|
// Определяем область контента из реальных Y позиций items
|
||||||
|
int contentBottom = device.screenHeight * 2 / 3;
|
||||||
|
int contentTop = device.screenHeight / 3;
|
||||||
|
if (!items.isEmpty()) {
|
||||||
|
contentBottom = items.last().node.y();
|
||||||
|
contentTop = items.first().node.y();
|
||||||
|
}
|
||||||
|
const int fromY = qMin(contentBottom, device.screenHeight * 2 / 3);
|
||||||
|
const int toY = qMax(contentTop, device.screenHeight / 4);
|
||||||
|
qDebug() << "[Dushanbe::GetLastTransactions] Swipe from" << fromY << "to" << toY;
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
|
||||||
|
QThread::msleep(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
43
android/dushanbe/GetLastTransactionsScript.h
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include "db/DeviceInfo.h"
|
||||||
|
#include "db/BankProfileInfo.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
class GetLastTransactionsScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit GetLastTransactionsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
double searchAmount = 0,
|
||||||
|
QDateTime searchTime = {},
|
||||||
|
QString materialId = {},
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
~GetLastTransactionsScript() override;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
// Испускается, когда транзакция найдена и распарсена с экрана деталей.
|
||||||
|
// EventHandler использует эти данные напрямую для task_result вместо
|
||||||
|
// повторного чтения из БД.
|
||||||
|
void transactionParsed(const QJsonObject &tx);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
const double m_searchAmount; // сумма в сомони (0 = все последние)
|
||||||
|
const QDateTime m_searchTime; // примерное время (UTC)
|
||||||
|
const QString m_materialId; // UUID задачи (для сохранения дампов)
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
156
android/dushanbe/GetProfileInfoScript.cpp
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
#include "GetProfileInfoScript.h"
|
||||||
|
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
GetProfileInfoScript::GetProfileInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)),
|
||||||
|
m_appCode(std::move(appCode)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
GetProfileInfoScript::~GetProfileInfoScript() = default;
|
||||||
|
|
||||||
|
void GetProfileInfoScript::doStart() {
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe::GetProfileInfo] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Всегда тапаем кнопку обновления — баланс на экране может быть закэширован
|
||||||
|
// и не отражать актуальное состояние счёта. Лоадера у refresh нет,
|
||||||
|
// поэтому ждём фиксированно.
|
||||||
|
Node refreshBtn = xmlScreenParser.findRefreshButtonOnHomeScreen();
|
||||||
|
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));
|
||||||
|
} else {
|
||||||
|
qWarning() << "[Dushanbe::GetProfileInfo] Refresh button not found, parsing as-is";
|
||||||
|
}
|
||||||
|
|
||||||
|
const double parsedBalance = xmlScreenParser.parseBalanceFromHomeScreen();
|
||||||
|
qDebug() << "[Dushanbe::GetProfileInfo] Parsed balance after refresh:" << parsedBalance;
|
||||||
|
if (parsedBalance <= 0.0) {
|
||||||
|
m_error = "Balance is zero after refresh: " + m_deviceId + " " + m_appCode;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BankProfileDAO::updateAmount(app.id, parsedBalance);
|
||||||
|
// У Душанбе все карты сидят на одном счёте, а экран деталей карты
|
||||||
|
// отдаёт placeholder "0,00 TJS" — единственный источник правды
|
||||||
|
// для materials.amount это баланс home-screen.
|
||||||
|
MaterialDAO::setAmountByApp(device.id, m_appCode, parsedBalance);
|
||||||
|
|
||||||
|
// Тапаем по кнопке меню (гамбургер, левый верхний угол)
|
||||||
|
// Это первый clickable ImageView в верхней панели
|
||||||
|
Node menuButton;
|
||||||
|
for (const Node &node : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (node.className == "android.widget.ImageView" && node.clickable && node.y() < 300) {
|
||||||
|
menuButton = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuButton.x() == 0 && menuButton.y() == 0) {
|
||||||
|
m_error = "Cannot find menu button: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, menuButton.x(), menuButton.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// Ждём боковое меню
|
||||||
|
bool sideMenuFound = false;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isSideMenu()) {
|
||||||
|
sideMenuFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sideMenuFound) {
|
||||||
|
m_error = "Side menu not detected: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим ФИО и телефон из бокового меню
|
||||||
|
const QString fullName = xmlScreenParser.parseProfileFullName();
|
||||||
|
QString phone = xmlScreenParser.parseProfilePhone();
|
||||||
|
phone.remove(QRegularExpression("[^0-9+]"));
|
||||||
|
qDebug() << "[Dushanbe::GetProfileInfo] fullName:" << fullName << "phone:" << phone;
|
||||||
|
|
||||||
|
if (!fullName.isEmpty()) {
|
||||||
|
BankProfileDAO::updateProfileInfo(app.id, fullName, phone, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) {
|
||||||
|
m_error = err;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||||
|
QList<QPair<MaterialInfo, Node>> accountsToPost;
|
||||||
|
for (const auto &acc : allAccounts) {
|
||||||
|
if (acc.materialId.isEmpty()) {
|
||||||
|
accountsToPost.append({acc, Node()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!accountsToPost.isEmpty()) {
|
||||||
|
postAccountsData(accountsToPost);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
27
android/dushanbe/GetProfileInfoScript.h
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
class GetProfileInfoScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
~GetProfileInfoScript() override;
|
||||||
|
|
||||||
|
explicit GetProfileInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
80
android/dushanbe/LoginAndCheckAccountsScript.cpp
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
#include "LoginAndCheckAccountsScript.h"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
#include "GetProfileInfoScript.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
LoginAndCheckAccountsScript::LoginAndCheckAccountsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent,
|
||||||
|
bool pinCheckOnly
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)),
|
||||||
|
m_appCode(std::move(appCode)),
|
||||||
|
m_pinCheckOnly(pinCheckOnly) {
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
|
||||||
|
|
||||||
|
void LoginAndCheckAccountsScript::doStart() {
|
||||||
|
// Канонический ключ материала (device.id в БД) — для лукапов BankProfile;
|
||||||
|
// m_deviceId же перезаписываем ADB-серийником для команд.
|
||||||
|
const QString canonicalDeviceId = m_deviceId;
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
qDebug() << "[Dushanbe::Login] device.id=" << device.id << "adbSerial=" << device.adbSerial
|
||||||
|
<< "appCode=" << m_appCode << "app.id=" << app.id
|
||||||
|
<< "pinCode.length=" << app.pinCode.length() << "package=" << app.package;
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cant open the home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
|
||||||
|
qWarning() << m_error;
|
||||||
|
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||||
|
AppLogger::log("dushanbe/LoginAndCheck", m_deviceId, m_error, {}, "FETCH_PROFILE");
|
||||||
|
BankProfileDAO::updateComment(app.id,
|
||||||
|
"Не прошла проверка " + QDateTime::currentDateTime().toString("dd.MM.yyyy HH:mm:ss"));
|
||||||
|
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скриншот домашнего экрана — отправляем в мониторинг
|
||||||
|
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||||
|
|
||||||
|
if (m_pinCheckOnly) {
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// В GetProfileInfoScript отдаём КАНОНИЧЕСКИЙ ключ материала, а не ADB-серийник,
|
||||||
|
// чтобы BankProfileDAO::getApplication нашла правильную запись с bank_profile_id.
|
||||||
|
GetProfileInfoScript profileScript(canonicalDeviceId, m_appCode);
|
||||||
|
profileScript.start();
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
29
android/dushanbe/LoginAndCheckAccountsScript.h
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
class LoginAndCheckAccountsScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
~LoginAndCheckAccountsScript() override;
|
||||||
|
|
||||||
|
explicit LoginAndCheckAccountsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent = nullptr,
|
||||||
|
bool pinCheckOnly = false
|
||||||
|
);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
const bool m_pinCheckOnly;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
618
android/dushanbe/PayByCardScript.cpp
Normal file
@ -0,0 +1,618 @@
|
|||||||
|
#include "PayByCardScript.h"
|
||||||
|
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include <tesseract/baseapi.h>
|
||||||
|
#include <leptonica/allheaders.h>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "ocr/OcrUtils.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/TransactionDAO.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
// Точечный OCR: находит строку "операции" и кропает правую часть для digit-only OCR
|
||||||
|
static QString recognizeOperationId(const QByteArray &pngData) {
|
||||||
|
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
|
||||||
|
static_cast<size_t>(pngData.size()));
|
||||||
|
if (!pix) return {};
|
||||||
|
|
||||||
|
const int w = pixGetWidth(pix);
|
||||||
|
const int h = pixGetHeight(pix);
|
||||||
|
|
||||||
|
auto *api = new tesseract::TessBaseAPI();
|
||||||
|
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||||||
|
pixDestroy(&pix); delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
api->SetImage(pix);
|
||||||
|
api->Recognize(nullptr);
|
||||||
|
|
||||||
|
int opLineY = -1, opLineH = 0;
|
||||||
|
bool prevWasNomer = false;
|
||||||
|
tesseract::ResultIterator *ri = api->GetIterator();
|
||||||
|
if (ri) {
|
||||||
|
do {
|
||||||
|
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
|
||||||
|
if (word) {
|
||||||
|
const QString w_str = QString::fromUtf8(word);
|
||||||
|
delete[] word;
|
||||||
|
// Ищем "Номер" (или OCR-артефакты: "Homep", "Номep")
|
||||||
|
if (w_str.contains(QString::fromUtf8("омер")) ||
|
||||||
|
w_str.contains(QString::fromUtf8("omep"))) {
|
||||||
|
prevWasNomer = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// После "Номер" ищем "операции"
|
||||||
|
if (prevWasNomer &&
|
||||||
|
(w_str.contains(QString::fromUtf8("операции")) ||
|
||||||
|
w_str.contains(QString::fromUtf8("onepaции")))) {
|
||||||
|
int x1, y1, x2, y2;
|
||||||
|
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
|
||||||
|
opLineY = y1;
|
||||||
|
opLineH = y2 - y1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prevWasNomer = false;
|
||||||
|
}
|
||||||
|
} while (ri->Next(tesseract::RIL_WORD));
|
||||||
|
}
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
|
||||||
|
if (opLineY < 0) { pixDestroy(&pix); return {}; }
|
||||||
|
|
||||||
|
const int cropY = qMax(0, opLineY - opLineH / 2);
|
||||||
|
const int cropH = opLineH * 2;
|
||||||
|
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
|
||||||
|
Pix *cropped = pixClipRectangle(pix, box, nullptr);
|
||||||
|
boxDestroy(&box);
|
||||||
|
pixDestroy(&pix);
|
||||||
|
if (!cropped) return {};
|
||||||
|
|
||||||
|
auto *api2 = new tesseract::TessBaseAPI();
|
||||||
|
if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) {
|
||||||
|
pixDestroy(&cropped); delete api2;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
api2->SetVariable("tessedit_char_whitelist", "0123456789");
|
||||||
|
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
|
||||||
|
api2->SetImage(cropped);
|
||||||
|
char *text = api2->GetUTF8Text();
|
||||||
|
QString result = QString::fromUtf8(text).trimmed();
|
||||||
|
delete[] text;
|
||||||
|
pixDestroy(&cropped);
|
||||||
|
api2->End();
|
||||||
|
delete api2;
|
||||||
|
|
||||||
|
result.remove(QRegularExpression("[^0-9]"));
|
||||||
|
if (result.length() >= 8) {
|
||||||
|
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
PayByCardScript::PayByCardScript(
|
||||||
|
MaterialInfo account,
|
||||||
|
QString cardNumber,
|
||||||
|
double amount,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_account(std::move(account)),
|
||||||
|
m_cardNumber(std::move(cardNumber)),
|
||||||
|
m_amount(amount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
PayByCardScript::~PayByCardScript() = default;
|
||||||
|
|
||||||
|
void PayByCardScript::doStart() {
|
||||||
|
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_account.deviceId;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_CARD", device.adbSerial, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
|
||||||
|
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(device.adbSerial, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", device.id, m_error, {}, "CREATE_TRANSACTION");
|
||||||
|
AdbUtils::tryToKillApplication(device.adbSerial, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
makePayment(device.adbSerial, app.pinCode, app.phone, device.screenWidth, device.screenHeight);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PayByCardScript::makePayment(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &pinCode,
|
||||||
|
const QString &bankProfilePhone,
|
||||||
|
const int width,
|
||||||
|
const int height
|
||||||
|
) {
|
||||||
|
// 1. Тапаем "Переводы" в нижней навигации
|
||||||
|
Node transfersTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Переводы"));
|
||||||
|
if (transfersTab.isEmpty()) {
|
||||||
|
m_error = "Tab 'Переводы' not found on home screen";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Tapping 'Переводы' tab";
|
||||||
|
AdbUtils::makeTap(deviceId, transfersTab.x(), transfersTab.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 2. Ждём экран переводов — ищем "DC (по номеру карты)"
|
||||||
|
bool transfersLoaded = false;
|
||||||
|
Node cardTransferBtn;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
cardTransferBtn = xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("DC (по номеру карты)"), true);
|
||||||
|
if (!cardTransferBtn.isEmpty()) {
|
||||||
|
transfersLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Waiting for transfers screen, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transfersLoaded) {
|
||||||
|
m_error = "Transfers screen not loaded or 'DC (по номеру карты)' not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Тапаем "DC (по номеру карты)"
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Tapping 'DC (по номеру карты)'";
|
||||||
|
AdbUtils::makeTap(deviceId, cardTransferBtn.x(), cardTransferBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 4. Ждём экран ввода — ищем EditText с hint "Номер карты получателя"
|
||||||
|
bool inputScreenLoaded = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
Node editText = xmlScreenParser.findEditTextByHint(
|
||||||
|
QString::fromUtf8("Номер карты получателя"));
|
||||||
|
if (editText.isEmpty()) {
|
||||||
|
editText = xmlScreenParser.findFirstEditText();
|
||||||
|
}
|
||||||
|
if (!editText.isEmpty()) {
|
||||||
|
inputScreenLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Waiting for card input screen, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputScreenLoaded) {
|
||||||
|
m_error = "Card input screen not loaded";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Вводим номер карты в поле "Номер карты получателя"
|
||||||
|
Node cardEditText = xmlScreenParser.findEditTextByHint(
|
||||||
|
QString::fromUtf8("Номер карты получателя"));
|
||||||
|
if (cardEditText.isEmpty()) {
|
||||||
|
cardEditText = xmlScreenParser.findFirstEditText();
|
||||||
|
}
|
||||||
|
AdbUtils::makeTap(deviceId, cardEditText.x(), cardEditText.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
AdbUtils::inputText(deviceId, m_cardNumber);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
// Скрываем клавиатуру чтобы поле суммы стало видимым
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
// 6. Вводим сумму в поле "Сумма" (hint содержит "Сумма")
|
||||||
|
Node sumEditText;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма"));
|
||||||
|
if (sumEditText.isEmpty()) sumEditText = xmlScreenParser.findSumEditText();
|
||||||
|
if (!sumEditText.isEmpty()) break;
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Waiting for sum input, attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
if (sumEditText.isEmpty()) {
|
||||||
|
m_error = "Sum input field not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
|
||||||
|
// Сумма приходит с копейками (например 100.50), форматируем как целое или с точкой
|
||||||
|
const QString amountStr = (m_amount == static_cast<int>(m_amount))
|
||||||
|
? QString::number(static_cast<int>(m_amount))
|
||||||
|
: QString::number(m_amount, 'f', 2);
|
||||||
|
AdbUtils::inputText(deviceId, amountStr);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
// 6a. Скрываем клавиатуру и выбираем карту-источник (под полем "Комментарий")
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
{
|
||||||
|
const QString cardMask = "****" + m_account.lastNumbers;
|
||||||
|
Node cardNode;
|
||||||
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (!n.clickable) continue;
|
||||||
|
if (n.contentDesc.contains(cardMask)) { cardNode = n; break; }
|
||||||
|
}
|
||||||
|
if (cardNode.isEmpty()) {
|
||||||
|
m_error = "Source card not found: " + cardMask;
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Selecting source card:" << cardMask;
|
||||||
|
AdbUtils::makeTap(deviceId, cardNode.x(), cardNode.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Нажимаем "Далее"
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
Node daleeBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Далее"));
|
||||||
|
if (daleeBtn.isEmpty()) {
|
||||||
|
daleeBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Далее"), false);
|
||||||
|
}
|
||||||
|
if (daleeBtn.isEmpty()) {
|
||||||
|
m_error = "Button 'Далее' not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Tapping 'Далее'";
|
||||||
|
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 8. После "Далее" возможны три исхода:
|
||||||
|
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
|
||||||
|
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
|
||||||
|
// c) экран ошибки.
|
||||||
|
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
|
||||||
|
bool oplatitFound = false;
|
||||||
|
bool earlySuccess = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Payment completed immediately after 'Далее'";
|
||||||
|
oplatitFound = true;
|
||||||
|
earlySuccess = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
|
||||||
|
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
|
||||||
|
m_error = "Transfer failed: error screen detected after 'Далее'";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
|
||||||
|
if (oplatitBtn.isEmpty()) {
|
||||||
|
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
|
||||||
|
}
|
||||||
|
if (!oplatitBtn.isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Tapping 'Оплатить'";
|
||||||
|
AdbUtils::makeTap(deviceId, oplatitBtn.x(), oplatitBtn.y());
|
||||||
|
oplatitFound = true;
|
||||||
|
QThread::msleep(3000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Waiting for 'Оплатить', attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oplatitFound) {
|
||||||
|
m_error = "Button 'Оплатить' not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
|
||||||
|
// content-desc содержит "Подтверждение платежа...Подтверждаю"
|
||||||
|
// Кнопка "Подтверждаю" визуально внизу bottom sheet
|
||||||
|
bool confirmed = earlySuccess;
|
||||||
|
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
// Пин-код может появиться
|
||||||
|
if (xmlScreenParser.isPinCodeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] PIN code screen, entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinCode);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем элемент с "Подтверждение платежа" и "Подтверждаю" в content-desc
|
||||||
|
Node confirmNode = xmlScreenParser.findNodeByContentDesc(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap;
|
||||||
|
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||||
|
confirmed = true;
|
||||||
|
QThread::msleep(4000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Waiting for confirmation sheet, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
m_error = "Confirmation sheet not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Ждём результат — "Платеж выполнен" или ошибку.
|
||||||
|
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
|
||||||
|
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
|
||||||
|
// Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал,
|
||||||
|
// bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий
|
||||||
|
// sheet → пробуем другую Y. Без лимита получали бесконечный
|
||||||
|
// "Оплатить ↔ Подтверждаю" со случайными попаданиями в системные экраны
|
||||||
|
// (security/launcher).
|
||||||
|
bool success = earlySuccess;
|
||||||
|
int confirmRetries = 0;
|
||||||
|
int oplatitRetries = 0;
|
||||||
|
const int maxConfirmRetries = 2;
|
||||||
|
const int maxOplatitRetries = 2;
|
||||||
|
for (int attempt = 0; !earlySuccess && attempt < 60; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
// Пин-код
|
||||||
|
if (xmlScreenParser.isPinCodeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] PIN code screen (retry), entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinCode);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Успех: content-desc начинается с "Платеж выполнен"
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
|
||||||
|
success = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ошибка
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
|
||||||
|
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
|
||||||
|
m_error = "Transfer failed: error screen detected";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Промежуточный статус — банк ещё обрабатывает перевод
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Выполняется"), true).isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Status 'Выполняется', waiting for completion, attempt"
|
||||||
|
<< attempt + 1;
|
||||||
|
QThread::msleep(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если bottom sheet "Подтверждение платежа" всё ещё виден — первый тап
|
||||||
|
// не сработал (мимо OCR-координат), повторяем его той же логикой.
|
||||||
|
Node retryConfirm = xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Подтверждение платежа"), true);
|
||||||
|
if (!retryConfirm.isEmpty() &&
|
||||||
|
retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
|
||||||
|
if (confirmRetries >= maxConfirmRetries) {
|
||||||
|
m_error = "Confirm tap missed " + QString::number(confirmRetries)
|
||||||
|
+ " times (sheet still visible)";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
++confirmRetries;
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap"
|
||||||
|
<< confirmRetries << "/" << maxConfirmRetries;
|
||||||
|
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
|
||||||
|
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
QPoint tap = OcrUtils::findTextCenter(screen,
|
||||||
|
QString::fromUtf8("Подтверждаю"),
|
||||||
|
ocrYMin, retryConfirm.y2());
|
||||||
|
if (tap.x() < 0) {
|
||||||
|
// Варьируем Y: первый retry — выше fallback на 100px,
|
||||||
|
// второй — ещё на 100px выше. Compose-кнопка действия
|
||||||
|
// обычно на y2 - 150..350; перебираем диапазон.
|
||||||
|
const int extra = 100 * confirmRetries;
|
||||||
|
const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra);
|
||||||
|
tap = QPoint(retryConfirm.x(), tapY);
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
|
||||||
|
}
|
||||||
|
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||||
|
QThread::msleep(4000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sheet "Подтверждение платежа" пропал, но снова виден input-экран
|
||||||
|
// (кнопка "Оплатить" вернулась) — значит тап по "Подтверждаю" промахнулся
|
||||||
|
// и Compose дисмиснул bottom sheet (clip на scrim/Закрыть/X). Перезапускаем
|
||||||
|
// цикл: тапаем "Оплатить" → следующая итерация увидит sheet и retryConfirm
|
||||||
|
// выше повторно тапнет "Подтверждаю".
|
||||||
|
Node retryOplatit = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
|
||||||
|
if (retryOplatit.isEmpty()) {
|
||||||
|
retryOplatit = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
|
||||||
|
}
|
||||||
|
if (!retryOplatit.isEmpty() && retryOplatit.clickable) {
|
||||||
|
if (oplatitRetries >= maxOplatitRetries) {
|
||||||
|
m_error = "Sheet dismissed and re-tap exhausted ("
|
||||||
|
+ QString::number(oplatitRetries) + " retries)";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
++oplatitRetries;
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Sheet dismissed, re-tap 'Оплатить'"
|
||||||
|
<< oplatitRetries << "/" << maxOplatitRetries;
|
||||||
|
AdbUtils::makeTap(deviceId, retryOplatit.x(), retryOplatit.y());
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Waiting for result, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
m_error = "Payment result not received within timeout";
|
||||||
|
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Payment successful!";
|
||||||
|
m_error.clear();
|
||||||
|
|
||||||
|
// Скриншот чека — сохраняем как m_resultScreenshot и отправляем в мониторинг
|
||||||
|
m_resultScreenshot = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
const QByteArray &receiptImage = m_resultScreenshot;
|
||||||
|
if (!m_resultScreenshot.isEmpty())
|
||||||
|
emit screenshotReady(m_resultScreenshot);
|
||||||
|
|
||||||
|
// Парсим детали из экрана "Платеж выполнен".
|
||||||
|
// До 3.2.33 все поля шли одним content-desc с ключами "Дата:" / "Время:" / "Комиссия:".
|
||||||
|
// С 3.2.33 лейблы и значения — отдельные View-ноды в двух колонках.
|
||||||
|
QString bankTime;
|
||||||
|
double fee = 0;
|
||||||
|
|
||||||
|
Node resultNode = xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Платеж выполнен"), true);
|
||||||
|
if (!resultNode.isEmpty()) {
|
||||||
|
const QStringList lines = resultNode.contentDesc.split('\n');
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
const QString line = lines[i].trimmed();
|
||||||
|
if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
|
||||||
|
fee = lines[i + 1].trimmed().toDouble();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Дата:")) && i + 1 < lines.size()) {
|
||||||
|
bankTime = lines[i + 1].trimmed();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Время:")) && i + 1 < lines.size()) {
|
||||||
|
bankTime += " " + lines[i + 1].trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bankTime.isEmpty()) {
|
||||||
|
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
|
||||||
|
const QString d = pairs.value(QString::fromUtf8("Дата"));
|
||||||
|
const QString t = pairs.value(QString::fromUtf8("Время"));
|
||||||
|
if (!d.isEmpty() || !t.isEmpty()) {
|
||||||
|
bankTime = (d + " " + t).trimmed();
|
||||||
|
}
|
||||||
|
if (fee == 0) {
|
||||||
|
fee = pairs.value(QString::fromUtf8("Комиссия")).toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee;
|
||||||
|
|
||||||
|
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
|
||||||
|
// Уникальность за счёт <bankProfilePhone, секунда>.
|
||||||
|
const QString operationId = QString("dushanbe_") + bankProfilePhone + "_"
|
||||||
|
+ QString::number(QDateTime::currentSecsSinceEpoch());
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Generated operationId:" << operationId;
|
||||||
|
|
||||||
|
// Сохраняем транзакцию
|
||||||
|
TransactionInfo tx;
|
||||||
|
tx.accountId = m_account.id;
|
||||||
|
tx.amount = -m_amount;
|
||||||
|
tx.fee = fee;
|
||||||
|
tx.status = TransactionStatus::Complete;
|
||||||
|
tx.type = TransactionType::Card;
|
||||||
|
tx.phone = m_cardNumber;
|
||||||
|
tx.bankName = "dushanbe_city_bank";
|
||||||
|
tx.bankTime = bankTime;
|
||||||
|
tx.bankTrExternalId = operationId;
|
||||||
|
tx.timestamp = QDateTime::currentDateTimeUtc();
|
||||||
|
tx.completeTime = QDateTime::currentDateTimeUtc();
|
||||||
|
tx.receiptImage = receiptImage;
|
||||||
|
|
||||||
|
const int txId = TransactionDAO::insertTransaction(tx);
|
||||||
|
if (txId != -1) {
|
||||||
|
qDebug() << "[Dushanbe::PayByCard] Transaction saved, id:" << txId
|
||||||
|
<< "operationId:" << operationId;
|
||||||
|
if (!receiptImage.isEmpty()) {
|
||||||
|
TransactionDAO::updateReceiptImage(txId, receiptImage);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning() << "[Dushanbe::PayByCard] Failed to save transaction";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
37
android/dushanbe/PayByCardScript.h
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
class PayByCardScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
PayByCardScript(
|
||||||
|
MaterialInfo account,
|
||||||
|
QString cardNumber,
|
||||||
|
double amount,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
~PayByCardScript() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const MaterialInfo m_account;
|
||||||
|
const QString m_cardNumber;
|
||||||
|
const double m_amount;
|
||||||
|
QString m_error;
|
||||||
|
|
||||||
|
void makePayment(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &pinCode,
|
||||||
|
const QString &bankProfilePhone,
|
||||||
|
int width,
|
||||||
|
int height
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
610
android/dushanbe/PayByPhoneScript.cpp
Normal file
@ -0,0 +1,610 @@
|
|||||||
|
#include "PayByPhoneScript.h"
|
||||||
|
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include <tesseract/baseapi.h>
|
||||||
|
#include <leptonica/allheaders.h>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "ocr/OcrUtils.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/TransactionDAO.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
static QString recognizeOperationId(const QByteArray &pngData) {
|
||||||
|
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
|
||||||
|
static_cast<size_t>(pngData.size()));
|
||||||
|
if (!pix) return {};
|
||||||
|
|
||||||
|
const int w = pixGetWidth(pix);
|
||||||
|
const int h = pixGetHeight(pix);
|
||||||
|
|
||||||
|
auto *api = new tesseract::TessBaseAPI();
|
||||||
|
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||||||
|
pixDestroy(&pix); delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
api->SetImage(pix);
|
||||||
|
api->Recognize(nullptr);
|
||||||
|
|
||||||
|
int opLineY = -1, opLineH = 0;
|
||||||
|
bool prevWasNomer = false;
|
||||||
|
tesseract::ResultIterator *ri = api->GetIterator();
|
||||||
|
if (ri) {
|
||||||
|
do {
|
||||||
|
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
|
||||||
|
if (word) {
|
||||||
|
const QString w_str = QString::fromUtf8(word);
|
||||||
|
delete[] word;
|
||||||
|
if (w_str.contains(QString::fromUtf8("омер")) ||
|
||||||
|
w_str.contains(QString::fromUtf8("omep"))) {
|
||||||
|
prevWasNomer = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (prevWasNomer &&
|
||||||
|
(w_str.contains(QString::fromUtf8("операции")) ||
|
||||||
|
w_str.contains(QString::fromUtf8("onepaции")))) {
|
||||||
|
int x1, y1, x2, y2;
|
||||||
|
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
|
||||||
|
opLineY = y1;
|
||||||
|
opLineH = y2 - y1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prevWasNomer = false;
|
||||||
|
}
|
||||||
|
} while (ri->Next(tesseract::RIL_WORD));
|
||||||
|
}
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
|
||||||
|
if (opLineY < 0) { pixDestroy(&pix); return {}; }
|
||||||
|
|
||||||
|
const int cropY = qMax(0, opLineY - opLineH / 2);
|
||||||
|
const int cropH = opLineH * 2;
|
||||||
|
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
|
||||||
|
Pix *cropped = pixClipRectangle(pix, box, nullptr);
|
||||||
|
boxDestroy(&box);
|
||||||
|
pixDestroy(&pix);
|
||||||
|
if (!cropped) return {};
|
||||||
|
|
||||||
|
auto *api2 = new tesseract::TessBaseAPI();
|
||||||
|
if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) {
|
||||||
|
pixDestroy(&cropped); delete api2;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
api2->SetVariable("tessedit_char_whitelist", "0123456789");
|
||||||
|
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
|
||||||
|
api2->SetImage(cropped);
|
||||||
|
char *text = api2->GetUTF8Text();
|
||||||
|
QString result = QString::fromUtf8(text).trimmed();
|
||||||
|
delete[] text;
|
||||||
|
pixDestroy(&cropped);
|
||||||
|
api2->End();
|
||||||
|
delete api2;
|
||||||
|
|
||||||
|
result.remove(QRegularExpression("[^0-9]"));
|
||||||
|
if (result.length() >= 8) {
|
||||||
|
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
PayByPhoneScript::PayByPhoneScript(
|
||||||
|
MaterialInfo account,
|
||||||
|
QString phone,
|
||||||
|
double amount,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_account(std::move(account)),
|
||||||
|
m_phone(std::move(phone)),
|
||||||
|
m_amount(amount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
PayByPhoneScript::~PayByPhoneScript() = default;
|
||||||
|
|
||||||
|
void PayByPhoneScript::doStart() {
|
||||||
|
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_account.deviceId;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_PHONE", device.adbSerial, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
|
||||||
|
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(device.adbSerial, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", device.id, m_error, {}, "CREATE_TRANSACTION");
|
||||||
|
AdbUtils::tryToKillApplication(device.adbSerial, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
makePayment(device.adbSerial, app.pinCode, app.phone, device.screenWidth, device.screenHeight);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PayByPhoneScript::makePayment(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &pinCode,
|
||||||
|
const QString &bankProfilePhone,
|
||||||
|
const int width,
|
||||||
|
const int height
|
||||||
|
) {
|
||||||
|
// 1. Тапаем "Переводы" в нижней навигации
|
||||||
|
Node transfersTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Переводы"));
|
||||||
|
if (transfersTab.isEmpty()) {
|
||||||
|
m_error = "Tab 'Переводы' not found on home screen";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Переводы' tab";
|
||||||
|
AdbUtils::makeTap(deviceId, transfersTab.x(), transfersTab.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 2. Ждём экран переводов — ищем "DC (по номеру телефона)"
|
||||||
|
bool transfersLoaded = false;
|
||||||
|
Node phoneTransferBtn;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
phoneTransferBtn = xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("DC (по номеру телефона)"), true);
|
||||||
|
if (!phoneTransferBtn.isEmpty()) {
|
||||||
|
transfersLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Waiting for transfers screen, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transfersLoaded) {
|
||||||
|
m_error = "Transfers screen not loaded or 'DC (по номеру телефона)' not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Тапаем "DC (по номеру телефона)"
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Tapping 'DC (по номеру телефона)'";
|
||||||
|
AdbUtils::makeTap(deviceId, phoneTransferBtn.x(), phoneTransferBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 4. Ждём экран ввода — ищем EditText с hint "Номер телефона получателя"
|
||||||
|
bool inputScreenLoaded = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
Node editText = xmlScreenParser.findEditTextByHint(
|
||||||
|
QString::fromUtf8("Номер телефона получателя"));
|
||||||
|
if (editText.isEmpty()) {
|
||||||
|
editText = xmlScreenParser.findFirstEditText();
|
||||||
|
}
|
||||||
|
if (!editText.isEmpty()) {
|
||||||
|
inputScreenLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Waiting for phone input screen, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputScreenLoaded) {
|
||||||
|
m_error = "Phone input screen not loaded";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Вводим номер телефона
|
||||||
|
Node phoneEditText = xmlScreenParser.findEditTextByHint(
|
||||||
|
QString::fromUtf8("Номер телефона получателя"));
|
||||||
|
if (phoneEditText.isEmpty()) {
|
||||||
|
phoneEditText = xmlScreenParser.findFirstEditText();
|
||||||
|
}
|
||||||
|
AdbUtils::makeTap(deviceId, phoneEditText.x(), phoneEditText.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
|
||||||
|
// Убираем "+" из номера если есть — вводим только цифры
|
||||||
|
QString phoneDigits = m_phone;
|
||||||
|
phoneDigits.remove('+');
|
||||||
|
AdbUtils::inputText(deviceId, phoneDigits);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
// Скрываем клавиатуру чтобы поле суммы стало видимым
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
// 6. Вводим сумму в поле "Сумма"
|
||||||
|
Node sumEditText;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма"));
|
||||||
|
if (sumEditText.isEmpty()) sumEditText = xmlScreenParser.findSumEditText();
|
||||||
|
if (!sumEditText.isEmpty()) break;
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Waiting for sum input, attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
if (sumEditText.isEmpty()) {
|
||||||
|
m_error = "Sum input field not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
|
||||||
|
const QString amountStr = (m_amount == static_cast<int>(m_amount))
|
||||||
|
? QString::number(static_cast<int>(m_amount))
|
||||||
|
: QString::number(m_amount, 'f', 2);
|
||||||
|
AdbUtils::inputText(deviceId, amountStr);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
// 6a. Скрываем клавиатуру и выбираем карту-источник (под полем "Комментарий")
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
{
|
||||||
|
const QString cardMask = "****" + m_account.lastNumbers;
|
||||||
|
Node cardNode;
|
||||||
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (!n.clickable) continue;
|
||||||
|
if (n.contentDesc.contains(cardMask)) { cardNode = n; break; }
|
||||||
|
}
|
||||||
|
if (cardNode.isEmpty()) {
|
||||||
|
m_error = "Source card not found: " + cardMask;
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Selecting source card:" << cardMask;
|
||||||
|
AdbUtils::makeTap(deviceId, cardNode.x(), cardNode.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Нажимаем "Далее"
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
Node daleeBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Далее"));
|
||||||
|
if (daleeBtn.isEmpty()) {
|
||||||
|
daleeBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Далее"), false);
|
||||||
|
}
|
||||||
|
if (daleeBtn.isEmpty()) {
|
||||||
|
m_error = "Button 'Далее' not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Далее'";
|
||||||
|
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 8. После "Далее" возможны три исхода:
|
||||||
|
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
|
||||||
|
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
|
||||||
|
// c) экран ошибки.
|
||||||
|
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
|
||||||
|
bool oplatitFound = false;
|
||||||
|
bool earlySuccess = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Payment completed immediately after 'Далее'";
|
||||||
|
oplatitFound = true;
|
||||||
|
earlySuccess = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
|
||||||
|
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
|
||||||
|
m_error = "Transfer failed: error screen detected after 'Далее'";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
|
||||||
|
if (oplatitBtn.isEmpty()) {
|
||||||
|
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
|
||||||
|
}
|
||||||
|
if (!oplatitBtn.isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Оплатить'";
|
||||||
|
AdbUtils::makeTap(deviceId, oplatitBtn.x(), oplatitBtn.y());
|
||||||
|
oplatitFound = true;
|
||||||
|
QThread::msleep(3000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Waiting for 'Оплатить', attempt" << attempt + 1;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oplatitFound) {
|
||||||
|
m_error = "Button 'Оплатить' not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
|
||||||
|
bool confirmed = earlySuccess;
|
||||||
|
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
if (xmlScreenParser.isPinCodeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] PIN code screen, entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinCode);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node confirmNode = xmlScreenParser.findNodeByContentDesc(
|
||||||
|
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 сканирует весь
|
||||||
|
// скриншот и ловит ложные совпадения (буква "в" в шапке).
|
||||||
|
// Ограничиваем нижней половиной — действие-кнопка всегда внизу sheet'a.
|
||||||
|
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::PayByPhone] OCR miss, fallback tap at" << tap;
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Подтверждаю' at" << tap;
|
||||||
|
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||||
|
confirmed = true;
|
||||||
|
QThread::msleep(4000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Waiting for confirmation sheet, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
m_error = "Confirmation sheet not found";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Ждём результат — "Платеж выполнен" или ошибку.
|
||||||
|
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
|
||||||
|
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
|
||||||
|
// Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал,
|
||||||
|
// bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий
|
||||||
|
// sheet → пробуем другую Y. Без лимита получали бесконечный
|
||||||
|
// "Оплатить ↔ Подтверждаю" со случайными попаданиями в системные экраны
|
||||||
|
// (security/launcher).
|
||||||
|
bool success = earlySuccess;
|
||||||
|
int confirmRetries = 0;
|
||||||
|
int oplatitRetries = 0;
|
||||||
|
const int maxConfirmRetries = 2;
|
||||||
|
const int maxOplatitRetries = 2;
|
||||||
|
for (int attempt = 0; !earlySuccess && attempt < 60; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
if (xmlScreenParser.isPinCodeScreen()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] PIN code screen (retry), entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinCode);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
|
||||||
|
success = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
|
||||||
|
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
|
||||||
|
m_error = "Transfer failed: error screen detected";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Выполняется"), true).isEmpty()) {
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Status 'Выполняется', waiting for completion, attempt"
|
||||||
|
<< attempt + 1;
|
||||||
|
QThread::msleep(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если bottom sheet "Подтверждение платежа" всё ещё виден — первый тап
|
||||||
|
// не сработал (мимо OCR-координат), повторяем его той же логикой.
|
||||||
|
Node retryConfirm = xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Подтверждение платежа"), true);
|
||||||
|
if (!retryConfirm.isEmpty() &&
|
||||||
|
retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
|
||||||
|
if (confirmRetries >= maxConfirmRetries) {
|
||||||
|
m_error = "Confirm tap missed " + QString::number(confirmRetries)
|
||||||
|
+ " times (sheet still visible)";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
++confirmRetries;
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Confirm sheet still visible, re-tap"
|
||||||
|
<< confirmRetries << "/" << maxConfirmRetries;
|
||||||
|
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
|
||||||
|
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
QPoint tap = OcrUtils::findTextCenter(screen,
|
||||||
|
QString::fromUtf8("Подтверждаю"),
|
||||||
|
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::PayByPhone] OCR miss, fallback tap at" << tap;
|
||||||
|
}
|
||||||
|
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||||
|
QThread::msleep(4000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sheet "Подтверждение платежа" пропал, но снова виден input-экран
|
||||||
|
// (кнопка "Оплатить" вернулась) — значит тап по "Подтверждаю" промахнулся
|
||||||
|
// и Compose дисмиснул bottom sheet (clip на scrim/Закрыть/X). Перезапускаем
|
||||||
|
// цикл: тапаем "Оплатить" → следующая итерация увидит sheet и retryConfirm
|
||||||
|
// выше повторно тапнет "Подтверждаю".
|
||||||
|
Node retryOplatit = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
|
||||||
|
if (retryOplatit.isEmpty()) {
|
||||||
|
retryOplatit = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
|
||||||
|
}
|
||||||
|
if (!retryOplatit.isEmpty() && retryOplatit.clickable) {
|
||||||
|
if (oplatitRetries >= maxOplatitRetries) {
|
||||||
|
m_error = "Sheet dismissed and re-tap exhausted ("
|
||||||
|
+ QString::number(oplatitRetries) + " retries)";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
++oplatitRetries;
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Sheet dismissed, re-tap 'Оплатить'"
|
||||||
|
<< oplatitRetries << "/" << maxOplatitRetries;
|
||||||
|
AdbUtils::makeTap(deviceId, retryOplatit.x(), retryOplatit.y());
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Waiting for result, attempt" << attempt + 1;
|
||||||
|
QThread::msleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
m_error = "Payment result not received within timeout";
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||||
|
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||||
|
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Payment successful!";
|
||||||
|
m_error.clear();
|
||||||
|
|
||||||
|
// Скриншот чека — сохраняем как m_resultScreenshot и отправляем в мониторинг
|
||||||
|
m_resultScreenshot = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
const QByteArray &receiptImage = m_resultScreenshot;
|
||||||
|
if (!m_resultScreenshot.isEmpty())
|
||||||
|
emit screenshotReady(m_resultScreenshot);
|
||||||
|
|
||||||
|
// Парсим детали из экрана "Платеж выполнен". С 3.2.33 поля разнесены по
|
||||||
|
// отдельным View-узлам — пробуем сначала старый blob-формат, потом fallback.
|
||||||
|
QString bankTime;
|
||||||
|
double fee = 0;
|
||||||
|
|
||||||
|
Node resultNode = xmlScreenParser.findNodeByContentDesc(
|
||||||
|
QString::fromUtf8("Платеж выполнен"), true);
|
||||||
|
if (!resultNode.isEmpty()) {
|
||||||
|
const QStringList lines = resultNode.contentDesc.split('\n');
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
const QString line = lines[i].trimmed();
|
||||||
|
if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
|
||||||
|
fee = lines[i + 1].trimmed().toDouble();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Дата:")) && i + 1 < lines.size()) {
|
||||||
|
bankTime = lines[i + 1].trimmed();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Время:")) && i + 1 < lines.size()) {
|
||||||
|
bankTime += " " + lines[i + 1].trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bankTime.isEmpty()) {
|
||||||
|
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
|
||||||
|
const QString d = pairs.value(QString::fromUtf8("Дата"));
|
||||||
|
const QString t = pairs.value(QString::fromUtf8("Время"));
|
||||||
|
if (!d.isEmpty() || !t.isEmpty()) {
|
||||||
|
bankTime = (d + " " + t).trimmed();
|
||||||
|
}
|
||||||
|
if (fee == 0) {
|
||||||
|
fee = pairs.value(QString::fromUtf8("Комиссия")).toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Result: date=" << bankTime << "fee=" << fee;
|
||||||
|
|
||||||
|
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
|
||||||
|
// Уникальность за счёт <bankProfilePhone, секунда>.
|
||||||
|
const QString operationId = QString("dushanbe_") + bankProfilePhone + "_"
|
||||||
|
+ QString::number(QDateTime::currentSecsSinceEpoch());
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Generated operationId:" << operationId;
|
||||||
|
|
||||||
|
// Сохраняем транзакцию
|
||||||
|
TransactionInfo tx;
|
||||||
|
tx.accountId = m_account.id;
|
||||||
|
tx.amount = -m_amount;
|
||||||
|
tx.fee = fee;
|
||||||
|
tx.status = TransactionStatus::Complete;
|
||||||
|
tx.type = TransactionType::Phone;
|
||||||
|
tx.phone = m_phone;
|
||||||
|
tx.bankName = "dushanbe_city_bank";
|
||||||
|
tx.bankTime = bankTime;
|
||||||
|
tx.bankTrExternalId = operationId;
|
||||||
|
tx.timestamp = QDateTime::currentDateTimeUtc();
|
||||||
|
tx.completeTime = QDateTime::currentDateTimeUtc();
|
||||||
|
tx.receiptImage = receiptImage;
|
||||||
|
|
||||||
|
const int txId = TransactionDAO::insertTransaction(tx);
|
||||||
|
if (txId != -1) {
|
||||||
|
qDebug() << "[Dushanbe::PayByPhone] Transaction saved, id:" << txId
|
||||||
|
<< "operationId:" << operationId;
|
||||||
|
if (!receiptImage.isEmpty()) {
|
||||||
|
TransactionDAO::updateReceiptImage(txId, receiptImage);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning() << "[Dushanbe::PayByPhone] Failed to save transaction";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
@ -1,34 +1,37 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "CommonScript.h"
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
class PayByPhoneScript final : public CommonScript {
|
class PayByPhoneScript final : public CommonScript {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
~PayByPhoneScript() override;
|
|
||||||
|
|
||||||
PayByPhoneScript(
|
PayByPhoneScript(
|
||||||
AccountInfo account,
|
MaterialInfo account,
|
||||||
QString phone,
|
QString phone,
|
||||||
QString bankName,
|
|
||||||
double amount,
|
double amount,
|
||||||
QObject *parent = nullptr
|
QObject *parent = nullptr
|
||||||
);
|
);
|
||||||
|
|
||||||
|
~PayByPhoneScript() override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void doStart() override;
|
void doStart() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const AccountInfo m_account;
|
const MaterialInfo m_account;
|
||||||
const QString m_phone;
|
const QString m_phone;
|
||||||
const QString m_bankName;
|
|
||||||
const double m_amount;
|
const double m_amount;
|
||||||
QString m_error;
|
QString m_error;
|
||||||
|
|
||||||
void makePayment(
|
void makePayment(
|
||||||
const QString &deviceId,
|
const QString &deviceId,
|
||||||
const QString &pinCode,
|
const QString &pinCode,
|
||||||
|
const QString &bankProfilePhone,
|
||||||
int width,
|
int width,
|
||||||
int height
|
int height
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
702
android/dushanbe/ScreenXmlParser.cpp
Normal file
@ -0,0 +1,702 @@
|
|||||||
|
#include "ScreenXmlParser.h"
|
||||||
|
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QXmlStreamReader>
|
||||||
|
#include <QDomDocument>
|
||||||
|
|
||||||
|
#include "android/xml/Node.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
static const QRegularExpression boundsRegex(R"(\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\])");
|
||||||
|
|
||||||
|
ScreenXmlParser::ScreenXmlParser(QObject *parent) : QObject(parent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenXmlParser::~ScreenXmlParser() = default;
|
||||||
|
|
||||||
|
void ScreenXmlParser::parseAndSaveXml(const QString &xml) {
|
||||||
|
QDomDocument doc;
|
||||||
|
doc.setContent(xml);
|
||||||
|
m_xml = doc.documentElement();
|
||||||
|
m_nodes.clear();
|
||||||
|
|
||||||
|
QXmlStreamReader reader(xml);
|
||||||
|
while (!reader.atEnd()) {
|
||||||
|
reader.readNext();
|
||||||
|
if (reader.isStartElement() && reader.name() == u"node") {
|
||||||
|
Node node;
|
||||||
|
if (reader.attributes().hasAttribute("bounds")) {
|
||||||
|
QString bounds = reader.attributes().value("bounds").toString();
|
||||||
|
if (QRegularExpressionMatch match = boundsRegex.match(bounds); match.hasMatch()) {
|
||||||
|
node.setBounds(
|
||||||
|
match.captured(1).toInt(),
|
||||||
|
match.captured(2).toInt(),
|
||||||
|
match.captured(3).toInt(),
|
||||||
|
match.captured(4).toInt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("text")) {
|
||||||
|
node.text = reader.attributes().value("text").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("content-desc")) {
|
||||||
|
node.contentDesc = reader.attributes().value("content-desc").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("class")) {
|
||||||
|
node.className = reader.attributes().value("class").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("clickable")) {
|
||||||
|
node.clickable = reader.attributes().value("clickable").toString() == "true";
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("focusable")) {
|
||||||
|
node.focusable = reader.attributes().value("focusable").toString() == "true";
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("resource-id")) {
|
||||||
|
node.resourceId = reader.attributes().value("resource-id").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("hint")) {
|
||||||
|
node.hint = reader.attributes().value("hint").toString();
|
||||||
|
}
|
||||||
|
if (reader.attributes().hasAttribute("password")) {
|
||||||
|
node.password = reader.attributes().value("password").toString() == "true";
|
||||||
|
}
|
||||||
|
m_nodes.append(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QList<Node> &ScreenXmlParser::findAllNodes() const {
|
||||||
|
return m_nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findTextNode(const QString &text) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.text.trimmed() == text) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == contentDesc) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc, bool contains) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (contains) {
|
||||||
|
if (node.contentDesc.contains(contentDesc)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (node.contentDesc == contentDesc) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findButtonNode(const QString &text, bool contains) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (!node.clickable) continue;
|
||||||
|
if (contains) {
|
||||||
|
if (node.text.contains(text) || node.contentDesc.contains(text)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (node.text.trimmed() == text || node.contentDesc == text) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findFirstEditText() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.EditText") {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isPinCodeScreen() {
|
||||||
|
// 1. Прямое совпадение по тексту заголовка
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains(QString::fromUtf8("Введите код доступа")) ||
|
||||||
|
node.contentDesc.contains(QString::fromUtf8("Введите ПИН")) ||
|
||||||
|
node.contentDesc.contains(QString::fromUtf8("код доступа")) ||
|
||||||
|
node.text.contains(QString::fromUtf8("код доступа"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Fallback: если на экране есть полная цифровая клавиатура 0-9
|
||||||
|
// (content-desc каждой кнопки = одна цифра) — это PIN-экран.
|
||||||
|
int digitButtons = 0;
|
||||||
|
bool seen[10] = {false};
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.size() != 1) continue;
|
||||||
|
const QChar c = node.contentDesc.at(0);
|
||||||
|
if (!c.isDigit()) continue;
|
||||||
|
const int d = c.digitValue();
|
||||||
|
if (d < 0 || d > 9 || seen[d]) continue;
|
||||||
|
seen[d] = true;
|
||||||
|
++digitButtons;
|
||||||
|
}
|
||||||
|
return digitButtons >= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findPinCodeEditText() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.EditText" && node.password) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isHomeScreen() {
|
||||||
|
bool hasGlavnaya = false;
|
||||||
|
bool hasBottomNav = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.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("История")) {
|
||||||
|
hasBottomNav = true;
|
||||||
|
}
|
||||||
|
if (hasGlavnaya && hasBottomNav) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
double ScreenXmlParser::parseBalanceFromHomeScreen() {
|
||||||
|
// На домашнем экране: parent node content-desc="TJS\n03.04.26 - 21:46:15"
|
||||||
|
// внутри него child node content-desc="0.00" (или "10.00")
|
||||||
|
// Стратегия: находим индекс parent, затем child сразу после него с числовым content-desc
|
||||||
|
for (int i = 0; i < m_nodes.size(); ++i) {
|
||||||
|
if (!m_nodes[i].contentDesc.startsWith("TJS")) continue;
|
||||||
|
// Проверяем что это блок с датой (не просто "TJS" текст)
|
||||||
|
if (!m_nodes[i].contentDesc.contains('\n')) continue;
|
||||||
|
|
||||||
|
// Child node с балансом идёт сразу после parent или рядом
|
||||||
|
for (int j = i + 1; j < qMin(i + 5, m_nodes.size()); ++j) {
|
||||||
|
QString raw = m_nodes[j].contentDesc.trimmed();
|
||||||
|
if (raw.isEmpty()) continue;
|
||||||
|
raw.replace(',', '.');
|
||||||
|
raw.remove(' ');
|
||||||
|
bool ok = false;
|
||||||
|
double val = raw.toDouble(&ok);
|
||||||
|
if (ok) return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseUserNameFromHomeScreen() {
|
||||||
|
// TODO: реализовать парсинг имени
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findRefreshButtonOnHomeScreen() {
|
||||||
|
// В блоке content-desc="TJS\n<дата>" три clickable дочерних элемента:
|
||||||
|
// View с балансом, Button (глазик) и ImageView (рефреш). Тапать нужно
|
||||||
|
// именно ImageView — он правее глазика и единственный ImageView внутри
|
||||||
|
// bounds parent'а.
|
||||||
|
Node parent;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.startsWith("TJS") && node.contentDesc.contains('\n')) {
|
||||||
|
parent = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parent.x1() < 0) return {};
|
||||||
|
|
||||||
|
Node best;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (!node.clickable) continue;
|
||||||
|
if (node.className != "android.widget.ImageView") continue;
|
||||||
|
if (node.x1() < parent.x1() || node.x2() > parent.x2()) continue;
|
||||||
|
if (node.y1() < parent.y1() || node.y2() > parent.y2()) continue;
|
||||||
|
if (best.x1() < 0 || node.x1() > best.x1()) {
|
||||||
|
best = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isSideMenu() {
|
||||||
|
bool hasVyhod = false;
|
||||||
|
bool hasIdentified = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
// До 3.2.33 кнопка была "Выход", с 3.2.33 — "Выйти".
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Выход") ||
|
||||||
|
node.contentDesc == QString::fromUtf8("Выйти")) {
|
||||||
|
hasVyhod = true;
|
||||||
|
}
|
||||||
|
// До 3.2.33: "Идентифицирован\nУспешно" — отдельная нода (startsWith).
|
||||||
|
// С 3.2.33: "Идентифицирован" вшито в блок профиля
|
||||||
|
// ("IN\nIsroil Nazarov\n+992 ...\nИдентифицирован\nУспешно") — нужен contains.
|
||||||
|
if (node.contentDesc.contains(QString::fromUtf8("Идентифицирован"))) {
|
||||||
|
hasIdentified = true;
|
||||||
|
}
|
||||||
|
if (hasVyhod && hasIdentified) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseProfileFullName() {
|
||||||
|
// До 3.2.33 одна нода содержит:
|
||||||
|
// "Имя Фамилия\n+992 (18) 555-5519\nВерсия: 3.2.5\nСборка: 390:3443"
|
||||||
|
// С 3.2.33 "Версия"/"Сборка" вынесены в отдельные ноды, а профиль:
|
||||||
|
// "IN\nIsroil Nazarov\n+992 (17) 682-7668\nИдентифицирован\nУспешно"
|
||||||
|
// Находим ноду по наличию "Идентифицирован" ИЛИ "Версия:+Сборка:", затем
|
||||||
|
// выбираем строку, идущую непосредственно перед строкой с телефоном "+...".
|
||||||
|
static const QRegularExpression phoneRe(R"(^\+\d)");
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован"));
|
||||||
|
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
|
||||||
|
node.contentDesc.contains(QString::fromUtf8("Сборка:"));
|
||||||
|
if (!isNewProfileBlock && !isOldProfileBlock) continue;
|
||||||
|
const QStringList lines = node.contentDesc.split('\n');
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
if (phoneRe.match(lines[i].trimmed()).hasMatch() && i > 0) {
|
||||||
|
return lines[i - 1].trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseProfilePhone() {
|
||||||
|
static const QRegularExpression phoneRe(R"(^\+\d)");
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован"));
|
||||||
|
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
|
||||||
|
node.contentDesc.contains(QString::fromUtf8("Сборка:"));
|
||||||
|
if (!isNewProfileBlock && !isOldProfileBlock) continue;
|
||||||
|
for (const QString &line : node.contentDesc.split('\n')) {
|
||||||
|
const QString trimmed = line.trimmed();
|
||||||
|
if (phoneRe.match(trimmed).hasMatch()) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QMap<QString, QString> ScreenXmlParser::parseLabelValuePairs() {
|
||||||
|
// Применяется к экранам "Платеж выполнен" и подобным, где с 3.2.33
|
||||||
|
// лейблы и значения сидят в отдельных View-нодах: лейбл слева
|
||||||
|
// (content-desc оканчивается ':'), значение — справа на той же Y-полосе.
|
||||||
|
QMap<QString, QString> result;
|
||||||
|
for (int i = 0; i < m_nodes.size(); ++i) {
|
||||||
|
const QString rawLabel = m_nodes[i].contentDesc.trimmed();
|
||||||
|
if (rawLabel.isEmpty() || !rawLabel.endsWith(':')) continue;
|
||||||
|
const QString label = rawLabel.left(rawLabel.size() - 1).trimmed();
|
||||||
|
if (label.isEmpty() || result.contains(label)) continue;
|
||||||
|
|
||||||
|
const Node &lbl = m_nodes[i];
|
||||||
|
int bestIdx = -1;
|
||||||
|
for (int j = 0; j < m_nodes.size(); ++j) {
|
||||||
|
if (j == i) continue;
|
||||||
|
const QString candidate = m_nodes[j].contentDesc.trimmed();
|
||||||
|
if (candidate.isEmpty()) continue;
|
||||||
|
if (candidate.endsWith(':')) continue;
|
||||||
|
if (m_nodes[j].x1() < lbl.x2()) continue;
|
||||||
|
if (qAbs(m_nodes[j].y1() - lbl.y1()) > 8) continue;
|
||||||
|
if (bestIdx < 0 || m_nodes[j].x1() < m_nodes[bestIdx].x1()) {
|
||||||
|
bestIdx = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestIdx >= 0) {
|
||||||
|
result[label] = m_nodes[bestIdx].contentDesc.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isCardsScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Мои карты")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<ScreenXmlParser::CardInfo> ScreenXmlParser::parseCards() {
|
||||||
|
// Карты — ImageView с content-desc формата:
|
||||||
|
// "Обновлено: 03.04.26 - 22:43:57\n0.00 TJS\n**** 3258\n до \n11/35\nIMOMKULZODA MEKHRUBON"
|
||||||
|
// или "-\n**** \n до \n12/29\n \nКредитная карта"
|
||||||
|
QList<CardInfo> result;
|
||||||
|
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.ImageView") continue;
|
||||||
|
if (!node.clickable) continue;
|
||||||
|
if (!node.contentDesc.contains("****")) continue;
|
||||||
|
if (node.contentDesc.contains(QString::fromUtf8("Кредитная карта"))) continue;
|
||||||
|
|
||||||
|
QStringList lines;
|
||||||
|
for (const QString &line : node.contentDesc.split('\n')) {
|
||||||
|
QString trimmed = line.trimmed();
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
lines.append(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CardInfo card;
|
||||||
|
|
||||||
|
for (const QString &line : lines) {
|
||||||
|
// Баланс: "0.00 TJS"
|
||||||
|
if (line.contains("TJS")) {
|
||||||
|
QStringList parts = line.split(' ');
|
||||||
|
if (parts.size() >= 2) {
|
||||||
|
card.amount = parts[0].toDouble();
|
||||||
|
card.currency = "TJS";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Последние цифры: "**** 3258"
|
||||||
|
if (line.startsWith("****")) {
|
||||||
|
QString nums = line.mid(4).trimmed();
|
||||||
|
if (!nums.isEmpty() && nums[0].isDigit()) {
|
||||||
|
card.lastNumbers = nums;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Срок: "11/35" или "12/29"
|
||||||
|
static const QRegularExpression expiryRe(R"(^\d{2}/\d{2}$)");
|
||||||
|
if (expiryRe.match(line).hasMatch()) {
|
||||||
|
card.expiry = line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Пропускаем "до", "Обновлено:...", "-"
|
||||||
|
if (line == QString::fromUtf8("до") || line.startsWith(QString::fromUtf8("Обновлено")) || line == "-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Имя держателя (CAPS) или описание
|
||||||
|
if (line == line.toUpper() && line.length() > 3) {
|
||||||
|
card.holderName = line;
|
||||||
|
} else {
|
||||||
|
card.description = line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isCardDetailScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Карта")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseFullCardNumber() {
|
||||||
|
// Полный номер карты: "9762 0002 0711 3258" — clickable View
|
||||||
|
static const QRegularExpression cardNumberRe(R"(^\d{4}\s\d{4}\s\d{4}\s\d{4}$)");
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (cardNumberRe.match(node.contentDesc).hasMatch()) {
|
||||||
|
return node.contentDesc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
double ScreenXmlParser::parseCardDetailBalance() {
|
||||||
|
// "0,00 TJS" или "- TJS" — содержит TJS, но не "сомони"
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains("TJS") && !node.contentDesc.contains(QString::fromUtf8("сомони"))) {
|
||||||
|
QString raw = node.contentDesc.trimmed();
|
||||||
|
raw.remove("TJS");
|
||||||
|
raw = raw.trimmed();
|
||||||
|
if (raw == "-" || raw.isEmpty()) return 0.0;
|
||||||
|
raw.replace(',', '.');
|
||||||
|
raw.remove(' ');
|
||||||
|
bool ok = false;
|
||||||
|
double val = raw.toDouble(&ok);
|
||||||
|
if (ok) return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseCardDetailHolder() {
|
||||||
|
// Имя держателя — текст в CAPS, не содержит цифр, длина > 3
|
||||||
|
static const QRegularExpression cardNumberRe(R"(\d{4}\s\d{4})");
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
const QString &desc = node.contentDesc;
|
||||||
|
if (desc.isEmpty() || desc.length() < 4) continue;
|
||||||
|
if (desc == desc.toUpper() && !desc.contains(QRegularExpression(R"(\d)")) &&
|
||||||
|
desc != QString::fromUtf8("TJS")) {
|
||||||
|
return desc.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findCardSettingsButton() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.clickable && node.contentDesc == QString::fromUtf8("Настройки карты")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isCardSettingsBottomSheet() {
|
||||||
|
bool hasRequisites = false;
|
||||||
|
bool hasBlock = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Реквизиты карты")) hasRequisites = true;
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Заблокировать карту")) hasBlock = true;
|
||||||
|
if (hasRequisites && hasBlock) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findCardRequisitesMenuItem() {
|
||||||
|
// На bottom sheet это ImageView; на экране "Реквизиты и информация"
|
||||||
|
// одноимённый таб — Button. Берём именно ImageView, чтобы не перепутать.
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.clickable
|
||||||
|
&& node.className == "android.widget.ImageView"
|
||||||
|
&& node.contentDesc == QString::fromUtf8("Реквизиты карты")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isCardRequisitesScreen() {
|
||||||
|
bool hasCardTab = false;
|
||||||
|
bool hasAccountTab = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.Button") continue;
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Реквизиты карты")) hasCardTab = true;
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Реквизиты счета")) hasAccountTab = true;
|
||||||
|
if (hasCardTab && hasAccountTab) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findAccountRequisitesTab() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.clickable
|
||||||
|
&& node.className == "android.widget.Button"
|
||||||
|
&& node.contentDesc == QString::fromUtf8("Реквизиты счета")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseAccountNumber() {
|
||||||
|
// Берём ноду "Номер счета" (label) и ищем ближайший узел снизу
|
||||||
|
// с чисто цифровым content-desc — это и есть номер счёта.
|
||||||
|
Node label;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Номер счета")) {
|
||||||
|
label = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (label.y2() < 0) return {};
|
||||||
|
|
||||||
|
static const QRegularExpression digitsOnly(R"(^\d{10,}$)");
|
||||||
|
Node best;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.y1() < label.y2()) continue;
|
||||||
|
if (!digitsOnly.match(node.contentDesc).hasMatch()) continue;
|
||||||
|
if (best.y1() < 0 || node.y1() < best.y1()) {
|
||||||
|
best = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best.contentDesc;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isHistoryScreen() {
|
||||||
|
bool hasOperacii = false;
|
||||||
|
bool hasVypiska = false;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Операции")) {
|
||||||
|
hasOperacii = true;
|
||||||
|
}
|
||||||
|
if (node.contentDesc == QString::fromUtf8("Выписка")) {
|
||||||
|
hasVypiska = true;
|
||||||
|
}
|
||||||
|
if (hasOperacii && hasVypiska) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<ScreenXmlParser::TransactionItem> ScreenXmlParser::parseTransactionItems() {
|
||||||
|
// Транзакции — ImageView с content-desc формата:
|
||||||
|
// "9762***3258\n992882770011\n493.00 \nDC (по номеру телефона)\n 18:57:10"
|
||||||
|
QList<TransactionItem> result;
|
||||||
|
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.ImageView") continue;
|
||||||
|
if (!node.contentDesc.contains("***")) continue;
|
||||||
|
|
||||||
|
QStringList lines;
|
||||||
|
for (const QString &line : node.contentDesc.split('\n')) {
|
||||||
|
QString trimmed = line.trimmed();
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
lines.append(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.size() < 4) continue;
|
||||||
|
|
||||||
|
TransactionItem tx;
|
||||||
|
tx.cardMask = lines[0]; // "9762***3258"
|
||||||
|
tx.phone = lines[1]; // "992882770011"
|
||||||
|
|
||||||
|
// Сумма: "493.00" (может быть с пробелом в конце)
|
||||||
|
bool ok = false;
|
||||||
|
tx.amount = lines[2].toDouble(&ok);
|
||||||
|
if (!ok) tx.amount = 0.0;
|
||||||
|
|
||||||
|
tx.description = lines[3]; // "DC (по номеру телефона)" или "Tcell"
|
||||||
|
|
||||||
|
// Время — последний элемент
|
||||||
|
if (lines.size() >= 5) {
|
||||||
|
tx.time = lines[4]; // "18:57:10"
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isTransactionDetailScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains(QString::fromUtf8("Дата операции:")) &&
|
||||||
|
node.contentDesc.contains(QString::fromUtf8("Номер операции:"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenXmlParser::TransactionDetail ScreenXmlParser::parseTransactionDetail() {
|
||||||
|
// Все данные в одном content-desc, формат "ключ: \nзначение"
|
||||||
|
TransactionDetail detail;
|
||||||
|
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (!node.contentDesc.contains(QString::fromUtf8("Дата операции:"))) continue;
|
||||||
|
|
||||||
|
// Разбиваем по \n, парсим пары "ключ:" + следующая строка = значение
|
||||||
|
QStringList lines = node.contentDesc.split('\n');
|
||||||
|
|
||||||
|
for (int i = 0; i < lines.size(); ++i) {
|
||||||
|
const QString line = lines[i].trimmed();
|
||||||
|
|
||||||
|
if (line.startsWith(QString::fromUtf8("Дата операции:")) && i + 1 < lines.size()) {
|
||||||
|
detail.date = lines[i + 1].trimmed();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Время операции:")) && i + 1 < lines.size()) {
|
||||||
|
detail.time = lines[i + 1].trimmed();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Номер операции:")) && i + 1 < lines.size()) {
|
||||||
|
detail.operationId = lines[i + 1].trimmed();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Поставщик:")) && i + 1 < lines.size()) {
|
||||||
|
detail.provider = lines[i + 1].trimmed();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Счет отправителя:")) && i + 1 < lines.size()) {
|
||||||
|
detail.senderAccount = lines[i + 1].trimmed();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Счет получателя:")) && i + 1 < lines.size()) {
|
||||||
|
detail.receiverAccount = lines[i + 1].trimmed();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Сумма операции:")) && i + 1 < lines.size()) {
|
||||||
|
detail.amount = lines[i + 1].trimmed().toDouble();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
|
||||||
|
detail.fee = lines[i + 1].trimmed().toDouble();
|
||||||
|
} else if (line.startsWith(QString::fromUtf8("Статус:")) && i + 1 < lines.size()) {
|
||||||
|
detail.status = lines[i + 1].trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.EditText" && node.hint.contains(hint)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findSumEditText() {
|
||||||
|
// На экране перевода по карте порядок EditText'ов сверху вниз:
|
||||||
|
// [0] номер карты, [1] сумма, [2] комментарий.
|
||||||
|
QList<Node> editTexts;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.EditText") editTexts.append(node);
|
||||||
|
}
|
||||||
|
std::sort(editTexts.begin(), editTexts.end(),
|
||||||
|
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
|
||||||
|
return editTexts.size() >= 2 ? editTexts[1] : Node{};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button"
|
||||||
|
&& node.clickable) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.clickable && node.text.trimmed() == QString::fromUtf8("ЗАПРЕТИТЬ")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int screenHeight) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains("Доступно обновление")) {
|
||||||
|
Node tapNode;
|
||||||
|
const int cx = screenWidth / 2;
|
||||||
|
const int cy = screenHeight / 20;
|
||||||
|
tapNode.setBounds(cx, cy, cx + 1, cy + 1);
|
||||||
|
return tapNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
165
android/dushanbe/ScreenXmlParser.h
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QMap>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QDomElement>
|
||||||
|
#include "android/xml/Node.h"
|
||||||
|
#include "db/MaterialInfo.h"
|
||||||
|
|
||||||
|
namespace Dushanbe {
|
||||||
|
|
||||||
|
class ScreenXmlParser final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ScreenXmlParser(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
~ScreenXmlParser() override;
|
||||||
|
|
||||||
|
void parseAndSaveXml(const QString &xml);
|
||||||
|
|
||||||
|
const QList<Node> &findAllNodes() const;
|
||||||
|
|
||||||
|
Node findTextNode(const QString &text);
|
||||||
|
|
||||||
|
Node findNodeByContentDesc(const QString &contentDesc);
|
||||||
|
|
||||||
|
Node findNodeByContentDesc(const QString &contentDesc, bool contains);
|
||||||
|
|
||||||
|
Node findButtonNode(const QString &text, bool contains);
|
||||||
|
|
||||||
|
Node findFirstEditText();
|
||||||
|
|
||||||
|
// TODO: реализовать после снятия XML дампов экранов
|
||||||
|
bool isPinCodeScreen();
|
||||||
|
|
||||||
|
Node findPinCodeEditText();
|
||||||
|
|
||||||
|
bool isHomeScreen();
|
||||||
|
|
||||||
|
// Парсит баланс с домашнего экрана
|
||||||
|
double parseBalanceFromHomeScreen();
|
||||||
|
|
||||||
|
// Находит кнопку обновления данных в блоке "TJS\n<дата>" — правый clickable
|
||||||
|
// ImageView (рядом слева сидит глазик-Button, его не трогаем).
|
||||||
|
Node findRefreshButtonOnHomeScreen();
|
||||||
|
|
||||||
|
// Парсит имя пользователя с домашнего экрана
|
||||||
|
QString parseUserNameFromHomeScreen();
|
||||||
|
|
||||||
|
// Определяет боковое меню (профиль)
|
||||||
|
bool isSideMenu();
|
||||||
|
|
||||||
|
// Парсит данные профиля из бокового меню
|
||||||
|
// content-desc: "Имя Фамилия\n+992...\nВерсия:...\nСборка:..."
|
||||||
|
QString parseProfileFullName();
|
||||||
|
QString parseProfilePhone();
|
||||||
|
|
||||||
|
// Определяет экран "Мои карты"
|
||||||
|
bool isCardsScreen();
|
||||||
|
|
||||||
|
// Структура для карты из экрана "Мои карты"
|
||||||
|
struct CardInfo {
|
||||||
|
double amount = 0.0;
|
||||||
|
QString currency;
|
||||||
|
QString lastNumbers; // "3258"
|
||||||
|
QString expiry; // "11/35"
|
||||||
|
QString holderName; // "IMOMKULZODA MEKHRUBON"
|
||||||
|
QString description; // "Кредитная карта" и т.д.
|
||||||
|
};
|
||||||
|
|
||||||
|
// Парсит все карты с экрана "Мои карты" (lastNumbers только)
|
||||||
|
QList<CardInfo> parseCards();
|
||||||
|
|
||||||
|
// Определяет экран детали карты (заголовок "Карта")
|
||||||
|
bool isCardDetailScreen();
|
||||||
|
|
||||||
|
// Парсит полный номер карты с экрана детали ("9762 0002 0711 3258")
|
||||||
|
QString parseFullCardNumber();
|
||||||
|
|
||||||
|
// Парсит баланс с экрана детали ("0,00 TJS")
|
||||||
|
double parseCardDetailBalance();
|
||||||
|
|
||||||
|
// Парсит имя держателя с экрана детали
|
||||||
|
QString parseCardDetailHolder();
|
||||||
|
|
||||||
|
// Кнопка "Настройки карты" на экране детали карты
|
||||||
|
Node findCardSettingsButton();
|
||||||
|
|
||||||
|
// Bottom sheet с пунктами "Реквизиты карты" / "Заблокировать карту" и т.д.
|
||||||
|
bool isCardSettingsBottomSheet();
|
||||||
|
|
||||||
|
// Кнопка "Реквизиты карты" внутри bottom sheet "Настройки карты"
|
||||||
|
Node findCardRequisitesMenuItem();
|
||||||
|
|
||||||
|
// Экран "Реквизиты и информация" с табами "Реквизиты карты" / "Реквизиты счета"
|
||||||
|
bool isCardRequisitesScreen();
|
||||||
|
|
||||||
|
// Таб "Реквизиты счета" (правый) на экране "Реквизиты и информация"
|
||||||
|
Node findAccountRequisitesTab();
|
||||||
|
|
||||||
|
// Значение под лейблом "Номер счета" — длинная цифровая строка
|
||||||
|
QString parseAccountNumber();
|
||||||
|
|
||||||
|
// Определяет экран истории операций (табы "Операции" + "Выписка")
|
||||||
|
bool isHistoryScreen();
|
||||||
|
|
||||||
|
// Структура для транзакции из экрана истории
|
||||||
|
struct TransactionItem {
|
||||||
|
QString cardMask; // "9762***3258"
|
||||||
|
QString phone; // "992882770011"
|
||||||
|
double amount = 0.0; // 493.00
|
||||||
|
QString description; // "DC (по номеру телефона)"
|
||||||
|
QString time; // "18:57:10"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Парсит транзакции с экрана истории
|
||||||
|
QList<TransactionItem> parseTransactionItems();
|
||||||
|
|
||||||
|
// Определяет экран детали транзакции (содержит "Дата операции:" и "Номер операции:")
|
||||||
|
bool isTransactionDetailScreen();
|
||||||
|
|
||||||
|
// Структура для деталей транзакции
|
||||||
|
struct TransactionDetail {
|
||||||
|
QString date; // "03.04.2026"
|
||||||
|
QString time; // "18:57:10"
|
||||||
|
QString operationId; // "1593369670"
|
||||||
|
QString provider; // "DC (по номеру телефона)"
|
||||||
|
QString senderAccount; // "9762***3258"
|
||||||
|
QString receiverAccount;// "992882770011"
|
||||||
|
double amount = 0.0; // 493.00
|
||||||
|
double fee = 0.0; // 0.00
|
||||||
|
QString status; // "Успешный"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Парсит детали транзакции
|
||||||
|
TransactionDetail parseTransactionDetail();
|
||||||
|
|
||||||
|
// Находит EditText по hint
|
||||||
|
// Парсит пары "Лейбл: " → "Значение" для экранов, где поля разложены по
|
||||||
|
// отдельным View-узлам в две колонки (формат с 3.2.33). Метод сканирует
|
||||||
|
// все ноды: лейблом считается контент, оканчивающийся на ':', значением —
|
||||||
|
// ближайший справа узел на той же Y-полосе. Возвращает map без двоеточий.
|
||||||
|
QMap<QString, QString> parseLabelValuePairs();
|
||||||
|
|
||||||
|
Node findEditTextByHint(const QString &hint);
|
||||||
|
|
||||||
|
// Поле ввода суммы на экране перевода. У EditText'а нет hint/content-desc
|
||||||
|
// (NAF), поэтому ищем по якорю — View "Мин. сумма: ..." под полем —
|
||||||
|
// и берём ближайший EditText выше якоря.
|
||||||
|
Node findSumEditText();
|
||||||
|
|
||||||
|
// Находит кнопку закрытия Google Play in-app update bottom sheet
|
||||||
|
// ("com.android.vending" оверлей "Доступно обновление"), если он показан
|
||||||
|
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
|
||||||
|
|
||||||
|
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android
|
||||||
|
// (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден.
|
||||||
|
Node tryToFindSystemPermissionDenyButton();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QList<Node> m_nodes;
|
||||||
|
QDomElement m_xml;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Dushanbe
|
||||||
205
android/ocr/OcrUtils.cpp
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
#include "OcrUtils.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QElapsedTimer>
|
||||||
|
#include <QFile>
|
||||||
|
|
||||||
|
#include <tesseract/baseapi.h>
|
||||||
|
#include <leptonica/allheaders.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Кэш байт rus.traineddata. Грузим файл через Qt (Unicode-aware пути) и
|
||||||
|
// отдаём Tesseract в виде in-memory blob через Init(data, size, lang, ...).
|
||||||
|
// Это обходит проблему, что на Windows Tesseract открывает traineddata через
|
||||||
|
// `fopen`, который использует ANSI-кодировку (cp1251 на русской системе).
|
||||||
|
// Если путь к exe содержит кириллицу (например, OneDrive\Рабочий стол) —
|
||||||
|
// fopen не находит файл и Init возвращает -1. Memory-init не зависит от пути.
|
||||||
|
const QByteArray &tessdataBytes() {
|
||||||
|
static QByteArray bytes;
|
||||||
|
static bool tried = false;
|
||||||
|
if (tried) return bytes;
|
||||||
|
tried = true;
|
||||||
|
|
||||||
|
const QString appDir = QCoreApplication::applicationDirPath();
|
||||||
|
const QStringList candidates = {
|
||||||
|
appDir + "/tessdata/rus.traineddata", // канонический путь
|
||||||
|
appDir + "/rus.traineddata", // legacy: рядом с exe
|
||||||
|
};
|
||||||
|
for (const QString &path : candidates) {
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly)) continue;
|
||||||
|
bytes = f.readAll();
|
||||||
|
f.close();
|
||||||
|
if (!bytes.isEmpty()) {
|
||||||
|
qDebug() << "[OcrUtils] loaded rus.traineddata from" << path
|
||||||
|
<< "(" << bytes.size() << "bytes)";
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qWarning() << "[OcrUtils] rus.traineddata NOT FOUND. Checked:" << candidates
|
||||||
|
<< "— OCR будет работать в fallback-режиме без поиска текста.";
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Расстояние Левенштейна на QString — нужен для нечёткого сравнения OCR-вывода
|
||||||
|
// с искомым словом. Для "Подтверждаю" допускаем 1-2 ошибки.
|
||||||
|
int editDistance(const QString &a, const QString &b) {
|
||||||
|
const int n = a.size();
|
||||||
|
const int m = b.size();
|
||||||
|
if (n == 0) return m;
|
||||||
|
if (m == 0) return n;
|
||||||
|
QVector<int> prev(m + 1), cur(m + 1);
|
||||||
|
for (int j = 0; j <= m; ++j) prev[j] = j;
|
||||||
|
for (int i = 1; i <= n; ++i) {
|
||||||
|
cur[0] = i;
|
||||||
|
for (int j = 1; j <= m; ++j) {
|
||||||
|
const int cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||||
|
cur[j] = qMin(qMin(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
|
||||||
|
}
|
||||||
|
prev.swap(cur);
|
||||||
|
}
|
||||||
|
return prev[m];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool fuzzyMatch(const QString &word, const QString &needle) {
|
||||||
|
if (word.isEmpty() || needle.isEmpty()) return false;
|
||||||
|
const QString w = word.toLower();
|
||||||
|
const QString n = needle.toLower();
|
||||||
|
// Substring-ветка работает только если word достаточно длинный.
|
||||||
|
// Иначе любая одиночная буква, входящая в needle (для «подтверждаю»:
|
||||||
|
// п/о/д/т/в/е/р/ж/а/ю), даст ложный матч на случайный текст экрана —
|
||||||
|
// ровно так бот тапал в (620,302) по букве «в» где-то в шапке sheet'a.
|
||||||
|
const int minLen = qMax(qMin(n.size(), 3), n.size() * 3 / 5);
|
||||||
|
if (w.size() >= minLen && (w.contains(n) || n.contains(w))) return true;
|
||||||
|
// Допускаем до 20% ошибок (но не меньше 1) — Tesseract на «Подтверждаю» иногда
|
||||||
|
// даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания.
|
||||||
|
const int tolerance = qMax(1, n.size() / 5);
|
||||||
|
return editDistance(w, n) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace OcrUtils {
|
||||||
|
|
||||||
|
QPoint findTextCenter(const QByteArray &png,
|
||||||
|
const QString &needle,
|
||||||
|
int yMin,
|
||||||
|
int yMax) {
|
||||||
|
if (png.isEmpty() || needle.isEmpty()) return {-1, -1};
|
||||||
|
|
||||||
|
QElapsedTimer timer;
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(png.constData()),
|
||||||
|
static_cast<size_t>(png.size()));
|
||||||
|
if (!pix) {
|
||||||
|
qWarning() << "[OcrUtils] pixReadMem failed";
|
||||||
|
return {-1, -1};
|
||||||
|
}
|
||||||
|
|
||||||
|
const int fullW = pixGetWidth(pix);
|
||||||
|
const int fullH = pixGetHeight(pix);
|
||||||
|
|
||||||
|
int cropY = 0;
|
||||||
|
Pix *target = pix;
|
||||||
|
Pix *cropped = nullptr;
|
||||||
|
if (yMin > 0 || (yMax > 0 && yMax < fullH)) {
|
||||||
|
const int y0 = qMax(0, yMin);
|
||||||
|
const int y1 = (yMax > 0 && yMax <= fullH) ? yMax : fullH;
|
||||||
|
if (y1 > y0) {
|
||||||
|
BOX *box = boxCreate(0, y0, fullW, y1 - y0);
|
||||||
|
cropped = pixClipRectangle(pix, box, nullptr);
|
||||||
|
boxDestroy(&box);
|
||||||
|
if (cropped) {
|
||||||
|
target = cropped;
|
||||||
|
cropY = y0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Гоняем OCR двумя проходами: оригинал + инверсия. Текст бывает разный —
|
||||||
|
// тёмный на светлом (заголовки/поля) и светлый на цветном (sticky-action
|
||||||
|
// кнопка, оранжевая/синяя). Инверсия белого-на-оранжевом превращает её в
|
||||||
|
// тёмное-на-голубом, что Tesseract разбирает на порядок надёжнее.
|
||||||
|
auto runPass = [&](Pix *src, bool invert) -> QPoint {
|
||||||
|
Pix *gray = pixConvertTo8(src, 0);
|
||||||
|
if (!gray) return {-1, -1};
|
||||||
|
Pix *prepared = gray;
|
||||||
|
Pix *inverted = nullptr;
|
||||||
|
if (invert) {
|
||||||
|
inverted = pixInvert(nullptr, gray);
|
||||||
|
if (inverted) prepared = inverted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray &td = tessdataBytes();
|
||||||
|
if (td.isEmpty()) {
|
||||||
|
if (inverted) pixDestroy(&inverted);
|
||||||
|
pixDestroy(&gray);
|
||||||
|
return {-1, -1};
|
||||||
|
}
|
||||||
|
auto *api = new tesseract::TessBaseAPI();
|
||||||
|
// Init(data, size, lang, ...) — грузим traineddata из памяти, чтобы
|
||||||
|
// обойти fopen на путях с кириллицей (Windows ANSI-API).
|
||||||
|
if (api->Init(td.constData(), td.size(), "rus",
|
||||||
|
tesseract::OEM_DEFAULT,
|
||||||
|
nullptr, 0, nullptr, nullptr, false, nullptr)) {
|
||||||
|
qWarning() << "[OcrUtils] Tesseract Init(memory) failed —"
|
||||||
|
<< "traineddata повреждён или несовместимая версия";
|
||||||
|
delete api;
|
||||||
|
if (inverted) pixDestroy(&inverted);
|
||||||
|
pixDestroy(&gray);
|
||||||
|
return {-1, -1};
|
||||||
|
}
|
||||||
|
api->SetImage(prepared);
|
||||||
|
api->Recognize(nullptr);
|
||||||
|
|
||||||
|
QPoint hit(-1, -1);
|
||||||
|
tesseract::ResultIterator *ri = api->GetIterator();
|
||||||
|
if (ri) {
|
||||||
|
do {
|
||||||
|
const char *raw = ri->GetUTF8Text(tesseract::RIL_WORD);
|
||||||
|
if (!raw) continue;
|
||||||
|
const QString word = QString::fromUtf8(raw).trimmed();
|
||||||
|
delete[] raw;
|
||||||
|
if (!fuzzyMatch(word, needle)) continue;
|
||||||
|
|
||||||
|
int x1, y1, x2, y2;
|
||||||
|
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
|
||||||
|
const int cx = (x1 + x2) / 2;
|
||||||
|
const int cy = (y1 + y2) / 2 + cropY;
|
||||||
|
qDebug() << "[OcrUtils] found" << needle << "→" << word
|
||||||
|
<< (invert ? "[inverted]" : "[normal]")
|
||||||
|
<< "at (" << cx << "," << cy << ")";
|
||||||
|
hit = QPoint(cx, cy);
|
||||||
|
break;
|
||||||
|
} while (ri->Next(tesseract::RIL_WORD));
|
||||||
|
}
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
if (inverted) pixDestroy(&inverted);
|
||||||
|
pixDestroy(&gray);
|
||||||
|
return hit;
|
||||||
|
};
|
||||||
|
|
||||||
|
QPoint result = runPass(target, /*invert=*/false);
|
||||||
|
if (result.x() < 0) {
|
||||||
|
result = runPass(target, /*invert=*/true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cropped) pixDestroy(&cropped);
|
||||||
|
pixDestroy(&pix);
|
||||||
|
|
||||||
|
if (result.x() < 0) {
|
||||||
|
qDebug() << "[OcrUtils]" << needle << "not found in" << timer.elapsed() << "ms";
|
||||||
|
} else {
|
||||||
|
qDebug() << "[OcrUtils]" << needle << "located in" << timer.elapsed() << "ms";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace OcrUtils
|
||||||
22
android/ocr/OcrUtils.h
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace OcrUtils {
|
||||||
|
|
||||||
|
// Находит надпись `needle` на скриншоте (PNG-байты) и возвращает координаты
|
||||||
|
// центра bounding box-а в системе координат экрана. Если текст не найден —
|
||||||
|
// возвращает QPoint(-1, -1). Совпадение нечувствительно к регистру и допускает
|
||||||
|
// одну-две OCR-ошибки в букве (для "Подтверждаю" на оранжевом фоне это важно).
|
||||||
|
//
|
||||||
|
// `yMin`/`yMax` — необязательное окно по Y в координатах экрана. Если задано,
|
||||||
|
// OCR гоняется только по этой полосе, что в 2-3 раза быстрее на 1080×2340.
|
||||||
|
// 0/0 — обрабатывать всё изображение.
|
||||||
|
QPoint findTextCenter(const QByteArray &png,
|
||||||
|
const QString &needle,
|
||||||
|
int yMin = 0,
|
||||||
|
int yMax = 0);
|
||||||
|
|
||||||
|
} // namespace OcrUtils
|
||||||
503
android/ozon/CommonScript.cpp
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QRandomGenerator>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
CommonScript::CommonScript(QObject *parent)
|
||||||
|
: QObject(parent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
CommonScript::~CommonScript() = default;
|
||||||
|
|
||||||
|
void CommonScript::start() {
|
||||||
|
doStart();
|
||||||
|
emit finished();
|
||||||
|
}
|
||||||
|
|
||||||
|
Node CommonScript::swipeToButton(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &text, int &counter, const int width, const int height
|
||||||
|
) {
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
if (Node button = xmlScreenParser.findButtonNode(text, true); button.x() > 0 && button.y() > 0) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
const int x = width / 2;
|
||||||
|
const int offset = height / 4;
|
||||||
|
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::inputPinCode(const QString &deviceId, const QList<Node> &pincode) {
|
||||||
|
for (const Node &node: pincode) {
|
||||||
|
const bool completePincode = AdbUtils::makeTap(deviceId, node.x(), node.y());
|
||||||
|
if (!completePincode) {
|
||||||
|
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
||||||
|
}
|
||||||
|
QThread::msleep(QRandomGenerator::global()->bounded(100, 901));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::runAppAndGoToHomeScreen(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &packageName,
|
||||||
|
const QString &pinCode,
|
||||||
|
const int width,
|
||||||
|
const int height
|
||||||
|
) {
|
||||||
|
// Разблокируем экран перед запуском
|
||||||
|
AdbUtils::unlockScreen(deviceId, width, height);
|
||||||
|
|
||||||
|
// Быстрый path: если приложение уже запущено — не перезапускаем сразу,
|
||||||
|
// а смотрим текущий экран. Home → готово. PIN → вводим и ждём Home.
|
||||||
|
// Только если не смогли — убиваем и рестартим.
|
||||||
|
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
|
||||||
|
qDebug() << "Process already running, pid: " << pid;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Ozon] Fast path: already on Home screen";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const QList<Node> fastPincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
||||||
|
if (fastPincode.size() == pinCode.length() && fastPincode.size() >= 4) {
|
||||||
|
qDebug() << "[Ozon] Fast path: PIN screen, entering PIN";
|
||||||
|
if (Node wake = xmlScreenParser.findTextNode("Введите ПИН"); !wake.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(deviceId, wake.x(), wake.y());
|
||||||
|
}
|
||||||
|
inputPinCode(deviceId, fastPincode);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(
|
||||||
|
AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[Ozon] Fast path: Home reached after PIN";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Ошибка загрузки — тапаем "Обновить"
|
||||||
|
{
|
||||||
|
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
|
||||||
|
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
|
||||||
|
qDebug() << "[Ozon] Fast path: page error after PIN, tapping 'Обновить'";
|
||||||
|
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
|
||||||
|
QThread::msleep(5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qDebug() << "[Ozon] Fast path failed, restarting app";
|
||||||
|
AdbUtils::tryToKillApplication(deviceId, packageName);
|
||||||
|
}
|
||||||
|
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
|
||||||
|
AdbUtils::takeScreenshot(deviceId, "run_app_error");
|
||||||
|
qWarning() << "Process has not started";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Даем минимальное время на запуск
|
||||||
|
QThread::msleep(4500);
|
||||||
|
int counter = 0;
|
||||||
|
bool findAndInputPincode = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
counter++;
|
||||||
|
|
||||||
|
// Если более 10 раз мы пытаемся перейти на домашнюю
|
||||||
|
if (counter > 10) {
|
||||||
|
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем на банеры после пин-кодом
|
||||||
|
Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
|
||||||
|
if (closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
|
||||||
|
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем на ошибку загрузки WebView ("Обновить")
|
||||||
|
{
|
||||||
|
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
|
||||||
|
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
|
||||||
|
qDebug() << "[runAppAndGoToHomeScreen] Page load error, tapping 'Обновить'";
|
||||||
|
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
|
||||||
|
QThread::msleep(5000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем на банеры перед пин-кодом
|
||||||
|
Node closeBannerNode = xmlScreenParser.tryToFindSecurityBanner();
|
||||||
|
if (!closeBannerNode.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(deviceId, closeBannerNode.x(), closeBannerNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем на баннер Google Play "Доступно обновление"
|
||||||
|
if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height); !gpNode.isEmpty()) {
|
||||||
|
qDebug() << "[runAppAndGoToHomeScreen] Google Play update banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем на рекламный bottom sheet (ипотека, открыть счёт и т.п.)
|
||||||
|
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||||
|
qDebug() << "[runAppAndGoToHomeScreen] Ad bottom sheet detected, closing...";
|
||||||
|
AdbUtils::makeTap(deviceId, adNode.x(), adNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
// Если touch_outside не помог — жмём назад
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
|
||||||
|
qDebug() << "[runAppAndGoToHomeScreen] Ad still visible, pressing back...";
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что это экран ввода пин-кода
|
||||||
|
if (!findAndInputPincode) {
|
||||||
|
const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
||||||
|
if (pincode.size() == pinCode.length() && pincode.size() >= 4) {
|
||||||
|
// чтобы пробудить экран
|
||||||
|
if (Node textPinCodeNode = xmlScreenParser.findTextNode("Введите ПИН"); !textPinCodeNode.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(deviceId, textPinCodeNode.x(), textPinCodeNode.y());
|
||||||
|
}
|
||||||
|
inputPinCode(deviceId, pincode);
|
||||||
|
// После ввода пин-код нужно сделать задержку, пока подгрузка идет, чтобы снова не спарсить
|
||||||
|
findAndInputPincode = true;
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::tryToCloseAllBannersOnHomePage(const QString &deviceId, const int width, const int height) {
|
||||||
|
bool closedAny = false;
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
bool closedThis = false;
|
||||||
|
|
||||||
|
// Баннер Google Play "Доступно обновление" — появляется поверх Ozon при возврате на домашнюю
|
||||||
|
if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height); !gpNode.isEmpty()) {
|
||||||
|
qDebug() << "[tryToCloseAllBannersOnHomePage] Google Play update banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
closedThis = true;
|
||||||
|
} else if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||||
|
// Рекламный bottom sheet (ипотека, бесплатная карта, "Копите звёзды" и т.п.)
|
||||||
|
qDebug() << "[tryToCloseAllBannersOnHomePage] Ad bottom sheet detected, closing...";
|
||||||
|
AdbUtils::makeTap(deviceId, adNode.x(), adNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
// Если touch_outside не помог — жмём назад
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
|
||||||
|
qDebug() << "[tryToCloseAllBannersOnHomePage] Ad still visible, pressing back...";
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
closedAny = true;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
continue;
|
||||||
|
} else if (Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
|
||||||
|
closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
|
||||||
|
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
closedThis = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!closedThis) break;
|
||||||
|
closedAny = true;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
return closedAny;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::closeGooglePlayBannerIfVisible(
|
||||||
|
const QString &deviceId, const int width, const int height
|
||||||
|
) {
|
||||||
|
// 1. Системный диалог запроса разрешения Android (permissioncontroller) — тапаем "ЗАПРЕТИТЬ"
|
||||||
|
if (Node denyBtn = xmlScreenParser.tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
|
||||||
|
qDebug() << "[Ozon] System permission dialog detected mid-task, tapping 'ЗАПРЕТИТЬ'...";
|
||||||
|
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Google Play in-app update bottom sheet
|
||||||
|
Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height);
|
||||||
|
if (gpNode.isEmpty()) return false;
|
||||||
|
qDebug() << "[Ozon] Google Play update banner detected mid-task, closing...";
|
||||||
|
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::goToAllProducts(
|
||||||
|
const QString &deviceId,
|
||||||
|
const int width,
|
||||||
|
const int height
|
||||||
|
) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, m_counter));
|
||||||
|
if (tryToCloseAllBannersOnHomePage(deviceId, width, height)) {
|
||||||
|
QThread::msleep(500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ozon: домашний экран и есть "Финансы" — счета уже доступны
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если по какой-то причине не на домашнем — пробуем тапнуть по вкладке "Финансы"
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
if (Node financeTab = xmlScreenParser.findNodeByContentDesc("Финансы"); !financeTab.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(deviceId, financeTab.x(), financeTab.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "goToAllProducts: not home screen";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::findAndTapOnAccount(const QString &deviceId, const QString &accountNumbers) {
|
||||||
|
QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();
|
||||||
|
for (auto &[account, node]: accounts) {
|
||||||
|
// FIXME будем считать что числа последние везде уникальные
|
||||||
|
if (accountNumbers == account.lastNumbers) {
|
||||||
|
qDebug() << "findAndTapOnAccount: " << accountNumbers;
|
||||||
|
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CommonScript::postAccountsData(const QList<QPair<MaterialInfo, Node> > &accounts) {
|
||||||
|
if (accounts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonArray list;
|
||||||
|
for (const QPair<MaterialInfo, Node> &pair: accounts) {
|
||||||
|
QJsonObject obj;
|
||||||
|
MaterialInfo account = pair.first;
|
||||||
|
obj["appName"] = account.appCode;
|
||||||
|
obj["lastNumbers"] = account.lastNumbers;
|
||||||
|
obj["amount"] = account.amount;
|
||||||
|
obj["description"] = account.description;
|
||||||
|
obj["currency"] = account.currency;
|
||||||
|
obj["status"] = materialStatusToString(account.status);
|
||||||
|
|
||||||
|
list.append(obj);
|
||||||
|
}
|
||||||
|
// FIXME доставить десктоп ид из настроеек
|
||||||
|
worker->setDeviceId(accounts.first().first.deviceId);
|
||||||
|
worker->setPayload(QJsonDocument(list).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
|
||||||
|
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction) {
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonObject obj;
|
||||||
|
obj["id"] = transaction.id;
|
||||||
|
obj["amount"] = transaction.amount;
|
||||||
|
obj["name"] = transaction.name;
|
||||||
|
obj["fee"] = transaction.fee;
|
||||||
|
obj["status"] = transactionStatusToString(transaction.status);
|
||||||
|
obj["type"] = transactionTypeToString(transaction.type);
|
||||||
|
obj["phone"] = transaction.phone;
|
||||||
|
obj["description"] = transaction.description;
|
||||||
|
obj["updatedDescription"] = transaction.updatedDescription;
|
||||||
|
obj["bankName"] = transaction.bankName;
|
||||||
|
obj["bankTime"] = transaction.bankTime;
|
||||||
|
obj["bankTransactionId"] = transaction.bankTrExternalId;
|
||||||
|
|
||||||
|
// FIXME доставить десктоп ид из настроеек
|
||||||
|
worker->setDeviceId(account.deviceId);
|
||||||
|
worker->addExtra("lastNumbers", account.lastNumbers);
|
||||||
|
worker->addExtra("transactionId", transaction.id);
|
||||||
|
worker->setPayload(QJsonDocument(obj).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::started, worker, &NetworkService::insertTransactionData);
|
||||||
|
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::updateTransactionData(
|
||||||
|
const MaterialInfo &account,
|
||||||
|
const int transactionId,
|
||||||
|
const TransactionStatus status,
|
||||||
|
const QString &oldDesc,
|
||||||
|
const QString &newDesc
|
||||||
|
) {
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonObject obj;
|
||||||
|
obj["id"] = transactionId;
|
||||||
|
obj["status"] = transactionStatusToString(status);
|
||||||
|
obj["description"] = newDesc;
|
||||||
|
obj["updatedDescription"] = oldDesc;
|
||||||
|
|
||||||
|
// FIXME доставить десктоп ид из настроеек
|
||||||
|
worker->setDeviceId(account.deviceId);
|
||||||
|
worker->addExtra("lastNumbers", account.lastNumbers);
|
||||||
|
worker->setPayload(QJsonDocument(obj).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::started, worker, &NetworkService::updateTransactionData);
|
||||||
|
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
|
|
||||||
|
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
|
||||||
|
qWarning() << "[Ozon] createBankProfileIfNeeded: skipping — fullName or phone is empty";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QMap<QString, int> currencyCodes = {
|
||||||
|
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
|
||||||
|
};
|
||||||
|
const int currencyCode = currencyCodes.value(app.currency.toUpper(), 643);
|
||||||
|
const QStringList parts = app.fullName.split(' ', Qt::SkipEmptyParts);
|
||||||
|
|
||||||
|
// Нормализуем телефон: оставляем только цифры, добавляем код страны и "+"
|
||||||
|
const QSettings settings("config.ini", QSettings::IniFormat);
|
||||||
|
const QString phoneCode = settings.value(appCode + "/phone_code").toString();
|
||||||
|
QString phone = app.phone;
|
||||||
|
phone.remove(QRegularExpression("[^0-9]"));
|
||||||
|
if (!phoneCode.isEmpty() && !phone.startsWith(phoneCode)) {
|
||||||
|
if (phone.startsWith('8') && phoneCode != "8") {
|
||||||
|
phone.replace(0, 1, phoneCode);
|
||||||
|
} else {
|
||||||
|
phone.prepend(phoneCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phone.prepend('+');
|
||||||
|
|
||||||
|
const DeviceInfo device = DeviceDAO::getDeviceById(deviceId);
|
||||||
|
|
||||||
|
QJsonObject profileBody;
|
||||||
|
profileBody["first_name"] = parts.value(0);
|
||||||
|
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : QString("");
|
||||||
|
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
|
||||||
|
profileBody["bank_name"] = appCode;
|
||||||
|
profileBody["phone_number"] = phone;
|
||||||
|
const double balance = (parsedBalance >= 0.0) ? parsedBalance : app.amount;
|
||||||
|
profileBody["balance"] = QString::number(qRound64(balance * 100));
|
||||||
|
profileBody["state"] = (app.status == "active") ? "enabled" : "disabled";
|
||||||
|
profileBody["device_id"] = device.apiId;
|
||||||
|
profileBody["currency"] = currencyCode;
|
||||||
|
|
||||||
|
NetworkService ns;
|
||||||
|
ns.setDeviceId(deviceId);
|
||||||
|
ns.setPayload(QJsonDocument(profileBody).toJson());
|
||||||
|
|
||||||
|
if (app.bankProfileId.isEmpty()) {
|
||||||
|
ns.addExtra("app_id", app.id);
|
||||||
|
ns.postBankProfile();
|
||||||
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
|
if (afterPost.bankProfileId.isEmpty()) {
|
||||||
|
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
|
||||||
|
qWarning() << "[Ozon] createBankProfileIfNeeded:" << err;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
qDebug() << "[Ozon] createBankProfileIfNeeded: postBankProfile done";
|
||||||
|
} else {
|
||||||
|
ns.addExtra("bank_profile_id", app.bankProfileId);
|
||||||
|
ns.patchBankProfile();
|
||||||
|
qDebug() << "[Ozon] createBankProfileIfNeeded: patchBankProfile done";
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::isAppRunning(const QString &deviceId, const QString &packageName) {
|
||||||
|
return !AdbUtils::tryToGetRunningAppPid(deviceId, packageName).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::FreezeDetector::isUsefulDump(const QString &xml) {
|
||||||
|
if (xml.isEmpty()) return false;
|
||||||
|
// Полезный дамп должен содержать хотя бы один Button с непустым text
|
||||||
|
// ИЛИ EditText. Иначе — это shell-only state (только нав-табы, у которых
|
||||||
|
// text пуст и есть только content-desc).
|
||||||
|
if (!xml.contains(QStringLiteral("class=\"android.widget.Button\""))) return false;
|
||||||
|
static const QRegularExpression usefulRe(
|
||||||
|
R"(class="android\.widget\.Button"[^/]*text="[^"]+"|text="[^"]+"[^/]*class="android\.widget\.Button"|class="android\.widget\.EditText")"
|
||||||
|
);
|
||||||
|
return usefulRe.match(xml).hasMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommonScript::FreezeDetector::noticeAndCheckFrozen(const QString &xml) {
|
||||||
|
if (!isUsefulDump(xml)) {
|
||||||
|
// Пустой или WebView-shell-only — не учитываем.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!m_prev.isEmpty() && xml == m_prev) {
|
||||||
|
++m_streak;
|
||||||
|
if (m_streak >= m_maxStreak) return true;
|
||||||
|
} else {
|
||||||
|
m_streak = 0;
|
||||||
|
}
|
||||||
|
m_prev = xml;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommonScript::stop() {
|
||||||
|
m_running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
122
android/ozon/CommonScript.h
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include "ScreenXmlParser.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
class CommonScript : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit CommonScript(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
~CommonScript() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void doStart() = 0;
|
||||||
|
|
||||||
|
// static method
|
||||||
|
static bool inputPinCode(const QString &deviceId, const QList<Node> &pincode);
|
||||||
|
|
||||||
|
bool goToAllProducts(
|
||||||
|
const QString &deviceId,
|
||||||
|
int width,
|
||||||
|
int height
|
||||||
|
);
|
||||||
|
|
||||||
|
bool findAndTapOnAccount(const QString &deviceId, const QString &accountNumbers);
|
||||||
|
|
||||||
|
void postAccountsData(const QList<QPair<MaterialInfo, Node> > &accounts);
|
||||||
|
|
||||||
|
void postNewTransactionData(const MaterialInfo &account, const TransactionInfo &transaction);
|
||||||
|
|
||||||
|
void updateTransactionData(
|
||||||
|
const MaterialInfo &account,
|
||||||
|
int transactionId,
|
||||||
|
TransactionStatus status,
|
||||||
|
const QString &oldDesc,
|
||||||
|
const QString &newDesc
|
||||||
|
);
|
||||||
|
|
||||||
|
Node swipeToButton(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &text,
|
||||||
|
int &counter, int width, int height
|
||||||
|
);
|
||||||
|
|
||||||
|
bool runAppAndGoToHomeScreen(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &packageName,
|
||||||
|
const QString &pinCode,
|
||||||
|
int width,
|
||||||
|
int height
|
||||||
|
);
|
||||||
|
|
||||||
|
bool tryToCloseAllBannersOnHomePage(
|
||||||
|
const QString &deviceId, int width, int height
|
||||||
|
);
|
||||||
|
|
||||||
|
// Если виден баннер Google Play "Доступно обновление" — закрывает его и перепарсивает XML.
|
||||||
|
// Безопасно вызывать на любом экране; возвращает true если баннер был закрыт.
|
||||||
|
bool closeGooglePlayBannerIfVisible(
|
||||||
|
const QString &deviceId, int width, int height
|
||||||
|
);
|
||||||
|
|
||||||
|
// Создаёт bank_profile на сервере если ещё не создан.
|
||||||
|
// Возвращает пустую строку при успехе, текст ошибки при провале.
|
||||||
|
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
|
||||||
|
|
||||||
|
// Проверяет что приложение ещё запущено на устройстве
|
||||||
|
static bool isAppRunning(const QString &deviceId, const QString &packageName);
|
||||||
|
|
||||||
|
// Детектор зафризившегося экрана: считает подряд идущие байт-идентичные
|
||||||
|
// дампы с реальным контентом. Бесполезные дампы (пустые из-за adb-сбоя
|
||||||
|
// или WebView-shell-only без отрисованного Svelte-контента) пропускаются —
|
||||||
|
// не обнуляют счётчик и не накручивают его.
|
||||||
|
//
|
||||||
|
// Использование в wait-loop:
|
||||||
|
// FreezeDetector freezeCheck;
|
||||||
|
// for (int attempt = 0; attempt < N; ++attempt) {
|
||||||
|
// QString xml = AdbUtils::getScreenDumpAsXml(...);
|
||||||
|
// xmlScreenParser.parseAndSaveXml(xml);
|
||||||
|
// if (freezeCheck.noticeAndCheckFrozen(xml)) {
|
||||||
|
// m_error = "Экран зафризился"; return;
|
||||||
|
// }
|
||||||
|
// ... existing logic
|
||||||
|
// }
|
||||||
|
class FreezeDetector {
|
||||||
|
public:
|
||||||
|
explicit FreezeDetector(int maxStreak = 3) : m_maxStreak(maxStreak) {}
|
||||||
|
// True если получили (m_maxStreak + 1) подряд байт-идентичных полезных дампов.
|
||||||
|
bool noticeAndCheckFrozen(const QString &xml);
|
||||||
|
private:
|
||||||
|
static bool isUsefulDump(const QString &xml);
|
||||||
|
QString m_prev;
|
||||||
|
int m_streak = 0;
|
||||||
|
int m_maxStreak;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool m_running = true;
|
||||||
|
ScreenXmlParser xmlScreenParser;
|
||||||
|
int m_counter = 0;
|
||||||
|
|
||||||
|
QByteArray m_resultScreenshot;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void finished();
|
||||||
|
|
||||||
|
void finishedWithResult(const QString &result);
|
||||||
|
|
||||||
|
void screenshotReady(const QByteArray &screenshot);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
virtual void start() final;
|
||||||
|
|
||||||
|
virtual void stop() final;
|
||||||
|
|
||||||
|
private:
|
||||||
|
Q_DISABLE_COPY(CommonScript);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
578
android/ozon/GetCardInfoScript.cpp
Normal file
@ -0,0 +1,578 @@
|
|||||||
|
#include "GetCardInfoScript.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/SettingsDAO.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
GetCardInfoScript::GetCardInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QString lastNumbers,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)),
|
||||||
|
m_appCode(std::move(appCode)),
|
||||||
|
m_lastNumbers(std::move(lastNumbers)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
GetCardInfoScript::~GetCardInfoScript() = default;
|
||||||
|
|
||||||
|
void GetCardInfoScript::doStart() {
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Читаем текущий экран
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Закрываем рекламный bottom sheet, если открыт
|
||||||
|
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Ad banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежий дамп перед проверкой домашнего экрана
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Если не на домашнем экране — перезапускаем приложение
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[GetCardInfo] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем баннеры на главной
|
||||||
|
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
QThread::msleep(500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Переходим в профиль и парсим имя и телефон (только если ещё нет в БД)
|
||||||
|
const bool hasProfileInfo = !app.fullName.isEmpty() && !app.phone.isEmpty();
|
||||||
|
Node userNameNode;
|
||||||
|
if (!hasProfileInfo) {
|
||||||
|
userNameNode = xmlScreenParser.findUserNameOnHomeScreen(device.screenHeight);
|
||||||
|
}
|
||||||
|
if (!hasProfileInfo && !userNameNode.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Navigating to profile to parse name/phone...";
|
||||||
|
AdbUtils::makeTap(m_deviceId, userNameNode.x(), userNameNode.y());
|
||||||
|
QThread::msleep(2500);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isProfileScreen()) break;
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xmlScreenParser.isProfileScreen()) {
|
||||||
|
const QString fullName = xmlScreenParser.parseProfileFullName();
|
||||||
|
const QString phone = xmlScreenParser.parseProfilePhone();
|
||||||
|
qDebug() << "[GetCardInfo] Profile parsed - fullName:" << fullName << "phone:" << phone;
|
||||||
|
BankProfileDAO::updateProfileInfo(app.id, fullName, phone);
|
||||||
|
} else {
|
||||||
|
qWarning() << "[GetCardInfo] Profile screen not reached, skipping profile parse";
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
} else {
|
||||||
|
qDebug() << "[GetCardInfo] Profile info already in DB or username not found, skipping profile parse";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что на домашнем экране перед поиском карт
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[GetCardInfo] Not on home screen before card search, restarting...";
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen before card search: " + device.name + " (" + m_deviceId + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
QThread::msleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждём пока все карты подгрузятся — до 5 попыток
|
||||||
|
QList<MaterialInfo> cards;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
if (closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight)) {
|
||||||
|
// баннер закрыт, дамп уже обновлён внутри helper'а
|
||||||
|
}
|
||||||
|
cards = xmlScreenParser.parseCardsOnHomeScreen();
|
||||||
|
if (!cards.isEmpty()) break;
|
||||||
|
qDebug() << "[GetCardInfo] No cards found on home screen, attempt" << attempt + 1 << "/ 5";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cards.isEmpty()) {
|
||||||
|
m_error = "No cards found on home screen: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[GetCardInfo] Found" << cards.size() << "card(s) on home screen";
|
||||||
|
|
||||||
|
// Заполняем метаданные на найденных картах. Новые карты (которых ещё нет в БД)
|
||||||
|
// не создаём здесь — запись появится только после успешного OCR полного номера
|
||||||
|
// в success-ветке ниже. Это защищает от записей из ленты переводов и не оставляет
|
||||||
|
// в БД материал с пустым card_number.
|
||||||
|
//
|
||||||
|
// Для карт, которые уже в БД, обновляем только amount/update_time —
|
||||||
|
// прочие поля (cardNumber, materialId, status, ...) сохраняем как было.
|
||||||
|
for (MaterialInfo &card : cards) {
|
||||||
|
card.deviceId = device.id;
|
||||||
|
card.appCode = m_appCode;
|
||||||
|
card.updateTime = QDateTime::currentDateTimeUtc();
|
||||||
|
card.status = MaterialStatus::Off;
|
||||||
|
|
||||||
|
const MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
|
||||||
|
if (existing.id == -1) continue;
|
||||||
|
|
||||||
|
MaterialInfo refresh = existing;
|
||||||
|
refresh.amount = card.amount;
|
||||||
|
refresh.updateTime = card.updateTime;
|
||||||
|
if (!MaterialDAO::upsertAccount(refresh)) {
|
||||||
|
qWarning() << "[GetCardInfo] Failed to refresh amount for card" << card.lastNumbers;
|
||||||
|
} else {
|
||||||
|
qDebug() << "[GetCardInfo] Refreshed amount for card"
|
||||||
|
<< card.lastNumbers << "to" << card.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем баланс домашнего экрана в application
|
||||||
|
const double totalAmount = cards.isEmpty() ? 0.0 : cards[0].amount;
|
||||||
|
if (totalAmount > 0.0) {
|
||||||
|
BankProfileDAO::updateAmount(app.id, totalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Синхронная отправка на сервер (NetworkService блокируется через QEventLoop внутри)
|
||||||
|
NetworkService ns;
|
||||||
|
ns.setDeviceId(m_deviceId);
|
||||||
|
|
||||||
|
// 1. Создаём девайс если нет api_id
|
||||||
|
if (device.apiId.isEmpty()) {
|
||||||
|
QJsonObject deviceBody;
|
||||||
|
deviceBody["desktop_id"] = SettingsDAO::get("desktop_id");
|
||||||
|
deviceBody["status"] = "ONLINE";
|
||||||
|
deviceBody["name"] = device.name;
|
||||||
|
deviceBody["screen_width"] = device.screenWidth;
|
||||||
|
deviceBody["screen_height"]= device.screenHeight;
|
||||||
|
deviceBody["battery"] = device.battery;
|
||||||
|
ns.setPayload(QJsonDocument(deviceBody).toJson());
|
||||||
|
ns.createDevice();
|
||||||
|
qDebug() << "[GetCardInfo] createDevice done";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Отправляем банковский профиль (используем свежие данные из БД — имя/телефон уже спарсены)
|
||||||
|
{
|
||||||
|
const BankProfileInfo freshApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
const QStringList parts = freshApp.fullName.split(' ', Qt::SkipEmptyParts);
|
||||||
|
|
||||||
|
// ISO 4217 numeric currency codes
|
||||||
|
const QMap<QString, int> currencyCodes = {
|
||||||
|
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
|
||||||
|
};
|
||||||
|
const int currencyCode = currencyCodes.value(freshApp.currency.toUpper(), 643);
|
||||||
|
|
||||||
|
QJsonObject profileBody;
|
||||||
|
profileBody["first_name"] = parts.value(0);
|
||||||
|
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : "";
|
||||||
|
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
|
||||||
|
profileBody["bank_name"] = m_appCode;
|
||||||
|
// Телефон: убираем пробелы, оставляем "+"
|
||||||
|
const QString phone = freshApp.phone.simplified().remove(' ');
|
||||||
|
profileBody["phone_number"] = phone;
|
||||||
|
profileBody["balance"] = QString::number(qRound64(totalAmount * 100));
|
||||||
|
profileBody["state"] = (freshApp.status == "active") ? "enabled" : "disabled";
|
||||||
|
profileBody["device_id"] = device.apiId;
|
||||||
|
profileBody["currency"] = currencyCode;
|
||||||
|
ns.setPayload(QJsonDocument(profileBody).toJson());
|
||||||
|
|
||||||
|
if (freshApp.bankProfileId.isEmpty()) {
|
||||||
|
ns.addExtra("app_id", freshApp.id);
|
||||||
|
ns.postBankProfile();
|
||||||
|
// Проверяем что bankProfileId сохранился
|
||||||
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
if (afterPost.bankProfileId.isEmpty()) {
|
||||||
|
const QString err = "postBankProfile failed for " + m_deviceId;
|
||||||
|
qWarning() << "[GetCardInfo]" << err;
|
||||||
|
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
|
||||||
|
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||||
|
"FETCH_PROFILE");
|
||||||
|
m_error = err;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qDebug() << "[GetCardInfo] postBankProfile done";
|
||||||
|
} else {
|
||||||
|
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
|
||||||
|
ns.patchBankProfile();
|
||||||
|
qDebug() << "[GetCardInfo] patchBankProfile done";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для каждой карты переходим на её экран и получаем полный номер через OCR
|
||||||
|
for (int i = 0; i < cards.size(); ++i) {
|
||||||
|
const MaterialInfo &card = cards[i];
|
||||||
|
|
||||||
|
// Если передан конкретный lastNumbers — обрабатываем только его
|
||||||
|
if (!m_lastNumbers.isEmpty() && card.lastNumbers != m_lastNumbers) continue;
|
||||||
|
|
||||||
|
qDebug() << "[GetCardInfo] Getting card number for" << card.lastNumbers;
|
||||||
|
|
||||||
|
// Если номер карты уже есть в БД — сумма уже обновлена через upsert, навигация не нужна
|
||||||
|
MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
|
||||||
|
if (existing.id != -1 && !existing.cardNumber.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "already has number, skipping";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тапаем по кнопке конкретной карты — каждая открывает свой экран
|
||||||
|
Node cardBtn = xmlScreenParser.findCardButtonByLastNumbers(card.lastNumbers);
|
||||||
|
if (cardBtn.isEmpty()) {
|
||||||
|
qWarning() << "[GetCardInfo] Card button not found for" << card.lastNumbers;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
|
||||||
|
|
||||||
|
// Скроллим WebView вверх, пока "Показать" не окажется в верхней трети экрана.
|
||||||
|
// На устройствах с короткой высотой (например, SM-A165F, h=2340) кнопка "Показать"
|
||||||
|
// находится за bottom navigation, и тап уходит в нав-бар.
|
||||||
|
const int targetTopY = device.screenHeight / 3;
|
||||||
|
for (int scrollAttempt = 0; scrollAttempt < 6; ++scrollAttempt) {
|
||||||
|
QThread::msleep(scrollAttempt == 0 ? 2000 : 600);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
Node candidate = xmlScreenParser.findButtonNode("Показать", false);
|
||||||
|
if (candidate.isEmpty()) {
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
cardBtn = xmlScreenParser.findCardButtonByLastNumbers(card.lastNumbers);
|
||||||
|
if (!cardBtn.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Still on home before scroll, re-tapping card" << card.lastNumbers;
|
||||||
|
AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidate.y() <= targetTopY) {
|
||||||
|
qDebug() << "[GetCardInfo] 'Показать' already at top y=" << candidate.y();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждём экран карты и стабильной геометрии блока реквизитов.
|
||||||
|
// Без этого тап по "Показать" во время reflow уезжает ниже — в "Защиту от мошенников".
|
||||||
|
// Стабильность: "Показать" совпадает по y1 с "Реквизиты карты" И координаты не меняются в двух подряд дампах.
|
||||||
|
Node showBtn;
|
||||||
|
int prevShowY = -1;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
QThread::msleep(attempt == 0 ? 2000 : 500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
Node candidate = xmlScreenParser.findButtonNode("Показать", false);
|
||||||
|
Node requisites = xmlScreenParser.findTextNode("Реквизиты карты");
|
||||||
|
|
||||||
|
if (candidate.isEmpty() || requisites.isEmpty()) {
|
||||||
|
// Если всё ещё на домашнем экране — тап по карточке не сработал, повторяем
|
||||||
|
if (xmlScreenParser.isHomeScreen()) {
|
||||||
|
cardBtn = xmlScreenParser.findCardButtonByLastNumbers(card.lastNumbers);
|
||||||
|
if (!cardBtn.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Still on home screen, re-tapping card" << card.lastNumbers;
|
||||||
|
AdbUtils::makeTap(m_deviceId, cardBtn.x(), cardBtn.y());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevShowY = -1;
|
||||||
|
qDebug() << "[GetCardInfo] Requisites not ready, attempt" << attempt + 1 << "/ 10";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Показать" должен быть в одной строке с "Реквизиты карты"
|
||||||
|
if (qAbs(candidate.y1() - requisites.y1()) > 10) {
|
||||||
|
prevShowY = -1;
|
||||||
|
qDebug() << "[GetCardInfo] 'Показать' not aligned with 'Реквизиты карты', waiting reflow";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y должна совпасть в двух подряд дампах (layout устаканился)
|
||||||
|
if (prevShowY >= 0 && qAbs(candidate.y() - prevShowY) <= 5) {
|
||||||
|
showBtn = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prevShowY = candidate.y();
|
||||||
|
qDebug() << "[GetCardInfo] 'Показать' seen at y=" << candidate.y() << ", waiting for stable frame";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBtn.isEmpty()) {
|
||||||
|
qWarning() << "[GetCardInfo] 'Показать' not stable for card" << card.lastNumbers;
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[GetCardInfo] 'Показать' at" << showBtn.x() << showBtn.y();
|
||||||
|
AdbUtils::makeTap(m_deviceId, showBtn.x(), showBtn.y());
|
||||||
|
|
||||||
|
// Ждём появления полного номера карты в XML дампе
|
||||||
|
QString cardNumber;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
QThread::msleep(2000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
cardNumber = xmlScreenParser.findFullCardNumber();
|
||||||
|
if (!cardNumber.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Card number found on attempt" << attempt + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем баннер "Понятно" и повторно нажимаем "Показать"
|
||||||
|
Node bannerBtn = xmlScreenParser.findTextNode("Понятно");
|
||||||
|
if (!bannerBtn.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Banner 'Понятно' detected, closing...";
|
||||||
|
AdbUtils::makeTap(m_deviceId, bannerBtn.x(), bannerBtn.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
Node retryShowBtn = xmlScreenParser.findButtonNode("Показать", false);
|
||||||
|
if (!retryShowBtn.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Re-tapping 'Показать' after banner close";
|
||||||
|
AdbUtils::makeTap(m_deviceId, retryShowBtn.x(), retryShowBtn.y());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Подождите" — загрузка в процессе, просто ждём
|
||||||
|
if (!xmlScreenParser.findButtonNode("Подождите", false).isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] 'Подождите' visible, waiting... attempt" << attempt + 1 << "/ 10";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Скрыть" видна — номер должен быть раскрыт, но парсер не нашёл
|
||||||
|
if (!xmlScreenParser.findButtonNode("Скрыть", false).isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] 'Скрыть' visible but card number not parsed, attempt" << attempt + 1 << "/ 10";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторно нажимаем "Показать" если тап не сработал
|
||||||
|
Node retryShowBtn = xmlScreenParser.findButtonNode("Показать", false);
|
||||||
|
if (!retryShowBtn.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Re-tapping 'Показать', attempt" << attempt + 1 << "/ 10";
|
||||||
|
AdbUtils::makeTap(m_deviceId, retryShowBtn.x(), retryShowBtn.y());
|
||||||
|
} else {
|
||||||
|
qDebug() << "[GetCardInfo] Card number not visible, attempt" << attempt + 1 << "/ 10";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardNumber.isEmpty()) {
|
||||||
|
qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "full number:" << cardNumber;
|
||||||
|
// Апсёртим запись только теперь — с полным номером карты.
|
||||||
|
MaterialInfo toSave = card;
|
||||||
|
toSave.cardNumber = cardNumber;
|
||||||
|
toSave.updateTime = QDateTime::currentDateTimeUtc();
|
||||||
|
if (!MaterialDAO::upsertAccount(toSave)) {
|
||||||
|
qWarning() << "[GetCardInfo] Failed to upsert account for card" << card.lastNumbers;
|
||||||
|
} else {
|
||||||
|
qDebug() << "[GetCardInfo] Upserted account:" << card.lastNumbers
|
||||||
|
<< card.description << card.amount;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const QString err = "Card number not found for " + card.lastNumbers;
|
||||||
|
qWarning() << "[GetCardInfo]" << err;
|
||||||
|
// Без полного номера материал в БД не создаём.
|
||||||
|
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
|
||||||
|
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||||
|
"FETCH_PROFILE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаемся на домашний экран для следующей карты
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5. Достаём номер счёта (общий для всех карт): Основной счёт → Посмотреть реквизиты
|
||||||
|
// ВРЕМЕННО ОТКЛЮЧЕНО
|
||||||
|
// QString accountNumber;
|
||||||
|
// {
|
||||||
|
// bool needFetch = false;
|
||||||
|
// for (const MaterialInfo &c : cards) {
|
||||||
|
// const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, c.lastNumbers);
|
||||||
|
// if (dbCard.id != -1 && dbCard.accountNumber.isEmpty()) { needFetch = true; break; }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (needFetch) {
|
||||||
|
// // Возвращаемся на главный экран — критерий именно по наличию кнопки
|
||||||
|
// // "Основной счёт ... ₽". isHomeScreen() для Озона нестрогий
|
||||||
|
// // (Перевести/Пополнить присутствуют и на других экранах).
|
||||||
|
// Node mainAcc;
|
||||||
|
// for (int attempt = 0; attempt < 6; ++attempt) {
|
||||||
|
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
// mainAcc = xmlScreenParser.findMainAccountButton();
|
||||||
|
// if (mainAcc.x() > 0) break;
|
||||||
|
// AdbUtils::goBack(m_deviceId);
|
||||||
|
// QThread::msleep(1500);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (mainAcc.x() > 0) {
|
||||||
|
// AdbUtils::makeTap(m_deviceId, mainAcc.x(), mainAcc.y());
|
||||||
|
// QThread::msleep(2000);
|
||||||
|
//
|
||||||
|
// // Скроллим WebView, пока "Посмотреть реквизиты" не окажется в верхней половине,
|
||||||
|
// // чтобы тап не ушёл в системный нав-бар.
|
||||||
|
// Node showReq;
|
||||||
|
// const int targetMaxY = device.screenHeight / 2;
|
||||||
|
// for (int attempt = 0; attempt < 6; ++attempt) {
|
||||||
|
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
// Node candidate = xmlScreenParser.findShowRequisitesButton();
|
||||||
|
// if (candidate.x() > 0 && candidate.y() <= targetMaxY) {
|
||||||
|
// showReq = candidate;
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||||
|
// device.screenHeight * 2 / 3,
|
||||||
|
// device.screenWidth / 2,
|
||||||
|
// device.screenHeight / 3);
|
||||||
|
// QThread::msleep(800);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (showReq.x() > 0) {
|
||||||
|
// AdbUtils::makeTap(m_deviceId, showReq.x(), showReq.y());
|
||||||
|
// QThread::msleep(2000);
|
||||||
|
//
|
||||||
|
// bool reqScreen = false;
|
||||||
|
// for (int attempt = 0; attempt < 6; ++attempt) {
|
||||||
|
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
// if (xmlScreenParser.isAccountRequisitesScreen()) {
|
||||||
|
// reqScreen = true;
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// QThread::msleep(1000);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (reqScreen) {
|
||||||
|
// for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
// accountNumber = xmlScreenParser.parseAccountNumberFromRequisites();
|
||||||
|
// if (!accountNumber.isEmpty()) break;
|
||||||
|
// QThread::msleep(800);
|
||||||
|
// }
|
||||||
|
// qDebug() << "[GetCardInfo] accountNumber:" << accountNumber;
|
||||||
|
// } else {
|
||||||
|
// qWarning() << "[GetCardInfo] Account requisites screen not detected";
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// qWarning() << "[GetCardInfo] 'Посмотреть реквизиты' not reached";
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// qWarning() << "[GetCardInfo] 'Основной счёт' button not found on home";
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Возврат на главный — критерий по кнопке "Основной счёт ... ₽"
|
||||||
|
// for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
// if (xmlScreenParser.findMainAccountButton().x() > 0) break;
|
||||||
|
// AdbUtils::goBack(m_deviceId);
|
||||||
|
// QThread::msleep(1500);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Записываем номер счёта во все материалы device/app
|
||||||
|
// if (!accountNumber.isEmpty()) {
|
||||||
|
// for (const MaterialInfo &c : cards) {
|
||||||
|
// MaterialInfo upd = MaterialDAO::findAppAccount(device.id, m_appCode, c.lastNumbers);
|
||||||
|
// if (upd.id == -1) continue;
|
||||||
|
// upd.accountNumber = accountNumber;
|
||||||
|
// upd.updateTime = QDateTime::currentDateTimeUtc();
|
||||||
|
// if (!MaterialDAO::upsertAccount(upd)) {
|
||||||
|
// qWarning() << "[GetCardInfo] Failed to save accountNumber for" << upd.lastNumbers;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 3. Отправляем материалы (карты) — после OCR, когда номера карт уже в БД
|
||||||
|
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 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
32
android/ozon/GetCardInfoScript.h
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
class GetCardInfoScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// lastNumbers — последние 4 цифры карты (например "9865").
|
||||||
|
// Если пусто — берём первую найденную карту на домашнем экране.
|
||||||
|
explicit GetCardInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QString lastNumbers,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
~GetCardInfoScript() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
const QString m_lastNumbers;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
963
android/ozon/GetLastTransactionsScript.cpp
Normal file
@ -0,0 +1,963 @@
|
|||||||
|
#include "GetLastTransactionsScript.h"
|
||||||
|
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QTimeZone>
|
||||||
|
|
||||||
|
#include <tesseract/baseapi.h>
|
||||||
|
#include <leptonica/allheaders.h>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/TransactionDAO.h"
|
||||||
|
#include "time/DateUtils.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
static QString recognizeTextFromImage(const QByteArray &pngData) {
|
||||||
|
auto *api = new tesseract::TessBaseAPI();
|
||||||
|
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||||||
|
qWarning() << "[OCR] Failed to init Tesseract";
|
||||||
|
delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
|
||||||
|
static_cast<size_t>(pngData.size()));
|
||||||
|
if (!pix) {
|
||||||
|
qWarning() << "[OCR] Failed to read image";
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f);
|
||||||
|
pixDestroy(&pix);
|
||||||
|
if (!gray) {
|
||||||
|
qWarning() << "[OCR] Failed to convert to grayscale";
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
api->SetImage(gray);
|
||||||
|
api->SetSourceResolution(300);
|
||||||
|
char *text = api->GetUTF8Text();
|
||||||
|
QString result = QString::fromUtf8(text).trimmed();
|
||||||
|
delete[] text;
|
||||||
|
pixDestroy(&gray);
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static QString extractTransactionId(const QString &ocrText) {
|
||||||
|
// ID может быть на одной строке: "ID операции B606513042211200000120011700501"
|
||||||
|
// или с дефисами: "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
|
||||||
|
// или разбит на 2 строки: "ID ... po-3140eb01-002c-5000-8000-033396b\n ... 637e1"
|
||||||
|
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-z0-9\-]+(?:[^\S\n]+[A-Za-z0-9\-]+)?))");
|
||||||
|
if (auto m = reId.match(ocrText); m.hasMatch()) {
|
||||||
|
QString id = m.captured(1);
|
||||||
|
id.remove(' ');
|
||||||
|
id.remove(QChar(0x00A0));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
|
||||||
|
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
GetLastTransactionsScript::GetLastTransactionsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
int maxCount,
|
||||||
|
double searchAmount,
|
||||||
|
QDateTime searchTime,
|
||||||
|
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_materialId(std::move(materialId)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
GetLastTransactionsScript::~GetLastTransactionsScript() = default;
|
||||||
|
|
||||||
|
void GetLastTransactionsScript::doStart() {
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId);
|
||||||
|
if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId);
|
||||||
|
if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
if (!navigateToTransactionList(device, app)) {
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим accountId
|
||||||
|
int accountId = -1;
|
||||||
|
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||||
|
if (!accounts.isEmpty()) {
|
||||||
|
accountId = accounts.first().id;
|
||||||
|
} else {
|
||||||
|
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор режима: поиск конкретной транзакции или сканирование последних
|
||||||
|
if (qAbs(m_searchAmount) > 0.01) {
|
||||||
|
searchTransaction(device, accountId);
|
||||||
|
} else {
|
||||||
|
scanRecentTransactions(device, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
|
||||||
|
// Хелпер: если на экране ошибка загрузки ("Обновить"), тапаем один раз
|
||||||
|
auto tryRefreshIfError = [&]() {
|
||||||
|
// Кнопка "Обновить" — Button с content-desc="Обновить" (text пустой)
|
||||||
|
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
|
||||||
|
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
|
||||||
|
qDebug() << "[GetLastTransactions] Page load error, tapping 'Обновить' at"
|
||||||
|
<< refreshBtn.x() << refreshBtn.y();
|
||||||
|
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
|
||||||
|
QThread::msleep(5000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
tryRefreshIfError();
|
||||||
|
|
||||||
|
// Закрываем рекламный bottom sheet
|
||||||
|
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Если уже на экране операций — скроллим наверх и готово
|
||||||
|
if (xmlScreenParser.isTransactionListScreen()) {
|
||||||
|
qDebug() << "[GetLastTransactions] Already on transaction list screen, scrolling to top";
|
||||||
|
// Несколько быстрых свайпов вверх чтобы вернуться к началу списка
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||||
|
device.screenHeight / 3,
|
||||||
|
device.screenWidth / 2,
|
||||||
|
device.screenHeight * 2 / 3, 200);
|
||||||
|
QThread::msleep(500);
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не на домашнем экране — перезапускаем
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||||
|
AppLogger::log("ozon/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
tryRefreshIfError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем баннеры на главной
|
||||||
|
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
QThread::msleep(500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Скроллим до "Все" и тапаем (быстрый fling чтобы пропустить баннеры)
|
||||||
|
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
|
||||||
|
Node allBtn;
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
if (QThread::currentThread()->isInterruptionRequested()) return false;
|
||||||
|
const int x = device.screenWidth / 2;
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - 200, x, 200, 100);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
allBtn = xmlScreenParser.findButtonNode("Все", false);
|
||||||
|
if (!allBtn.isEmpty() && allBtn.y() > 0 && allBtn.y() < navBarTop) break;
|
||||||
|
allBtn = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allBtn.isEmpty()) {
|
||||||
|
m_error = "Button 'Все' not found: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, allBtn.x(), allBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// Ждём экран "Операции"
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (QThread::currentThread()->isInterruptionRequested()) return false;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isTransactionListScreen()) return true;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_error = "Transaction list screen not loaded: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
|
||||||
|
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
|
||||||
|
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
|
||||||
|
// Время в Ozon показывается в таймзоне устройства, а не в MSK
|
||||||
|
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
|
||||||
|
QTimeZone deviceTz(tzName.toUtf8());
|
||||||
|
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
|
||||||
|
const int maxScrolls = 30;
|
||||||
|
// Реальный y верхней границы нижнего нав-холдера. Если в XML есть
|
||||||
|
// ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder,
|
||||||
|
// используем его y1; иначе грубая оценка ~10% снизу.
|
||||||
|
auto detectNavBarTop = [&]() -> int {
|
||||||
|
for (const Node &n : xmlScreenParser.getNodes()) {
|
||||||
|
if (n.resourceId == "ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder"
|
||||||
|
&& n.height() > 0) {
|
||||||
|
return n.y1();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return device.screenHeight - (device.screenHeight / 10);
|
||||||
|
};
|
||||||
|
int navBarTop = detectNavBarTop();
|
||||||
|
|
||||||
|
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
|
||||||
|
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
|
||||||
|
const QDate searchDate = searchLocal.date();
|
||||||
|
|
||||||
|
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
|
||||||
|
<< "amount=" << m_searchAmount
|
||||||
|
<< "time=" << m_searchTime.toString(Qt::ISODate)
|
||||||
|
<< "tz=" << tzName
|
||||||
|
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
|
||||||
|
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
|
||||||
|
|
||||||
|
// ── Шаг 1: скроллим до нужной даты ──────────────────────────────
|
||||||
|
static const QMap<QString, int> monthMap = {
|
||||||
|
{"января", 1}, {"февраля", 2}, {"марта", 3}, {"апреля", 4},
|
||||||
|
{"мая", 5}, {"июня", 6}, {"июля", 7}, {"августа", 8},
|
||||||
|
{"сентября", 9}, {"октября", 10}, {"ноября", 11}, {"декабря", 12}
|
||||||
|
};
|
||||||
|
static const QRegularExpression dateRx(R"((\d{1,2})\s+(\S+),\s+\S+)");
|
||||||
|
|
||||||
|
auto parseDateHeader = [&](const QString &t) -> QDate {
|
||||||
|
const QDate today = QDateTime::currentDateTimeUtc().toTimeZone(deviceTz).date();
|
||||||
|
if (t.compare("Сегодня", Qt::CaseInsensitive) == 0) return today;
|
||||||
|
if (t.compare("Вчера", Qt::CaseInsensitive) == 0) return today.addDays(-1);
|
||||||
|
const auto match = dateRx.match(t);
|
||||||
|
if (!match.hasMatch()) return {};
|
||||||
|
const int day = match.captured(1).toInt();
|
||||||
|
const int month = monthMap.value(match.captured(2), 0);
|
||||||
|
if (month == 0) return {};
|
||||||
|
return QDate(searchDate.year(), month, day);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ozon WebView сворачивает заголовки дат вне вьюпорта в один y (0 или общий
|
||||||
|
// верхний y). Несколько разных дат на одной y-позиции — pile-up скрытых
|
||||||
|
// заголовков, их надо считать невидимыми.
|
||||||
|
auto computeDateYCount = [&]() -> QMap<int, int> {
|
||||||
|
QMap<int, int> cnt;
|
||||||
|
for (const Node &node : xmlScreenParser.getNodes()) {
|
||||||
|
if (node.className != "android.widget.TextView") continue;
|
||||||
|
if (node.y() <= 0) continue;
|
||||||
|
if (parseDateHeader(node.text.trimmed()).isValid()) cnt[node.y()]++;
|
||||||
|
}
|
||||||
|
return cnt;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto hasDateOnScreen = [&]() -> bool {
|
||||||
|
const QMap<int, int> yCount = computeDateYCount();
|
||||||
|
for (const Node &node : xmlScreenParser.getNodes()) {
|
||||||
|
if (node.className != "android.widget.TextView") continue;
|
||||||
|
if (node.y() <= 0) continue;
|
||||||
|
if (yCount.value(node.y(), 0) != 1) continue;
|
||||||
|
if (parseDateHeader(node.text.trimmed()) == searchDate) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto hasOlderDateOnScreen = [&]() -> bool {
|
||||||
|
const QMap<int, int> yCount = computeDateYCount();
|
||||||
|
for (const Node &node : xmlScreenParser.getNodes()) {
|
||||||
|
if (node.className != "android.widget.TextView") continue;
|
||||||
|
if (node.y() <= 0) continue;
|
||||||
|
if (yCount.value(node.y(), 0) != 1) continue;
|
||||||
|
const QDate d = parseDateHeader(node.text.trimmed());
|
||||||
|
if (d.isValid() && d < searchDate) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ждём пока WebView раскроет accessibility tree (иначе dump пустой)
|
||||||
|
for (int wait = 0; wait < 5; ++wait) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.findTransactionButtons(1).size() > 0) break;
|
||||||
|
if (hasDateOnScreen()) break;
|
||||||
|
qDebug() << "[FetchTransactions] Waiting for WebView content..." << (wait + 1);
|
||||||
|
QThread::msleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скроллим к нужной дате, запоминаем количество скроллов
|
||||||
|
int scrollsToDate = 0;
|
||||||
|
bool dateFound = false;
|
||||||
|
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
|
||||||
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
|
m_error = "Interrupted";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (hasDateOnScreen()) {
|
||||||
|
dateFound = true;
|
||||||
|
scrollsToDate = scroll;
|
||||||
|
qDebug() << "[FetchTransactions] Found target date on screen after" << scroll << "scrolls";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (hasOlderDateOnScreen()) {
|
||||||
|
qDebug() << "[FetchTransactions] Passed target date, not found in list";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||||
|
device.screenHeight * 2 / 3, device.screenWidth / 2,
|
||||||
|
device.screenHeight / 3, 600);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dateFound) {
|
||||||
|
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
|
||||||
|
qWarning() << "[FetchTransactions]" << m_error;
|
||||||
|
m_resultScreenshot = listScreenshot.isEmpty()
|
||||||
|
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||||
|
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
||||||
|
// Кнопки транзакций между этими Y принадлежат нужному дню.
|
||||||
|
// Возвращает специальные коды через dateY:
|
||||||
|
// dateY >= 0 : header виден на этом Y (нормальный случай)
|
||||||
|
// dateY == 0 : header выше viewport, есть более старая видимая дата
|
||||||
|
// dateY == -1 : header не виден / лежит в нижней sticky-пачке
|
||||||
|
// (скролл вниз, чтобы target выехал в видимую часть)
|
||||||
|
// dateY == -2 : header в верхней sticky-пачке (несколько дат свёрнуто
|
||||||
|
// над viewport) — скролл вверх, чтобы пачка распалась
|
||||||
|
auto findDateYRange = [&]() -> QPair<int, int> {
|
||||||
|
const QMap<int, int> yCount = computeDateYCount();
|
||||||
|
auto isAnchored = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; };
|
||||||
|
auto isPiled = [&](int y) { return y > 0 && yCount.value(y, 0) > 1; };
|
||||||
|
const int viewportMid = device.screenHeight / 2;
|
||||||
|
|
||||||
|
int dateY = -1;
|
||||||
|
int nextDateY = device.screenHeight;
|
||||||
|
int targetPileY = -1;
|
||||||
|
bool foundOlderDate = false;
|
||||||
|
for (const Node &node : xmlScreenParser.getNodes()) {
|
||||||
|
if (node.className != "android.widget.TextView") continue;
|
||||||
|
const QDate d = parseDateHeader(node.text.trimmed());
|
||||||
|
if (!d.isValid()) continue;
|
||||||
|
if (d == searchDate) {
|
||||||
|
if (isAnchored(node.y())) dateY = node.y();
|
||||||
|
else if (isPiled(node.y())) targetPileY = node.y();
|
||||||
|
} else if (d < searchDate) {
|
||||||
|
foundOlderDate = true;
|
||||||
|
if (isAnchored(node.y())
|
||||||
|
&& (dateY < 0 || node.y() > dateY)
|
||||||
|
&& node.y() < nextDateY) {
|
||||||
|
nextDateY = node.y();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Target в sticky-пачке: пачка над viewport (малый Y) → секция уже
|
||||||
|
// прокручена выше, надо вернуться (dateY = -2). Пачка снизу viewport
|
||||||
|
// (большой Y) → target ещё не доехал до экрана, скролл вниз (dateY = -1).
|
||||||
|
if (dateY < 0 && targetPileY >= 0) {
|
||||||
|
dateY = (targetPileY < viewportMid) ? -2 : -1;
|
||||||
|
}
|
||||||
|
// Target-header не виден, но видна более старая дата → target ушёл вверх:
|
||||||
|
// всё выше nextDateY принадлежит нашему дню.
|
||||||
|
if (dateY < 0 && nextDateY < device.screenHeight && targetPileY < 0) {
|
||||||
|
dateY = 0;
|
||||||
|
}
|
||||||
|
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
|
||||||
|
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate
|
||||||
|
<< "targetPileY=" << targetPileY;
|
||||||
|
return {dateY, nextDateY};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
|
||||||
|
QSet<QString> checkedDetails;
|
||||||
|
int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок)
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||||
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
|
m_error = "Interrupted";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
// Логируем все заголовки дат на экране
|
||||||
|
{
|
||||||
|
QStringList datesOnScreen;
|
||||||
|
for (const Node &node : xmlScreenParser.getNodes()) {
|
||||||
|
if (node.className != "android.widget.TextView") continue;
|
||||||
|
const QDate d = parseDateHeader(node.text.trimmed());
|
||||||
|
if (d.isValid()) {
|
||||||
|
datesOnScreen << QString("%1@y=%2").arg(d.toString("dd.MM")).arg(node.y());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qDebug() << "[FetchTransactions] attempt=" << attempt << "dates on screen:" << datesOnScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим Y-диапазон заголовка нужной даты
|
||||||
|
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
|
||||||
|
if (dateHeaderY == -2) {
|
||||||
|
// Target застрял в sticky-пачке (Ozon стекает заголовки выше viewport
|
||||||
|
// в одну Y-позицию). Ниже пачки есть anchored более старая дата →
|
||||||
|
// секцию target уже проскроллили мимо. Скроллим обратно вверх,
|
||||||
|
// чтобы target распался из пачки и занял реальный Y.
|
||||||
|
qDebug() << "[FetchTransactions] Target piled in sticky band — scrolling up to detach";
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||||
|
device.screenHeight / 3,
|
||||||
|
device.screenWidth / 2,
|
||||||
|
device.screenHeight * 2 / 3, 600);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dateHeaderY < 0) {
|
||||||
|
// Target-header не виден и ни одной более старой даты не видно →
|
||||||
|
// target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх).
|
||||||
|
qDebug() << "[FetchTransactions] Target not visible, no older date — scrolling down";
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||||
|
device.screenHeight * 2 / 3,
|
||||||
|
device.screenWidth / 2,
|
||||||
|
device.screenHeight / 3, 500);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
|
||||||
|
// Target-header ушёл вверх, видна только более старая дата.
|
||||||
|
// Кнопки выше nextDateHeaderY могут принадлежать промежуточным
|
||||||
|
// (пропущенным) датам — матчить их опасно. Скроллим обратно вверх.
|
||||||
|
qDebug() << "[FetchTransactions] Target scrolled above — scrolling up to recover";
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||||
|
device.screenHeight / 3,
|
||||||
|
device.screenWidth / 2,
|
||||||
|
device.screenHeight * 2 / 3, 600);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
|
||||||
|
qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:"
|
||||||
|
<< dateHeaderY << "-" << nextDateHeaderY;
|
||||||
|
|
||||||
|
bool tapped = false;
|
||||||
|
bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой
|
||||||
|
int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты
|
||||||
|
for (int bi = 0; bi < txButtons.size(); ++bi) {
|
||||||
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
|
m_error = "Interrupted";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пропускаем кнопки вне Y-диапазона нужной даты.
|
||||||
|
// Используем y1 (верх кнопки), а не центр — длинные кнопки могут
|
||||||
|
// заходить под нав-бар, но всё равно tappable (тапнем по верхней части).
|
||||||
|
const int btnY1 = txButtons[bi].y1();
|
||||||
|
const int btnY = txButtons[bi].y();
|
||||||
|
if (btnY1 <= dateHeaderY || btnY1 >= nextDateHeaderY) continue;
|
||||||
|
if (btnY1 <= 0 || btnY1 >= navBarTop) continue;
|
||||||
|
++buttonsInRange;
|
||||||
|
|
||||||
|
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
|
||||||
|
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
|
||||||
|
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
|
||||||
|
<< "text=" << txButtons[bi].text.left(60);
|
||||||
|
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
|
||||||
|
|
||||||
|
const Node &visibleBtn = txButtons[bi];
|
||||||
|
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
|
||||||
|
// нав-бар, центр окажется на нав-баре и тап улетит мимо.
|
||||||
|
const int safeMaxY = navBarTop - 20;
|
||||||
|
int tapY = visibleBtn.y();
|
||||||
|
if (tapY > safeMaxY) {
|
||||||
|
tapY = qMax(visibleBtn.y1() + 30, safeMaxY);
|
||||||
|
}
|
||||||
|
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
|
||||||
|
<< "at" << visibleBtn.x() << tapY
|
||||||
|
<< "(btn y1=" << visibleBtn.y1() << "y2=" << visibleBtn.y2()
|
||||||
|
<< "navBarTop=" << navBarTop
|
||||||
|
<< "dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
|
||||||
|
|
||||||
|
// Тапаем и ждём деталей
|
||||||
|
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), tapY);
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
TransactionInfo detail;
|
||||||
|
for (int da = 0; da < 10; ++da) {
|
||||||
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
|
m_error = "Interrupted";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
detail = xmlScreenParser.parseTransactionDetail();
|
||||||
|
if (!detail.bankTime.isEmpty()) break;
|
||||||
|
if (xmlScreenParser.isTransactionListScreen()) {
|
||||||
|
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
|
||||||
|
}
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.bankTime.isEmpty()) {
|
||||||
|
qWarning() << "[FetchTransactions] Could not load detail, going back";
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим дату из деталей
|
||||||
|
QString dtStr = detail.bankTime;
|
||||||
|
for (int ci = 0; ci < dtStr.size(); ++ci) {
|
||||||
|
const QChar ch = dtStr.at(ci);
|
||||||
|
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
|
||||||
|
}
|
||||||
|
QDateTime txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
|
||||||
|
if (!txTime.isValid()) txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
|
||||||
|
if (txTime.isValid()) txTime.setTimeZone(deviceTz);
|
||||||
|
|
||||||
|
// Пропускаем если уже проверяли эту транзакцию
|
||||||
|
if (checkedDetails.contains(dtStr)) {
|
||||||
|
qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping";
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
checkedDetails.insert(dtStr);
|
||||||
|
tapped = true;
|
||||||
|
hasNewButton = true;
|
||||||
|
|
||||||
|
// Проверяем что транзакция принадлежит нужной дате (защита от широкого Y-диапазона)
|
||||||
|
if (txTime.isValid()) {
|
||||||
|
const QDate txLocalDate = txTime.toTimeZone(deviceTz).date();
|
||||||
|
if (txLocalDate != searchDate) {
|
||||||
|
qDebug() << "[FetchTransactions] Wrong date in detail:" << dtStr
|
||||||
|
<< "expected date" << searchDate.toString("dd.MM.yyyy")
|
||||||
|
<< "- button belongs to different date, skipping";
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем время (±10 минут)
|
||||||
|
if (txTime.isValid() && m_searchTime.isValid()) {
|
||||||
|
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
|
||||||
|
if (diffSecs > 600) {
|
||||||
|
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
|
||||||
|
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
|
||||||
|
<< "diff=" << diffSecs << "sec";
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нашли!
|
||||||
|
qDebug() << "[FetchTransactions] FOUND transaction: device=" << m_deviceId
|
||||||
|
<< "amount=" << parsed.amount << "time=" << dtStr
|
||||||
|
<< "name=" << detail.name;
|
||||||
|
|
||||||
|
// Скриншот экрана деталей транзакции — отдаём в EventHandler.
|
||||||
|
// Раньше дополнительно тапали "Документы" + OCR ради ID операции,
|
||||||
|
// но это медленно и нестабильно. Теперь полагаемся на дату+время+
|
||||||
|
// сумму как идентификатор.
|
||||||
|
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
|
||||||
|
QString externalId = detail.bankTrExternalId;
|
||||||
|
|
||||||
|
// Сохраняем
|
||||||
|
if (accountId != -1) {
|
||||||
|
TransactionInfo tx;
|
||||||
|
tx.accountId = accountId;
|
||||||
|
tx.amount = parsed.amount;
|
||||||
|
tx.bankName = m_appCode;
|
||||||
|
tx.bankTime = dtStr;
|
||||||
|
tx.bankTrExternalId = externalId.isEmpty()
|
||||||
|
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
|
||||||
|
: externalId;
|
||||||
|
tx.status = TransactionStatus::Complete;
|
||||||
|
tx.timestamp = DateUtils::getUtcNow();
|
||||||
|
if (txTime.isValid()) tx.completeTime = txTime;
|
||||||
|
if (!detail.name.isEmpty()) tx.name = detail.name;
|
||||||
|
if (!detail.description.isEmpty()) tx.description = detail.description;
|
||||||
|
if (!detail.phone.isEmpty()) {
|
||||||
|
QString cleanPhone;
|
||||||
|
for (const QChar &ch : detail.phone)
|
||||||
|
if (ch.isDigit()) cleanPhone.append(ch);
|
||||||
|
tx.phone = cleanPhone;
|
||||||
|
tx.type = TransactionType::Phone;
|
||||||
|
}
|
||||||
|
TransactionDAO::insertTransactionIfNotExists(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отдаём результат в 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);
|
||||||
|
txJson["currency"] = 643; // RUB
|
||||||
|
txJson["transaction_type"] = "bank";
|
||||||
|
QString participator = detail.phone;
|
||||||
|
if (participator.isEmpty()) participator = detail.name;
|
||||||
|
txJson["bank_participator_id"] = participator;
|
||||||
|
txJson["status"] = "completed";
|
||||||
|
QJsonObject extraObj;
|
||||||
|
extraObj["fee"] = 0;
|
||||||
|
txJson["extra"] = extraObj;
|
||||||
|
txJson["created_at"] = txTime.isValid()
|
||||||
|
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
|
||||||
|
|
||||||
|
qDebug() << "[FetchTransactions] Emitting parsed tx:"
|
||||||
|
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
|
||||||
|
emit transactionParsed(txJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_resultScreenshot.isEmpty())
|
||||||
|
emit screenshotReady(m_resultScreenshot);
|
||||||
|
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
return; // успех
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
|
||||||
|
// суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше,
|
||||||
|
// нужная кнопка может быть ниже.
|
||||||
|
if (!hasNewButton && tapped) {
|
||||||
|
++noProgressCount;
|
||||||
|
if (noProgressCount >= 5) {
|
||||||
|
qDebug() << "[FetchTransactions] No progress after" << noProgressCount
|
||||||
|
<< "attempts with matching amount — giving up";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (hasNewButton) {
|
||||||
|
noProgressCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скроллим если не нашли нужную транзакцию
|
||||||
|
if (!tapped || !hasNewButton) {
|
||||||
|
// Признак того, что под фолдом ещё есть не раскрытые транзакции
|
||||||
|
// (Ozon хранит off-screen-row как android.view.View с height=0).
|
||||||
|
const bool moreBelow = xmlScreenParser.hasCollapsedTransactionsBelow(device.screenHeight);
|
||||||
|
qDebug() << "[FetchTransactions] Scrolling (tapped=" << tapped
|
||||||
|
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
|
||||||
|
<< "noProgress=" << noProgressCount
|
||||||
|
<< "dateHeaderY=" << dateHeaderY
|
||||||
|
<< "nextDateHeaderY=" << nextDateHeaderY
|
||||||
|
<< "moreBelow=" << moreBelow << ")";
|
||||||
|
|
||||||
|
const int cx = device.screenWidth / 2;
|
||||||
|
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
|
||||||
|
// Target-header ушёл вверх — свайпим вниз чтоб вернуть его.
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, cx,
|
||||||
|
device.screenHeight * 2 / 5, cx,
|
||||||
|
device.screenHeight * 3 / 4, 600);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
} else {
|
||||||
|
// Скролл вниз. Ozon WebView ведёт себя странно: ОДИН медленный
|
||||||
|
// свайп иногда игнорируется (видимо, расценивается как hold).
|
||||||
|
// Стратегия: серия из 3 коротких быстрых свайпов внутри
|
||||||
|
// безопасной зоны (выше нав-бара). Это надёжно триггерит
|
||||||
|
// нативный fling в WebView.
|
||||||
|
// safeBottom — верхняя граница нав-бара минус запас.
|
||||||
|
// Когда target-секция уже у нижнего края (dateHeaderY >= safeBottom),
|
||||||
|
// зона "внутри секции" схлопывается — свайпаем фиксированным
|
||||||
|
// диапазоном относительно safeBottom, чтобы вытащить контент
|
||||||
|
// секции из-под нав-бара.
|
||||||
|
const int safeBottom = navBarTop - 30;
|
||||||
|
int amplitude;
|
||||||
|
if (dateHeaderY > 0 && dateHeaderY < safeBottom - 100) {
|
||||||
|
// Header виден достаточно высоко — скроллим внутри его секции.
|
||||||
|
const int safeTop = qMax(dateHeaderY + 80, device.screenHeight / 4);
|
||||||
|
amplitude = qMin(safeBottom - safeTop, 700);
|
||||||
|
} else {
|
||||||
|
// Header у самого низа или ниже нав-бара — большой свайп.
|
||||||
|
amplitude = qMin(safeBottom - device.screenHeight / 4, 700);
|
||||||
|
}
|
||||||
|
amplitude = qMax(amplitude, 300); // минимум 300px чтобы было ощутимо
|
||||||
|
qDebug() << "[FetchTransactions] Triple-swipe scroll:"
|
||||||
|
<< "safeBottom=" << safeBottom << "amplitude=" << amplitude
|
||||||
|
<< "(dateHeaderY=" << dateHeaderY << " moreBelow=" << moreBelow << ")";
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, cx, safeBottom,
|
||||||
|
cx, safeBottom - amplitude, 250);
|
||||||
|
QThread::msleep(350);
|
||||||
|
}
|
||||||
|
QThread::msleep(800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_resultScreenshot = listScreenshot.isEmpty()
|
||||||
|
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||||
|
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) {
|
||||||
|
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
|
||||||
|
QTimeZone deviceTz(tzName.toUtf8());
|
||||||
|
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
|
||||||
|
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
|
||||||
|
|
||||||
|
// Ждём появления транзакций
|
||||||
|
QList<Node> txButtons;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
txButtons = xmlScreenParser.findTransactionButtons(8);
|
||||||
|
if (!txButtons.isEmpty()) break;
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[GetLastTransactions] Found" << txButtons.size() << "transaction buttons";
|
||||||
|
|
||||||
|
QList<TransactionInfo> transactions;
|
||||||
|
for (int i = 0; i < txButtons.size(); ++i) {
|
||||||
|
const Node &btn = txButtons[i];
|
||||||
|
|
||||||
|
// Скроллим до кнопки если за экраном
|
||||||
|
if (btn.y() <= 0 || btn.y() >= navBarTop) {
|
||||||
|
for (int s = 0; s < 5; ++s) {
|
||||||
|
const int x = device.screenWidth / 2;
|
||||||
|
const int offset = device.screenHeight / 4;
|
||||||
|
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
txButtons = xmlScreenParser.findTransactionButtons(8);
|
||||||
|
if (i < txButtons.size() && txButtons[i].y() > 0 && txButtons[i].y() < navBarTop) break;
|
||||||
|
}
|
||||||
|
if (i >= txButtons.size()) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Node &visibleBtn = txButtons[i];
|
||||||
|
TransactionInfo tx = xmlScreenParser.parseTransactionButtonText(visibleBtn.text);
|
||||||
|
|
||||||
|
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// Ждём загрузки деталей
|
||||||
|
TransactionInfo detail;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
detail = xmlScreenParser.parseTransactionDetail();
|
||||||
|
if (!detail.bankTime.isEmpty()) break;
|
||||||
|
if (xmlScreenParser.isTransactionListScreen()) {
|
||||||
|
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
|
||||||
|
}
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.bankName = m_appCode;
|
||||||
|
tx.bankTime = detail.bankTime;
|
||||||
|
tx.bankTrExternalId = detail.bankTrExternalId;
|
||||||
|
|
||||||
|
// Если ID не найден в XML — пробуем через "Документы" + OCR
|
||||||
|
if (tx.bankTrExternalId.isEmpty()) {
|
||||||
|
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
|
||||||
|
if (!docBtn.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
bool docLoaded = false;
|
||||||
|
for (int da = 0; da < 15; ++da) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
|
||||||
|
docLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
if (docLoaded) {
|
||||||
|
QThread::msleep(500);
|
||||||
|
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
if (!screenshot.isEmpty()) {
|
||||||
|
const QString ocrText = recognizeTextFromImage(screenshot);
|
||||||
|
if (!ocrText.isEmpty()) {
|
||||||
|
tx.bankTrExternalId = extractTransactionId(ocrText);
|
||||||
|
qDebug() << "[GetLastTransactions] OCR extracted ID:" << tx.bankTrExternalId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.status = TransactionStatus::Complete;
|
||||||
|
tx.timestamp = DateUtils::getUtcNow();
|
||||||
|
if (!detail.name.isEmpty()) tx.name = detail.name;
|
||||||
|
if (!detail.phone.isEmpty()) {
|
||||||
|
QString cleanPhone;
|
||||||
|
for (const QChar &ch : detail.phone)
|
||||||
|
if (ch.isDigit()) cleanPhone.append(ch);
|
||||||
|
tx.phone = cleanPhone;
|
||||||
|
tx.type = TransactionType::Phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим время
|
||||||
|
if (!tx.bankTime.isEmpty()) {
|
||||||
|
QString dtStr = tx.bankTime;
|
||||||
|
for (int ci = 0; ci < dtStr.size(); ++ci) {
|
||||||
|
const QChar ch = dtStr.at(ci);
|
||||||
|
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
|
||||||
|
}
|
||||||
|
tx.bankTime = dtStr;
|
||||||
|
QDateTime parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
|
||||||
|
if (!parsed.isValid()) parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
|
||||||
|
if (parsed.isValid()) {
|
||||||
|
parsed.setTimeZone(deviceTz);
|
||||||
|
tx.completeTime = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по времени (24ч) если нет maxCount
|
||||||
|
if (m_maxCount <= 0 && tx.completeTime.isValid()) {
|
||||||
|
const qint64 ageSecs = tx.completeTime.toUTC().secsTo(QDateTime::currentDateTimeUtc());
|
||||||
|
if (ageSecs > 24 * 3600) {
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем
|
||||||
|
if (accountId != -1) {
|
||||||
|
tx.accountId = accountId;
|
||||||
|
if (TransactionDAO::insertTransactionIfNotExists(tx)) {
|
||||||
|
// Скриншот чека
|
||||||
|
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
|
||||||
|
if (!docBtn.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
bool docLoaded = false;
|
||||||
|
for (int da = 0; da < 20; ++da) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
|
||||||
|
docLoaded = true; break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docLoaded) {
|
||||||
|
QThread::msleep(500);
|
||||||
|
QProcess screencapProc;
|
||||||
|
screencapProc.start("adb", QStringList() << "-s" << m_deviceId << "exec-out" << "screencap" << "-p");
|
||||||
|
screencapProc.waitForFinished(5000);
|
||||||
|
const QByteArray screenshot = screencapProc.readAllStandardOutput();
|
||||||
|
if (!screenshot.isEmpty()) {
|
||||||
|
TransactionInfo saved = TransactionDAO::findByBankTimeAndAmount(
|
||||||
|
accountId, tx.bankName, tx.bankTime, tx.amount);
|
||||||
|
if (saved.id != -1) {
|
||||||
|
TransactionDAO::updateReceiptImage(saved.id, screenshot);
|
||||||
|
const QString ocrText = recognizeTextFromImage(screenshot);
|
||||||
|
if (!ocrText.isEmpty()) {
|
||||||
|
const QString txId = extractTransactionId(ocrText);
|
||||||
|
if (!txId.isEmpty()) {
|
||||||
|
TransactionDAO::updateBankTrExternalId(saved.id, txId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions.append(tx);
|
||||||
|
|
||||||
|
if (m_maxCount > 0 && transactions.size() >= m_maxCount) {
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1500);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isTransactionListScreen()) break;
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
txButtons = xmlScreenParser.findTransactionButtons(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
51
android/ozon/GetLastTransactionsScript.h
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include "db/DeviceInfo.h"
|
||||||
|
#include "db/BankProfileInfo.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
class GetLastTransactionsScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void transactionParsed(const QJsonObject &tx);
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit GetLastTransactionsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
int maxCount = 0,
|
||||||
|
double searchAmount = 0,
|
||||||
|
QDateTime searchTime = {},
|
||||||
|
QString materialId = {},
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
~GetLastTransactionsScript() override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Навигация до экрана "Операции", возвращает true если успешно
|
||||||
|
bool navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app);
|
||||||
|
|
||||||
|
// Сканирование последних транзакций (для UI: 24ч или maxCount)
|
||||||
|
void scanRecentTransactions(const DeviceInfo &device, int accountId);
|
||||||
|
|
||||||
|
// Поиск конкретной транзакции по сумме и времени (для задач FETCH_TRANSACTIONS)
|
||||||
|
void searchTransaction(const DeviceInfo &device, int accountId);
|
||||||
|
|
||||||
|
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)
|
||||||
|
const QString m_materialId; // UUID задачи (для сохранения дампов)
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
207
android/ozon/GetProfileInfoScript.cpp
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
#include "GetProfileInfoScript.h"
|
||||||
|
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QtGlobal>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "net/NetworkService.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
GetProfileInfoScript::GetProfileInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
GetProfileInfoScript::~GetProfileInfoScript() = default;
|
||||||
|
|
||||||
|
void GetProfileInfoScript::doStart() {
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE_INFO", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
// Читаем текущий экран
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Закрываем рекламный bottom sheet, если открыт (home_ad_1)
|
||||||
|
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||||
|
qDebug() << "[GetProfileInfo] Ad banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежий дамп перед проверкой домашнего экрана
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Если мы не на домашнем экране — перезапускаем приложение и вводим пин-код
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[GetProfileInfo] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// После runAppAndGoToHomeScreen xml уже обновлён внутри метода,
|
||||||
|
// но делаем свежий дамп, чтобы убедиться
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем баннеры на главной, если есть
|
||||||
|
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
QThread::msleep(500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Парсим баланс с домашнего экрана
|
||||||
|
const QList<MaterialInfo> parsedCards = xmlScreenParser.parseCardsOnHomeScreen();
|
||||||
|
double parsedBalance = parsedCards.isEmpty() ? 0.0 : parsedCards[0].amount;
|
||||||
|
qDebug() << "[GetProfileInfo] parseCardsOnHomeScreen returned"
|
||||||
|
<< parsedCards.size() << "cards, balance:" << parsedBalance;
|
||||||
|
// Fallback: если парсинг не нашёл баланс, берём из materials
|
||||||
|
if (parsedBalance <= 0.0) {
|
||||||
|
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||||
|
for (const auto &acc : accounts) {
|
||||||
|
if (acc.amount > 0.0) { parsedBalance = acc.amount; break; }
|
||||||
|
}
|
||||||
|
qDebug() << "[GetProfileInfo] Fallback balance from materials:" << parsedBalance;
|
||||||
|
}
|
||||||
|
if (parsedBalance > 0.0) {
|
||||||
|
BankProfileDAO::updateAmount(app.id, parsedBalance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим имя пользователя в верхней части домашнего экрана и тапаем по нему
|
||||||
|
Node userNameNode = xmlScreenParser.findUserNameOnHomeScreen(device.screenHeight);
|
||||||
|
if (userNameNode.isEmpty()) {
|
||||||
|
m_error = "Cannot find user name on home screen: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[GetProfileInfo] Tapping on user name:" << userNameNode.text
|
||||||
|
<< "at" << userNameNode.x() << userNameNode.y();
|
||||||
|
AdbUtils::makeTap(m_deviceId, userNameNode.x(), userNameNode.y());
|
||||||
|
|
||||||
|
// Ждём загрузки страницы профиля
|
||||||
|
QThread::msleep(2500);
|
||||||
|
|
||||||
|
// Читаем XML профиля и проверяем что мы действительно там
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||||
|
if (xmlScreenParser.isProfileScreen()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xmlScreenParser.isProfileScreen()) {
|
||||||
|
m_error = "Profile screen not detected after tap: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем полное имя и телефон
|
||||||
|
const QString fullName = xmlScreenParser.parseProfileFullName();
|
||||||
|
QString phone = xmlScreenParser.parseProfilePhone();
|
||||||
|
phone.remove(' ');
|
||||||
|
phone.remove(QChar(0x00A0));
|
||||||
|
|
||||||
|
qDebug() << "[GetProfileInfo] fullName:" << fullName << "phone:" << phone;
|
||||||
|
|
||||||
|
if (!BankProfileDAO::updateProfileInfo(app.id, fullName, phone)) {
|
||||||
|
m_error = "Failed to save profile info to DB: " + m_deviceId;
|
||||||
|
qWarning() << m_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем bank_profile на сервер — используем свежепарсенные данные
|
||||||
|
const BankProfileInfo freshApp = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
const QMap<QString, int> currencyCodes = {
|
||||||
|
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
|
||||||
|
};
|
||||||
|
const int currencyCode = currencyCodes.value(freshApp.currency.toUpper(), 643);
|
||||||
|
|
||||||
|
const QStringList parts = fullName.split(' ', Qt::SkipEmptyParts);
|
||||||
|
QJsonObject profileBody;
|
||||||
|
profileBody["first_name"] = parts.value(0);
|
||||||
|
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : "";
|
||||||
|
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
|
||||||
|
profileBody["bank_name"] = m_appCode;
|
||||||
|
profileBody["phone_number"] = phone;
|
||||||
|
profileBody["balance"] = QString::number(qRound64(parsedBalance * 100));
|
||||||
|
profileBody["state"] = (freshApp.status == "active") ? "enabled" : "disabled";
|
||||||
|
profileBody["device_id"] = device.apiId;
|
||||||
|
profileBody["currency"] = currencyCode;
|
||||||
|
|
||||||
|
NetworkService ns;
|
||||||
|
ns.setDeviceId(m_deviceId);
|
||||||
|
ns.setPayload(QJsonDocument(profileBody).toJson());
|
||||||
|
|
||||||
|
if (freshApp.bankProfileId.isEmpty()) {
|
||||||
|
ns.addExtra("app_id", freshApp.id);
|
||||||
|
ns.postBankProfile();
|
||||||
|
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
if (afterPost.bankProfileId.isEmpty()) {
|
||||||
|
const QString err = "postBankProfile failed for " + m_deviceId;
|
||||||
|
qWarning() << "[GetProfileInfo]" << err;
|
||||||
|
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
|
||||||
|
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||||
|
"FETCH_PROFILE");
|
||||||
|
m_error = err;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qDebug() << "[GetProfileInfo] postBankProfile done";
|
||||||
|
} else {
|
||||||
|
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
|
||||||
|
ns.patchBankProfile();
|
||||||
|
qDebug() << "[GetProfileInfo] patchBankProfile done";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем данные аккаунтов на сервер если нет material_id
|
||||||
|
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||||
|
QList<QPair<MaterialInfo, Node>> accountsToPost;
|
||||||
|
for (const auto &acc : allAccounts) {
|
||||||
|
if (acc.materialId.isEmpty()) {
|
||||||
|
accountsToPost.append({acc, Node()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!accountsToPost.isEmpty()) {
|
||||||
|
postAccountsData(accountsToPost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаемся на домашний экран
|
||||||
|
AdbUtils::goBack(m_deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
27
android/ozon/GetProfileInfoScript.h
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
class GetProfileInfoScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
~GetProfileInfoScript() override;
|
||||||
|
|
||||||
|
explicit GetProfileInfoScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
133
android/ozon/LoginAndCheckAccountsScript.cpp
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
#include "LoginAndCheckAccountsScript.h"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "AppLogger.h"
|
||||||
|
#include "GetCardInfoScript.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
LoginAndCheckAccountsScript::LoginAndCheckAccountsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent,
|
||||||
|
bool fetchProfileOnly,
|
||||||
|
bool pinCheckOnly
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_deviceId(std::move(deviceId)),
|
||||||
|
m_appCode(std::move(appCode)),
|
||||||
|
m_fetchProfileOnly(fetchProfileOnly),
|
||||||
|
m_pinCheckOnly(pinCheckOnly) {
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
|
||||||
|
|
||||||
|
|
||||||
|
void LoginAndCheckAccountsScript::doStart() {
|
||||||
|
// m_deviceId = ADB serial, device.id = android_id (для БД)
|
||||||
|
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskDumpMeta dumpMeta{};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_PROFILE", m_deviceId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
|
||||||
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cant open the home screen: " + device.name + " (" + m_deviceId + ") " + m_appCode;
|
||||||
|
qWarning() << m_error;
|
||||||
|
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||||
|
BankProfileDAO::updateComment(app.id,
|
||||||
|
"Не прошла проверка " + QDateTime::currentDateTime().toString("dd.MM.yyyy HH:mm:ss"));
|
||||||
|
|
||||||
|
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
if (m_pinCheckOnly) {
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скриншот домашнего экрана
|
||||||
|
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||||
|
if (!m_resultScreenshot.isEmpty())
|
||||||
|
emit screenshotReady(m_resultScreenshot);
|
||||||
|
|
||||||
|
// Парсим баланс с домашнего экрана
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
const QList<MaterialInfo> parsedCards = xmlScreenParser.parseCardsOnHomeScreen();
|
||||||
|
double parsedBalance = parsedCards.isEmpty() ? 0.0 : parsedCards[0].amount;
|
||||||
|
qDebug() << "[LoginAndCheck] Parsed balance:" << parsedBalance << "cards:" << parsedCards.size();
|
||||||
|
if (parsedBalance > 0.0) {
|
||||||
|
BankProfileDAO::updateAmount(app.id, parsedBalance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Освежаем amount только для уже существующих материалов (cardNumber/materialId
|
||||||
|
// и прочие поля сохраняем). Новые карты здесь не создаём — запись появится
|
||||||
|
// только после успешного OCR полного номера в GetCardInfoScript.
|
||||||
|
// Если ниже goToAllProducts + parseAccountsInfo отработают — они уточнят
|
||||||
|
// per-card сумму поверх.
|
||||||
|
for (const MaterialInfo &card : parsedCards) {
|
||||||
|
const MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
|
||||||
|
if (existing.id == -1) continue;
|
||||||
|
MaterialInfo refresh = existing;
|
||||||
|
refresh.amount = card.amount;
|
||||||
|
refresh.updateTime = QDateTime::currentDateTimeUtc();
|
||||||
|
if (!MaterialDAO::upsertAccount(refresh)) {
|
||||||
|
qWarning() << "[LoginAndCheck] Failed to refresh amount for card" << card.lastNumbers;
|
||||||
|
} else {
|
||||||
|
qDebug() << "[LoginAndCheck] Refreshed amount for card"
|
||||||
|
<< card.lastNumbers << "to" << card.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём/обновляем bank profile на сервере
|
||||||
|
if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) {
|
||||||
|
m_error = err;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (goToAllProducts(m_deviceId, device.screenWidth, device.screenHeight)) {
|
||||||
|
QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();
|
||||||
|
for (auto &[account, node]: accounts) {
|
||||||
|
account.deviceId = device.id;
|
||||||
|
account.appCode = m_appCode;
|
||||||
|
if (!MaterialDAO::upsertAccount(account)) {
|
||||||
|
m_error = "Not updated: " + account.lastNumbers;
|
||||||
|
qCritical() << m_error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_fetchProfileOnly) {
|
||||||
|
postAccountsData(accounts);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning() << "Cant go to all products: " << m_deviceId << " " << m_appCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_fetchProfileOnly) {
|
||||||
|
// Запускаем сканирование баланса, данных карт и отправку на сервер (синхронно)
|
||||||
|
GetCardInfoScript cardScript(m_deviceId, m_appCode, "");
|
||||||
|
cardScript.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
31
android/ozon/LoginAndCheckAccountsScript.h
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
class LoginAndCheckAccountsScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
~LoginAndCheckAccountsScript() override;
|
||||||
|
|
||||||
|
explicit LoginAndCheckAccountsScript(
|
||||||
|
QString deviceId,
|
||||||
|
QString appCode,
|
||||||
|
QObject *parent = nullptr,
|
||||||
|
bool fetchProfileOnly = false,
|
||||||
|
bool pinCheckOnly = false
|
||||||
|
);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_deviceId;
|
||||||
|
const QString m_appCode;
|
||||||
|
const bool m_fetchProfileOnly;
|
||||||
|
const bool m_pinCheckOnly;
|
||||||
|
QString m_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
486
android/ozon/PayByCardScript.cpp
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
#include "PayByCardScript.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QTimeZone>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <tesseract/baseapi.h>
|
||||||
|
#include <leptonica/allheaders.h>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/TransactionDAO.h"
|
||||||
|
#include "time/DateUtils.h"
|
||||||
|
|
||||||
|
static QString recognizeTextFromImage(const QByteArray &pngData) {
|
||||||
|
auto *api = new tesseract::TessBaseAPI();
|
||||||
|
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||||||
|
qWarning() << "[OCR] Failed to init Tesseract";
|
||||||
|
delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
|
||||||
|
static_cast<size_t>(pngData.size()));
|
||||||
|
if (!pix) {
|
||||||
|
qWarning() << "[OCR] Failed to read image";
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
api->SetImage(pix);
|
||||||
|
char *text = api->GetUTF8Text();
|
||||||
|
QString result = QString::fromUtf8(text).trimmed();
|
||||||
|
delete[] text;
|
||||||
|
pixDestroy(&pix);
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static QString extractTransactionId(const QString &ocrText) {
|
||||||
|
// "ID операции B606513042211200000120011700501"
|
||||||
|
// "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
|
||||||
|
// или разбит на 2 строки
|
||||||
|
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-яЁё0-9\-]+(?:[^\S\n]+[A-Za-zА-Яа-яЁё0-9\-]+)?))");
|
||||||
|
if (auto m = reId.match(ocrText); m.hasMatch()) {
|
||||||
|
QString id = m.captured(1);
|
||||||
|
id.remove(' ');
|
||||||
|
id.remove(QChar(0x00A0));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "№ Ф-2026-25235663"
|
||||||
|
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
|
||||||
|
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
PayByCardScript::PayByCardScript(
|
||||||
|
MaterialInfo account,
|
||||||
|
QString cardNumber,
|
||||||
|
const double amount,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_account(std::move(account)), m_cardNumber(std::move(cardNumber)),
|
||||||
|
m_amount(amount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
PayByCardScript::~PayByCardScript() = default;
|
||||||
|
|
||||||
|
void PayByCardScript::doStart() {
|
||||||
|
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_account.deviceId;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString &adbId = device.adbSerial;
|
||||||
|
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_CARD", adbId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
|
||||||
|
// Закрываем рекламный bottom sheet
|
||||||
|
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||||
|
qDebug() << "[PayByCard] Ad banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(adbId, adNode.x(), adNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не на домашнем экране — перезапускаем
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[PayByCard] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(adbId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(adbId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем баннеры
|
||||||
|
tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight);
|
||||||
|
QThread::msleep(500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
|
||||||
|
// Закрываем баннер Google Play "Доступно обновление"
|
||||||
|
if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight); !gpNode.isEmpty()) {
|
||||||
|
qDebug() << "[PayByCard] Google Play update banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(adbId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем баннер "Заказать карту" — тапаем сверху экрана
|
||||||
|
if (Node orderCardNode = xmlScreenParser.findTextNode("Заказать карту"); !orderCardNode.isEmpty()) {
|
||||||
|
qDebug() << "[PayByCard] 'Заказать карту' banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(adbId, device.screenWidth / 2, device.screenHeight / 20);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
makePayment(device.adbSerial, app.package, app.pinCode, app.phone, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PayByCardScript::makePayment(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &packageName,
|
||||||
|
const QString &pinCode,
|
||||||
|
const QString &bankProfilePhone,
|
||||||
|
const int width,
|
||||||
|
const int height
|
||||||
|
) {
|
||||||
|
auto checkApp = [&]() -> bool {
|
||||||
|
if (!isAppRunning(deviceId, packageName)) {
|
||||||
|
m_error = "Приложение было закрыто";
|
||||||
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// 1. Нажимаем "Перевести" на домашнем экране
|
||||||
|
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
|
||||||
|
if (transferBtn.isEmpty()) {
|
||||||
|
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
||||||
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[PayByCard] Tapping 'Перевести'...";
|
||||||
|
AdbUtils::makeTap(deviceId, transferBtn.x(), transferBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 2. Ждём экран "Платежи и переводы" И прогрузки кнопки "По номеру карты"
|
||||||
|
Node cardBtn;
|
||||||
|
bool paymentsPageLoaded = false;
|
||||||
|
bool titleSeen = false;
|
||||||
|
{
|
||||||
|
FreezeDetector freezeCheck;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
|
||||||
|
xmlScreenParser.parseAndSaveXml(xml);
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
if (freezeCheck.noticeAndCheckFrozen(xml)) {
|
||||||
|
qWarning() << "[PayByCard] screen frozen waiting Платежи и переводы";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Node title = xmlScreenParser.findTextNode("Платежи и переводы");
|
||||||
|
if (!title.isEmpty()) titleSeen = true;
|
||||||
|
cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false);
|
||||||
|
if (!title.isEmpty() && !cardBtn.isEmpty()) {
|
||||||
|
paymentsPageLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[PayByCard] Waiting for 'Платежи и переводы' + 'По номеру карты', attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paymentsPageLoaded) {
|
||||||
|
m_error = titleSeen
|
||||||
|
? "Кнопка 'По номеру карты' не найдена на экране платежей"
|
||||||
|
: "Экран 'Платежи и переводы' не загрузился";
|
||||||
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Нажимаем "По номеру карты" в секции "ДРУГИЕ СПОСОБЫ"
|
||||||
|
qDebug() << "[PayByCard] Tapping 'По номеру карты'...";
|
||||||
|
AdbUtils::makeTap(deviceId, cardBtn.x(), cardBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 4. Ждём экран "Перевод по номеру карты"
|
||||||
|
bool cardScreenLoaded = false;
|
||||||
|
{
|
||||||
|
FreezeDetector freezeCheck;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
|
||||||
|
xmlScreenParser.parseAndSaveXml(xml);
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
if (freezeCheck.noticeAndCheckFrozen(xml)) {
|
||||||
|
qWarning() << "[PayByCard] screen frozen waiting 'Перевод по номеру карты'";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Node title = xmlScreenParser.findTextNode("Перевод по номеру карты");
|
||||||
|
if (!title.isEmpty()) {
|
||||||
|
cardScreenLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[PayByCard] Waiting for 'Перевод по номеру карты', attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardScreenLoaded) {
|
||||||
|
m_error = "Экран 'Перевод по номеру карты' не загрузился";
|
||||||
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[PayByCard] Card payment screen loaded successfully";
|
||||||
|
|
||||||
|
// 5. Вводим номер карты в поле "Номер карты получателя"
|
||||||
|
Node cardInput;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
cardInput = xmlScreenParser.findEditTextByHint("Номер карты получателя");
|
||||||
|
if (cardInput.isEmpty()) {
|
||||||
|
// На части сборок Ozon WebView hint у поля PAN пустой, а лейбла
|
||||||
|
// "Номер карты получателя" на экране нет (есть только "КУДА").
|
||||||
|
// Поле PAN всегда внутри элемента resource-id="movable-pan-iframe".
|
||||||
|
cardInput = xmlScreenParser.findPanCardNumberInput();
|
||||||
|
}
|
||||||
|
if (!cardInput.isEmpty()) break;
|
||||||
|
qDebug() << "[PayByCard] Waiting for 'Номер карты получателя' input, attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardInput.isEmpty()) {
|
||||||
|
m_error = "Поле 'Номер карты получателя' не найдено";
|
||||||
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, cardInput.x(), cardInput.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
AdbUtils::inputText(deviceId, m_cardNumber);
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// Закрываем системную клавиатуру — она перекрывает поле суммы и кнопку
|
||||||
|
// "Перевести" внизу, а на TECNO ещё мешает Ozon WebView дорендерить AT.
|
||||||
|
AdbUtils::hideKeyboard(deviceId);
|
||||||
|
QThread::msleep(800);
|
||||||
|
|
||||||
|
// 6. Ждём распознавания карты и вводим сумму
|
||||||
|
Node sumInput;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
sumInput = xmlScreenParser.findEditTextByHint("Сумма от");
|
||||||
|
if (!sumInput.isEmpty()) break;
|
||||||
|
|
||||||
|
// Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO).
|
||||||
|
// На экране "Перевод по номеру карты" всегда 2 EditText в DOM-порядке:
|
||||||
|
// карта (сверху), сумма (снизу). Оба имеют resource-id="input___xxxx",
|
||||||
|
// поэтому отличать по rid нельзя — берём второй по порядку обхода XML.
|
||||||
|
QList<Node> editTexts;
|
||||||
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (n.className != "android.widget.EditText") continue;
|
||||||
|
if (n.height() <= 0 || n.height() > height * 4 / 5) continue;
|
||||||
|
editTexts.append(n);
|
||||||
|
}
|
||||||
|
if (editTexts.size() >= 2) {
|
||||||
|
sumInput = editTexts[1];
|
||||||
|
}
|
||||||
|
if (!sumInput.isEmpty()) break;
|
||||||
|
|
||||||
|
qDebug() << "[PayByCard] Waiting for 'Сумма от' input, attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sumInput.isEmpty()) {
|
||||||
|
m_error = "Поле ввода суммы не найдено";
|
||||||
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, sumInput.x(), sumInput.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
AdbUtils::inputText(deviceId, QString::number(static_cast<int>(m_amount)));
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
qDebug() << "[PayByCard] Card number and amount entered";
|
||||||
|
|
||||||
|
// Закрываем системную софт-клавиатуру — иначе она перекрывает кнопку "Перевести"
|
||||||
|
// внизу экрана (bounds схлопываются до нулевой высоты).
|
||||||
|
AdbUtils::hideKeyboard(deviceId);
|
||||||
|
QThread::msleep(800);
|
||||||
|
|
||||||
|
// 7. Ждём расчёта комиссии (текст после "Комиссия" перестаёт быть "Рассчитаем...")
|
||||||
|
double fee = 0;
|
||||||
|
bool feeCalculated = false;
|
||||||
|
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
bool foundKomissiya = false;
|
||||||
|
for (const Node &node : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (node.className == "android.widget.TextView" && node.text.trimmed() == "Комиссия") {
|
||||||
|
foundKomissiya = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (foundKomissiya && node.className == "android.widget.TextView" && !node.text.isEmpty()) {
|
||||||
|
const QString feeText = node.text.trimmed();
|
||||||
|
if (feeText.startsWith("Рассчитаем")) {
|
||||||
|
foundKomissiya = false;
|
||||||
|
break; // ещё не рассчитана
|
||||||
|
}
|
||||||
|
// Парсим "45 ₽" → 45.0
|
||||||
|
QString digits = feeText;
|
||||||
|
digits.remove(QRegularExpression("[^0-9,\\.]"));
|
||||||
|
digits.replace(',', '.');
|
||||||
|
fee = digits.toDouble();
|
||||||
|
feeCalculated = true;
|
||||||
|
qDebug() << "[PayByCard] Fee parsed:" << fee << "from text:" << feeText;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (feeCalculated) break;
|
||||||
|
qDebug() << "[PayByCard] Waiting for fee calculation, attempt" << attempt + 1 << "/ 15";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feeCalculated) {
|
||||||
|
qWarning() << "[PayByCard] Fee not calculated after timeout, proceeding with fee=0";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Нажимаем "Перевести" (или "Перевести X ₽")
|
||||||
|
// Ретраим, пока bounds кнопки не будут ненулевыми — клавиатура могла всплыть
|
||||||
|
// заново (напр. если расчёт комиссии сфокусировал поле суммы).
|
||||||
|
Node transferBtn2;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
transferBtn2 = xmlScreenParser.findButtonNode("Перевести", true);
|
||||||
|
if (!transferBtn2.isEmpty() && transferBtn2.height() > 0) break;
|
||||||
|
qDebug() << "[PayByCard] Waiting for tappable 'Перевести', attempt" << attempt + 1 << "/ 5"
|
||||||
|
<< "(bounds collapsed — клавиатура ещё видна?)";
|
||||||
|
AdbUtils::hideKeyboard(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
if (transferBtn2.isEmpty() || transferBtn2.height() == 0) {
|
||||||
|
m_error = "Кнопка 'Перевести' не найдена или перекрыта клавиатурой";
|
||||||
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[PayByCard] Tapping transfer button:" << transferBtn2.text;
|
||||||
|
AdbUtils::makeTap(deviceId, transferBtn2.x(), transferBtn2.y());
|
||||||
|
QThread::msleep(3000);
|
||||||
|
|
||||||
|
// 8.5. Проверяем экран ввода PIN-кода
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
||||||
|
!pinNodes.isEmpty()) {
|
||||||
|
qDebug() << "[PayByCard] PIN code screen detected after confirm, entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinNodes);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Ждём результат: "Успешно" или "Не удалось"
|
||||||
|
FreezeDetector resultFreeze;
|
||||||
|
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
|
||||||
|
xmlScreenParser.parseAndSaveXml(xml);
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
if (resultFreeze.noticeAndCheckFrozen(xml)) {
|
||||||
|
qWarning() << "[PayByCard] screen frozen while waiting payment result";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если PIN-код появился позже — вводим
|
||||||
|
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
||||||
|
!pinNodes.isEmpty()) {
|
||||||
|
qDebug() << "[PayByCard] PIN code screen detected (retry), entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinNodes);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) {
|
||||||
|
qDebug() << "[PayByCard] Payment successful!";
|
||||||
|
m_error.clear();
|
||||||
|
|
||||||
|
// Сохраняем транзакцию в БД
|
||||||
|
TransactionInfo tx;
|
||||||
|
tx.accountId = m_account.id;
|
||||||
|
tx.amount = -m_amount;
|
||||||
|
tx.fee = fee;
|
||||||
|
tx.phone = m_cardNumber;
|
||||||
|
tx.bankName = "ozon";
|
||||||
|
tx.type = TransactionType::Card;
|
||||||
|
tx.status = TransactionStatus::Complete;
|
||||||
|
tx.timestamp = DateUtils::getUtcNow();
|
||||||
|
tx.completeTime = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow"));
|
||||||
|
tx.bankTime = tx.completeTime.toString("dd.MM.yyyy, HH:mm");
|
||||||
|
const int txId = TransactionDAO::insertTransaction(tx);
|
||||||
|
qDebug() << "[PayByCard] Saved transaction to DB, id:" << txId << "fee:" << fee;
|
||||||
|
|
||||||
|
// Скриншот экрана "Успешно" — отдаём как receipt-image и в EventHandler.
|
||||||
|
// Раньше дополнительно тапали "Документы" + ждали "Поделиться" + OCR
|
||||||
|
// ради bank_transaction_id, но это медленно (до 30 сек ожидания) и
|
||||||
|
// нестабильно. Теперь полагаемся на fallback-ID.
|
||||||
|
QString externalId;
|
||||||
|
if (txId != -1) {
|
||||||
|
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
if (!screenshot.isEmpty()) {
|
||||||
|
m_resultScreenshot = screenshot;
|
||||||
|
emit screenshotReady(m_resultScreenshot);
|
||||||
|
TransactionDAO::updateReceiptImage(txId, screenshot);
|
||||||
|
qDebug() << "[PayByCard] Receipt saved to DB, txId:" << txId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalId.isEmpty() && txId != -1) {
|
||||||
|
externalId = "ozon_" + bankProfilePhone + "_" + QString::number(QDateTime::currentSecsSinceEpoch());
|
||||||
|
qDebug() << "[PayByCard] Generated fallback transaction ID:" << externalId;
|
||||||
|
}
|
||||||
|
if (!externalId.isEmpty() && txId != -1) {
|
||||||
|
TransactionDAO::updateBankTrExternalId(txId, externalId);
|
||||||
|
qDebug() << "[PayByCard] Transaction ID:" << externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xmlScreenParser.findTextNode("Не удалось").isEmpty()) {
|
||||||
|
m_error = "Перевод не удался (экран 'Не удалось')";
|
||||||
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[PayByCard] Waiting for payment result, attempt" << attempt + 1 << "/ 15";
|
||||||
|
QThread::msleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_error = "Не дождались результата перевода";
|
||||||
|
qWarning() << "[PayByCard]" << m_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
38
android/ozon/PayByCardScript.h
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
class PayByCardScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
~PayByCardScript() override;
|
||||||
|
|
||||||
|
PayByCardScript(
|
||||||
|
MaterialInfo account,
|
||||||
|
QString cardNumber,
|
||||||
|
double amount,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const MaterialInfo m_account;
|
||||||
|
const QString m_cardNumber;
|
||||||
|
const double m_amount;
|
||||||
|
QString m_error;
|
||||||
|
|
||||||
|
void makePayment(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &packageName,
|
||||||
|
const QString &pinCode,
|
||||||
|
const QString &bankProfilePhone,
|
||||||
|
int width,
|
||||||
|
int height
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
657
android/ozon/PayByPhoneScript.cpp
Normal file
@ -0,0 +1,657 @@
|
|||||||
|
#include "PayByPhoneScript.h"
|
||||||
|
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QTimeZone>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <tesseract/baseapi.h>
|
||||||
|
#include <leptonica/allheaders.h>
|
||||||
|
|
||||||
|
#include "adb/AdbUtils.h"
|
||||||
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/BankProfileDAO.h"
|
||||||
|
#include "dao/DeviceDAO.h"
|
||||||
|
#include "dao/TransactionDAO.h"
|
||||||
|
#include "time/DateUtils.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
static QString recognizeTextFromImage(const QByteArray &pngData) {
|
||||||
|
auto *api = new tesseract::TessBaseAPI();
|
||||||
|
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||||||
|
qWarning() << "[OCR] Failed to init Tesseract";
|
||||||
|
delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
|
||||||
|
static_cast<size_t>(pngData.size()));
|
||||||
|
if (!pix) {
|
||||||
|
qWarning() << "[OCR] Failed to read image";
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
api->SetImage(pix);
|
||||||
|
char *text = api->GetUTF8Text();
|
||||||
|
QString result = QString::fromUtf8(text).trimmed();
|
||||||
|
delete[] text;
|
||||||
|
pixDestroy(&pix);
|
||||||
|
api->End();
|
||||||
|
delete api;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static QString extractTransactionId(const QString &ocrText) {
|
||||||
|
// "ID операции B606513042211200000120011700501"
|
||||||
|
// "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
|
||||||
|
// или разбит на 2 строки
|
||||||
|
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-яЁё0-9\-]+(?:[^\S\n]+[A-Za-zА-Яа-яЁё0-9\-]+)?))");
|
||||||
|
if (auto m = reId.match(ocrText); m.hasMatch()) {
|
||||||
|
QString id = m.captured(1);
|
||||||
|
id.remove(' ');
|
||||||
|
id.remove(QChar(0x00A0));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "№ Ф-2026-25235663"
|
||||||
|
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
|
||||||
|
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
static QString sanitizeBankName(const QString &raw) {
|
||||||
|
// "Озон банк (Ozon)" -> "Озон банк"; убираем содержимое круглых скобок
|
||||||
|
// вместе с предшествующими пробелами и обрезаем края.
|
||||||
|
QString s = raw;
|
||||||
|
s.remove(QRegularExpression(R"(\s*\([^)]*\))"));
|
||||||
|
return s.trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
PayByPhoneScript::PayByPhoneScript(
|
||||||
|
MaterialInfo account,
|
||||||
|
QString phone,
|
||||||
|
QString bankName,
|
||||||
|
const double amount,
|
||||||
|
QObject *parent
|
||||||
|
) : CommonScript(parent),
|
||||||
|
m_account(std::move(account)), m_phone(std::move(phone)),
|
||||||
|
m_bankName(sanitizeBankName(bankName)), m_amount(amount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
PayByPhoneScript::~PayByPhoneScript() = default;
|
||||||
|
|
||||||
|
void PayByPhoneScript::doStart() {
|
||||||
|
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
|
||||||
|
|
||||||
|
if (device.id.isEmpty() || app.id == -1) {
|
||||||
|
m_error = "Device or app not found: " + m_account.deviceId;
|
||||||
|
qCritical() << m_error;
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString &adbId = device.adbSerial;
|
||||||
|
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
|
||||||
|
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_PHONE", adbId, dumpMeta, &m_error);
|
||||||
|
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
|
||||||
|
// Закрываем рекламный bottom sheet
|
||||||
|
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||||
|
qDebug() << "[PayByPhone] Ad banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(adbId, adNode.x(), adNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не на домашнем экране — перезапускаем
|
||||||
|
if (!xmlScreenParser.isHomeScreen()) {
|
||||||
|
qDebug() << "[PayByPhone] Not on home screen, restarting app...";
|
||||||
|
if (!runAppAndGoToHomeScreen(adbId, app.package, app.pinCode,
|
||||||
|
device.screenWidth, device.screenHeight)) {
|
||||||
|
m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
|
||||||
|
qWarning() << m_error;
|
||||||
|
AdbUtils::tryToKillApplication(adbId, app.package);
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем баннеры
|
||||||
|
tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight);
|
||||||
|
QThread::msleep(500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
|
||||||
|
// Закрываем баннер Google Play "Доступно обновление"
|
||||||
|
if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight); !gpNode.isEmpty()) {
|
||||||
|
qDebug() << "[PayByPhone] Google Play update banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(adbId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем баннер "Заказать карту" — тапаем сверху экрана
|
||||||
|
if (Node orderCardNode = xmlScreenParser.findTextNode("Заказать карту"); !orderCardNode.isEmpty()) {
|
||||||
|
qDebug() << "[PayByPhone] 'Заказать карту' banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(adbId, device.screenWidth / 2, device.screenHeight / 20);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
makePayment(device.adbSerial, app.package, app.pinCode, app.phone, device.screenWidth, device.screenHeight);
|
||||||
|
|
||||||
|
emit finishedWithResult(m_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PayByPhoneScript::makePayment(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &packageName,
|
||||||
|
const QString &pinCode,
|
||||||
|
const QString &bankProfilePhone,
|
||||||
|
const int width,
|
||||||
|
const int height
|
||||||
|
) {
|
||||||
|
// Макрос-проверка: приложение живо?
|
||||||
|
auto checkApp = [&]() -> bool {
|
||||||
|
if (!isAppRunning(deviceId, packageName)) {
|
||||||
|
m_error = "Приложение было закрыто";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// 1. Нажимаем "Перевести" на домашнем экране.
|
||||||
|
// Внимание: поверх правой части кнопки сидит referral-бейдж "badge icon за друга"
|
||||||
|
// (bounds типа [550,90][602,140]), который перекрывает центр кнопки. Тап в центр
|
||||||
|
// открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы
|
||||||
|
// гарантированно попасть мимо бейджа.
|
||||||
|
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
|
||||||
|
if (transferBtn.isEmpty()) {
|
||||||
|
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тапаем в нижнюю часть кнопки (там, где визуально надпись "Перевести"),
|
||||||
|
// ниже бейджа "за друга" (он заканчивается на y≈140).
|
||||||
|
const int transferTapX = transferBtn.x();
|
||||||
|
const int transferTapY = transferBtn.y1() + transferBtn.height() * 4 / 5;
|
||||||
|
qDebug() << "[PayByPhone] Tapping 'Перевести' at" << transferTapX << "," << transferTapY
|
||||||
|
<< "(below 'за друга' badge)";
|
||||||
|
AdbUtils::makeTap(deviceId, transferTapX, transferTapY);
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 2. Ждём экран "Платежи и переводы" с полем "Телефон получателя"
|
||||||
|
bool paymentsPageLoaded = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
Node phoneField = xmlScreenParser.findFirstEditText();
|
||||||
|
if (!phoneField.isEmpty()) {
|
||||||
|
paymentsPageLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[PayByPhone] Waiting for payments page, attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paymentsPageLoaded) {
|
||||||
|
m_error = "Экран 'Платежи и переводы' не загрузился";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Тапаем на поле "Телефон получателя" чтобы перейти на экран "Кому перевести"
|
||||||
|
Node phoneFieldTap = xmlScreenParser.findFirstEditText();
|
||||||
|
qDebug() << "[PayByPhone] Tapping 'Телефон получателя'...";
|
||||||
|
AdbUtils::makeTap(deviceId, phoneFieldTap.x(), phoneFieldTap.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 4. Ждём экран "Кому перевести"
|
||||||
|
bool phoneScreenLoaded = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
// Закрываем диалог "Разрешить доступ к контактам"
|
||||||
|
Node notNowBtn = xmlScreenParser.findButtonNode("Не сейчас", false);
|
||||||
|
if (!notNowBtn.isEmpty()) {
|
||||||
|
qDebug() << "[PayByPhone] Contacts permission dialog detected, tapping 'Не сейчас'...";
|
||||||
|
AdbUtils::makeTap(deviceId, notNowBtn.x(), notNowBtn.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
}
|
||||||
|
|
||||||
|
Node title = xmlScreenParser.findTextNode("Кому перевести");
|
||||||
|
if (!title.isEmpty()) {
|
||||||
|
phoneScreenLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[PayByPhone] Waiting for 'Кому перевести', attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!phoneScreenLoaded) {
|
||||||
|
m_error = "Экран 'Кому перевести' не загрузился";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Тапаем на EditText и вводим номер телефона
|
||||||
|
Node phoneInput;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
phoneInput = xmlScreenParser.findFirstEditText();
|
||||||
|
if (!phoneInput.isEmpty()) break;
|
||||||
|
qDebug() << "[PayByPhone] Waiting for phone input field, attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
if (phoneInput.isEmpty()) {
|
||||||
|
m_error = "Поле ввода телефона не найдено";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, phoneInput.x(), phoneInput.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
AdbUtils::inputText(deviceId, m_phone);
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 6. Ждём экран "По номеру телефона", обрабатывая промежуточные экраны
|
||||||
|
bool sumScreenLoaded = false;
|
||||||
|
bool bankSelected = false;
|
||||||
|
bool otherBankClicked = false;
|
||||||
|
FreezeDetector freezeCheck;
|
||||||
|
for (int attempt = 0; attempt < 20; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
const QString currentDumpXml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
|
||||||
|
xmlScreenParser.parseAndSaveXml(currentDumpXml);
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
if (freezeCheck.noticeAndCheckFrozen(currentDumpXml)) {
|
||||||
|
qWarning() << "[PayByPhone] 4 identical dumps in a row — screen frozen, aborting";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем: уже на экране суммы?
|
||||||
|
Node sumTitle = xmlScreenParser.findTextNode("По номеру телефона");
|
||||||
|
if (!sumTitle.isEmpty() && sumTitle.y() < height / 7) {
|
||||||
|
sumScreenLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем кнопку с номером телефона (новый перевод / последние переводы)
|
||||||
|
QString phoneDigits = m_phone;
|
||||||
|
phoneDigits.remove(QRegularExpression("[^0-9]"));
|
||||||
|
bool foundPhoneBtn = false;
|
||||||
|
for (const Node &btn : xmlScreenParser.findAllButtons()) {
|
||||||
|
QString btnDigits = btn.text;
|
||||||
|
btnDigits.remove(QRegularExpression("[^0-9]"));
|
||||||
|
if (btnDigits.contains(phoneDigits) || phoneDigits.contains(btnDigits.right(10))) {
|
||||||
|
qDebug() << "[PayByPhone] Found button with phone number:" << btn.text;
|
||||||
|
AdbUtils::makeTap(deviceId, btn.x(), btn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
foundPhoneBtn = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundPhoneBtn) continue;
|
||||||
|
|
||||||
|
// Экран выбора банка: ищем "Другой банк" (текст) или EditText с hint "Название банка"
|
||||||
|
if (!bankSelected) {
|
||||||
|
// Вариант 1: кнопка "Другой банк" (content-desc="other-bank-item", text пустой).
|
||||||
|
// На устройствах с маленьким экраном кнопка лежит в конце виртуализированного
|
||||||
|
// списка и приходит с bounds=[0,0][0,0] — Node::isEmpty это не ловит, тап
|
||||||
|
// улетает в (0,0). Скроллим список вниз, пока bounds не станут ненулевыми
|
||||||
|
// (после первого скролла кнопка обычно материализуется у нижнего края, но
|
||||||
|
// тап по ней работает — это её настоящая позиция в layout).
|
||||||
|
auto findOtherBank = [&]() -> Node {
|
||||||
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (n.contentDesc.contains("other-bank-item")) return n;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
bool foundOtherBank = false;
|
||||||
|
Node node = findOtherBank();
|
||||||
|
for (int scrollAttempt = 0;
|
||||||
|
!node.isEmpty() && (node.width() == 0 || node.height() == 0) && scrollAttempt < 6;
|
||||||
|
++scrollAttempt) {
|
||||||
|
qDebug() << "[PayByPhone] 'Другой банк' has collapsed bounds, scrolling list down, attempt"
|
||||||
|
<< scrollAttempt + 1 << "/ 6";
|
||||||
|
AdbUtils::makeSwipe(deviceId, width / 2, height * 3 / 4, width / 2, height / 4);
|
||||||
|
QThread::msleep(800);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
node = findOtherBank();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.isEmpty() && node.width() > 0 && node.height() > 0) {
|
||||||
|
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item) at"
|
||||||
|
<< node.x() << "," << node.y();
|
||||||
|
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
foundOtherBank = true;
|
||||||
|
otherBankClicked = true;
|
||||||
|
}
|
||||||
|
if (foundOtherBank) continue;
|
||||||
|
|
||||||
|
// Вариант 2: уже на экране поиска банка (EditText) — только после клика на "Другой банк"
|
||||||
|
if (otherBankClicked) {
|
||||||
|
// После перехода на экран поиска банка поле ввода прячется под верхним
|
||||||
|
// тулбаром (рендерится Svelte WebView'ом). Свайпаем сверху вниз, чтобы
|
||||||
|
// подтянуть содержимое и сделать поле физически видимым/активным.
|
||||||
|
qDebug() << "[PayByPhone] Scrolling up to reveal hidden bank search input...";
|
||||||
|
AdbUtils::makeSwipe(deviceId, width / 2, height / 4, width / 2, height * 3 / 4);
|
||||||
|
QThread::msleep(800);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
Node bankInput = xmlScreenParser.findFirstEditText();
|
||||||
|
if (!bankInput.isEmpty()) {
|
||||||
|
qDebug() << "[PayByPhone] Bank search screen detected, entering bank name...";
|
||||||
|
|
||||||
|
// Переключаем IME ДО тапа по полю — иначе смена IME после тапа
|
||||||
|
// закроет системную клавиатуру и WebView резайзится, из-за чего
|
||||||
|
// сохранённые координаты bankInput уезжают и повторный тап мимо.
|
||||||
|
if (!AdbUtils::enableAdbIMEKeyboard(deviceId)) {
|
||||||
|
m_error = "Не удалось активировать ADB Keyboard (см. логи 'ime list -s' / default_input_method)";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QThread::msleep(800);
|
||||||
|
|
||||||
|
// Перечитываем XML — layout с ADBKeyboard (без UI) отличается от layout
|
||||||
|
// с системной клавиатурой. Берём актуальные bounds поля.
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
Node freshBankInput = xmlScreenParser.findFirstEditText();
|
||||||
|
if (!freshBankInput.isEmpty()) bankInput = freshBankInput;
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, bankInput.x(), bankInput.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
AdbUtils::inputAdbIMEText(deviceId, m_bankName);
|
||||||
|
QThread::msleep(2500);
|
||||||
|
|
||||||
|
// Верифицируем, что текст действительно попал в поле
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
Node verifyInput = xmlScreenParser.findFirstEditText();
|
||||||
|
if (verifyInput.text.isEmpty()) {
|
||||||
|
qWarning() << "[PayByPhone] Поле банка пустое после inputAdbIMEText — ретраим";
|
||||||
|
AdbUtils::makeTap(deviceId, bankInput.x(), bankInput.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
AdbUtils::inputAdbIMEText(deviceId, m_bankName);
|
||||||
|
QThread::msleep(2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::disableAdbIMEKeyboard(deviceId);
|
||||||
|
qDebug() << "[PayByPhone] Entered bank name via ADB IME:" << m_bankName;
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// Ищем банк в списке и кликаем по нему (кнопка с числовым content-desc)
|
||||||
|
bool bankFound = false;
|
||||||
|
for (int ba = 0; ba < 10; ++ba) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
|
||||||
|
// Сначала проверяем, что поле поиска реально содержит введённый текст —
|
||||||
|
// иначе мы тапаем по первому попавшемуся банку из несфильтрованного списка.
|
||||||
|
Node searchField = xmlScreenParser.findFirstEditText();
|
||||||
|
if (searchField.text.trimmed().isEmpty()) {
|
||||||
|
qDebug() << "[PayByPhone] Search field still empty, waiting for filter, attempt" << ba + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const Node &node : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (node.className == "android.widget.Button"
|
||||||
|
&& !node.contentDesc.isEmpty()
|
||||||
|
&& node.contentDesc != "base-go-back-button"
|
||||||
|
&& node.contentDesc != "native-toolbar-close-button"
|
||||||
|
&& node.contentDesc != "clear-input-icon") {
|
||||||
|
const int tapX = node.x1() + node.width() / 4;
|
||||||
|
qDebug() << "[PayByPhone] Found bank in list, tapping:" << node.contentDesc << "tapX:" << tapX << "y:" << node.y();
|
||||||
|
AdbUtils::makeTap(deviceId, tapX, node.y());
|
||||||
|
bankFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bankFound) break;
|
||||||
|
qDebug() << "[PayByPhone] Waiting for bank list, attempt" << ba + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bankFound) {
|
||||||
|
m_error = "Банк '" + m_bankName + "' не найден в списке";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bankSelected = true;
|
||||||
|
QThread::msleep(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} // otherBankClicked
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[PayByPhone] Waiting for 'По номеру телефона', attempt" << attempt + 1 << "/ 20";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sumScreenLoaded) {
|
||||||
|
m_error = "Экран ввода суммы не загрузился";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Вводим сумму
|
||||||
|
// На экране "По номеру телефона" обычно 2 EditText: сумма (сверху) и
|
||||||
|
// сообщение получателю (снизу). Оба имеют rid="input___xxxx" в Svelte UI.
|
||||||
|
// Берём верхний (минимальный y1) — это amount.
|
||||||
|
Node sumInput;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
QList<Node> candidateInputs;
|
||||||
|
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||||
|
if (n.className != "android.widget.EditText") continue;
|
||||||
|
if (n.height() <= 0 || n.height() > height * 4 / 5) continue;
|
||||||
|
if (!n.resourceId.startsWith("input___")) continue;
|
||||||
|
candidateInputs.append(n);
|
||||||
|
}
|
||||||
|
if (!candidateInputs.isEmpty()) {
|
||||||
|
std::sort(candidateInputs.begin(), candidateInputs.end(),
|
||||||
|
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
|
||||||
|
sumInput = candidateInputs.first(); // верхний = amount
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Старая логика — если нет ни одного "input___": берём любой EditText.
|
||||||
|
sumInput = xmlScreenParser.findFirstEditText();
|
||||||
|
if (!sumInput.isEmpty()) break;
|
||||||
|
|
||||||
|
qDebug() << "[PayByPhone] Waiting for sum input field, attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
}
|
||||||
|
if (sumInput.isEmpty()) {
|
||||||
|
m_error = "Поле ввода суммы не найдено";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, sumInput.x(), sumInput.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
AdbUtils::inputText(deviceId, QString::number(static_cast<int>(m_amount)));
|
||||||
|
QThread::msleep(1000);
|
||||||
|
|
||||||
|
// Закрываем системную софт-клавиатуру, иначе она перекрывает "Продолжить"
|
||||||
|
// (кнопка уезжает за нижнюю границу web_view_holder и имеет нулевую высоту).
|
||||||
|
AdbUtils::hideKeyboard(deviceId);
|
||||||
|
QThread::msleep(800);
|
||||||
|
|
||||||
|
// 6. Нажимаем "Продолжить" (ретраим, пока bounds перестанут быть нулевыми)
|
||||||
|
Node continueBtn;
|
||||||
|
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
continueBtn = xmlScreenParser.findButtonNode("Продолжить", false);
|
||||||
|
if (!continueBtn.isEmpty() && continueBtn.height() > 0) break;
|
||||||
|
qDebug() << "[PayByPhone] Waiting for tappable 'Продолжить', attempt" << attempt + 1 << "/ 5"
|
||||||
|
<< "(bounds collapsed — клавиатура ещё видна?)";
|
||||||
|
AdbUtils::hideKeyboard(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
if (continueBtn.isEmpty() || continueBtn.height() == 0) {
|
||||||
|
m_error = "Кнопка 'Продолжить' не найдена или перекрыта клавиатурой";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::makeTap(deviceId, continueBtn.x(), continueBtn.y());
|
||||||
|
QThread::msleep(2000);
|
||||||
|
|
||||||
|
// 7. Ждём экран "Подтвердите перевод" с кнопкой "Перевести ..."
|
||||||
|
bool confirmScreenLoaded = false;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
Node confirmTitle = xmlScreenParser.findTextNode("Подтвердите перевод");
|
||||||
|
if (!confirmTitle.isEmpty()) {
|
||||||
|
confirmScreenLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[PayByPhone] Waiting for 'Подтвердите перевод', attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmScreenLoaded) {
|
||||||
|
m_error = "Экран подтверждения не загрузился";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Нажимаем "Перевести ..." (ищем по первому слову)
|
||||||
|
Node transferConfirmBtn;
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
transferConfirmBtn = xmlScreenParser.findButtonNode("Перевести", true);
|
||||||
|
if (!transferConfirmBtn.isEmpty()) break;
|
||||||
|
qDebug() << "[PayByPhone] Waiting for 'Перевести ...' button, attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
}
|
||||||
|
if (transferConfirmBtn.isEmpty()) {
|
||||||
|
m_error = "Кнопка 'Перевести ...' не найдена на экране подтверждения";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[PayByPhone] Tapping confirm button:" << transferConfirmBtn.text;
|
||||||
|
AdbUtils::makeTap(deviceId, transferConfirmBtn.x(), transferConfirmBtn.y());
|
||||||
|
QThread::msleep(3000);
|
||||||
|
|
||||||
|
// 8.5. Проверяем экран ввода PIN-кода
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
||||||
|
!pinNodes.isEmpty()) {
|
||||||
|
qDebug() << "[PayByPhone] PIN code screen detected after confirm, entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinNodes);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Ждём результат: "Успешно" или "Не удалось"
|
||||||
|
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||||
|
if (!checkApp()) return;
|
||||||
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||||
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
|
|
||||||
|
// Если PIN-код появился позже — вводим
|
||||||
|
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
||||||
|
!pinNodes.isEmpty()) {
|
||||||
|
qDebug() << "[PayByPhone] PIN code screen detected (retry), entering PIN...";
|
||||||
|
inputPinCode(deviceId, pinNodes);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) {
|
||||||
|
qDebug() << "[PayByPhone] Payment successful!";
|
||||||
|
m_error.clear();
|
||||||
|
|
||||||
|
// Сохраняем транзакцию в БД
|
||||||
|
TransactionInfo tx;
|
||||||
|
tx.accountId = m_account.id;
|
||||||
|
tx.amount = -m_amount;
|
||||||
|
tx.phone = m_phone;
|
||||||
|
tx.bankName = "ozon";
|
||||||
|
tx.type = TransactionType::Phone;
|
||||||
|
tx.status = TransactionStatus::Complete;
|
||||||
|
tx.timestamp = DateUtils::getUtcNow();
|
||||||
|
tx.completeTime = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow"));
|
||||||
|
tx.bankTime = tx.completeTime.toString("dd.MM.yyyy, HH:mm");
|
||||||
|
const int txId = TransactionDAO::insertTransaction(tx);
|
||||||
|
qDebug() << "[PayByPhone] Saved transaction to DB, id:" << txId;
|
||||||
|
|
||||||
|
// Скриншот экрана "Успешно" — отдаём как receipt-image и в EventHandler.
|
||||||
|
// Раньше дополнительно тапали "Документы" + ждали "Поделиться" + OCR
|
||||||
|
// ради bank_transaction_id, но это медленно (до 30 сек ожидания) и
|
||||||
|
// нестабильно. Теперь полагаемся на fallback-ID из phone+timestamp.
|
||||||
|
QString externalId;
|
||||||
|
if (txId != -1) {
|
||||||
|
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
if (!screenshot.isEmpty()) {
|
||||||
|
m_resultScreenshot = screenshot;
|
||||||
|
emit screenshotReady(m_resultScreenshot);
|
||||||
|
TransactionDAO::updateReceiptImage(txId, screenshot);
|
||||||
|
qDebug() << "[PayByPhone] Receipt saved to DB, txId:" << txId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalId.isEmpty() && txId != -1) {
|
||||||
|
externalId = "ozon_" + bankProfilePhone + "_" + QString::number(QDateTime::currentSecsSinceEpoch());
|
||||||
|
qDebug() << "[PayByPhone] Generated fallback transaction ID:" << externalId;
|
||||||
|
}
|
||||||
|
if (!externalId.isEmpty() && txId != -1) {
|
||||||
|
TransactionDAO::updateBankTrExternalId(txId, externalId);
|
||||||
|
qDebug() << "[PayByPhone] Transaction ID:" << externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xmlScreenParser.findTextNode("Не удалось").isEmpty()) {
|
||||||
|
m_error = "Перевод не удался (экран 'Не удалось')";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[PayByPhone] Waiting for payment result, attempt" << attempt + 1 << "/ 15";
|
||||||
|
QThread::msleep(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_error = "Не дождались результата перевода";
|
||||||
|
qWarning() << "[PayByPhone]" << m_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
40
android/ozon/PayByPhoneScript.h
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "CommonScript.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
class PayByPhoneScript final : public CommonScript {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
~PayByPhoneScript() override;
|
||||||
|
|
||||||
|
PayByPhoneScript(
|
||||||
|
MaterialInfo account,
|
||||||
|
QString phone,
|
||||||
|
QString bankName,
|
||||||
|
double amount,
|
||||||
|
QObject *parent = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void doStart() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const MaterialInfo m_account;
|
||||||
|
const QString m_phone;
|
||||||
|
const QString m_bankName;
|
||||||
|
const double m_amount;
|
||||||
|
QString m_error;
|
||||||
|
|
||||||
|
void makePayment(
|
||||||
|
const QString &deviceId,
|
||||||
|
const QString &packageName,
|
||||||
|
const QString &pinCode,
|
||||||
|
const QString &bankProfilePhone,
|
||||||
|
int width,
|
||||||
|
int height
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
@ -5,16 +5,20 @@
|
|||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QXmlStreamReader>
|
#include <QXmlStreamReader>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <climits>
|
||||||
#include <QDomDocument>
|
#include <QDomDocument>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
|
||||||
#include "adb/AdbUtils.h"
|
#include "adb/AdbUtils.h"
|
||||||
#include "android/xml/Node.h"
|
#include "android/xml/Node.h"
|
||||||
#include "db/AccountInfo.h"
|
#include "db/MaterialInfo.h"
|
||||||
#include "db/TransactionInfo.h"
|
#include "db/TransactionInfo.h"
|
||||||
#include "time/DateUtils.h"
|
#include "time/DateUtils.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
ScreenXmlParser::ScreenXmlParser(QObject *parent) : QObject(parent) {
|
ScreenXmlParser::ScreenXmlParser(QObject *parent) : QObject(parent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,6 +40,7 @@ static const QRegularExpression reNameAlt(R"(Перевод ([А-Яа-яЁё\s\*
|
|||||||
static const QRegularExpression reCardData(R"(цифрами\s+(\d\s\d\s\d\s\d).*?Сумма:\s([\d\s]+)\sруб)");
|
static const QRegularExpression reCardData(R"(цифрами\s+(\d\s\d\s\d\s\d).*?Сумма:\s([\d\s]+)\sруб)");
|
||||||
|
|
||||||
Node ScreenXmlParser::tryToFindBannerOrDialog(const int width, const int height) {
|
Node ScreenXmlParser::tryToFindBannerOrDialog(const int width, const int height) {
|
||||||
|
if (m_nodes.isEmpty()) return {};
|
||||||
// 1. Ищем push banner - он перекрывает весь экран
|
// 1. Ищем push banner - он перекрывает весь экран
|
||||||
Node first = m_nodes[0];
|
Node first = m_nodes[0];
|
||||||
for (const Node &node: m_nodes) {
|
for (const Node &node: m_nodes) {
|
||||||
@ -48,7 +53,8 @@ Node ScreenXmlParser::tryToFindBannerOrDialog(const int width, const int height)
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
// Если нашли пустую кнпоку
|
// Если нашли пустую кнпоку
|
||||||
if (node.className == "android.widget.Button" && node.clickable && node.text.isEmpty()) {
|
if (node.className == "android.widget.Button" && node.clickable
|
||||||
|
&& node.text.isEmpty() && node.contentDesc.isEmpty()) {
|
||||||
// <node class="android.widget.Button" package="ru.rshb.dbo" clickable="true" bounds="[630,142][691,205]"/>
|
// <node class="android.widget.Button" package="ru.rshb.dbo" clickable="true" bounds="[630,142][691,205]"/>
|
||||||
if (node.x() > width / 2 && node.y() < width / 2) {
|
if (node.x() > width / 2 && node.y() < width / 2) {
|
||||||
return node;
|
return node;
|
||||||
@ -66,6 +72,9 @@ Node ScreenXmlParser::tryToFindBannerOrDialog(const int width, const int height)
|
|||||||
Node ScreenXmlParser::tryToFindSecurityBanner() {
|
Node ScreenXmlParser::tryToFindSecurityBanner() {
|
||||||
Node text;
|
Node text;
|
||||||
Node closeBtn;
|
Node closeBtn;
|
||||||
|
bool hasBiometryScreen = false;
|
||||||
|
Node notNowNode;
|
||||||
|
|
||||||
for (const Node &node: m_nodes) {
|
for (const Node &node: m_nodes) {
|
||||||
if (node.text.trimmed() == "Мы заботимся о вашей безопасности" && node.className == "android.app.Dialog") {
|
if (node.text.trimmed() == "Мы заботимся о вашей безопасности" && node.className == "android.app.Dialog") {
|
||||||
text = node;
|
text = node;
|
||||||
@ -73,12 +82,36 @@ Node ScreenXmlParser::tryToFindSecurityBanner() {
|
|||||||
if (node.text.trimmed() == "Отложить" && node.className == "android.widget.Button") {
|
if (node.text.trimmed() == "Отложить" && node.className == "android.widget.Button") {
|
||||||
closeBtn = node;
|
closeBtn = node;
|
||||||
}
|
}
|
||||||
|
// Экран биометрии "Быстрый вход" — нажимаем "Не сейчас"
|
||||||
|
if (node.text.trimmed() == "Быстрый вход") {
|
||||||
|
hasBiometryScreen = true;
|
||||||
|
}
|
||||||
|
if (node.text.trimmed() == "Не сейчас") {
|
||||||
|
notNowNode = node;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Не нужно проверять все, если хотя бы одна координата есть
|
|
||||||
if (!text.isEmpty() && !closeBtn.isEmpty()) {
|
if (!text.isEmpty() && !closeBtn.isEmpty()) {
|
||||||
return closeBtn;
|
return closeBtn;
|
||||||
}
|
}
|
||||||
|
if (hasBiometryScreen && !notNowNode.isEmpty()) {
|
||||||
|
return notNowNode;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::tryToFindSelectBottomSheet() {
|
||||||
|
Node closeBtn;
|
||||||
|
for (const Node &node: m_nodes) {
|
||||||
|
if ((node.text.trimmed() == "Отмена" || node.text.trimmed() == "Отложить") && node.className ==
|
||||||
|
"android.widget.Button") {
|
||||||
|
closeBtn = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Не нужно проверять все, если хотя бы одна координата есть
|
||||||
|
if (!closeBtn.isEmpty()) {
|
||||||
|
return closeBtn;
|
||||||
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,6 +143,80 @@ Node ScreenXmlParser::findFirstEditText() {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findEditTextByHint(const QString &hintPrefix) {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.EditText" && node.hint.contains(hintPrefix)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Некоторые устройства/прошивки (напр. Infinix) в uiautomator-дампе не отдают
|
||||||
|
// атрибут hint для EditText внутри WebView. В этом случае ищем TextView-label
|
||||||
|
// с нужным текстом и ближайший к нему EditText с горизонтальным перекрытием.
|
||||||
|
Node label;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.TextView" && node.text.contains(hintPrefix)) {
|
||||||
|
label = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (label.isEmpty()) return {};
|
||||||
|
|
||||||
|
Node best;
|
||||||
|
int bestDy = INT_MAX;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.EditText") continue;
|
||||||
|
const bool hOverlap = node.x1() < label.x2() && node.x2() > label.x1();
|
||||||
|
if (!hOverlap) continue;
|
||||||
|
const int dy = std::abs(node.y() - label.y());
|
||||||
|
if (dy < bestDy) {
|
||||||
|
bestDy = dy;
|
||||||
|
best = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
static QDomElement findPanIframeElement(const QDomElement &root) {
|
||||||
|
if (root.isNull()) return {};
|
||||||
|
if (root.attribute("resource-id") == "movable-pan-iframe") return root;
|
||||||
|
for (QDomElement child = root.firstChildElement(); !child.isNull(); child = child.nextSiblingElement()) {
|
||||||
|
QDomElement found = findPanIframeElement(child);
|
||||||
|
if (!found.isNull()) return found;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
static QDomElement findFirstEditTextDescendant(const QDomElement &root) {
|
||||||
|
if (root.isNull()) return {};
|
||||||
|
if (root.attribute("class") == "android.widget.EditText") return root;
|
||||||
|
for (QDomElement child = root.firstChildElement(); !child.isNull(); child = child.nextSiblingElement()) {
|
||||||
|
QDomElement found = findFirstEditTextDescendant(child);
|
||||||
|
if (!found.isNull()) return found;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findPanCardNumberInput() {
|
||||||
|
const QDomElement iframe = findPanIframeElement(m_xml);
|
||||||
|
if (iframe.isNull()) return {};
|
||||||
|
const QDomElement edit = findFirstEditTextDescendant(iframe);
|
||||||
|
if (edit.isNull()) return {};
|
||||||
|
|
||||||
|
Node node;
|
||||||
|
if (QRegularExpressionMatch m = boundsRegex.match(edit.attribute("bounds")); m.hasMatch()) {
|
||||||
|
node.setBounds(m.captured(1).toInt(), m.captured(2).toInt(),
|
||||||
|
m.captured(3).toInt(), m.captured(4).toInt());
|
||||||
|
}
|
||||||
|
node.className = edit.attribute("class");
|
||||||
|
node.text = edit.attribute("text");
|
||||||
|
node.contentDesc = edit.attribute("content-desc");
|
||||||
|
node.resourceId = edit.attribute("resource-id");
|
||||||
|
node.hint = edit.attribute("hint");
|
||||||
|
node.clickable = edit.attribute("clickable") == "true";
|
||||||
|
node.focusable = edit.attribute("focusable") == "true";
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
Node ScreenXmlParser::findButtonNode(const QString &text, const bool contains) {
|
Node ScreenXmlParser::findButtonNode(const QString &text, const bool contains) {
|
||||||
for (const Node &node: m_nodes) {
|
for (const Node &node: m_nodes) {
|
||||||
if (contains) {
|
if (contains) {
|
||||||
@ -125,51 +232,528 @@ Node ScreenXmlParser::findButtonNode(const QString &text, const bool contains) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ScreenXmlParser::isHomeScreen() {
|
QList<Node> ScreenXmlParser::findAllButtons() {
|
||||||
// Есть заголовок "Счета и карты" и кнопка "Все продукты"
|
QList<Node> buttons;
|
||||||
Node text;
|
for (const Node &node : m_nodes) {
|
||||||
Node allProductBtn;
|
if (node.className == "android.widget.Button" && !node.text.isEmpty()) {
|
||||||
|
buttons.append(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QList<Node> &ScreenXmlParser::findAllNodes() const {
|
||||||
|
return m_nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) {
|
||||||
for (const Node &node: m_nodes) {
|
for (const Node &node: m_nodes) {
|
||||||
if (node.text.trimmed() == "Счета и карты" && node.className == "android.widget.TextView") {
|
if (node.clickable && node.contentDesc.trimmed() == contentDesc) {
|
||||||
text = node;
|
return node;
|
||||||
}
|
|
||||||
if (node.text.trimmed() == "Все продукты" && node.className == "android.widget.Button") {
|
|
||||||
allProductBtn = node;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return !text.isEmpty() && !allProductBtn.isEmpty();
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isHomeScreen() {
|
||||||
|
// Домашний экран Ozon Банка: одновременно присутствуют кнопки "Перевести" и "Пополнить"
|
||||||
|
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;
|
||||||
|
if (hasTransfer && hasTopUp) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::tryToFindAdBanner() {
|
||||||
|
// Рекламный bottom sheet (home_ad_1) определяется по наличию кликабельного
|
||||||
|
// фонового оверлея "touch_outside" и самого bottom sheet контейнера.
|
||||||
|
bool hasBottomSheet = false;
|
||||||
|
Node touchOutside;
|
||||||
|
for (const Node &node: m_nodes) {
|
||||||
|
if (node.resourceId == "ru.ozon.fintech.finance:id/design_bottom_sheet") {
|
||||||
|
hasBottomSheet = true;
|
||||||
|
}
|
||||||
|
if (node.resourceId == "ru.ozon.fintech.finance:id/touch_outside" && node.clickable) {
|
||||||
|
touchOutside = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasBottomSheet && !touchOutside.isEmpty()) {
|
||||||
|
return touchOutside;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button"
|
||||||
|
&& node.clickable) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.clickable && node.text.trimmed() == QString::fromUtf8("ЗАПРЕТИТЬ")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int screenHeight) {
|
||||||
|
// Баннер Google Play "Доступно обновление" — ищем кнопку закрытия по content-desc
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Альтернативно — по content-desc "Доступно обновление"
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.contentDesc.contains("Доступно обновление")) {
|
||||||
|
// Тапаем в верхнюю часть экрана (выше баннера), чтобы закрыть
|
||||||
|
Node tapNode;
|
||||||
|
const int cx = screenWidth / 2;
|
||||||
|
const int cy = screenHeight / 20;
|
||||||
|
tapNode.setBounds(cx, cy, cx + 1, cy + 1);
|
||||||
|
return tapNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isProfileScreen() {
|
||||||
|
// Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти"
|
||||||
|
bool hasTitle = false;
|
||||||
|
bool hasExit = false;
|
||||||
|
for (const Node &node: m_nodes) {
|
||||||
|
const QString t = node.text.trimmed();
|
||||||
|
if (t == "Аккаунт") hasTitle = true;
|
||||||
|
if (t == "Выйти") hasExit = true;
|
||||||
|
if (hasTitle && hasExit) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findUserNameOnHomeScreen(const int screenHeight) {
|
||||||
|
// Имя пользователя — TextView в верхней части домашнего экрана (y < screenHeight/5).
|
||||||
|
// Тап по его координатам делегируется родительскому кликабельному View.
|
||||||
|
static const QSet<QString> knownLabels = {
|
||||||
|
"Финансы", "Платежи", "Выгода", "Чат", "Ещё"
|
||||||
|
};
|
||||||
|
for (const Node &node: m_nodes) {
|
||||||
|
if (node.className == "android.widget.TextView"
|
||||||
|
&& !node.text.trimmed().isEmpty()
|
||||||
|
&& node.y() > 0
|
||||||
|
&& node.y() < screenHeight / 5
|
||||||
|
&& !knownLabels.contains(node.text.trimmed())) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseProfileFullName() {
|
||||||
|
// Полное имя — первый TextView ниже заголовка "Аккаунт",
|
||||||
|
// который не является известным элементом UI и не содержит спецсимволов.
|
||||||
|
static const QSet<QString> uiLabels = {
|
||||||
|
"Аккаунт", "Мои документы", "Паспорт и ИНН",
|
||||||
|
"Уровень счёта Лимиты и тарифы", "Повысить",
|
||||||
|
"Изменить код-пароль", "Изменить кодовое слово",
|
||||||
|
"Использовать данные биометрии", "Выйти",
|
||||||
|
"Оформите подписку",
|
||||||
|
"QR-код для пополнений Принимайте чаевые и донаты",
|
||||||
|
"Добавить Почта"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Находим y-координату заголовка "Аккаунт"
|
||||||
|
int titleY = -1;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.TextView"
|
||||||
|
&& node.text.trimmed() == "Аккаунт"
|
||||||
|
&& node.width() > 50) {
|
||||||
|
titleY = node.y();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (titleY < 0) return {};
|
||||||
|
|
||||||
|
// Находим y-координату блока с телефоном (ограничение снизу)
|
||||||
|
int phoneY = INT_MAX;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.text.contains("Номер телефона")) {
|
||||||
|
phoneY = node.y();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первый подходящий TextView между заголовком и телефоном
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.TextView"
|
||||||
|
&& !node.text.trimmed().isEmpty()
|
||||||
|
&& node.y() > titleY
|
||||||
|
&& node.y() < phoneY
|
||||||
|
&& !uiLabels.contains(node.text.trimmed())) {
|
||||||
|
return node.text.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findCardButtonOnHomeScreen() {
|
||||||
|
// Карточка на домашнем экране — Button, текст которого заканчивается на 4 цифры.
|
||||||
|
// Формат может быть "9865", "•• 9865", "**** 9865" и т.п.
|
||||||
|
static const QRegularExpression endsWithFourDigits(R"(\d{4}\s*$)");
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.Button"
|
||||||
|
&& node.clickable
|
||||||
|
&& endsWithFourDigits.match(node.text.trimmed()).hasMatch()) {
|
||||||
|
qDebug() << "[findCardButton] Found:" << node.text << "at" << node.x() << node.y();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Диагностика: выводим все clickable Button'ы чтобы понять что есть на экране
|
||||||
|
qDebug() << "[findCardButton] Not found. All clickable Buttons:";
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.Button" && node.clickable) {
|
||||||
|
qDebug() << " text=" << node.text << "resId=" << node.resourceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
// Отсекаем строки из ленты переводов (см. parseCardsOnHomeScreen)
|
||||||
|
if (t.contains(QString::fromUtf8("Переводы"))
|
||||||
|
|| t.contains(QStringLiteral("**"))
|
||||||
|
|| t.contains(QChar(0x2212)) || t.contains(QChar('+'))) {
|
||||||
|
qDebug() << "[findCardBtn] Skipping transfer-feed row for lastNumbers"
|
||||||
|
<< lastNumbers << "text=" << node.text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
qDebug() << "[findCardBtn] Found by lastNumbers" << lastNumbers
|
||||||
|
<< "text=" << node.text << "at" << node.x() << node.y();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
qWarning() << "[findCardBtn] Not found for lastNumbers=" << lastNumbers
|
||||||
|
<< ". Clickable buttons:";
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.Button" && node.clickable)
|
||||||
|
qWarning() << " text=" << node.text;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findMainAccountButton() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.Button") continue;
|
||||||
|
QString t = node.text;
|
||||||
|
t.replace(QChar(0x00A0), ' ').replace(QChar(0x202F), ' ');
|
||||||
|
if (t.contains("Основной", Qt::CaseInsensitive) && t.contains("₽")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
Node ScreenXmlParser::findShowRequisitesButton() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.text.trimmed() == QString::fromUtf8("Посмотреть реквизиты")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isAccountRequisitesScreen() {
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.text.trimmed() == QString::fromUtf8("Реквизиты счёта")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseAccountNumberFromRequisites() {
|
||||||
|
// text="Номер счёта получателя 40914810100039708263"
|
||||||
|
// Префикс может варьироваться по регистру/пробелам — берём после первого совпадения цифр в конце.
|
||||||
|
static const QRegularExpression tailDigits(R"((\d{15,}))");
|
||||||
|
static const QString prefix = QString::fromUtf8("Номер счёта получателя");
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
const QString t = node.text;
|
||||||
|
if (!t.contains(prefix)) continue;
|
||||||
|
const QRegularExpressionMatch m = tailDigits.match(t);
|
||||||
|
if (m.hasMatch()) return m.captured(1);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<MaterialInfo> ScreenXmlParser::parseCardsOnHomeScreen() {
|
||||||
|
// "Основной счёт X ₽" — общая кнопка с балансом для всех карт.
|
||||||
|
// Карты — отдельные кнопки с текстом из 4 цифр (могут быть любые non-breaking spaces).
|
||||||
|
// Сумма из "Основной счёт" проставляется на все карты.
|
||||||
|
|
||||||
|
static const QRegularExpression endsWithFourDigits(R"(\d{4}\s*$)");
|
||||||
|
|
||||||
|
// 1. Ищем кнопку "Основной счёт X ₽" — содержит "Основной" и "₽"
|
||||||
|
double commonAmount = 0.0;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.Button") continue;
|
||||||
|
QString t = node.text;
|
||||||
|
t.replace(QChar(0x00A0), ' ').replace(QChar(0x202F), ' ');
|
||||||
|
if (!t.contains("Основной", Qt::CaseInsensitive) || !t.contains("₽")) continue;
|
||||||
|
qDebug() << "[parseCards] Found balance button, text:" << t;
|
||||||
|
const QRegularExpressionMatch m = QRegularExpression(
|
||||||
|
R"(([\d\s]+(?:[.,]\d+)?)\s*₽)"
|
||||||
|
).match(t);
|
||||||
|
if (m.hasMatch()) {
|
||||||
|
QString amtStr = m.captured(1).remove(' ').replace(',', '.');
|
||||||
|
commonAmount = amtStr.toDouble();
|
||||||
|
qDebug() << "[parseCards] Parsed amount:" << commonAmount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qWarning() << "[parseCards] Amount regex not matched in:" << t;
|
||||||
|
}
|
||||||
|
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("Переводы"))
|
||||||
|
|| t.contains(QStringLiteral("**"))
|
||||||
|
|| t.contains(QChar(0x2212)) || t.contains(QChar('+'))) {
|
||||||
|
qDebug() << "[parseCards] Skipping transfer-feed row:" << t;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialInfo acc;
|
||||||
|
acc.lastNumbers = t.right(4);
|
||||||
|
acc.amount = commonAmount;
|
||||||
|
acc.currency = "RUB";
|
||||||
|
acc.description = "Виртуальная карта";
|
||||||
|
qDebug() << "[parseCards] Card found: lastNumbers=" << acc.lastNumbers
|
||||||
|
<< "text=" << node.text << "amount=" << commonAmount;
|
||||||
|
cards.append(acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "[parseCards] Total cards found:" << cards.size();
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::parseProfilePhone() {
|
||||||
|
// Телефон — Button с текстом вида "+7 953 803 83 18 Номер телефона"
|
||||||
|
for (const Node &node: m_nodes) {
|
||||||
|
if (node.text.contains("Номер телефона")) {
|
||||||
|
const int idx = node.text.indexOf("Номер телефона");
|
||||||
|
QString phone = node.text.left(idx).trimmed();
|
||||||
|
phone.remove(' ');
|
||||||
|
phone.remove(QChar(0x00A0));
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<Node> ScreenXmlParser::findPinCodeNodes(const QString &pinCode) {
|
QList<Node> ScreenXmlParser::findPinCodeNodes(const QString &pinCode) {
|
||||||
QMap<QString, Node> buttons;
|
const QString cleanPin = QString(pinCode).remove(' ');
|
||||||
const QSet<QString> numberSet = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"};
|
if (cleanPin.isEmpty()) {
|
||||||
|
qWarning() << "[findPinCodeNodes] pinCode is empty — check bank_profiles.pin_code in DB";
|
||||||
for (const Node &node: m_nodes) {
|
return {};
|
||||||
const QString &text = node.text.trimmed();
|
}
|
||||||
if (node.className == "android.widget.Button" && numberSet.contains(text)) {
|
// Способ 1: ищем кликабельные ноды с текстом-цифрой (0-9)
|
||||||
buttons[text] = node;
|
// Поддерживаем форматы: "1", "key 1", и т.п.
|
||||||
|
QMap<QString, Node> digitMap;
|
||||||
|
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()) {
|
||||||
|
digitMap[match.captured(1)] = node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<Node> nodes;
|
qDebug() << "[findPinCodeNodes] digitMap size:" << digitMap.size()
|
||||||
for (int i = 0; i < pinCode.length(); ++i) {
|
<< "keys:" << digitMap.keys();
|
||||||
nodes.append(buttons[QString(pinCode.at(i))]);
|
if (digitMap.size() >= 10) {
|
||||||
}
|
QList<Node> result;
|
||||||
return nodes;
|
for (const QChar &c : cleanPin) {
|
||||||
}
|
const QString d = QString(c);
|
||||||
|
if (digitMap.contains(d)) {
|
||||||
QList<Node> ScreenXmlParser::tryToFindPinCode(const QString &pinCode, const QString &title) {
|
result.append(digitMap[d]);
|
||||||
for (const Node &node: m_nodes) {
|
} else {
|
||||||
// если там есть такая кнопка значит банер есть, и пин-код не прожать
|
qWarning() << "[findPinCodeNodes] digit not found:" << d;
|
||||||
if (node.text.trimmed() == "Отложить" && node.className == "android.widget.Button") {
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const Node &node: m_nodes) {
|
return result;
|
||||||
// FIXME определение пин-кода лучше сделать
|
|
||||||
if (node.text.trimmed() == title) {
|
|
||||||
return findPinCodeNodes(pinCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Способ 2 (fallback): группируем clickable-ноды похожего размера,
|
||||||
|
// ищем сетку 4x3 по паттерну (не привязываясь к координатам экрана)
|
||||||
|
constexpr int SIZE_TOLERANCE = 10; // допуск в пикселях
|
||||||
|
|
||||||
|
// Собираем все clickable ноды с положительным размером
|
||||||
|
QList<Node> allClickable;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue;
|
||||||
|
allClickable.append(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем с допуском: для каждой ноды проверяем, подходит ли она к существующей группе
|
||||||
|
auto fuzzyGroup = [&](bool squareOnly) -> QList<Node> {
|
||||||
|
QList<QList<Node>> groups;
|
||||||
|
QList<QPair<int,int>> groupSizes; // эталонный размер каждой группы
|
||||||
|
|
||||||
|
for (const Node &node : allClickable) {
|
||||||
|
if (squareOnly && std::abs(node.width() - node.height()) > SIZE_TOLERANCE)
|
||||||
|
continue;
|
||||||
|
bool placed = false;
|
||||||
|
for (int g = 0; g < groups.size(); ++g) {
|
||||||
|
if (std::abs(node.width() - groupSizes[g].first) <= SIZE_TOLERANCE &&
|
||||||
|
std::abs(node.height() - groupSizes[g].second) <= SIZE_TOLERANCE) {
|
||||||
|
groups[g].append(node);
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
groups.append({node});
|
||||||
|
groupSizes.append({node.width(), node.height()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Node> best;
|
||||||
|
for (const auto &grp : groups) {
|
||||||
|
if (grp.size() >= 10 && (best.size() < 10 || grp.size() < best.size()))
|
||||||
|
best = grp;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сначала квадратные, потом любые
|
||||||
|
QList<Node> candidates = fuzzyGroup(true);
|
||||||
|
qDebug() << "[findPinCodeNodes] fallback square candidates:" << candidates.size();
|
||||||
|
if (candidates.size() < 10) {
|
||||||
|
candidates = fuzzyGroup(false);
|
||||||
|
qDebug() << "[findPinCodeNodes] fallback any candidates:" << candidates.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.size() < 10) {
|
||||||
|
// Дебаг: выводим все clickable ноды и их размеры
|
||||||
|
qWarning() << "[findPinCodeNodes] grid not found. Total clickable nodes:" << allClickable.size();
|
||||||
|
for (const Node &n : allClickable) {
|
||||||
|
qDebug() << " clickable:" << n.width() << "x" << n.height()
|
||||||
|
<< "pos:" << n.x() << "," << n.y()
|
||||||
|
<< "class:" << n.className
|
||||||
|
<< "text:" << n.text.left(30);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем по Y, затем группируем в строки
|
||||||
|
std::sort(candidates.begin(), candidates.end(), [](const Node &a, const Node &b) {
|
||||||
|
return a.y() < b.y();
|
||||||
|
});
|
||||||
|
|
||||||
|
QList<QList<Node>> rows;
|
||||||
|
for (const Node &node : candidates) {
|
||||||
|
bool placed = false;
|
||||||
|
for (QList<Node> &row : rows) {
|
||||||
|
if (std::abs(node.y() - row.first().y()) < row.first().height()) {
|
||||||
|
row.append(node);
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) rows.append({node});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (QList<Node> &row : rows) {
|
||||||
|
std::sort(row.begin(), row.end(), [](const Node &a, const Node &b) {
|
||||||
|
return a.x() < b.x();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строки по 3 — основные ряды (1-9), строки по 1-3 в конце — ряд с "0"
|
||||||
|
QList<QList<Node>> fullRows; // строки по 3 кнопки
|
||||||
|
QList<QList<Node>> shortRows; // строки с 1-2 кнопками (последний ряд)
|
||||||
|
for (const QList<Node> &row : rows) {
|
||||||
|
if (row.size() == 3) fullRows.append(row);
|
||||||
|
else if (row.size() >= 1 && row.size() <= 2) shortRows.append(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullRows.size() < 3) {
|
||||||
|
qWarning() << "[findPinCodeNodes] grid not found: fullRows=" << fullRows.size()
|
||||||
|
<< "candidates=" << candidates.size();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList digits19 = {"1", "2", "3", "4", "5", "6", "7", "8", "9"};
|
||||||
|
int idx = 0;
|
||||||
|
for (int r = 0; r < fullRows.size() && idx < digits19.size(); ++r) {
|
||||||
|
for (const Node &n : fullRows[r]) {
|
||||||
|
if (idx < digits19.size()) digitMap[digits19[idx++]] = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "0" — в последнем ряду: если 3 кнопки — средняя, если 1 — единственная
|
||||||
|
if (!shortRows.isEmpty()) {
|
||||||
|
const auto &lastRow = shortRows.last();
|
||||||
|
if (lastRow.size() >= 2)
|
||||||
|
digitMap["0"] = lastRow[1]; // средняя кнопка
|
||||||
|
else
|
||||||
|
digitMap["0"] = lastRow[0]; // единственная кнопка
|
||||||
|
} else if (fullRows.size() >= 4) {
|
||||||
|
// fallback: "0" — средняя кнопка последнего полного ряда
|
||||||
|
digitMap["0"] = fullRows.last()[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Node> result;
|
||||||
|
for (const QChar &c : cleanPin) {
|
||||||
|
if (const QString d = QString(c); digitMap.contains(d)) {
|
||||||
|
result.append(digitMap[d]);
|
||||||
|
} else {
|
||||||
|
qWarning() << "[findPinCodeNodes] fallback: digit not in digitMap:" << d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qDebug() << "[findPinCodeNodes] fallback result size:" << result.size()
|
||||||
|
<< "digitMap size:" << digitMap.size();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Node> ScreenXmlParser::tryToFindPinCode(const QString &pinCode, const QString &title) {
|
||||||
|
bool isPinScreen = false;
|
||||||
|
for (const Node &node: m_nodes) {
|
||||||
|
// Баннер — пин-код не вводить
|
||||||
|
if (node.text.trimmed() == "Отложить" && node.className == "android.widget.Button") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// В Ozon PIN экран определяется по ссылке "Не помню код-пароль"
|
||||||
|
if (node.text.trimmed() == "Не помню код-пароль") {
|
||||||
|
isPinScreen = true;
|
||||||
|
}
|
||||||
|
// Fallback: другие банки используют title текст
|
||||||
|
if (!title.isEmpty() && node.text.trimmed() == title) {
|
||||||
|
isPinScreen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isPinScreen) {
|
||||||
|
return findPinCodeNodes(pinCode);
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@ -220,6 +804,10 @@ void ScreenXmlParser::parseAndSaveXml(const QString &xml) {
|
|||||||
node.text = reader.attributes().value("text").toString();
|
node.text = reader.attributes().value("text").toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reader.attributes().hasAttribute("content-desc")) {
|
||||||
|
node.contentDesc = reader.attributes().value("content-desc").toString();
|
||||||
|
}
|
||||||
|
|
||||||
if (reader.attributes().hasAttribute("class")) {
|
if (reader.attributes().hasAttribute("class")) {
|
||||||
node.className = reader.attributes().value("class").toString();
|
node.className = reader.attributes().value("class").toString();
|
||||||
}
|
}
|
||||||
@ -233,6 +821,9 @@ void ScreenXmlParser::parseAndSaveXml(const QString &xml) {
|
|||||||
if (reader.attributes().hasAttribute("resource-id")) {
|
if (reader.attributes().hasAttribute("resource-id")) {
|
||||||
node.resourceId = reader.attributes().value("resource-id").toString();
|
node.resourceId = reader.attributes().value("resource-id").toString();
|
||||||
}
|
}
|
||||||
|
if (reader.attributes().hasAttribute("hint")) {
|
||||||
|
node.hint = reader.attributes().value("hint").toString();
|
||||||
|
}
|
||||||
|
|
||||||
m_nodes.append(node);
|
m_nodes.append(node);
|
||||||
}
|
}
|
||||||
@ -660,7 +1251,7 @@ QList<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayHistory() {
|
|||||||
TransactionInfo info;
|
TransactionInfo info;
|
||||||
info.amount = amount;
|
info.amount = amount;
|
||||||
info.bankTime = date.toString("dd.MM.yyyy");
|
info.bankTime = date.toString("dd.MM.yyyy");
|
||||||
info.shortDescription = shortDesc;
|
info.description = shortDesc;
|
||||||
|
|
||||||
transactions.append(info);
|
transactions.append(info);
|
||||||
}
|
}
|
||||||
@ -900,8 +1491,8 @@ TransactionInfo ScreenXmlParser::parseTransactionInfo(int screenWidth, int scree
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
QList<QPair<AccountInfo, Node> > ScreenXmlParser::parseAccountsInfo() {
|
QList<QPair<MaterialInfo, Node> > ScreenXmlParser::parseAccountsInfo() {
|
||||||
QList<QPair<AccountInfo, Node> > accounts;
|
QList<QPair<MaterialInfo, Node> > accounts;
|
||||||
for (const Node &node: m_nodes) {
|
for (const Node &node: m_nodes) {
|
||||||
const QString text = node.text;
|
const QString text = node.text;
|
||||||
|
|
||||||
@ -914,7 +1505,7 @@ QList<QPair<AccountInfo, Node> > ScreenXmlParser::parseAccountsInfo() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
AccountInfo info;
|
MaterialInfo info;
|
||||||
info.lastNumbers = match.captured(1).remove(" ");
|
info.lastNumbers = match.captured(1).remove(" ");
|
||||||
info.amount = match.captured(2).remove(" ").toFloat();
|
info.amount = match.captured(2).remove(" ").toFloat();
|
||||||
info.description = text.mid(0, text.indexOf("Номер карты"));
|
info.description = text.mid(0, text.indexOf("Номер карты"));
|
||||||
@ -926,6 +1517,176 @@ QList<QPair<AccountInfo, Node> > ScreenXmlParser::parseAccountsInfo() {
|
|||||||
return accounts;
|
return accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ScreenXmlParser::findFullCardNumber() {
|
||||||
|
static const QRegularExpression cardNumberRegex(R"(^\d{4}[\s\x{00A0}\x{202F}]\d{4}[\s\x{00A0}\x{202F}]\d{4}[\s\x{00A0}\x{202F}]\d{4}$)");
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.TextView") {
|
||||||
|
const QString text = node.text.trimmed();
|
||||||
|
if (cardNumberRegex.match(text).hasMatch()) {
|
||||||
|
QString result = text;
|
||||||
|
result.remove(QChar(' ')).remove(QChar(0x00A0)).remove(QChar(0x202F));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::isTransactionListScreen() {
|
||||||
|
// Экран "Операции": WebView с text="Операции" + наличие кнопок фильтров
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.webkit.WebView" && node.text.trimmed() == "Операции") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<Node> ScreenXmlParser::findTransactionButtons(const int maxCount) {
|
||||||
|
const QChar rubleSign(0x20BD); // ₽
|
||||||
|
QList<Node> buttons;
|
||||||
|
|
||||||
|
// В новом Svelte-UI Ozon ноды устроены так: Button-обёртка с пустым text,
|
||||||
|
// а сам текст транзакции живёт во вложенной android.view.View с теми же
|
||||||
|
// bounds. В старом UI текст был прямо в Button. Поддерживаем оба варианта:
|
||||||
|
// сначала сматчим Button с непустым text, затем пройдёмся по пустым
|
||||||
|
// Button-ам и подтянем текст из перекрывающейся View с ₽-знаком.
|
||||||
|
auto isSummary = [](const QString &t) {
|
||||||
|
return t.startsWith("Расходы") || t.startsWith("Доходы");
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.Button") continue;
|
||||||
|
const QString text = node.text.trimmed();
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
// Ищем View с тем же bounds (или внутри Button-bounds), содержащую ₽.
|
||||||
|
// У транзакционной строки высота ~170px — отсекаем мелкие декоры.
|
||||||
|
if (node.height() < 80 || node.height() > 260) continue;
|
||||||
|
QString viewText;
|
||||||
|
for (const Node &v : m_nodes) {
|
||||||
|
if (v.className != "android.view.View") continue;
|
||||||
|
if (!v.text.contains(rubleSign)) continue;
|
||||||
|
if (v.y1() < node.y1() || v.y2() > node.y2()) continue;
|
||||||
|
if (v.x1() < node.x1() || v.x2() > node.x2()) continue;
|
||||||
|
viewText = v.text.trimmed();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (viewText.isEmpty() || isSummary(viewText)) continue;
|
||||||
|
// Возвращаем Button с подставленным текстом, чтобы вызывающий код
|
||||||
|
// (parseTransactionButtonText / makeTap по координатам) работал
|
||||||
|
// прозрачно для обоих UI-вариантов.
|
||||||
|
Node enriched = node;
|
||||||
|
enriched.text = viewText;
|
||||||
|
buttons.append(enriched);
|
||||||
|
} else {
|
||||||
|
if (!text.contains(rubleSign)) continue;
|
||||||
|
if (isSummary(text)) continue;
|
||||||
|
buttons.append(node);
|
||||||
|
}
|
||||||
|
if (buttons.size() >= maxCount) break;
|
||||||
|
}
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ScreenXmlParser::hasCollapsedTransactionsBelow(const int screenHeight) {
|
||||||
|
const QChar rubleSign(0x20BD);
|
||||||
|
// Off-screen строка в Ozon WebView выглядит так: bounds=[0,Y][W,Y] (height=0)
|
||||||
|
// на нижнем краю viewport. Текст транзакции лежит во внутреннем
|
||||||
|
// android.widget.Button (внешний wrapper-View имеет пустой text).
|
||||||
|
// Ищем именно Button-ы с h=0 и ₽ в тексте — в этом случае и количество
|
||||||
|
// совпадает с числом скрытых строк.
|
||||||
|
const int bottomHalf = screenHeight / 2;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.Button") continue;
|
||||||
|
if (node.height() != 0) continue;
|
||||||
|
if (node.y1() < bottomHalf) continue;
|
||||||
|
const QString text = node.text.trimmed();
|
||||||
|
if (text.isEmpty() || !text.contains(rubleSign)) continue;
|
||||||
|
if (text.startsWith("Расходы") || text.startsWith("Доходы")) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionInfo ScreenXmlParser::parseTransactionButtonText(const QString &text) {
|
||||||
|
const QChar rubleSign(0x20BD); // ₽
|
||||||
|
const QChar unicodeMinus(0x2212); // −
|
||||||
|
const QChar nbsp(0x00A0); // non-breaking space
|
||||||
|
|
||||||
|
TransactionInfo info;
|
||||||
|
|
||||||
|
const int rubleIdx = text.indexOf(rubleSign);
|
||||||
|
if (rubleIdx < 0) return info;
|
||||||
|
|
||||||
|
int signIdx = -1;
|
||||||
|
bool isExpense = false;
|
||||||
|
for (int i = rubleIdx - 1; i >= 0; --i) {
|
||||||
|
const QChar ch = text.at(i);
|
||||||
|
if (ch == '+') { signIdx = i; isExpense = false; break; }
|
||||||
|
if (ch == '-' || ch == unicodeMinus) { signIdx = i; isExpense = true; break; }
|
||||||
|
}
|
||||||
|
if (signIdx < 0) return info;
|
||||||
|
|
||||||
|
info.description = text.left(signIdx).trimmed();
|
||||||
|
|
||||||
|
QString amountStr = text.mid(signIdx + 1, rubleIdx - signIdx - 1).trimmed();
|
||||||
|
amountStr.remove(QChar(' '));
|
||||||
|
amountStr.remove(nbsp);
|
||||||
|
amountStr.replace(',', '.');
|
||||||
|
info.amount = amountStr.toDouble();
|
||||||
|
if (isExpense) info.amount = -info.amount;
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionInfo ScreenXmlParser::parseTransactionDetail() {
|
||||||
|
TransactionInfo info;
|
||||||
|
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.TextView") continue;
|
||||||
|
const QString text = node.text.trimmed();
|
||||||
|
if (text.isEmpty()) continue;
|
||||||
|
|
||||||
|
// Дата+время: "15.01.2026, 15:55"
|
||||||
|
if (info.bankTime.isEmpty() && text.contains('.') && text.contains(':') && text.length() <= 25) {
|
||||||
|
info.bankTime = text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Телефон: "+7 (985) 553-28-96"
|
||||||
|
if (info.phone.isEmpty() && text.startsWith('+') && text.contains('(')) {
|
||||||
|
info.phone = text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имя отправителя — TextView прямо перед телефоном (по y-координате чуть выше)
|
||||||
|
if (!info.phone.isEmpty()) {
|
||||||
|
// Ищем ноду телефона и берём ближайший TextView выше
|
||||||
|
int phoneY = -1;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className == "android.widget.TextView" && node.text.trimmed() == info.phone) {
|
||||||
|
phoneY = node.y();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (phoneY > 0) {
|
||||||
|
int bestY = -1;
|
||||||
|
for (const Node &node : m_nodes) {
|
||||||
|
if (node.className != "android.widget.TextView") continue;
|
||||||
|
const QString text = node.text.trimmed();
|
||||||
|
if (text.isEmpty() || text == info.bankTime || text == info.phone) continue;
|
||||||
|
if (node.y() > 0 && node.y() < phoneY && node.y() > bestY) {
|
||||||
|
bestY = node.y();
|
||||||
|
info.name = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
void ScreenXmlParser::test() {
|
void ScreenXmlParser::test() {
|
||||||
// TransactionInfo info2 = parseExpandedInfo(m_xml);
|
// TransactionInfo info2 = parseExpandedInfo(m_xml);
|
||||||
// qDebug().noquote().nospace() << convertTransactionToString(info2);
|
// qDebug().noquote().nospace() << convertTransactionToString(info2);
|
||||||
@ -947,10 +1708,12 @@ void ScreenXmlParser::test() {
|
|||||||
// Номер карты с последними цифрами
|
// Номер карты с последними цифрами
|
||||||
QDomElement root = doc.documentElement();
|
QDomElement root = doc.documentElement();
|
||||||
parseAndSaveXml(doc.toString());
|
parseAndSaveXml(doc.toString());
|
||||||
// for (const AccountInfo &info: parseAccountsInfo()) {
|
// for (const MaterialInfo &info: parseAccountsInfo()) {
|
||||||
// qDebug().noquote().nospace() << convertAccountToString(info);
|
// qDebug().noquote().nospace() << convertAccountToString(info);
|
||||||
// }
|
// }
|
||||||
// TransactionInfo info = parseExpandedInfo(root);
|
// TransactionInfo info = parseExpandedInfo(root);
|
||||||
// qDebug().noquote().nospace() << convertTransactionToString(info);
|
// qDebug().noquote().nospace() << convertTransactionToString(info);
|
||||||
// ЕСПП
|
// ЕСПП
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
135
android/ozon/ScreenXmlParser.h
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QDomElement>
|
||||||
|
#include "android/xml/Node.h"
|
||||||
|
#include "db/MaterialInfo.h"
|
||||||
|
#include "db/TransactionInfo.h"
|
||||||
|
|
||||||
|
namespace Ozon {
|
||||||
|
|
||||||
|
class ScreenXmlParser final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ScreenXmlParser(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
~ScreenXmlParser() override;
|
||||||
|
|
||||||
|
Node tryToFindBannerOrDialog(int width, int height);
|
||||||
|
|
||||||
|
Node tryToFindSecurityBanner();
|
||||||
|
|
||||||
|
Node tryToFindSelectBottomSheet();
|
||||||
|
|
||||||
|
Node findTextNode(const QString &text);
|
||||||
|
|
||||||
|
Node findTextNode(const QString &text, int x1, int y1, int x2, int y2);
|
||||||
|
|
||||||
|
Node findFirstEditText();
|
||||||
|
|
||||||
|
Node findEditTextByHint(const QString &hintPrefix);
|
||||||
|
|
||||||
|
// Возвращает EditText внутри Ozon "movable-pan-iframe" — поле номера карты
|
||||||
|
// получателя на экране "Перевод по номеру карты". Нужно как fallback, когда
|
||||||
|
// WebView не отдаёт hint и на экране нет TextView-лейбла "Номер карты
|
||||||
|
// получателя" (лейбл — просто "КУДА").
|
||||||
|
Node findPanCardNumberInput();
|
||||||
|
|
||||||
|
Node findButtonNode(const QString &text, bool contains);
|
||||||
|
|
||||||
|
QList<Node> findAllButtons();
|
||||||
|
|
||||||
|
const QList<Node> &findAllNodes() const;
|
||||||
|
|
||||||
|
Node findNodeByContentDesc(const QString &contentDesc);
|
||||||
|
|
||||||
|
bool isHomeScreen();
|
||||||
|
|
||||||
|
Node tryToFindAdBanner();
|
||||||
|
|
||||||
|
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
|
||||||
|
|
||||||
|
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android
|
||||||
|
// (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден.
|
||||||
|
Node tryToFindSystemPermissionDenyButton();
|
||||||
|
|
||||||
|
// Returns a Button node whose text is exactly 4 digits (card last numbers)
|
||||||
|
Node findCardButtonOnHomeScreen();
|
||||||
|
|
||||||
|
// Finds a card button whose text ends with the given last 4 digits
|
||||||
|
Node findCardButtonByLastNumbers(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();
|
||||||
|
|
||||||
|
bool isProfileScreen();
|
||||||
|
|
||||||
|
Node findUserNameOnHomeScreen(int screenHeight);
|
||||||
|
|
||||||
|
QString parseProfileFullName();
|
||||||
|
|
||||||
|
QString parseProfilePhone();
|
||||||
|
|
||||||
|
QList<Node> tryToFindPinCode(const QString &pinCode, const QString &title);
|
||||||
|
|
||||||
|
QList<Node> getLast2DaysTransactions();
|
||||||
|
|
||||||
|
void parseAndSaveXml(const QString &xml);
|
||||||
|
|
||||||
|
TransactionInfo parseTransactionInfoHistory(int screenWidth, int screenHeight);
|
||||||
|
TransactionInfo parseTransactionInfo(int screenWidth, int screenHeight);
|
||||||
|
|
||||||
|
QList<TransactionInfo> parseTodayAndYesterdayReportHistory();
|
||||||
|
|
||||||
|
QList<TransactionInfo> parseTodayAndYesterdayHistory();
|
||||||
|
|
||||||
|
|
||||||
|
QList<QPair<MaterialInfo, Node> > parseAccountsInfo();
|
||||||
|
|
||||||
|
// Finds a TextView with full card number (format "XXXX XXXX XXXX XXXX")
|
||||||
|
QString findFullCardNumber();
|
||||||
|
|
||||||
|
// Кнопка "Основной счёт X ₽" на главном экране
|
||||||
|
Node findMainAccountButton();
|
||||||
|
|
||||||
|
// Кнопка/текст "Посмотреть реквизиты" на экране "Основной счёт"
|
||||||
|
Node findShowRequisitesButton();
|
||||||
|
|
||||||
|
// Экран "Реквизиты счёта" — заголовок text="Реквизиты счёта"
|
||||||
|
bool isAccountRequisitesScreen();
|
||||||
|
|
||||||
|
// Парсит "Номер счёта получателя <20 цифр>" из текстовой ноды
|
||||||
|
QString parseAccountNumberFromRequisites();
|
||||||
|
|
||||||
|
bool isTransactionListScreen();
|
||||||
|
|
||||||
|
// Возвращает кнопки транзакций на экране "Операции" (до maxCount штук)
|
||||||
|
QList<Node> findTransactionButtons(int maxCount);
|
||||||
|
|
||||||
|
// Ozon WebView рендерит tappable-Button только для видимых транзакций.
|
||||||
|
// Off-screen элементы остаются в accessibility-tree как android.view.View
|
||||||
|
// с "схлопнутыми" bounds (height == 0). Если такие View с ₽-знаком есть —
|
||||||
|
// значит в списке осталось что прокручивать. Используется для детекции
|
||||||
|
// того, что скролл не завершён.
|
||||||
|
bool hasCollapsedTransactionsBelow(int screenHeight);
|
||||||
|
|
||||||
|
// Парсит имя и сумму из текста кнопки: "NAME + 150 ₽ CATEGORY"
|
||||||
|
TransactionInfo parseTransactionButtonText(const QString &text);
|
||||||
|
|
||||||
|
// Парсит детали транзакции: дату, имя отправителя, телефон
|
||||||
|
TransactionInfo parseTransactionDetail();
|
||||||
|
|
||||||
|
void test();
|
||||||
|
|
||||||
|
[[nodiscard]] const QList<Node> &getNodes() const { return m_nodes; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QList<Node> m_nodes;
|
||||||
|
QDomElement m_xml;
|
||||||
|
|
||||||
|
QList<Node> findPinCodeNodes(const QString &pinCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Ozon
|
||||||
@ -1,303 +0,0 @@
|
|||||||
#include "CommonScript.h"
|
|
||||||
|
|
||||||
#include <QJsonArray>
|
|
||||||
#include <QJsonObject>
|
|
||||||
#include <QRandomGenerator>
|
|
||||||
#include <QThread>
|
|
||||||
|
|
||||||
#include "adb/AdbUtils.h"
|
|
||||||
#include "net/NetworkService.h"
|
|
||||||
|
|
||||||
CommonScript::CommonScript(QObject *parent)
|
|
||||||
: QObject(parent) {
|
|
||||||
}
|
|
||||||
|
|
||||||
CommonScript::~CommonScript() = default;
|
|
||||||
|
|
||||||
void CommonScript::start() {
|
|
||||||
doStart();
|
|
||||||
emit finished();
|
|
||||||
}
|
|
||||||
|
|
||||||
Node CommonScript::swipeToButton(
|
|
||||||
const QString &deviceId,
|
|
||||||
const QString &text, int &counter, const int width, const int height
|
|
||||||
) {
|
|
||||||
for (int i = 0; i < 10; ++i) {
|
|
||||||
if (Node button = xmlScreenParser.findButtonNode(text, true); button.x() > 0 && button.y() > 0) {
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
const int x = width / 2;
|
|
||||||
const int offset = height / 4;
|
|
||||||
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CommonScript::inputPinCode(const QString &deviceId, const QList<Node> &pincode) {
|
|
||||||
for (const Node &node: pincode) {
|
|
||||||
const bool completePincode = AdbUtils::makeTap(deviceId, node.x(), node.y());
|
|
||||||
if (!completePincode) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
QThread::msleep(QRandomGenerator::global()->bounded(100, 901));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CommonScript::runAppAndGoToHomeScreen(
|
|
||||||
const QString &deviceId,
|
|
||||||
const QString &packageName,
|
|
||||||
const QString &pinCode,
|
|
||||||
const int width,
|
|
||||||
const int height
|
|
||||||
) {
|
|
||||||
// Запустить устройство
|
|
||||||
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
|
|
||||||
qDebug() << "Process already running, pid: " << pid;
|
|
||||||
AdbUtils::getScreenDumpAsXml(deviceId, 1000);
|
|
||||||
AdbUtils::tryToKillApplication(deviceId, packageName);
|
|
||||||
}
|
|
||||||
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
|
|
||||||
AdbUtils::takeScreenshot(deviceId, "run_app_error");
|
|
||||||
qWarning() << "Process has not started";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Даем минимальное время на запуск
|
|
||||||
QThread::msleep(4500);
|
|
||||||
int counter = 0;
|
|
||||||
int restarted = 0;
|
|
||||||
int totalCounter = 0;
|
|
||||||
bool findAndInputPincode = false;
|
|
||||||
while (true) {
|
|
||||||
counter++;
|
|
||||||
totalCounter++;
|
|
||||||
|
|
||||||
// 3 раза уже перезапускали приложение, завершаем попытки
|
|
||||||
if (restarted > 2) {
|
|
||||||
AdbUtils::takeScreenshot(deviceId, "3_times_try_start");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если более 30 секунд мы пытаемся перейти на домашнюю, перезапускаем
|
|
||||||
if (counter > 30) {
|
|
||||||
AdbUtils::tryToKillApplication(deviceId, packageName);
|
|
||||||
QThread::msleep(4500);
|
|
||||||
++restarted;
|
|
||||||
counter = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
|
|
||||||
if (xmlScreenParser.isHomeScreen()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем на банеры после пин-кодом
|
|
||||||
Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
|
|
||||||
if (closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
|
|
||||||
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем на банеры перед пин-кодом
|
|
||||||
Node closeBannerNode = xmlScreenParser.tryToFindSecurityBanner();
|
|
||||||
if (!closeBannerNode.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, closeBannerNode.x(), closeBannerNode.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, что это экран ввода пин-кода
|
|
||||||
if (!findAndInputPincode) {
|
|
||||||
if (const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
|
||||||
pincode.size() == 4) {
|
|
||||||
inputPinCode(deviceId, pincode);
|
|
||||||
// После ввода пин-код нужно сделать задержку, пока подгрузка идет, чтобы снова не спарсить
|
|
||||||
findAndInputPincode = true;
|
|
||||||
QThread::msleep(3000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
QThread::msleep(1000);
|
|
||||||
|
|
||||||
// Если что-то пойдет не по плану...
|
|
||||||
if (totalCounter > 100) {
|
|
||||||
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CommonScript::tryToCloseAllBannersOnHomePage(const QString &deviceId, const int width, const int height) {
|
|
||||||
Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
|
|
||||||
if (closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
|
|
||||||
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO тут все другие банеры попробовать закрыть
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CommonScript::goToAllProducts(
|
|
||||||
const QString &deviceId,
|
|
||||||
const int width,
|
|
||||||
const int height
|
|
||||||
) {
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, m_counter));
|
|
||||||
if (tryToCloseAllBannersOnHomePage(deviceId, width, height)) {
|
|
||||||
QThread::msleep(500);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xmlScreenParser.isHomeScreen()) {
|
|
||||||
// идем на экран "Все продукты"
|
|
||||||
if (Node allCardTitle = xmlScreenParser.findTextNode("Счета и карты"); !allCardTitle.isEmpty()) {
|
|
||||||
// 1. Если нет кнопки видимой, свайпаем. Кликаем на "Все продукты"
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node allProductsBtn = xmlScreenParser.findButtonNode("Все продукты", false);
|
|
||||||
if (allProductsBtn.x() > 0 && allProductsBtn.y() > 0) {
|
|
||||||
AdbUtils::makeTap(deviceId, allProductsBtn.x(), allProductsBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
AdbUtils::makeSwipe(deviceId,
|
|
||||||
allCardTitle.x(), allCardTitle.y(),
|
|
||||||
allCardTitle.x(), allCardTitle.y() - 200);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node allProductsTitle = xmlScreenParser.findTextNode("Все продукты");
|
|
||||||
// Проверяем что вверху заголовок
|
|
||||||
if (!allProductsTitle.isEmpty() && allProductsTitle.y() < 300) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// FIXME пишем в БД, что не дошли до домашней страницы, и завершаемся
|
|
||||||
// сохраняем скрин приложения
|
|
||||||
// дделаем алерт
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// FIXME
|
|
||||||
qDebug() << "Not home screen...";
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CommonScript::findAndTapOnAccount(const QString &deviceId, const QString &accountNumbers) {
|
|
||||||
QList<QPair<AccountInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();
|
|
||||||
for (auto &[account, node]: accounts) {
|
|
||||||
// FIXME будем считать что числа последние везде уникальные
|
|
||||||
if (accountNumbers == account.lastNumbers) {
|
|
||||||
qDebug() << "findAndTapOnAccount: " << accountNumbers;
|
|
||||||
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void CommonScript::postAccountsData(const QList<QPair<AccountInfo, Node> > &accounts) {
|
|
||||||
if (accounts.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto *thread = new QThread;
|
|
||||||
auto *worker = new NetworkService;
|
|
||||||
|
|
||||||
QJsonArray list;
|
|
||||||
for (const QPair<AccountInfo, Node> &pair: accounts) {
|
|
||||||
QJsonObject obj;
|
|
||||||
AccountInfo account = pair.first;
|
|
||||||
obj["appName"] = account.appName;
|
|
||||||
obj["lastNumbers"] = account.lastNumbers;
|
|
||||||
obj["amount"] = account.amount;
|
|
||||||
obj["description"] = account.description;
|
|
||||||
|
|
||||||
list.append(obj);
|
|
||||||
}
|
|
||||||
// FIXME доставить десктоп ид из настроеек
|
|
||||||
worker->setDeviceData("DT88bokcwQ", accounts.first().first.deviceId);
|
|
||||||
worker->setPayload(QJsonDocument(list).toJson());
|
|
||||||
worker->moveToThread(thread);
|
|
||||||
connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
|
|
||||||
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
|
||||||
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
|
||||||
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
|
||||||
thread->start();
|
|
||||||
}
|
|
||||||
|
|
||||||
void CommonScript::postNewTransactionData(const AccountInfo &account, const TransactionInfo &transaction) {
|
|
||||||
auto *thread = new QThread;
|
|
||||||
auto *worker = new NetworkService;
|
|
||||||
|
|
||||||
QJsonObject obj;
|
|
||||||
obj["id"] = transaction.id;
|
|
||||||
obj["amount"] = transaction.amount;
|
|
||||||
obj["name"] = transaction.name;
|
|
||||||
obj["fee"] = transaction.fee;
|
|
||||||
obj["status"] = transactionStatusToString(transaction.status);
|
|
||||||
obj["type"] = transactionTypeToString(transaction.type);
|
|
||||||
obj["phone"] = transaction.phone;
|
|
||||||
obj["description"] = transaction.description;
|
|
||||||
obj["updatedDescription"] = transaction.updatedDescription;
|
|
||||||
obj["bankName"] = transaction.bankName;
|
|
||||||
obj["bankTime"] = transaction.bankTime;
|
|
||||||
obj["bankTransactionId"] = transaction.bankTrExternalId;
|
|
||||||
|
|
||||||
// FIXME доставить десктоп ид из настроеек
|
|
||||||
worker->setDeviceData("DT88bokcwQ", account.deviceId);
|
|
||||||
worker->addExtra("lastNumbers", account.lastNumbers);
|
|
||||||
worker->addExtra("transactionId", transaction.id);
|
|
||||||
worker->setPayload(QJsonDocument(obj).toJson());
|
|
||||||
worker->moveToThread(thread);
|
|
||||||
connect(thread, &QThread::started, worker, &NetworkService::insertTransactionData);
|
|
||||||
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
|
||||||
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
|
||||||
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
|
||||||
thread->start();
|
|
||||||
}
|
|
||||||
|
|
||||||
void CommonScript::updateTransactionData(
|
|
||||||
const AccountInfo &account,
|
|
||||||
const int transactionId,
|
|
||||||
const TransactionStatus status,
|
|
||||||
const QString &oldDesc,
|
|
||||||
const QString &newDesc
|
|
||||||
) {
|
|
||||||
auto *thread = new QThread;
|
|
||||||
auto *worker = new NetworkService;
|
|
||||||
|
|
||||||
QJsonObject obj;
|
|
||||||
obj["id"] = transactionId;
|
|
||||||
obj["status"] = transactionStatusToString(status);
|
|
||||||
obj["description"] = newDesc;
|
|
||||||
obj["updatedDescription"] = oldDesc;
|
|
||||||
|
|
||||||
// FIXME доставить десктоп ид из настроеек
|
|
||||||
worker->setDeviceData("DT88bokcwQ", account.deviceId);
|
|
||||||
worker->addExtra("lastNumbers", account.lastNumbers);
|
|
||||||
worker->setPayload(QJsonDocument(obj).toJson());
|
|
||||||
worker->moveToThread(thread);
|
|
||||||
connect(thread, &QThread::started, worker, &NetworkService::updateTransactionData);
|
|
||||||
connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
|
||||||
connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
|
||||||
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
|
||||||
thread->start();
|
|
||||||
}
|
|
||||||
|
|
||||||
void CommonScript::stop() {
|
|
||||||
m_running = false;
|
|
||||||
}
|
|
||||||
@ -1,382 +0,0 @@
|
|||||||
#include "GetLastDaysHistoryScript.h"
|
|
||||||
|
|
||||||
#include <QJsonObject>
|
|
||||||
#include <QRandomGenerator>
|
|
||||||
#include <QThread>
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
#include "DatabaseManager.h"
|
|
||||||
#include "adb/AdbUtils.h"
|
|
||||||
#include "dao/AccountDAO.h"
|
|
||||||
#include "dao/DeviceDAO.h"
|
|
||||||
#include "dao/TransactionDAO.h"
|
|
||||||
#include "db/DeviceInfo.h"
|
|
||||||
#include "net/NetworkService.h"
|
|
||||||
#include "time/DateUtils.h"
|
|
||||||
|
|
||||||
GetLastDaysHistoryScript::GetLastDaysHistoryScript(
|
|
||||||
AccountInfo account,
|
|
||||||
QObject *parent
|
|
||||||
) : CommonScript(parent),
|
|
||||||
m_account(std::move(account)) {
|
|
||||||
}
|
|
||||||
|
|
||||||
GetLastDaysHistoryScript::~GetLastDaysHistoryScript() = default;
|
|
||||||
|
|
||||||
QList<TransactionInfo> findSavedTransaction(
|
|
||||||
const QList<TransactionInfo> &transactions,
|
|
||||||
const TransactionInfo &parsed
|
|
||||||
) {
|
|
||||||
QList<TransactionInfo> txs;
|
|
||||||
const QDateTime dateTime = QDateTime::fromString(parsed.bankTime, "dd.MM.yyyy"); // Московское время
|
|
||||||
|
|
||||||
for (const TransactionInfo &info: transactions) {
|
|
||||||
QDateTime timestamp = info.timestamp;
|
|
||||||
QString shortDescription = parsed.shortDescription;
|
|
||||||
// если тот же день
|
|
||||||
if (dateTime.date() == timestamp.date() && parsed.amount == info.amount) {
|
|
||||||
// Перевод Надежда Юрьевна К** в Альфа-Банк через СБП, по номеру телефона +79641815...
|
|
||||||
if (shortDescription.contains("Перевод")) {
|
|
||||||
if (shortDescription.contains(info.name)) {
|
|
||||||
txs.append(info);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Перевод по карте
|
|
||||||
if (shortDescription == info.shortDescription) {
|
|
||||||
txs.append(info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return txs;
|
|
||||||
}
|
|
||||||
|
|
||||||
int countSimilar(const TransactionInfo &transaction, const QList<TransactionInfo> &transactions) {
|
|
||||||
int count = 0;
|
|
||||||
for (const TransactionInfo &info: transactions) {
|
|
||||||
if (info.amount == transaction.amount
|
|
||||||
&& info.shortDescription == transaction.shortDescription
|
|
||||||
&& info.bankTime == transaction.bankTime) {
|
|
||||||
++count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString formatPrice(const double value) {
|
|
||||||
QString sign = value < 0 ? "Минус" : "Плюс";
|
|
||||||
const auto intValue = static_cast<qint64>(std::abs(value)); // округление до целого
|
|
||||||
|
|
||||||
QString number = QString::number(intValue);
|
|
||||||
QString formatted;
|
|
||||||
|
|
||||||
int count = 0;
|
|
||||||
for (int i = number.length() - 1; i >= 0; --i) {
|
|
||||||
formatted.prepend(number[i]);
|
|
||||||
++count;
|
|
||||||
if (count % 3 == 0 && i != 0) {
|
|
||||||
formatted.prepend(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QString("%1 %2").arg(sign, formatted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// bool GetLastDaysHistoryScript::openAndSaveDitailInfo(TransactionInfo &transaction, int width, int height) {
|
|
||||||
// QString text = QString("%1 %2").arg(transaction.shortDescription, formatPrice(transaction.amount));
|
|
||||||
//
|
|
||||||
// Node trButton = swipeToButton(m_account.deviceId, text, m_counter, width, height);
|
|
||||||
// if (!trButton.isEmpty()) {
|
|
||||||
// AdbUtils::makeTap(m_account.deviceId, trButton.x(), trButton.y());
|
|
||||||
// QThread::msleep(500);
|
|
||||||
//
|
|
||||||
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_account.deviceId, ++m_counter));
|
|
||||||
//
|
|
||||||
// Node moreDetailBtn = xmlScreenParser.findButtonNode("Детали операции", false);
|
|
||||||
// if (!moreDetailBtn.isEmpty()) {
|
|
||||||
// AdbUtils::makeTap(m_account.deviceId, moreDetailBtn.x(), moreDetailBtn.y());
|
|
||||||
// QThread::msleep(200);
|
|
||||||
// xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_account.deviceId, ++m_counter));
|
|
||||||
// TransactionInfo info = xmlScreenParser.parseTransactionInfoHistory(width, height);
|
|
||||||
//
|
|
||||||
// if (transaction.id == -1) {
|
|
||||||
// info.accountId = m_account.id;
|
|
||||||
// info.shortDescription = transaction.shortDescription;
|
|
||||||
// info.amount = transaction.amount;
|
|
||||||
// info.type = info.phone.isEmpty() ? TransactionType::Card : TransactionType::Phone;
|
|
||||||
// info.updateTime = DateUtils::getUtcNow();
|
|
||||||
// if (info.status == TransactionStatus::Complete) {
|
|
||||||
// info.completeTime = DateUtils::getUtcNow();
|
|
||||||
// }
|
|
||||||
// if (TransactionDAO::insertTransaction(info) == -1) {
|
|
||||||
// qWarning() << "--- insertTransaction failed";
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// transaction.status = info.status;
|
|
||||||
// transaction.description = info.description;
|
|
||||||
// if (transaction.trExternalId.isEmpty()) {
|
|
||||||
// transaction.trExternalId = info.trExternalId;
|
|
||||||
// }
|
|
||||||
// transaction.updateTime = DateUtils::getUtcNow();
|
|
||||||
// if (transaction.status == TransactionStatus::Complete && !transaction.completeTime.isValid()) {
|
|
||||||
// transaction.completeTime = DateUtils::getUtcNow();
|
|
||||||
// }
|
|
||||||
// if (!TransactionDAO::updateTransaction(transaction)) {
|
|
||||||
// qWarning() << "--- updateTransaction failed";
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// AdbUtils::goBack(m_account.deviceId);
|
|
||||||
// QThread::msleep(200);
|
|
||||||
// } else {
|
|
||||||
// qWarning() << "--- trButton not found..........";
|
|
||||||
// }
|
|
||||||
// return false;
|
|
||||||
// }
|
|
||||||
|
|
||||||
bool GetLastDaysHistoryScript::saveAndPostNewTransactionData(
|
|
||||||
const AccountInfo &account,
|
|
||||||
const TransactionInfo &transaction
|
|
||||||
) {
|
|
||||||
TransactionInfo tr;
|
|
||||||
tr.accountId = account.id;
|
|
||||||
tr.description = transaction.description;
|
|
||||||
tr.bankTime = transaction.bankTime;
|
|
||||||
tr.bankName = transaction.bankName;
|
|
||||||
tr.amount = transaction.amount;
|
|
||||||
tr.phone = transaction.phone;
|
|
||||||
tr.updateTime = DateUtils::getUtcNow();
|
|
||||||
tr.fee = transaction.fee;
|
|
||||||
tr.name = transaction.name;
|
|
||||||
tr.bankTrExternalId = transaction.bankTrExternalId;
|
|
||||||
tr.status = TransactionStatus::Complete;
|
|
||||||
tr.type = transaction.description.startsWith("Перевод") ? TransactionType::Phone : TransactionType::Card;
|
|
||||||
tr.completeTime = DateUtils::getUtcNow();
|
|
||||||
tr.id = TransactionDAO::insertTransaction(tr);
|
|
||||||
|
|
||||||
if (tr.id == -1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
postNewTransactionData(account, tr);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool GetLastDaysHistoryScript::updateAndPostTransactionData(
|
|
||||||
const AccountInfo &account,
|
|
||||||
const int transactionId,
|
|
||||||
const TransactionStatus status,
|
|
||||||
const QString &oldDesc,
|
|
||||||
const QString &newDesc
|
|
||||||
) {
|
|
||||||
if (TransactionDAO::updateTransaction(transactionId, status, oldDesc, newDesc)) {
|
|
||||||
updateTransactionData(account, transactionId, status, oldDesc, newDesc);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void GetLastDaysHistoryScript::doStart() {
|
|
||||||
|
|
||||||
return;
|
|
||||||
|
|
||||||
// берем все транзакции из БД
|
|
||||||
QList<TransactionInfo> localTransactions = TransactionDAO::getTransactionsWithinHours(m_account.id, 48);
|
|
||||||
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_account.deviceId, ++m_counter));
|
|
||||||
if (xmlScreenParser.isHomeScreen()) {
|
|
||||||
if (goToAllProducts(device.id, device.screenWidth, device.screenHeight)) {
|
|
||||||
QList<QPair<AccountInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();
|
|
||||||
for (auto &[account, node]: accounts) {
|
|
||||||
account.deviceId = m_account.deviceId;
|
|
||||||
account.appName = m_account.appName;
|
|
||||||
if (!AccountDAO::upsertAccount(account)) {
|
|
||||||
qDebug() << "Not updated: " << account.lastNumbers;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
postAccountsData(accounts);
|
|
||||||
|
|
||||||
findAndTapOnAccount(device.id, m_account.lastNumbers);
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_account.deviceId, ++m_counter));
|
|
||||||
Node actionBtn = xmlScreenParser.findButtonNode("Действия", false);
|
|
||||||
if (!actionBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(m_account.deviceId, actionBtn.x(), actionBtn.y());
|
|
||||||
QThread::msleep(200);
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- actionBtn not found";
|
|
||||||
}
|
|
||||||
Node reportButton = swipeToButton(m_account.deviceId, "Выписка", m_counter, device.screenWidth,
|
|
||||||
device.screenHeight);
|
|
||||||
|
|
||||||
if (!reportButton.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(m_account.deviceId, reportButton.x(), reportButton.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_account.deviceId, ++m_counter));
|
|
||||||
|
|
||||||
Node reportTitle = xmlScreenParser.findTextNode("Выписка");
|
|
||||||
if (!reportTitle.isEmpty() && reportTitle.y() < 300) {
|
|
||||||
QDate beforeYesterday = DateUtils::getMoscowNow().date().addDays(-2);
|
|
||||||
QString beforeYesterdayTitle = beforeYesterday.toString("dd.MM.yyyy");
|
|
||||||
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node byDayTitle = xmlScreenParser.findTextNode(beforeYesterdayTitle);
|
|
||||||
if (!byDayTitle.isEmpty()) {
|
|
||||||
qWarning() << "--- beforeYesterdayTitle found!";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
AdbUtils::makeSwipe(
|
|
||||||
m_account.deviceId,
|
|
||||||
device.screenWidth / 2, device.screenHeight / 4 * 3,
|
|
||||||
device.screenWidth / 2, device.screenHeight / 4
|
|
||||||
);
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_account.deviceId, ++m_counter));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- reportTitle not found";
|
|
||||||
}
|
|
||||||
|
|
||||||
// все поддвержденные
|
|
||||||
QList<TransactionInfo> parsedTransactions = xmlScreenParser.parseTodayAndYesterdayReportHistory();
|
|
||||||
QSet<int> parsedTransactionIds;
|
|
||||||
for (const TransactionInfo &transaction: parsedTransactions) {
|
|
||||||
bool updated = false;
|
|
||||||
|
|
||||||
for (const TransactionInfo &saved: localTransactions) {
|
|
||||||
if (parsedTransactionIds.contains(saved.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
QDate sDate = QDateTime::fromString(saved.bankTime, "dd.MM.yyyy HH:mm:ss").date();
|
|
||||||
if (!sDate.isValid()) {
|
|
||||||
sDate = QDateTime::fromString(saved.bankTime, "dd.MM.yyyy").date();
|
|
||||||
}
|
|
||||||
QDate pDate = QDateTime::fromString(transaction.bankTime, "dd.MM.yyyy HH:mm:ss").date();
|
|
||||||
if (!pDate.isValid()) {
|
|
||||||
pDate = QDateTime::fromString(transaction.bankTime, "dd.MM.yyyy").date();
|
|
||||||
}
|
|
||||||
// Если сумма и дата совпадают
|
|
||||||
if (saved.amount == transaction.amount && sDate == pDate) {
|
|
||||||
if (transaction.description.startsWith("Перевод")) {
|
|
||||||
// Перевод по номеру
|
|
||||||
if (saved.bankName == transaction.bankName && saved.phone == transaction.phone) {
|
|
||||||
parsedTransactionIds.insert(saved.id);
|
|
||||||
updated = true;
|
|
||||||
if (saved.status != TransactionStatus::Complete || saved.description.isEmpty()) {
|
|
||||||
if (!TransactionDAO::updateTransaction(
|
|
||||||
saved.id,
|
|
||||||
TransactionStatus::Complete,
|
|
||||||
saved.description,
|
|
||||||
transaction.description
|
|
||||||
)) {
|
|
||||||
qDebug().noquote().nospace() << "--- updated+phone failed" <<
|
|
||||||
convertTransactionToString(transaction);;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// перевод по карте
|
|
||||||
// перезаписать saved.description -> updatedDescription если не равны
|
|
||||||
if (!saved.description.isEmpty()) {
|
|
||||||
if (saved.description == transaction.description) {
|
|
||||||
// описания совпадают
|
|
||||||
if (saved.status != TransactionStatus::Complete) {
|
|
||||||
if (!TransactionDAO::updateTransaction(
|
|
||||||
saved.id,
|
|
||||||
TransactionStatus::Complete,
|
|
||||||
saved.description,
|
|
||||||
transaction.description
|
|
||||||
)) {
|
|
||||||
qDebug().noquote().nospace() << "--- updated+card[description same] failed"
|
|
||||||
<<
|
|
||||||
convertTransactionToString(transaction);;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parsedTransactionIds.insert(saved.id);
|
|
||||||
updated = true;
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
// описания не совпадают, то пробуем найти такое же, если не находим то это новая
|
|
||||||
bool findWithSameName = false;
|
|
||||||
for (const TransactionInfo &saved2: localTransactions) {
|
|
||||||
if (saved2.description == transaction.description && !parsedTransactionIds.
|
|
||||||
contains(
|
|
||||||
saved2.id)) {
|
|
||||||
// мы нашли с одинаковым описанием и еще не обработанную
|
|
||||||
if (saved2.status != TransactionStatus::Complete) {
|
|
||||||
if (!TransactionDAO::updateTransaction(
|
|
||||||
saved2.id,
|
|
||||||
TransactionStatus::Complete,
|
|
||||||
saved2.description,
|
|
||||||
transaction.description
|
|
||||||
)) {
|
|
||||||
qDebug().noquote().nospace() <<
|
|
||||||
"--- updated+card2[description same] failed"
|
|
||||||
<<
|
|
||||||
convertTransactionToString(transaction);;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parsedTransactionIds.insert(saved2.id);
|
|
||||||
updated = true;
|
|
||||||
findWithSameName = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!findWithSameName) {
|
|
||||||
// обновляем описание
|
|
||||||
if (!TransactionDAO::updateTransaction(
|
|
||||||
saved.id,
|
|
||||||
TransactionStatus::Complete,
|
|
||||||
saved.description,
|
|
||||||
transaction.description
|
|
||||||
)) {
|
|
||||||
qDebug().noquote().nospace() <<
|
|
||||||
"--- updated+card2+new desc[description same] failed" <<
|
|
||||||
convertTransactionToString(transaction);;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parsedTransactionIds.insert(saved.id);
|
|
||||||
updated = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// пустой описание
|
|
||||||
if (!TransactionDAO::updateTransaction(
|
|
||||||
saved.id,
|
|
||||||
TransactionStatus::Complete,
|
|
||||||
saved.description,
|
|
||||||
transaction.description
|
|
||||||
)) {
|
|
||||||
qDebug().noquote().nospace() <<
|
|
||||||
"--- updated+card[empty description]" <<
|
|
||||||
convertTransactionToString(transaction);;
|
|
||||||
}
|
|
||||||
parsedTransactionIds.insert(saved.id);
|
|
||||||
updated = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!updated) {
|
|
||||||
qDebug() << "--- insert: " << transaction.description;
|
|
||||||
if (!saveAndPostNewTransactionData(m_account, transaction)) {
|
|
||||||
qDebug() << "--- saveAndPostNewTransactionData failed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если это перевод
|
|
||||||
// Если совпадает по сумме + телефон + банк дата (без времени) + банк
|
|
||||||
|
|
||||||
// Если по карте, мы знаем ток дату + сумма
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
qDebug() << "--- home screen not found";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include "CommonScript.h"
|
|
||||||
|
|
||||||
class GetLastDaysHistoryScript final : public CommonScript {
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
~GetLastDaysHistoryScript() override;
|
|
||||||
|
|
||||||
explicit GetLastDaysHistoryScript(
|
|
||||||
AccountInfo account,
|
|
||||||
QObject *parent = nullptr
|
|
||||||
);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void doStart() override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
const AccountInfo m_account;
|
|
||||||
|
|
||||||
bool saveAndPostNewTransactionData(const AccountInfo &account, const TransactionInfo &transaction);
|
|
||||||
|
|
||||||
bool updateAndPostTransactionData(
|
|
||||||
const AccountInfo &account,
|
|
||||||
int transactionId,
|
|
||||||
TransactionStatus status,
|
|
||||||
const QString &oldDesc,
|
|
||||||
const QString &newDesc
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,510 +0,0 @@
|
|||||||
#include "HomeScreenScript.h"
|
|
||||||
|
|
||||||
#include <QThread>
|
|
||||||
#include <QDebug>
|
|
||||||
#include <QProcess>
|
|
||||||
#include <QRandomGenerator>
|
|
||||||
|
|
||||||
#include "ScreenXmlParser.h"
|
|
||||||
#include "adb/AdbUtils.h"
|
|
||||||
|
|
||||||
|
|
||||||
HomeScreenScript::HomeScreenScript(const int transactionId, QObject *parent)
|
|
||||||
: QObject(parent), transactionId(transactionId) {
|
|
||||||
}
|
|
||||||
|
|
||||||
HomeScreenScript::~HomeScreenScript() = default;
|
|
||||||
|
|
||||||
bool HomeScreenScript::goToHomeScreen(
|
|
||||||
const QString &deviceId,
|
|
||||||
const QString &packageName,
|
|
||||||
const QString &pinCode,
|
|
||||||
const int width,
|
|
||||||
const int height
|
|
||||||
) {
|
|
||||||
// Запустить устройство
|
|
||||||
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
|
|
||||||
qDebug() << "Process already running, pid: " << pid;
|
|
||||||
AdbUtils::getScreenDumpAsXml(deviceId, 1000);
|
|
||||||
AdbUtils::tryToKillApplication(deviceId, packageName);
|
|
||||||
}
|
|
||||||
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
|
|
||||||
qWarning() << "Process has not started";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(
|
|
||||||
AdbUtils::getScreenDumpAsXml(deviceId, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Даем минимальное время на запуск
|
|
||||||
QThread::msleep(4500);
|
|
||||||
int counter = 0;
|
|
||||||
int restarted = 0;
|
|
||||||
int totalCounter = 0;
|
|
||||||
bool findAndInputPincode = false;
|
|
||||||
while (true) {
|
|
||||||
counter++;
|
|
||||||
totalCounter++;
|
|
||||||
|
|
||||||
// 3 раза уже перезапускали приложение, завершаем попытки
|
|
||||||
if (restarted > 2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если более 30 секунд мы пытаемся перейти на домашнюю, перезапускаем
|
|
||||||
if (counter > 30) {
|
|
||||||
AdbUtils::tryToKillApplication(deviceId, packageName);
|
|
||||||
QThread::msleep(4500);
|
|
||||||
++restarted;
|
|
||||||
counter = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, totalCounter + 1);
|
|
||||||
xmlScreenParser.parseAndSaveXml(xml);
|
|
||||||
qDebug() << "Created xml+screenshot, idx: " << (totalCounter + 1);
|
|
||||||
|
|
||||||
if (xmlScreenParser.isHomeScreen()) {
|
|
||||||
qDebug() << "isHomeScreen!!!!!!!";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем на банеры после пин-кодом
|
|
||||||
Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
|
|
||||||
if (closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
|
|
||||||
qDebug() << "--- tryToFindBannerOrDialog";
|
|
||||||
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем на банеры перед пин-кодом
|
|
||||||
Node closeBannerNode = xmlScreenParser.tryToFindSecurityBanner();
|
|
||||||
if (!closeBannerNode.isEmpty()) {
|
|
||||||
qDebug() << "--- tryToFindSecurityBanner";
|
|
||||||
AdbUtils::makeTap(deviceId, closeBannerNode.x(), closeBannerNode.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, что это экран ввода пин-кода
|
|
||||||
if (!findAndInputPincode) {
|
|
||||||
if (const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
|
||||||
pincode.size() == 4) {
|
|
||||||
qDebug() << "--- tryToFindPinCode";
|
|
||||||
// делаем 4 тапа и ждем секунду и продолжаем
|
|
||||||
for (const Node &node: pincode) {
|
|
||||||
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
|
||||||
QThread::msleep(QRandomGenerator::global()->bounded(200, 901));
|
|
||||||
}
|
|
||||||
// После ввода пин-код нужно сделать задержку, пока подгрузка идет, чтобы снова не спарсить
|
|
||||||
findAndInputPincode = true;
|
|
||||||
QThread::msleep(3000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
QThread::msleep(1000);
|
|
||||||
|
|
||||||
// Если что-то пойдет не по плану...
|
|
||||||
if (totalCounter > 100) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
QString getPriceAsString(const double value) {
|
|
||||||
QString sign = value < 0 ? "Минус" : "Плюс";
|
|
||||||
const auto intValue = static_cast<qint64>(std::abs(value)); // округление до целого
|
|
||||||
|
|
||||||
QString number = QString::number(intValue);
|
|
||||||
QString formatted;
|
|
||||||
|
|
||||||
int count = 0;
|
|
||||||
for (int i = number.length() - 1; i >= 0; --i) {
|
|
||||||
formatted.prepend(number[i]);
|
|
||||||
++count;
|
|
||||||
if (count % 3 == 0 && i != 0) {
|
|
||||||
formatted.prepend(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QString("%1 %2").arg(sign, formatted);
|
|
||||||
}
|
|
||||||
|
|
||||||
Node swipeToButton(
|
|
||||||
ScreenXmlParser &xmlScreenParser, const QString &deviceId,
|
|
||||||
const QString &text, int &counter, const int width, const int height
|
|
||||||
) {
|
|
||||||
for (int i = 0; i < 10; ++i) {
|
|
||||||
if (Node button = xmlScreenParser.findButtonNode(text, true); button.x() > 0 && button.y() > 0) {
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
const int x = width / 2;
|
|
||||||
const int offset = height / 4;
|
|
||||||
AdbUtils::makeSwipe(deviceId, x, height - offset, x, offset);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
void HomeScreenScript::start() {
|
|
||||||
// Достаем данные из БД по ИД транзакции, перед тем как запустить мы кладем в БД все данные
|
|
||||||
const QString deviceId = "6edc4a47";
|
|
||||||
const QString packageName = "ru.rshb.dbo";
|
|
||||||
const QString pinCode = "1102";
|
|
||||||
const int width = 720;
|
|
||||||
const int height = 1600;
|
|
||||||
|
|
||||||
const QString cardNumber = "7 1 8 3";
|
|
||||||
// В том числе и данные на каком устройстве это делать
|
|
||||||
// TODO тут код работы с БД
|
|
||||||
|
|
||||||
// +7 (964) 181-51-46
|
|
||||||
const QString phoneNumber = "+79641815146";
|
|
||||||
const QString bankName = "Альфа-Банк";
|
|
||||||
const QString paySum = "10";
|
|
||||||
|
|
||||||
int index = 0;
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++index));
|
|
||||||
|
|
||||||
// xmlScreenParser.test();
|
|
||||||
// тут просто получаем инфу которую спарсил
|
|
||||||
// == 1. Входящий (TransactionType::Phone)
|
|
||||||
// Перевод через СБП от Егор Иванович Ж (+7(000)-000-00-87) из Озон Банк (Ozon) на 40817810328000022077 .
|
|
||||||
// Идентификатор операции в СБП С2С B5121111443333300170011491301 , Дата операции 01.05.2025 14:14:41 Статус перевода Исполнен
|
|
||||||
// == 2. Исходящий (TransactionType::Phone)
|
|
||||||
// Перевод Надежда Иванкина К** в Сбербанк через СБП, по номеру телефона +79000810046, ID перевода 445900141, дата 03.05.2025
|
|
||||||
|
|
||||||
// (TransactionType::Card) -> T-BANK CARD2CARD
|
|
||||||
// TransactionInfo trInfo = xmlScreenParser.parseTransactionInfoHistory(width, height);
|
|
||||||
// qDebug().noquote().nospace() << convertTransactionToString(trInfo);
|
|
||||||
|
|
||||||
// Если начинается с ЕСПП - то не кликаем еще, ждет обработку
|
|
||||||
// А все переводы по карте - они не начинаются со слова "Перевод" ->
|
|
||||||
// .. пробуем кликать, но если не кликается ???
|
|
||||||
QList<TransactionInfo> transactions = xmlScreenParser.parseTodayAndYesterdayHistory();
|
|
||||||
for (const TransactionInfo &info: transactions) {
|
|
||||||
QString text = QString("%1 %2").arg(info.shortDescription, getPriceAsString(info.amount));
|
|
||||||
qDebug().noquote().nospace() << convertTransactionToString(info);
|
|
||||||
|
|
||||||
// Node trButton = xmlScreenParser.findButtonNode(text, true);
|
|
||||||
Node trButton = swipeToButton(xmlScreenParser, deviceId, text, index, width, height);
|
|
||||||
if (!trButton.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, trButton.x(), trButton.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++index));
|
|
||||||
|
|
||||||
Node moreDetailBtn = xmlScreenParser.findButtonNode("Детали операции", false);
|
|
||||||
if (!moreDetailBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, moreDetailBtn.x(), moreDetailBtn.y());
|
|
||||||
QThread::msleep(200);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++index));
|
|
||||||
TransactionInfo info = xmlScreenParser.parseTransactionInfoHistory(width, height);
|
|
||||||
qDebug().noquote().nospace() << convertTransactionToString(info);
|
|
||||||
}
|
|
||||||
|
|
||||||
AdbUtils::goBack(deviceId);
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- trButton not found..........";
|
|
||||||
}
|
|
||||||
// Перевод ВЛАДИСЛАВ НИКОЛАЕВИЧ К** в Т-Банк через СБП, по номеру телефона +7985553... Минус 10 рублей 0 копеек
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
|
|
||||||
int payCounter = 0;
|
|
||||||
|
|
||||||
if (false) {
|
|
||||||
// Оплата со страницы карты
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
Node payBtn = xmlScreenParser.findButtonNode("Оплатить", false);
|
|
||||||
if (!payBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, payBtn.x(), payBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- payBtn not found";
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
// TODO - По номеру карты В РСХБ и другие банки
|
|
||||||
Node payTitle = xmlScreenParser.findTextNode("Платежи и переводы");
|
|
||||||
if (!payTitle.isEmpty() && payTitle.y() < 300) {
|
|
||||||
Node payByPhoneBtn = xmlScreenParser.findButtonNode("По номеру телефона В РСХБ и через", false);
|
|
||||||
if (!payByPhoneBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, payByPhoneBtn.x(), payByPhoneBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- payByPhoneBtn not found";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- payTitle not found";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
Node payByPhoneTitle = xmlScreenParser.findTextNode("Перевод по телефону");
|
|
||||||
Node continueBtn = xmlScreenParser.findButtonNode("Продолжить", false);
|
|
||||||
if (!payByPhoneTitle.isEmpty() && payByPhoneTitle.y() < 300 && continueBtn.isEmpty()) {
|
|
||||||
Node inputPhone = xmlScreenParser.findFirstEditText();
|
|
||||||
AdbUtils::makeTap(deviceId, inputPhone.x(), inputPhone.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
AdbUtils::inputText(deviceId, phoneNumber);
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- payByPhoneTitle not found (no continueBtn)";
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
QString payBankName;
|
|
||||||
payBankName = bankName == "Россельхозбанк" ? "Россельхозбанк" : "Другой банк через СБП";
|
|
||||||
Node payOtherBankBtn = xmlScreenParser.findButtonNode(payBankName, false);
|
|
||||||
if (!payOtherBankBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, payOtherBankBtn.x(), payOtherBankBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- payOtherBankBtn not found";
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node allBanksTitle = xmlScreenParser.findTextNode("Банки участники");
|
|
||||||
if (!allBanksTitle.isEmpty() && allBanksTitle.y() < 300) {
|
|
||||||
Node inputBankName = xmlScreenParser.findFirstEditText();
|
|
||||||
AdbUtils::makeTap(deviceId, inputBankName.x(), inputBankName.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
AdbUtils::enableAdbIMEKeyboard(deviceId);
|
|
||||||
AdbUtils::inputAdbIMEText(deviceId, bankName);
|
|
||||||
AdbUtils::disableAdbIMEKeyboard(deviceId);
|
|
||||||
QThread::msleep(1000);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- allBanksTitle not found, i: " << i;
|
|
||||||
}
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node bankNameListItem = xmlScreenParser.findTextNode(bankName);
|
|
||||||
if (!bankNameListItem.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, bankNameListItem.x(), bankNameListItem.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- bankNameListItem not found, i: " << i;
|
|
||||||
}
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
|
|
||||||
Node payByPhoneScreenTitle = xmlScreenParser.findTextNode("Перевод по телефону");
|
|
||||||
Node continuePayBtn = xmlScreenParser.findButtonNode("Продолжить", false);
|
|
||||||
if (!payByPhoneScreenTitle.isEmpty() && payByPhoneScreenTitle.y() < 300 && !continuePayBtn.isEmpty()) {
|
|
||||||
Node sumBtn = xmlScreenParser.findButtonNode("0", false);
|
|
||||||
if (!sumBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, sumBtn.x(), sumBtn.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
AdbUtils::inputText(deviceId, paySum);
|
|
||||||
QThread::msleep(500);
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
Node readyBtn = xmlScreenParser.findTextNode("Готово");
|
|
||||||
if (!readyBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, readyBtn.x(), readyBtn.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
AdbUtils::makeTap(deviceId, continuePayBtn.x(), continuePayBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- readyBtn not found";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- sumBtn not found";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- payByPhoneScreenTitle not found";
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
// TODO кнопка содержит "Перевести 10 ₽" если будет от 1000 то скорее всвего будет "Перевести 1 000 ₽",
|
|
||||||
// TODO не ясно как форматирование будет, поэтому пока на начало зашьем
|
|
||||||
Node makePayBtn = xmlScreenParser.findButtonNode("Перевести ", true);
|
|
||||||
if (!makePayBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, makePayBtn.x(), makePayBtn.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
if (const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите код доступа");
|
|
||||||
pincode.size() == 4) {
|
|
||||||
// FIXME вынести в отдельный метод
|
|
||||||
// делаем 4 тапа и ждем секунду и продолжаем
|
|
||||||
for (const Node &node: pincode) {
|
|
||||||
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
|
||||||
QThread::msleep(QRandomGenerator::global()->bounded(200, 901));
|
|
||||||
}
|
|
||||||
|
|
||||||
// После ввода пин-кода, ждем 3с
|
|
||||||
QThread::msleep(3000);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- makePayBtn not found, i: " << i;
|
|
||||||
}
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node moreDetailBtn = xmlScreenParser.findButtonNode("Детали операции", false);
|
|
||||||
if (!moreDetailBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, moreDetailBtn.x(), moreDetailBtn.y());
|
|
||||||
QThread::msleep(200);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
|
|
||||||
Node paymentCompleted = xmlScreenParser.findTextNode("Исполнен", 0, 0, width, height / 2);
|
|
||||||
Node paymentInProgress = xmlScreenParser.findTextNode("Выполняется", 0, 0, width, height / 2);
|
|
||||||
|
|
||||||
if (!paymentCompleted.isEmpty()) {
|
|
||||||
// Если вдруг исполнен
|
|
||||||
} else if (!paymentInProgress.isEmpty()) {
|
|
||||||
// Если все еще в ожидании
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- paymentCompleted or paymentInProgress not found...";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- moreDetailBtn not found, i: " << i;
|
|
||||||
}
|
|
||||||
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++payCounter));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return;
|
|
||||||
|
|
||||||
int counter = 0;
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, counter));
|
|
||||||
Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
|
|
||||||
if (closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
|
|
||||||
qDebug() << "--- tryToFindBannerOrDialog";
|
|
||||||
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
}
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
|
||||||
if (xmlScreenParser.isHomeScreen()) {
|
|
||||||
qDebug() << "--- isHomeScreen";
|
|
||||||
if (Node allCardTitle = xmlScreenParser.findTextNode("Счета и карты"); !allCardTitle.isEmpty()) {
|
|
||||||
// 1. Если нет кнопки видимой, свайпаем. Кликаем на "Все продукты"
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node allProductsBtn = xmlScreenParser.findButtonNode("Все продукты", false);
|
|
||||||
// Node allProductsBtn = xmlScreenParser.findTextNode("Курсы валют и металлов"); для теста
|
|
||||||
if (allProductsBtn.x() > 0 && allProductsBtn.y() > 0) {
|
|
||||||
qDebug() << "--- All Product in focus";
|
|
||||||
AdbUtils::makeTap(deviceId, allProductsBtn.x(), allProductsBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
AdbUtils::makeSwipe(deviceId,
|
|
||||||
allCardTitle.x(), allCardTitle.y(),
|
|
||||||
allCardTitle.x(), allCardTitle.y() - 200);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node allProductsTitle = xmlScreenParser.findTextNode("Все продукты");
|
|
||||||
// Проверяем что вверху заголовок
|
|
||||||
if (!allProductsTitle.isEmpty() && allProductsTitle.y() < 300) {
|
|
||||||
qDebug() << "--- All Product screen";
|
|
||||||
|
|
||||||
// TODO тут делаем синхронизацию данных все карт
|
|
||||||
|
|
||||||
Node cardNumberBtn = xmlScreenParser.findButtonNode(cardNumber, true);
|
|
||||||
AdbUtils::makeTap(deviceId, cardNumberBtn.x(), cardNumberBtn.y());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
|
||||||
Node filterBtn = xmlScreenParser.findButtonNode("РСХБ Онлайн", false);
|
|
||||||
if (!filterBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, filterBtn.x(), filterBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- filterBtn not found";
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
|
||||||
Node allTransactionFilerBtn = xmlScreenParser.findButtonNode("Все операции", false);
|
|
||||||
if (!allTransactionFilerBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, allTransactionFilerBtn.x(), allTransactionFilerBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- allTransactionFilerBtn not found";
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
QList<Node> transactions = xmlScreenParser.getLast2DaysTransactions();
|
|
||||||
if (!transactions.empty()) {
|
|
||||||
for (const Node &transaction: transactions) {
|
|
||||||
qDebug() << transaction.text;
|
|
||||||
// TODO вот мы дошли до списка транзакций
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++counter));
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
} else {
|
|
||||||
// пишем в БД, что не дошли до домашней страницы, и завершаемся
|
|
||||||
// сохраняем скрин приложения
|
|
||||||
// дделаем алерт
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// пишем в БД, что не дошли до домашней страницы, и завершаемся
|
|
||||||
// сохраняем скрин приложения
|
|
||||||
// дделаем алерт
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
|
|
||||||
// 1. Идем на домашнюю страницу
|
|
||||||
if (goToHomeScreen(deviceId, packageName, pinCode, width, height)) {
|
|
||||||
// мы на главном
|
|
||||||
// перед тем как юзать снова проверить на банеры
|
|
||||||
// Проверяем на банеры после пин-кодом
|
|
||||||
|
|
||||||
|
|
||||||
// Это уже для шага дальше
|
|
||||||
// если нулевые координаты, свайпаем вверх, чтобы текст "Счета и карты" был у верхнего края
|
|
||||||
} else {
|
|
||||||
// пишем в БД, что не дошли до домашней страницы, и завершаемся
|
|
||||||
// сохраняем скрин приложения
|
|
||||||
// дделаем алерт
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Идем дальше
|
|
||||||
|
|
||||||
|
|
||||||
emit finished();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void HomeScreenScript::stop() {
|
|
||||||
m_running = false;
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QObject>
|
|
||||||
#include <QPair>
|
|
||||||
|
|
||||||
#include "ScreenXmlParser.h"
|
|
||||||
|
|
||||||
class HomeScreenScript final : public QObject {
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
explicit HomeScreenScript(int transactionId, QObject *parent = nullptr);
|
|
||||||
|
|
||||||
~HomeScreenScript() override;
|
|
||||||
|
|
||||||
public slots:
|
|
||||||
void start();
|
|
||||||
|
|
||||||
void stop();
|
|
||||||
|
|
||||||
signals:
|
|
||||||
void finished();
|
|
||||||
|
|
||||||
private:
|
|
||||||
bool m_running = true;
|
|
||||||
int transactionId;
|
|
||||||
ScreenXmlParser xmlScreenParser;
|
|
||||||
|
|
||||||
bool goToHomeScreen(
|
|
||||||
const QString &deviceId,
|
|
||||||
const QString &packageName,
|
|
||||||
const QString &pinCode,
|
|
||||||
int width,
|
|
||||||
int height
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,313 +0,0 @@
|
|||||||
#include "PayByPhoneScript.h"
|
|
||||||
|
|
||||||
#include <QRandomGenerator>
|
|
||||||
#include <QThread>
|
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
#include "DatabaseManager.h"
|
|
||||||
#include "adb/AdbUtils.h"
|
|
||||||
#include "dao/AccountDAO.h"
|
|
||||||
#include "dao/ApplicationDAO.h"
|
|
||||||
#include "dao/DeviceDAO.h"
|
|
||||||
#include "dao/TransactionDAO.h"
|
|
||||||
#include "db/ApplicationInfo.h"
|
|
||||||
#include "db/DeviceInfo.h"
|
|
||||||
|
|
||||||
PayByPhoneScript::PayByPhoneScript(
|
|
||||||
AccountInfo account,
|
|
||||||
QString phone,
|
|
||||||
QString bankName,
|
|
||||||
const double amount,
|
|
||||||
QObject *parent
|
|
||||||
) : CommonScript(parent),
|
|
||||||
m_account(std::move(account)), m_phone(std::move(phone)),
|
|
||||||
m_bankName(std::move(bankName)), m_amount(amount) {
|
|
||||||
}
|
|
||||||
|
|
||||||
PayByPhoneScript::~PayByPhoneScript() = default;
|
|
||||||
|
|
||||||
|
|
||||||
void PayByPhoneScript::makePayment(
|
|
||||||
const QString &deviceId,
|
|
||||||
const QString &pinCode,
|
|
||||||
int width,
|
|
||||||
int height
|
|
||||||
) {
|
|
||||||
// Оплата со страницы карты
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
Node payBtn = xmlScreenParser.findButtonNode("Оплатить", false);
|
|
||||||
if (!payBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, payBtn.x(), payBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
m_error = "Кнопка 'Оплатить' не нашлась";
|
|
||||||
qDebug() << m_error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
// TODO - По номеру карты В РСХБ и другие банки
|
|
||||||
Node payTitle = xmlScreenParser.findTextNode("Платежи и переводы");
|
|
||||||
if (!payTitle.isEmpty() && payTitle.y() < 300) {
|
|
||||||
Node payByPhoneBtn = xmlScreenParser.findButtonNode("По номеру телефона В РСХБ и через", false);
|
|
||||||
if (!payByPhoneBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, payByPhoneBtn.x(), payByPhoneBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
m_error = "Не смогли найти пункт 'По номеру телефона В РСХБ и через'";
|
|
||||||
qDebug() << m_error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m_error = "Не смогли перейти на экран 'Платежи и переводы'";
|
|
||||||
qDebug() << m_error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
Node payByPhoneTitle = xmlScreenParser.findTextNode("Перевод по телефону");
|
|
||||||
Node continueBtn = xmlScreenParser.findButtonNode("Продолжить", false);
|
|
||||||
if (!payByPhoneTitle.isEmpty() && payByPhoneTitle.y() < 300 && continueBtn.isEmpty()) {
|
|
||||||
Node inputPhone = xmlScreenParser.findFirstEditText();
|
|
||||||
AdbUtils::makeTap(deviceId, inputPhone.x(), inputPhone.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
AdbUtils::inputText(deviceId, m_phone);
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
m_error = "Не смогли найти пункт 'Перевод по телефону'";
|
|
||||||
qDebug() << m_error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
QString payBankName = m_bankName == QStringLiteral("Россельхозбанк") ? "Россельхозбанк" : "Другой банк через СБП";
|
|
||||||
Node payOtherBankBtn = xmlScreenParser.findButtonNode(payBankName, false);
|
|
||||||
if (!payOtherBankBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, payOtherBankBtn.x(), payOtherBankBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
m_error = "Не смогли нажать на первичный выбор банка";
|
|
||||||
qDebug() << m_error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_bankName != QStringLiteral("Россельхозбанк")) {
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node allBanksTitle = xmlScreenParser.findTextNode("Банки участники");
|
|
||||||
if (!allBanksTitle.isEmpty() && allBanksTitle.y() < 300) {
|
|
||||||
Node inputBankName = xmlScreenParser.findFirstEditText();
|
|
||||||
AdbUtils::makeTap(deviceId, inputBankName.x(), inputBankName.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
AdbUtils::enableAdbIMEKeyboard(deviceId);
|
|
||||||
AdbUtils::inputAdbIMEText(deviceId, m_bankName);
|
|
||||||
AdbUtils::disableAdbIMEKeyboard(deviceId);
|
|
||||||
QThread::msleep(1000);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
qDebug() << "--- allBanksTitle not found, i: " << i;
|
|
||||||
}
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
Node bankNameListItem = xmlScreenParser.findTextNode(m_bankName);
|
|
||||||
if (!bankNameListItem.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, bankNameListItem.x(), bankNameListItem.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
qDebug() << "--- bankNameListItem not found, i: " << i;
|
|
||||||
}
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
|
|
||||||
const bool isRshbBank = m_bankName == QStringLiteral("Россельхозбанк");
|
|
||||||
QString payByPhoneScreenTitleText = isRshbBank ? "Перевод клиенту РСХБ" : "Перевод по телефону";
|
|
||||||
Node payByPhoneScreenTitle = xmlScreenParser.findTextNode(payByPhoneScreenTitleText);
|
|
||||||
Node continuePayBtn = xmlScreenParser.findButtonNode("Продолжить", false);
|
|
||||||
|
|
||||||
if (isRshbBank ||
|
|
||||||
!payByPhoneScreenTitle.isEmpty() && payByPhoneScreenTitle.y() < 300 && !continuePayBtn.isEmpty()) {
|
|
||||||
Node sumBtn = xmlScreenParser.findButtonNode("0", false);
|
|
||||||
if (isRshbBank || !sumBtn.isEmpty()) {
|
|
||||||
if (!isRshbBank) {
|
|
||||||
AdbUtils::makeTap(deviceId, sumBtn.x(), sumBtn.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
}
|
|
||||||
AdbUtils::inputText(deviceId, QString::number(m_amount));
|
|
||||||
QThread::msleep(500);
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
Node readyBtn = xmlScreenParser.findTextNode("Готово");
|
|
||||||
if (!readyBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, readyBtn.x(), readyBtn.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
if (isRshbBank) {
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
continuePayBtn = xmlScreenParser.findButtonNode("Продолжить", false);
|
|
||||||
}
|
|
||||||
AdbUtils::makeTap(deviceId, continuePayBtn.x(), continuePayBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
} else {
|
|
||||||
m_error = "Не смогли нажать 'Готово'";
|
|
||||||
qDebug() << m_error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m_error = "Не смогли нажать 'Продолжить'";
|
|
||||||
qDebug() << m_error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m_error = "Не смогли найти заголовок: " + payByPhoneScreenTitleText;
|
|
||||||
qDebug() << m_error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
bool inputedPinCode = false;
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
// TODO кнопка содержит "Перевести 10 ₽" если будет от 1000 то скорее всвего будет "Перевести 1 000 ₽",
|
|
||||||
// TODO не ясно как форматирование будет, поэтому пока на начало зашьем
|
|
||||||
Node makePayBtn = xmlScreenParser.findButtonNode("Перевести ", true);
|
|
||||||
if (!makePayBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, makePayBtn.x(), makePayBtn.y());
|
|
||||||
QThread::msleep(500);
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
if (const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите код доступа");
|
|
||||||
pincode.size() == 4) {
|
|
||||||
inputPinCode(deviceId, pincode);
|
|
||||||
inputedPinCode = true;
|
|
||||||
// После ввода пин-кода, ждем 3с
|
|
||||||
QThread::msleep(3000);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- makePayBtn not found, i: " << i;
|
|
||||||
}
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRshbBank && !inputedPinCode) {
|
|
||||||
m_error = "Не смогли ввести пин-код!'";
|
|
||||||
qDebug() << m_error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
|
|
||||||
for (int i = 0; i < 10; ++i) {
|
|
||||||
Node moreDetailBtn = xmlScreenParser.findButtonNode("Детали операции", false);
|
|
||||||
if (!moreDetailBtn.isEmpty()) {
|
|
||||||
AdbUtils::makeTap(deviceId, moreDetailBtn.x(), moreDetailBtn.y());
|
|
||||||
QThread::msleep(200);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
|
|
||||||
// TODO считываем все данные по переводу
|
|
||||||
TransactionInfo transaction = xmlScreenParser.parseTransactionInfo(width, height);
|
|
||||||
if (isRshbBank) {
|
|
||||||
transaction.bankName = m_bankName;
|
|
||||||
}
|
|
||||||
transaction.accountId = m_account.id;
|
|
||||||
transaction.amount = -m_amount;
|
|
||||||
transaction.type = TransactionType::Phone;
|
|
||||||
transaction.id = TransactionDAO::insertTransaction(transaction);
|
|
||||||
if (transaction.id == -1) {
|
|
||||||
// FIXME alert!!
|
|
||||||
qDebug() << "Transaction has not inserted...";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ждем 10 секунд смену статуса
|
|
||||||
for (int k = 0; k < 10; ++k) {
|
|
||||||
Node paymentCompleted = xmlScreenParser.findTextNode("Исполнен", 0, 0, width, height / 2);
|
|
||||||
if (!paymentCompleted.isEmpty()) {
|
|
||||||
transaction.status = TransactionStatus::Complete;
|
|
||||||
if (!TransactionDAO::updateTransactionStatus(transaction.id, TransactionStatus::Complete)) {
|
|
||||||
qDebug() << "Not updated: " << transaction.id;
|
|
||||||
}
|
|
||||||
// TODO обновляем данные
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
}
|
|
||||||
|
|
||||||
postNewTransactionData(m_account, transaction);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
qWarning() << "--- moreDetailBtn not found, i: " << i;
|
|
||||||
}
|
|
||||||
QThread::msleep(1000);
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
|
||||||
}
|
|
||||||
|
|
||||||
Node homeBtn = xmlScreenParser.findButtonNode("Вернуться на главную", false);
|
|
||||||
AdbUtils::makeTap(deviceId, homeBtn.x(), homeBtn.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
void PayByPhoneScript::doStart() {
|
|
||||||
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
|
|
||||||
const ApplicationInfo app = ApplicationDAO::getApplicationsByDeviceId(m_account.deviceId, m_account.appName);
|
|
||||||
|
|
||||||
if (device.id.isEmpty() || app.id.isEmpty()) {
|
|
||||||
m_error = "Device or app not found....";
|
|
||||||
qDebug() << m_error;
|
|
||||||
emit finishedWithResult(m_error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_account.deviceId, ++m_counter));
|
|
||||||
if (!xmlScreenParser.isHomeScreen()) {
|
|
||||||
if (!runAppAndGoToHomeScreen(device.id, app.package, app.pinCode, device.screenWidth, device.screenHeight)) {
|
|
||||||
m_error = "Не смогли дойти до домашней страницы....";
|
|
||||||
qDebug() << m_error;
|
|
||||||
emit finishedWithResult(m_error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (goToAllProducts(device.id, device.screenWidth, device.screenHeight)) {
|
|
||||||
qDebug() << "======== 1. All products";
|
|
||||||
QList<QPair<AccountInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();
|
|
||||||
for (auto &[account, node]: accounts) {
|
|
||||||
account.deviceId = m_account.deviceId;
|
|
||||||
account.appName = m_account.appName;
|
|
||||||
if (!AccountDAO::upsertAccount(account)) {
|
|
||||||
qDebug() << "Not updated: " << account.lastNumbers;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
postAccountsData(accounts);
|
|
||||||
|
|
||||||
for (auto &[account, node]: accounts) {
|
|
||||||
// FIXME будем считать что числа последние везде уникальные
|
|
||||||
if (m_account.lastNumbers == account.lastNumbers) {
|
|
||||||
qDebug() << "======== 2. Tap by card";
|
|
||||||
AdbUtils::makeTap(device.id, node.x(), node.y());
|
|
||||||
QThread::msleep(1000);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
qDebug() << "======== 3. Make payment";
|
|
||||||
makePayment(device.id, app.pinCode, device.screenWidth, device.screenHeight);
|
|
||||||
} else {
|
|
||||||
m_error = "Не смогли открыть 'Все продукты'";
|
|
||||||
qDebug() << m_error;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit finishedWithResult(m_error);
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QObject>
|
|
||||||
#include <QDomElement>
|
|
||||||
#include "android/xml/Node.h"
|
|
||||||
#include "db/AccountInfo.h"
|
|
||||||
#include "db/TransactionInfo.h"
|
|
||||||
|
|
||||||
class ScreenXmlParser final : public QObject {
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
explicit ScreenXmlParser(QObject *parent = nullptr);
|
|
||||||
|
|
||||||
~ScreenXmlParser() override;
|
|
||||||
|
|
||||||
Node tryToFindBannerOrDialog(int width, int height);
|
|
||||||
|
|
||||||
Node tryToFindSecurityBanner();
|
|
||||||
|
|
||||||
Node findTextNode(const QString &text);
|
|
||||||
|
|
||||||
Node findTextNode(const QString &text, int x1, int y1, int x2, int y2);
|
|
||||||
|
|
||||||
Node findFirstEditText();
|
|
||||||
|
|
||||||
Node findButtonNode(const QString &text, bool contains);
|
|
||||||
|
|
||||||
bool isHomeScreen();
|
|
||||||
|
|
||||||
|
|
||||||
QList<Node> tryToFindPinCode(const QString &pinCode, const QString &title);
|
|
||||||
|
|
||||||
QList<Node> getLast2DaysTransactions();
|
|
||||||
|
|
||||||
void parseAndSaveXml(const QString &xml);
|
|
||||||
|
|
||||||
TransactionInfo parseTransactionInfoHistory(int screenWidth, int screenHeight);
|
|
||||||
TransactionInfo parseTransactionInfo(int screenWidth, int screenHeight);
|
|
||||||
|
|
||||||
QList<TransactionInfo> parseTodayAndYesterdayReportHistory();
|
|
||||||
|
|
||||||
QList<TransactionInfo> parseTodayAndYesterdayHistory();
|
|
||||||
|
|
||||||
|
|
||||||
QList<QPair<AccountInfo, Node> > parseAccountsInfo();
|
|
||||||
|
|
||||||
void test();
|
|
||||||
|
|
||||||
private:
|
|
||||||
QList<Node> m_nodes;
|
|
||||||
QDomElement m_xml;
|
|
||||||
|
|
||||||
QList<Node> findPinCodeNodes(const QString &pinCode);
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
#include "SyncAccountDataScript.h"
|
|
||||||
|
|
||||||
SyncAccountDataScript::SyncAccountDataScript(
|
|
||||||
QString deviceId,
|
|
||||||
QObject *parent
|
|
||||||
) : CommonScript(parent),
|
|
||||||
m_deviceId(std::move(deviceId)) {
|
|
||||||
}
|
|
||||||
|
|
||||||
SyncAccountDataScript::~SyncAccountDataScript() = default;
|
|
||||||
|
|
||||||
void SyncAccountDataScript::doStart() {
|
|
||||||
|
|
||||||
// 1.
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include "CommonScript.h"
|
|
||||||
|
|
||||||
class SyncAccountDataScript final : public CommonScript {
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
~SyncAccountDataScript() override;
|
|
||||||
|
|
||||||
explicit SyncAccountDataScript(
|
|
||||||
QString deviceId,
|
|
||||||
QObject *parent = nullptr
|
|
||||||
);
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void doStart() override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
const QString m_deviceId;
|
|
||||||
};
|
|
||||||
|
|
||||||
BIN
assets/black/accounts.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
123
assets/black/accounts.xml
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,136][1080,2337]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[26,1256][1054,2337]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[89,1309][991,2059]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node NAF="true" index="0" text="" resource-id=""
|
||||||
|
class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[928,1309][991,1372]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Datos de tu cuenta"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[89,1372][478,1437]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="CVU" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[89,1516][165,1571]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="0000177501057000175004"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[89,1579][492,1626]" drawing-order="0" hint=""/>
|
||||||
|
<node NAF="true" index="4" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[938,1545][991,1598]" drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Alias"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[89,1653][173,1708]" drawing-order="0" hint=""/>
|
||||||
|
<node index="6" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="27335723320.black"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[89,1716][378,1763]" drawing-order="0" hint=""/>
|
||||||
|
<node NAF="true" index="7" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[938,1682][991,1734]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="Compartir datos"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[47,2143][1033,2316]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Cerrar" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/home_screen.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
248
assets/black/home_screen.xml
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.ScrollView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="true" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="¡Hola! veronica cintia heredia vc"
|
||||||
|
checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,158][1080,347]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tu wallet"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[107,465][414,570]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Saldo disponible"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[107,596][370,643]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
<node NAF="true" index="3" text="" resource-id=""
|
||||||
|
class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[440,495][484,539]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tu CVU"
|
||||||
|
checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[783,484][974,597]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="$0,00"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[107,643][316,761]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
<node index="6" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Accesos rápidos"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[53,1116][378,1179]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="7" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="QR Personal" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[53,1218][315,1488]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="8" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Enviar a CBU o alias" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[409,1218][671,1536]" drawing-order="0" hint=""/>
|
||||||
|
<node index="9" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Solicitar con link" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[765,1218][1028,1536]" drawing-order="0" hint=""/>
|
||||||
|
<node index="10" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tu actividad"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[53,1667][297,1730]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="11" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Ver más"
|
||||||
|
checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[802,1667][1028,1730]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="12" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickaEble="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,1756][1080,2229]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc=""
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,1756][1080,2229]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia 21 de febrero de 2026 - 09:39 $-1.857, 00"
|
||||||
|
checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[0,1756][1080,1880]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
<node index="1" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia 21 de febrero de 2026 - 09:38 $-123, 45"
|
||||||
|
checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[0,1880][1080,2003]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
<node index="2" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia externa de NOELIA ISABEL LEZCANO 21 de febrero de 2026 - 09:35 $123, 45"
|
||||||
|
checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[0,2003][1080,2179]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node index="13" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tarjetas"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[53,2255][215,2318]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="14" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Ver más"
|
||||||
|
checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[802,2255][1028,2318]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="15" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="No hay tarjetas en tu billetera"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[302,2344][778,2391]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node NAF="true" index="1" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[456,2106][624,2274]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="Inicio" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[88,2229][162,2350]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="Enviar" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[251,2229][336,2350]" drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="Recibir" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[729,2229][824,2350]" drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="Pagar" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[913,2229][992,2350]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/pay/pay_confirm.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
122
assets/black/pay/pay_confirm.xml
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,283]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,283][1080,1937]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Vas a transferir a JOEL ARCHENTI" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[280,322][800,501]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="$" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[378,815][406,883]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="500," checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[406,766][635,931]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="00" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[635,807][702,891]" drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="BANCO DE GALICIA Y BUENOS AIRES S.A.U." checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[161,1063][945,1118]" drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Alias:" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[293,1170][390,1225]" drawing-order="0" hint=""/>
|
||||||
|
<node index="6" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc=" GRUTA.MADRID.ATRIO"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[390,1170][813,1225]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="7" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="CBU:" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[257,1278][344,1333]" drawing-order="0" hint=""/>
|
||||||
|
<node index="8" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc=" 0070359630004039066690"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[344,1278][849,1333]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="9" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="$10.000,00 disponible"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[388,1674][692,1716]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="10" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Transferir" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[63,1743][1017,1874]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/pay/pay_input_card_number.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
134
assets/black/pay/pay_input_card_number.xml
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,283]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,283][1080,1918]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[63,322][1017,832]" drawing-order="0" hint="">
|
||||||
|
<node NAF="true" index="0" text="" resource-id=""
|
||||||
|
class="android.widget.EditText" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[63,401][1017,569]" drawing-order="0"
|
||||||
|
hint="Ingresar CBU, CVU o Alias"/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Continuar"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[63,648][1017,779]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Cuentas recientes"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[63,976][388,1031]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node NAF="true" index="2" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[938,990][1017,1043]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[63,1057][1017,1750]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[63,1057][1017,1750]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="RM ROCIO MICAELA MONTES BANCO NO INFORMADO"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[63,1057][1017,1215]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="GA Gabriel Alejandro del Roio BANCO NO INFORMADO"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[63,1215][1017,1372]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="AL Alexander Leonardo Nahuel Soto BANCO NO INFORMADO"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[63,1372][1017,1530]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="MS MICAELA SOLEDAD VARGAS BANCO NO INFORMADO"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[63,1530][1017,1687]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/pay/pay_input_sum.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
100
assets/black/pay/pay_input_sum.xml
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,283]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" 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,283][1080,1447]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.ScrollView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="true" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,283][1080,1447]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="¿Cuánto vas a transferír a JOEL ARCHENTI?"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[191,322][889,501]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[63,661][1017,1447]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="0,00" resource-id=""
|
||||||
|
class="android.widget.EditText" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="true" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[63,819][1017,945]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="$10.000,00 disponible"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[292,1050][788,1118]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Continuar"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[63,1328][1017,1447]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/pay/pay_success.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
196
assets/black/pay/pay_success.xml
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2190]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2190]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2190]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,283]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Atrás"
|
||||||
|
checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,136][147,283]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,1924]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[66,315][255,504]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[240,0][1080,548]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Dinero enviado!"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[66,570][752,704]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Tu dinero fue transferido de manera exitosa. Recordá que puede tardar hasta 24 hs en impactar en la cuenta destino."
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[66,730][1014,911]" drawing-order="0"
|
||||||
|
hint=""/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="JOEL ARCHENTI"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[63,1087][534,1176]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="$500,00"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[784,1087][1017,1176]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="6" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Black Wallet"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[63,1202][305,1263]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="7" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="12/03/26 04:01"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[719,1202][1017,1263]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="8" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Id Coelsa"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[63,1368][240,1428]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="9" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="1-30717458822-1773298911069rk-1"
|
||||||
|
checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[306,1368][1017,1428]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="10" text="" resource-id=""
|
||||||
|
class="android.widget.Button" package="app.blackwallet.ar"
|
||||||
|
content-desc="Compartir " checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[116,1691][530,1835]" drawing-order="0" hint=""/>
|
||||||
|
<node index="11" text="" resource-id=""
|
||||||
|
class="android.widget.Button" package="app.blackwallet.ar"
|
||||||
|
content-desc="Descargar" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[551,1691][965,1835]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node NAF="true" index="1" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[456,2106][624,2274]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="Inicio" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[88,2229][162,2350]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="Enviar" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[251,2229][336,2350]" drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="Recibir" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[729,2229][824,2350]" drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="Pagar" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[913,2229][992,2350]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/pay/pay_success_document.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
72
assets/black/pay/pay_success_document.xml
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,283]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Cerrar" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/pay/pay_transfer_confirm.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
118
assets/black/pay/pay_transfer_confirm.xml
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,136][1080,2337]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,1301][1080,2337]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[63,1354][1017,1954]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node NAF="true" index="0" text="" resource-id=""
|
||||||
|
class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[954,1354][1017,1417]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="JOEL ARCHENTI"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[355,1417][725,1490]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Alias: "
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[275,1551][385,1608]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="GRUTA.MADRID.ATRIO"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[385,1551][805,1608]" drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="CBU: "
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[244,1650][343,1708]" drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="0070359630004039066690"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[343,1650][836,1708]" drawing-order="0" hint=""/>
|
||||||
|
<node index="6" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="BANCO DE GALICIA Y BUENOS AIRES S.A.U."
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[143,1750][937,1808]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Transferir"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[87,2090][993,2264]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Cerrar" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/pin_code.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
97
assets/black/pin_code.xml
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,283]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Registrate" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[42,173][315,246]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,283][1080,2018]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Ingresá tu código PIN Es un código de seguridad que te va a permitir operar con más seguridad Ingresá tu PIN"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,283][1080,1396]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node NAF="true" index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="true" password="false"
|
||||||
|
selected="false" bounds="[263,1449][818,1514]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.widget.EditText"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="true" scrollable="false" long-clickable="false" password="true"
|
||||||
|
selected="false" bounds="[263,1449][818,1556]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="¿Olvidaste tu PIN?"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[357,1687][723,1750]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Tengo un código de verificación" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[218,1803][862,1866]" drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Cerrar sesión"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[435,1918][645,1966]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/profile_page.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
184
assets/black/profile_page.xml
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,283]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tu información"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[189,173][581,246]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.widget.ScrollView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="true" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,283][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="vc" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[496,494][584,597]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="veronica cintia heredia "
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[205,716][875,800]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="12 de febrero 1989"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[348,826][732,882]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[66,951][118,1043]" drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tu CVU" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[171,950][287,997]" drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="0000177501057000175004"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[171,997][606,1044]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node NAF="true" index="6" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[962,934][1041,1060]" drawing-order="0" hint=""/>
|
||||||
|
<node index="7" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[66,1106][118,1198]" drawing-order="0" hint=""/>
|
||||||
|
<node index="8" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tu Alias" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[171,1105][294,1152]" drawing-order="0" hint=""/>
|
||||||
|
<node index="9" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="27335723320.black"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[171,1152][481,1199]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node NAF="true" index="10" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[883,1089][962,1215]" drawing-order="0" hint=""/>
|
||||||
|
<node NAF="true" index="11" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[962,1089][1041,1215]" drawing-order="0" hint=""/>
|
||||||
|
<node index="12" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Editá tu información"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[79,1359][449,1414]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="13" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[79,1467][1001,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="veronica cintia " resource-id=""
|
||||||
|
class="android.view.View" package="app.blackwallet.ar" content-desc=""
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[79,1467][1001,1635]" drawing-order="0" hint="Nombre"/>
|
||||||
|
<node index="1" text="heredia " resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[79,1714][1001,1882]"
|
||||||
|
drawing-order="0" hint="Apellido"/>
|
||||||
|
<node index="2" text="Fiorinogriselda4@gmail.com" resource-id=""
|
||||||
|
class="android.view.View" package="app.blackwallet.ar" content-desc=""
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[79,1960][1001,2128]" drawing-order="0" hint="E-mail"/>
|
||||||
|
<node index="3" text="12/2/1989" resource-id=""
|
||||||
|
class="android.widget.EditText" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[79,2207][1001,2375]" drawing-order="0"
|
||||||
|
hint="Fecha de nacimiento"/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[875,2228][1001,2354]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" 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]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/profile_page_2.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
142
assets/black/profile_page_2.xml
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,283]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tu información"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[189,173][581,246]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.widget.ScrollView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="true" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,283][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Editá tu información"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[79,283][449,305]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[79,358][1001,2295]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="veronica cintia " resource-id=""
|
||||||
|
class="android.view.View" package="app.blackwallet.ar" content-desc=""
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[79,358][1001,526]" drawing-order="0" hint="Nombre"/>
|
||||||
|
<node index="1" text="heredia " resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[79,605][1001,773]"
|
||||||
|
drawing-order="0" hint="Apellido"/>
|
||||||
|
<node index="2" text="Fiorinogriselda4@gmail.com" resource-id=""
|
||||||
|
class="android.view.View" package="app.blackwallet.ar" content-desc=""
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[79,851][1001,1019]" drawing-order="0" hint="E-mail"/>
|
||||||
|
<node index="3" text="12/2/1989" resource-id=""
|
||||||
|
class="android.widget.EditText" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[79,1098][1001,1266]" drawing-order="0"
|
||||||
|
hint="Fecha de nacimiento"/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[875,1119][1001,1245]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Género Femenino"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[79,1345][1001,1513]" drawing-order="0" hint=""/>
|
||||||
|
<node index="6" text="3543554877" resource-id=""
|
||||||
|
class="android.widget.EditText" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[79,1592][1001,1760]" drawing-order="0"
|
||||||
|
hint="Teléfono celular"/>
|
||||||
|
<node index="7" text="********" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[79,1838][1001,2006]"
|
||||||
|
drawing-order="0" hint="Contraseña"/>
|
||||||
|
<node index="8" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[875,1856][1001,1989]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="9" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Guardar cambios"
|
||||||
|
checkable="false" checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[79,2111][1001,2243]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/side_menu.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
169
assets/black/side_menu.xml
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="vc veronica cintia heredia " checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,1856]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node NAF="true" index="0" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[42,178][134,238]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Tu Información" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[42,685][1038,763]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Notificaciones" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[42,845][1038,923]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Ajustes y preferencias" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[42,1005][1038,1084]" drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Ayuda" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[42,1165][1038,1244]" drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Invitar a Black Wallet" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[42,1325][1038,1404]" drawing-order="0" hint=""/>
|
||||||
|
<node index="6" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Cerrar sesión" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[42,1669][1038,1748]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,1856][1080,1882]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,1882][1080,2223]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Inicio" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[88,2024][162,2145]"
|
||||||
|
drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Enviar" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[251,2024][336,2145]" drawing-order="0" hint=""/>
|
||||||
|
<node NAF="true" index="2" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[456,1898][624,2066]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Recibir" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[729,2024][824,2145]" drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id=""
|
||||||
|
class="android.widget.ImageView" package="app.blackwallet.ar"
|
||||||
|
content-desc="Pagar" checkable="false" checked="false"
|
||||||
|
clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false"
|
||||||
|
bounds="[913,2024][992,2145]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
93
assets/black/transactions_loading_screen.xml
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,283]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tu Actividad" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[189,173][510,246]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,1221]" drawing-order="0" hint="">
|
||||||
|
<node NAF="true" index="0" text="" resource-id=""
|
||||||
|
class="android.widget.EditText" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[53,394][1028,520]" drawing-order="0" hint="Buscar"/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,677][1080,1116]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,677][1080,1116]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Aún no tienes actividad Una vez que tengas actividad en tu cuenta la podrás ver reflejada acá."
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,677][1080,1116]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/black/transactions_screen.png
Normal file
|
After Width: | Height: | Size: 300 KiB |
163
assets/black/transactions_screen.xml
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||||
|
<hierarchy rotation="0">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false" clickable="false"
|
||||||
|
enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="app.blackwallet.ar"
|
||||||
|
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][1080,2400]" drawing-order="1" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="false" focused="false"
|
||||||
|
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,283]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.Button"
|
||||||
|
package="app.blackwallet.ar" content-desc="Atrás" checkable="false"
|
||||||
|
checked="false" clickable="true" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,136][147,283]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="Tu Actividad" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="true"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[189,173][510,246]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
<node index="1" text="" resource-id="" class="android.widget.ScrollView"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false" checked="false"
|
||||||
|
clickable="false" enabled="true" focusable="true" focused="false"
|
||||||
|
scrollable="true" long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,0][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node NAF="true" index="0" text="" resource-id=""
|
||||||
|
class="android.widget.EditText" package="app.blackwallet.ar"
|
||||||
|
content-desc="" checkable="false" checked="false" clickable="true"
|
||||||
|
enabled="true" focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[53,394][1028,520]" drawing-order="0" hint="Buscar"/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false" password="false"
|
||||||
|
selected="false" bounds="[0,625][1080,2400]" drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.view.View"
|
||||||
|
package="app.blackwallet.ar" content-desc="" checkable="false"
|
||||||
|
checked="false" clickable="false" enabled="true" focusable="false"
|
||||||
|
focused="false" scrollable="false" long-clickable="false"
|
||||||
|
password="false" selected="false" bounds="[0,625][1080,2400]"
|
||||||
|
drawing-order="0" hint="">
|
||||||
|
<node index="0" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia externa de JOEL ARCHENTI 10 de marzo de 2026 - 16:23 $10.000, 00"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,625][1080,814]" drawing-order="0" hint=""/>
|
||||||
|
<node index="1" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia 21 de febrero de 2026 - 09:39 $-1.857, 00"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,814][1080,950]" drawing-order="0" hint=""/>
|
||||||
|
<node index="2" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia 21 de febrero de 2026 - 09:38 $-123, 45"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,950][1080,1087]" drawing-order="0" hint=""/>
|
||||||
|
<node index="3" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia externa de NOELIA ISABEL LEZCANO 21 de febrero de 2026 - 09:35 $123, 45"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,1087][1080,1276]" drawing-order="0" hint=""/>
|
||||||
|
<node index="4" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia externa de NOELIA ISABEL LEZCANO 21 de febrero de 2026 - 09:34 $1.234, 00"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,1276][1080,1465]" drawing-order="0" hint=""/>
|
||||||
|
<node index="5" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia externa de NOELIA ISABEL LEZCANO 21 de febrero de 2026 - 09:34 $123, 00"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,1465][1080,1654]" drawing-order="0" hint=""/>
|
||||||
|
<node index="6" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia 15 de febrero de 2026 - 07:07 $-56.030, 00"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,1654][1080,1790]" drawing-order="0" hint=""/>
|
||||||
|
<node index="7" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia 14 de febrero de 2026 - 23:59 $-161.000, 00"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,1790][1080,1927]" drawing-order="0" hint=""/>
|
||||||
|
<node index="8" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia externa de Daniela Lorena Saucedo 14 de febrero de 2026 - 21:30 $10.000, 00"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,1927][1080,2116]" drawing-order="0" hint=""/>
|
||||||
|
<node index="9" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia externa de Edgardo Sebastian Bravo 14 de febrero de 2026 - 21:29 $5.030, 00"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,2116][1080,2305]" drawing-order="0" hint=""/>
|
||||||
|
<node index="10" text="" resource-id="" class="android.widget.ImageView"
|
||||||
|
package="app.blackwallet.ar"
|
||||||
|
content-desc="Transferencia externa de JOAQUIN GABRIEL,LEONARDO 14 de febrero de 2026 - 21:28 $16.000, 00"
|
||||||
|
checkable="false" checked="false" clickable="false" enabled="true"
|
||||||
|
focusable="true" focused="false" scrollable="false"
|
||||||
|
long-clickable="false" password="false" selected="false"
|
||||||
|
bounds="[0,2305][1080,2400]" drawing-order="0" hint=""/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
BIN
assets/dushanbe/3.2.33/card_detail.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
1
assets/dushanbe/3.2.33/card_detail.xml
Normal file
BIN
assets/dushanbe/3.2.33/card_transfer_confirm.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
1
assets/dushanbe/3.2.33/card_transfer_confirm.xml
Normal file
@ -0,0 +1 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру карты) Номер/Счет 9762000186263942 Способ оплаты: 9762 **** **** 8865 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>
|
||||||
BIN
assets/dushanbe/3.2.33/card_transfer_confirm_2.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
1
assets/dushanbe/3.2.33/card_transfer_confirm_2.xml
Normal file
@ -0,0 +1 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру карты) Номер/Счет 9762000104225205 Способ оплаты: 9762 **** **** 8865 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>
|
||||||