1
0
forked from BRT/arc

Compare commits

...

177 Commits

Author SHA1 Message Date
slava
3f9d3eebd0 Add transactionId support to EventHandler, EventDAO, and related components for improved event tracking and error handling. Update database queries, error reporting, and UI logic accordingly. 2026-05-26 19:34:16 +05:00
slava
9fb209378f Merge branch 'dev' into main: align main with dev content (dev takes precedence in all conflicts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

# Conflicts:
#	database/app_data.db
#	views/DeviceWidget.cpp
#	views/DeviceWidget.h
2026-05-25 15:56:12 +05:00
slava
cff6ec7216 Add README with build instructions, architecture overview, and links to project documentation (docs/, BUILD.md, API specs, mock server, tutorials).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:48:53 +05:00
slava
82ac22f635 Remove outdated AppLogDAO, LogWindow, and related functionalities. Introduce NetworkErrorUtils for detailed error categorization and enhanced logging. Refactor EventHandler to support balance adjustments and optimize thread cleanup. Add retry and timeout mechanisms to network services with improved error handling. 2026-05-23 18:37:49 +05:00
slava
36567e78a4 Integrate DeviceScreener with NetworkService for status synchronization, including debounce logic for online/offline transitions and periodic sync initiation. Refactor startDeviceScreener and startNetworkService to enable inter-component interaction. 2026-05-22 16:19:41 +05:00
slava
8abe5a915f Improve GetLastTransactionsScript to parse bottom-sheet transaction details for version 3.2.33. Add parseDushanbeAmount for enhanced number extraction. 2026-05-21 17:29:49 +05:00
slava
3670e34938 Restrict OCR search area to the lower half of the confirm sheet in PayByPhoneScript and PayByCardScript to reduce false positives. Adjust minimum word length for substring matching in OcrUtils to improve tap accuracy. 2026-05-21 16:12:20 +05:00
slava
9da2a6fc52 Refactor TaskDumpRecorder to adjust LogArchiveSender invocation for improved dump and log dispatch sequencing, and remove redundant usages in EventHandler. 2026-05-21 15:06:52 +05:00
slava
0045c37d64 Refactor buildCaption in TaskDumpRecorder to improve HTML-escaped formatting, include additional metadata, and integrate LogArchiveSender for automatic error archive dispatch. 2026-05-21 14:48:54 +05:00
slava
4ab46b8b49 Add LogArchiveSender for automated log and database archiving with support for background processing and dispatching through NetworkService. 2026-05-21 13:52:42 +05:00
slava
07871231a3 Add enhanced error handling, logging, and monitoring capabilities across systems. Include archive naming, retry logic for partial PIN entry, API response error dialogs, and support for monitoring tokens. 2026-05-21 13:52:29 +05:00
slava
56ba89522c Handle missing timezone in created_at field for transaction parsing by enforcing UTC interpretation. Improve date range detection and scrolling logic in GetLastTransactionsScript to handle sticky headers. Prevent unregistered devices from displaying bank profiles in DeviceWidget. 2026-05-20 18:22:17 +05:00
slava
14e46cac84 Add retry limits for "Подтверждаю" and "Оплатить" taps in PayByPhoneScript and PayByCardScript to prevent infinite loops and improve error handling with detailed logs. 2026-05-19 23:15:44 +05:00
slava
feb839daf2 Improve transaction amount extraction in GetLastTransactionsScript by using regex to support varying field positions across formats. Adjust logic for compatibility with 5- and 6-line DCWallet and P2P formats. 2026-05-19 19:36:47 +05:00
slava
6b3f275781 Handle immediate payment completion and error screen scenarios in PayByPhoneScript and PayByCardScript. Adjust flow to skip redundant steps on early success and improve error detection after "Далее". 2026-05-19 19:18:43 +05:00
slava
f65840f28b Fix card balance retrieval from incorrect placeholder in GetCardInfoScript. Add retry logic for "Оплатить" button tap in PayByPhoneScript and PayByCardScript to handle bottom sheet dismissal. 2026-05-19 18:13:43 +05:00
slava
e7cf9a311e Fix for windows 2026-05-19 17:23:34 +07:00
slava
6c5b5a91c2 Fix for windows 2026-05-19 17:15:57 +07:00
slava
1457064423 Add support for split formats (v3.2.33+) in payment details parsing, fallback logic for older formats, and XML label-value pair extraction. Update ScreenXmlParser for enhanced node parsing. 2026-05-19 11:48:58 +05:00
slava
542fabf880 Expand .gitignore to exclude cache, generated files, dumps, and test build directories. 2026-05-19 11:24:40 +05:00
slava
078aae0d2f Add documentation for BankSetupWizard, detailing lifecycle, UI structure, verification flow, error handling, and worker thread management. Include XML assets for Dushanbe City Bank. 2026-05-19 11:22:22 +05:00
slava
7bf49ba749 Add app_version tracking: database schema migration, ADB utility support, and UI enhancements. 2026-05-18 18:42:59 +05:00
slava
0adb57d87e Refactor createBankProfileIfNeeded to return error messages instead of void. Update call sites to handle and propagate errors appropriately, improving error handling across card info and profile scripts. 2026-05-15 16:54:02 +07:00
slava
01190bfa69 Adjust "Перевести" button tap position in PayByPhoneScript to the lower part of the button, avoiding the "за друга" badge. Update debug logs accordingly. 2026-05-14 22:00:59 +07:00
slava
f696e816e2 Adjust "Перевести" button tap position in PayByPhoneScript to the lower part of the button, avoiding the "за друга" badge. Update debug logs accordingly. 2026-05-14 21:16:16 +07:00
slava
373d103212 Avoid referral badge overlap when tapping "Перевести" in PayByPhoneScript by adjusting tap position to the left quarter of the button. Add explanatory debug logs. 2026-05-14 20:48:10 +07:00
slava
53becd0183 Refine "Other Bank" button handling in PayByPhoneScript by simplifying bounds checks, reducing scroll attempts, and improving debug logging. 2026-05-14 20:21:12 +07:00
slava
ae04874c48 Mark isEmpty method in Node class as [[nodiscard]] and const to enforce proper usage and ensure immutability. 2026-05-14 19:36:49 +07:00
slava
a8d41baab7 Improve "Other Bank" button handling in PayByPhoneScript by ensuring full visibility with enhanced bounds checks and increased scroll attempts. 2026-05-14 19:33:44 +07:00
slava
7e5f90a450 Improve package state probing and logging in AdbUtils and enhance "Other Bank" button handling in PayByPhoneScript. 2026-05-14 18:36:33 +07:00
slava
ff4a468b35 Synchronize material statuses by setting MaterialStatus::Off in card info scripts and adding state enforcement logic to postMaterial in NetworkService. 2026-05-13 21:29:07 +07:00
slava
98f1d0f9d6 Simplify sum field detection in PayByCardScript by removing resource-id-based strategies and relying on DOM order. 2026-05-13 17:08:14 +07:00
slava
72d582f0ab Add closeGooglePlayBannerIfVisible helper to handle update banners and permission dialogs. Integrate into card info scripts for automated dismissal during screen parsing. Extend ScreenXmlParser with methods to detect specific UI elements. 2026-05-13 16:01:26 +07:00
slava
abcc3cadb2 Add card number validation and fix card table display logic in BankSetupWizard. 2026-05-13 10:41:56 +07:00
slava
a7cefa7fcf Delete all synced bank profiles from server when removing a device. 2026-05-13 02:17:17 +07:00
slava
3751406c36 Add PAN field detection and account number UI updates.
- Extend `ScreenXmlParser` with `findPanCardNumberInput` for detecting empty PAN fields in Ozon WebView.
- Update `PayByCardScript` to handle missing hints for recipient card input.
- Temporarily disable advanced account number parsing in `GetCardInfoScript`.
- Add `patchMaterial` method to `NetworkService` for updating account details.
- Update card management UI with refined "Switch" button logic and label changes.
2026-05-13 02:03:20 +07:00
slava
52d261a060 Integrate TaskDumpRecorder into scripts for enhanced error tracking and proper handling of nested scopes. 2026-05-11 20:16:05 +07:00
slava
afe83e17c2 Filter bank profiles by profile country and sync material statuses with server:
- Add `m_pathProfileGetInfo` to retrieve profile country and currency during login.
- Update bank profile filtering logic in `BankProfileDAO`, `DeviceSettingsWindow`, and `DeviceWidget` to match the profile country from `SettingsDAO`.
- Synchronize material statuses with the server in `toggleMaterial`.
2026-05-11 15:52:54 +07:00
slava
377844e94b Add account number parsing and database integration:
- Extend `ScreenXmlParser` with methods for detecting and extracting account numbers from multiple screens.
- Update database schema and DAO to support `account_number` field.
- Modify card information scripts to collect, save, and utilize account numbers.
- Enhance `GetCardInfoScript` logic to fetch account numbers via screen parsing and interaction.
2026-05-08 20:29:00 +07:00
slava
9a7a718d52 Add new monitoring paths (/monitoring/log and /monitoring/dump) to config.ini 2026-05-08 15:54:58 +07:00
slava
daa0123f7f Refine sum field detection logic in findSumEditText to account for an additional EditText above the anchor. 2026-05-07 23:09:56 +07:00
slava
46aca63341 Update MainWindow to call sendLogsToBackend instead of sendLogToTelegram. 2026-05-07 22:56:11 +07:00
slava
1ab023b45e Rename sendLogToTelegram to sendLogsToBackend in FileLogWindow. 2026-05-07 22:54:14 +07:00
slava
9c2ec74f76 - Add findSumEditText to ScreenXmlParser for enhanced sum field detection.
- Update `PayByPhoneScript` and `PayByCardScript` to use `findSumEditText` as a fallback.
- Enhance `FileLogWindow` to export logs and database files to backend via `NetworkService`.
- Add `networkService` getter in `TaskDumpRecorder` for centralizing access to `NetworkService`.
2026-05-07 22:54:06 +07:00
slava
558e881c75 Update ScreenXmlParser to use startsWith for handling multi-line "Идентифицирован" content-desc 2026-05-06 23:32:16 +07:00
slava
c144305240 Handle declined transactions and enhance payment scripts:
- Add unique `bank_transaction_id` generation for declined transactions in `EventHandler`.
- Extend timeout and iteration logic to better handle intermediate statuses ("Выполняется") in `PayByPhoneScript` and `PayByCardScript`.
- Update
2026-04-30 23:41:06 +07:00
slava
d58100083f Switch Tesseract initialization to memory-based loading of rus.traineddata to fix issues with paths containing Cyrillic characters (Windows). 2026-04-30 19:52:07 +07:00
slava
f611639532 Add OCR utilities and backend integration for enhanced screen dump analysis:
- Introduce `OcrUtils` with fuzzy-matching logic for text recognition on screenshots.
- Implement dual-pass OCR to handle light-on-dark and dark-on-light text cases.
- Replace hardcoded "Подтверждаю" button tap logic with OCR-based detection and fallback geometry.
- Update `TaskDumpRecorder` to utilize `NetworkService` for uploading screen dumps, images, and metadata to the backend.
- Add support for `/monitoring/dump` endpoint in `NetworkService`.
- Enhance UI functionality with new error handling and progress feedback during dump creation and upload.
2026-04-30 19:39:21 +07:00
slava
11e8f00e42 Add OCR utilities and backend integration for enhanced screen dump analysis:
- Introduce `OcrUtils` with fuzzy-matching logic for text recognition on screenshots.
- Implement dual-pass OCR to handle light-on-dark and dark-on-light text cases.
- Replace hardcoded "Подтверждаю" button tap logic with OCR-based detection and fallback geometry.
- Update `TaskDumpRecorder` to utilize `NetworkService` for uploading screen dumps, images, and metadata to the backend.
- Add support for `/monitoring/dump` endpoint in `NetworkService`.
- Enhance UI functionality with new error handling and progress feedback during dump creation and upload.
2026-04-30 19:17:17 +07:00
slava
6de3751a60 Add logic to refresh balance on the home screen:
- Introduce `findRefreshButtonOnHomeScreen` in `ScreenXmlParser` for locating the refresh button.
- Update `GetProfileInfoScript` to tap refresh button and retry balance parsing when balance is zero.
- Extend mock server profiles with additional test data.
2026-04-29 22:02:53 +07:00
slava
f19137280e Handle declined transactions in EventHandler:
- Add logic to identify and process declined transactions for `CreateTransaction
2026-04-28 00:06:13 +07:00
slava
d37acd5347 Handle closing of ad bottom sheets in CommonScript:
- Add detection and dismissal logic for ad banners on the homepage.
- Introduce retry mechanism with back button fallback for persistent ads.
2026-04-27 21:45:53 +07:00
slava
f7a187ec88 Add getRawScreenDumpAsXml to capture 2026-04-27 20:41:16 +07:00
slava
052f9051e1 Improve "Показать" button scrolling in GetCardInfoScript to handle devices with short screen heights:
- Add logic to scroll WebView until the button is in the visible top-third of the screen.
- Account for bottom navigation obstruction and ensure reliable tapping.
2026-04-27 18:19:10 +07:00
slava
44e60fc09f Introduce TaskDumpRecorder for centralized task dump management:
- Add TaskDumpRecorder to save task-related XML dumps and upload failure info to Telegram.
- Integrate TaskDumpRecorder into `PayByPhoneScript` and `PayByCardScript` for Dushanbe and Ozon banks.
- Extend ADB utilities to record XML dumps with TaskDumpRecorder and refine keyboard dismissal logic.
- Enhance freeze detection with consecutive dump checks in CommonScript and payment scripts.
- Update mock server profiles and scenario configurations with new material IDs.
2026-04-26 19:22:40 +07:00
slava
cd1828e2b5 Optimize button verification in payment scripts:
- Refactor retry logic for visibility and tappability of key payment buttons.
- Enhance error messaging for obstructed or missing buttons.
2026-04-24 22:22:24 +07:00
slava
a92610f9e5 Improve "Перевести" button handling in PayByCardScript:
- Add keyboard dismissal and retry logic to ensure button visibility and tappability.
- Update error messages to differentiate between missing or obstructed button scenarios.
2026-04-24 22:10:06 +07:00
slava
af47dc5888 Improve "Продолжить" button handling in PayByPhoneScript:
- Add keyboard dismissal and retry logic to ensure button visibility and tappability.
2026-04-24 22:07:34 +07:00
slava
f5be380292 Improve ADB Keyboard activation reliability:
- Add retries for `ime enable` to handle delayed IME registration after installation.
- Introduce flag to distinguish newly installed keyboards for adaptive retry logic.
2026-04-24 21:56:20 +07:00
slava
cc5a4ed15b Add copy logic for ADBKeyboard assets in CMake build process 2026-04-24 21:41:16 +07:00
slava
ac8b5922ad Enable reliable activation of ADB Keyboard and improve fallback handling:
- Add comprehensive verification and logging for IME activation steps in `AdbUtils`.
- Update `PayByPhoneScript` to handle IME activation failures with error reporting.
- Improve edit text detection in `ScreenXmlParser` for devices with missing hint attributes.
2026-04-24 21:31:37 +07:00
slava
f200eae2bd Add closeGooglePlayBannerIfVisible to handle Google Play update banners across scripts:
- Integrate banner dismissal in retry loops for XML parsing in payment and profile scripts.
- Add detection logic for permission deny buttons and update banner handling in `ScreenXmlParser`.
- Ensure reliable operation during input field detection, transactions fetching, and confirmation screens.
2026-04-24 16:39:38 +07:00
slava
d480cc51b0 Enhance input field detection and retry logic across payment scripts:
- Add retry loops for input fields in `PayByPhoneScript` and `PayByCardScript` to improve reliability.
- Update window title in `MainWindow` with version info from `config.ini`.
2026-04-24 16:09:36 +07:00
slava
8c2be04787 Improve button loading and error handling in PayByCardScript:
- Add checks for the "По номеру карты" button on the payments screen.
- Refine error messages to distinguish missing button vs. incomplete screen load.
2026-04-23 21:13:31 +07:00
slava
0a460d10a0 Add exclusive material activation logic to MaterialDAO and improve card selection in BankSetupWizard. 2026-04-23 20:44:44 +07:00
slava
efc2da5cd0 Refine transaction amount parsing in mock server:
- Ensure amounts include the ₽ currency symbol for accurate parsing.
2026-04-22 18:03:52 +07:00
slava
d854da3e8f Standardize error reporting across services:
- Add `event_type` to error data.
- Replace dynamic `type` with "ERROR" for task results and error messages.
2026-04-20 17:21:22 +07:00
slava
a2c9118c82 Increase maxScrolls limit to 30 in GetLastTransactionsScript for better transaction fetching. 2026-04-19 09:00:10 +07:00
slava
248fae6061 Improve scroll recovery logic in GetLastTransactionsScript:
- Add upward scroll handling to recover header visibility when the target header scrolls out of view.
2026-04-18 20:08:45 +07:00
slava
bac36aa5d2 Refine date header visibility logic and scroll behavior in GetLastTransactionsScript:
- Add `computeDateYCount` to detect overlapping date headers and improve visibility checks.
- Adjust swipe logic for better handling of target and older dates during transaction fetching.
-
2026-04-18 19:46:06 +07:00
slava
779e3cd74c Fix for windows 2026-04-18 19:23:56 +07:00
slava
ad4d87abc3 Fix for windows 2026-04-18 18:53:10 +07:00
slava
fc0b3f9e6e Fix for windows 2026-04-18 18:53:00 +07:00
slava
d59bf51416 Add closeGooglePlayBannerIfVisible to handle Google Play update banners:
- Ensure banners are closed during transaction fetching in `GetLastTransactionsScript`.
- Update XML parsing workflow to accommodate banner dismissal logic.
2026-04-18 18:52:28 +07:00
slava
d0330a509a Refine transaction amount parsing and improve banner closing in Ozon scripts:
- Handle fractional amounts during transaction parsing in mock server.
- Add support for closing Google Play update banners in `Ozon` homepage handling logic.
2026-04-18 17:27:12 +07:00
slava
d9479cd3ea Refactor EventHandler and Dushanbe scripts to centralize screenshot handling:
- Introduce `connectScreenshotDushanbe` for consistent signal-slot connections in `EventHandler`.
- Emit screenshots via `CommonScript` across `PayByCard`, `PayByPhone`, and `GetLastTransactions`.
- Eliminate redundant screenshot signals in `GetLastTransactionsScript`.
2026-04-17 11:17:59 +07:00
slava
312e0b37dd Simplify adb broadcast command construction in AdbUtils for cleaner and more concise execution logic. 2026-04-16 17:29:20 +07:00
slava
899f4ff6d6 Add support for mock server state persistence, error tracking, and screenshot handling. Extend bootstrapping logic and monitoring endpoints for enhanced debugging and task management. 2026-04-14 18:46:34 +07:00
slava
628323184f Refine transaction fetching and parsing:
- Add date header parsing and swipe functionality for streamlined `Ozon` transactions list processing.
- Improve deduplication logic, date assignment, and scroll behavior in `Dushanbe` and `Ozon` scripts.
- Introduce screenshot handling for debugging transaction fetching workflows.
- Add cancellation logic by `materialId` in `EventDAO`.
- Update mock server for enhanced filename generation and PATCH support for bank profiles.
2026-04-13 22:41:15 +07:00
slava
a97bdea977 Add timeout mechanism in NetworkService to prevent hanging requests by aborting after 30 seconds. 2026-04-13 19:16:22 +07:00
slava
e5e8500534 Improve logging and control flow in NetworkService and EventHandler; fix tapped assignment in GetLastTransactionsScript. 2026-04-13 18:55:08 +07:00
slava
db88cf373e Add currency mapping logic to GetCardInfoScript in all platforms and update .gitignore for mock server scenarios 2026-04-13 18:37:01 +07:00
slava
16cde75220 Fix for windows 2026-04-13 18:34:23 +07:00
slava
967397b5d5 Add scenarios for "FETCH_TRANSACTIONS" testing and improve bank-specific bootstrapping logic in mock server. 2026-04-12 17:29:48 +07:00
slava
dfbe1cbf72 Fix for windows 2026-04-12 16:54:34 +07:00
slava
0ab71db506 Fix for windows 2026-04-12 15:52:19 +07:00
slava
c2fc0104c6 Fix for windows 2026-04-12 15:17:28 +07:00
slava
23c3f366b1 Add mock server for local ARC backend integration tests, update config.ini for local API usage. 2026-04-12 12:11:39 +07:00
slava
d235508c70 Emit parsed transaction data as a signal to streamline GetLastTransactionsScript integration with EventHandler. 2026-04-11 19:29:47 +07:00
slava
33c8420910 Refine transaction parsing and handling logic:
- Add fast path for avoiding unnecessary app restarts if already on the home or PIN screen.
- Improve PIN code and home screen detection with fallback mechanisms.
- Implement URL and error sanitization for network messages to hide sensitive data from logs.
- Add embedded transaction parsing from content description with adjusted tap positioning logic.
- Refactor transaction fetching to use direct signal-based parsed data instead of database queries.
2026-04-11 16:37:14 +07:00
slava
4407f999c7 Use device name and timestamp to generate unique filenames for XML dumps to avoid overwriting files during uploads. 2026-04-11 14:51:44 +07:00
slava
4f3e2e472f Add screen dump feature with screenshot and XML upload to Telegram, adjust UI layout to use QVBoxLayout for button alignment. 2026-04-11 14:48:26 +07:00
slava
d79dd93514 Refine transaction filtering logic by adjusting time-based pre-filtering with wraparound midnight support and improving detail parsing for extracting transaction metadata. 2026-04-10 19:03:35 +07:00
slava
ffb206303e Refine transaction ID generation by replacing history-based retrieval with timestamp-based fallback logic. Adjust source card selection and transaction grouping in payment scripts. 2026-04-10 18:46:33 +07:00
slava
fd99281dfb Add device timezone handling in transaction filtering and refine match logic by amount and timestamp 2026-04-10 18:14:52 +07:00
slava
76ceaed0eb Refine pin code validation logic, add device timezone support in transaction scripts, and improve XML node logging for error handling. 2026-04-10 17:55:45 +07:00
slava
14ca8348c3 Add timeout handling and process kill logic for ADB operations. Refine date and time validation in transaction fetching. Adjust EventHandler to handle "ozon" bank accounts separately. Use blocking queued connection for stopping EventHandler. 2026-04-10 15:44:55 +07:00
slava
0c1d2675b3 Add validation to ensure device.name is not empty before registering a new device in API sync logic. 2026-04-09 22:32:58 +07:00
slava
87d74e7747 Add warning log for failed balance updates in GetCardInfoScript to improve error visibility. 2026-04-09 22:25:43 +07:00
slava
4c1ff0af0f Refine card number parsing logic in GetCardInfoScript and improve node filtering in ScreenXmlParser. 2026-04-09 22:21:05 +07:00
slava
d27f4b6b22 Add phone number to makePayment and implement fallback logic for generating transaction IDs in payment scripts. 2026-04-08 21:34:54 +07:00
slava
7931a10b88 Fix for windows 2026-04-08 15:43:59 +07:00
slava
3e22f9ea93 Add database/app_data.db to .gitignore for exclusion from version control 2026-04-08 09:44:46 +07:00
slava
6d3f76feea - Add log panel to the devices page and implement log parsing with enhanced context extraction for banks and devices. Remove unused bank synchronization logic and bank list actions. 2026-04-08 09:43:38 +07:00
slava
73461b93bf - Add OCR-based fallback logic to extract bankTrExternalId in GetLastTransactionsScript when missing.
- Copy required images, trained data, and config files to the build directory during the CMake process.
2026-04-06 20:57:48 +07:00
slava
baa60d008b Ensure BankSetupWizard triggers only when bankProfileId is empty and pinCodeChecked is false in DeviceSettingsWindow and DeviceWidget. 2026-04-06 20:16:58 +07:00
slava
c5e8fc4ed5 Ensure BankSetupWizard triggers only when bankProfileId is empty and pinCodeChecked is false in DeviceSettingsWindow and DeviceWidget. 2026-04-06 20:01:52 +07:00
slava
a046a27569 Fix for windows 2026-04-06 19:53:05 +07:00
slava
8ec9f37160 Replace nullptr with QCoreApplication::applicationDirPath() in OCR initialization across automation scripts to ensure correct Tesseract setup paths. Add UI overlay updates in AccountSettingsWindow for better progress visibility and enhance error handling in EventHandler for transactions missing bank_transaction_id. 2026-04-06 19:45:51 +07:00
slava
d7c296ba42 Fix for windows 2026-04-06 19:09:39 +07:00
slava
013a00c972 Add debug logging for card details in GetCardInfoScript to enhance troubleshooting. 2026-04-06 18:40:03 +07:00
slava
b2bbb2207f Add cross-platform archive creation for event and log exports using platform-specific tools (zip and PowerShell). 2026-04-06 18:02:59 +07:00
slava
6647a91c88 Handle error dialog in GetLastTransactionsScript with "OK" tap and pull-to-refresh; add time pre-filtering and enhanced match validation for transactions. 2026-04-06 17:18:42 +07:00
slava
41bb5e33fd Add pause/resume functionality for active profiles in AccountSettingsWindow and improve automation scripts with OCR-based transaction ID extraction, card balance waits, and absolute value handling in event parsing. 2026-04-06 17:02:52 +07:00
slava
58513eb15a - Add buttons for profile and card operations to AccountSettingsWindow with threading integration and UI state management.
- Implement `Dushanbe` automation for `GetProfileInfoScript` and `GetCardInfoScript`.
- Add device validation in `DeviceDAO` and enhance duplicate checks in `NetworkService` during synchronization.
2026-04-06 14:59:00 +07:00
slava
3ed2d4da20 Add PayByCardScript and PayByPhoneScript for Dushanbe automation, including transaction flow, OCR-based operation ID extraction, and enhanced logging. 2026-04-06 12:51:18 +07:00
slava
37b72938e8 Add CommonScript framework and implement bank-specific automation scripts including GetCardInfoScript, GetProfileInfoScript, and GetLastTransactionsScript. 2026-04-04 23:32:26 +07:00
slava
f28311b5cf - Add debug outputs in NetworkService for bank profile synchronization and adjust default installation status for new apps. 2026-04-02 14:18:09 +07:00
slava
8d0eae8ec0 - Remove app_logs table and related logic, transitioning to general_logs.
- Add `pinCheckOnly` parameter in scripts to support conditional account checks.
- Prevent activation of bank profiles without active cards in `AccountSettingsWindow` and `DeviceWidget`.
- Adjust logging flow in `AppLogger` for unified data handling.
2026-03-31 14:07:23 +07:00
slava
a6509b5653 - Add database migration for screenshot column in general_logs.
- Implement `cleanupOldData` in `DatabaseManager` to remove outdated entries and optimize storage.
- Introduce `unlockScreen` in `AdbUtils` and integrate it into common scripts for screen unlocking.
- Enhance `GeneralLogDAO` to support screenshot storage and retrieval.
- Expand `FileLogWindow` with screenshot viewer and horizontal layout.
- Add device ID migration logic in `BankProfileDAO` and `MaterialDAO`.
- Improve error handling in `EventHandler` by saving and logging screenshots for critical events.
2026-03-31 13:08:02 +07:00
slava
de8c2e84d0 - Add toggleMaterial logic to NetworkService for enabling/disabling materials via PATCH request.
- Implement `toggleMaterialStatus` in `AccountSettingsWindow` for UI control and threading integration.
- Enhance `MaterialDAO` with numeric validation for `lastNumbers` field to ensure data integrity.
- Update `PayByCardScript` and `PayByPhoneScript` to handle PIN entry if required after payment confirmation.
- Introduce dynamic UI adjustments for material toggling, including a new toggle button and layout refinements.
- Add documentation for task logic in `ozon_tasks.md` and `black_tasks.md`.
- Switch default currency in `GetCardInfoScript` (Black app) from USD to ARS.
2026-03-31 11:40:34 +07:00
slava
bceceb516b Remove AccountDAO, ApplicationDAO, and related models, replacing them with BankProfileDAO and BankProfileInfo. 2026-03-31 00:11:50 +07:00
slava
b395ed41a5 Add bank profile toggling in DeviceWidget and enhance error handling in EventHandler
- Implemented `sendBankStatus` logic with `NetworkService` and threading for bank profile enable/disable functionality.
- Improved error handling in `EventHandler` by logging and updating events when bank profiles are disabled.
2026-03-25 16:05:49 +07:00
slava
e1b977becc Fix for windows 2026-03-24 14:20:48 +07:00
slava
8f416b3303 Refactor project naming and executable targets, enhance UI with no-devices label, tutorial handling, and app branding updates.
- Renamed project and executable from `ARCDesktopProject` to `ARC`, updating CMake files accordingly.
- Introduced dynamic no-devices label with scaling and visibility handling.
- Added tutorial display logic for first-time users with `SettingsDAO`.
- Updated application name, window title, and icon for improved branding.
- Removed unused endpoints from `config.ini`.
2026-03-24 14:03:39 +07:00
slava
b42fb5eea9 Enhance error handling, improve UI parsing logic, and refine tap retry workflows across Ozon scripts 2026-03-23 21:30:37 +07:00
slava
01f8801a73 Refactor findPinCodeNodes in ScreenXmlParser for improved PIN handling and grid detection
Optimized PIN processing by trimming spaces from input to support flexible PIN formats. Enhanced grid detection logic by introducing distinct handling for full and short rows, refining the "0" digit node assignment. Updated size thresholds for better fallback support and added debug outputs for enhanced troubleshooting.
2026-03-21 18:41:02 +07:00
slava
cefad834c7 Enhance findPinCodeNodes logic in ScreenXmlParser with regex support and size tolerance improvements
Refactored the PIN pad detection method to support flexible text matching using regex (e.g., "1", "key 1"). Updated node grouping logic to allow size tolerance for identifying clickable nodes, enabling fallback handling when exact matches fail. Improved debugging output for easier issue diagnosis.
2026-03-21 18:14:05 +07:00
slava
712b73316c Remove CommonBirbankScript and its derived classes from the codebase
Deleted `CommonBirbankScript` and its associated derived classes (`GetLastDaysHistoryBirbankScript`, `LoginAndCheckAccountsBirbankScript`, `PayByCardBirbankScript`). Removed dependencies on these scripts, including XML parsing, account handling, and transaction workflows related to the Birbank app.
2026-03-21 17:41:05 +07:00
slava
10897164e9 Fix for windows 2026-03-16 18:31:45 +07:00
slava
8ed25698e8 Add GetLastTransactionsScript and PayByCardScript implementations for Black app
Implemented `GetLastTransactionsScript` for automated retrieval of transactions, handling home screen navigation, "Ver más" button detection, and transaction list parsing with database storage. Introduced `PayByCardScript` for automated card payments, covering recipient selection, amount input, payment confirmation, and transaction logging. Added XML assets and updates to support these workflows.
2026-03-12 18:42:38 +07:00
slava
eb14339623 Add core scripts for Black app automation and XML parsing
Implemented `CommonScript` as a base class with reusable functionality for automation workflows, including PIN input, app navigation, and server communication. Added specialized scripts like `GetCardInfoScript`, `GetProfileInfoScript`, and `LoginAndCheckAccountsScript` for card retrieval, profile parsing, and login validation. Introduced `ScreenXmlParser` for efficient XML-based UI parsing and node detection.
2026-03-11 19:45:08 +07:00
slava
55fef77c43 Fix for windows 2026-03-10 09:49:29 +07:00
slava
23d404b9c5 Add PayByCardScript implementation and XML asset for payment automation in Ozon
Implemented `PayByCardScript` to enable automated card payments on the Ozon platform. Includes ad banner detection/handling, OCR-based transaction ID extraction, fee calculation, and database updates for transactions. Added XML asset `pay_by_card_success.xml` for payment result handling.
2026-03-10 09:30:25 +07:00
slava
f95dabca77 Fix for windows 2026-03-09 18:17:05 +07:00
slava
1ed7cbe6f7 Fix for windows 2026-03-09 17:20:03 +07:00
slava
e9aaa71c56 Add maxCount support to GetLastTransactionsScript and UI updates for transaction limits
Implemented a `maxCount` parameter in `GetLastTransactionsScript` to limit the number of transactions retrieved. Updated `AccountSettingsWindow` to include new transaction limit options in the UI. Enhanced retrieval logic to support either time-based filtering (24 hours) or count-based limits, ensuring flexibility in transaction handling.
2026-03-09 16:56:01 +07:00
slava
277f94c15a Enforce light theme in UI by setting custom QPalette with Fusion style 2026-03-09 16:50:04 +07:00
slava
9f0b547100 Fix for windows 2026-03-09 16:49:06 +07:00
slava
3d3ed9cbf6 Add SettingsDAO implementation, schema migration, and related updates
Implemented `SettingsDAO` with `get`, `set`, `remove`, and related methods for key-value storage. Added database schema migration for a new `settings` table. Updated `EventDAO` with error handling improvements and `AbstractDAO` with helper utilities. Enhanced Ozon scripts with ad banner detection and handling.
2026-03-09 15:53:35 +07:00
slava
8eedd07ffd Add database migration, DAO enhancements, and OCR-based transaction handling updates
Implemented database migration for adding new fields (`json`, `sent_to_server`) in `events` table. Updated DAO methods to support the new fields and introduced `markSentToServer`. Enhanced Ozon transaction workflows with OCR-based extraction, event error handling improvements, and result reporting. Refactored `EventHandler` for streamlined task processing and stale event cleanup.
2026-03-08 14:20:12 +07:00
slava
832fb03aac Add EventWindow, database migrations, and enhanced DAO methods
Implemented `EventWindow` for viewing events with pagination and table-based UI. Added database migration to remove `UNIQUE` constraint on `external_id` in `events`. Enhanced DAO methods for event retrieval, pagination, and transaction updates. Integrated OCR recognition in transaction scripts and streamlined event handling in services.
2026-03-04 22:56:47 +07:00
slava
73e23f437d Add Ozon pay_to_new_number and pay_to_old_number XML assets
Included XML files for the "pay to new number" and "pay to old number" features in the Ozon platform.
2026-03-01 12:02:37 +07:00
slava
afbf3a3afb Add Ozon pay_to_new_number and pay_to_old_number XML assets
Included XML files for the "pay to new number" and "pay to old number" features in the Ozon platform.
2026-03-01 09:30:59 +07:00
slava
91f8e96fa6 Add Ozon scripts for retrieving card info, profile details, and transaction history
Implemented `GetCardInfoScript`, `GetProfileInfoScript`, and `GetLastTransactionsScript` to automate data extraction workflows for Ozon platform. Includes support for XML parsing, ad banner handling, card navigation, profile parsing, and transaction retrieval. Added related asset files.
2026-02-27 10:15:19 +07:00
slava
f5810acf6c Daily commit 2026-02-22 20:48:16 +07:00
slava
c9e266225a Daily commit 2025-07-31 11:32:52 +05:00
slava
6ded374ee8 Daily commit 2025-07-27 21:01:13 +05:00
slava
ee6de4fc5a Daily commit 2025-07-27 16:11:43 +05:00
slava
9e67063946 Daily commit 2025-07-27 14:28:35 +05:00
slava
93bf00352a Fix for windows 2025-06-18 20:02:38 +07:00
slava
9c6d1bd4e5 Add Ziraat Bank integration and refactor XML parsing
Added support for Ziraat Bank in BankSettingsWindow and parsing scripts. Refactored XML parsing code for improved readability, removing unused methods and renaming functions for consistency. Updated configuration and app data to include Ziraat Bank.
2025-06-18 18:33:11 +07:00
slava
e7db943a04 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	CMakeLists.txt
2025-06-17 18:14:14 +07:00
slava
b2179a8acf Remove obsolete IBAN step XML and update recipient configuration
Deleted an unused XML file for IBAN step 1 to declutter assets. Integrated necessary updates into the recipient configuration file for better alignment with the current app design.
2025-06-17 18:13:39 +07:00
slava
2552ca49be Add Ziraat Bank integration and support for IBAN payments
This update introduces support for Ziraat Bank, including scripts for retrieving transaction history, parsing account information, and performing IBAN payments. Enhancements include improved screen parsing logic, additional transaction types, and better handling of amounts and IBAN-specific operations.
2025-06-17 18:13:29 +07:00
slava
ab16b8962f Add ScreenXmlZiraatParser for parsing Ziraat XML data
Introduce a new class, `ScreenXmlZiraatParser`, to handle Ziraat-related XML parsing. This includes functionality to extract transaction details, banners/dialogs recognition, and history parsing for today and yesterday. Regular expressions are utilized for precise data extraction.
2025-06-16 20:56:27 +07:00
slava
748d515fbf Update build configuration and modify database file
Updated the CMake build instructions to use Visual Studio 2022 with specific platform settings. Additionally, made changes to the database file, though the specifics of the database modification require further review.
2025-06-16 20:56:17 +07:00
slava
5ba4cc8dcb Add XML file for step 3 transaction warning
This new file defines the transaction warning displayed after step 3. It ensures the correct warning message is shown to the user at the appropriate stage.
2025-06-16 20:56:03 +07:00
slava
6823676e14 Add UI dump visualizer with HTML, CSS, and JS components
Introduced a new tool for visualizing UI dumps, enabling users to upload XML and image files for inspection. The implementation includes an HTML structure, styled with CSS, and JavaScript for processing files and rendering elements dynamically onto a canvas. This provides a lightweight and functional interface for visual analysis.
2025-06-16 20:55:55 +07:00
slava
5f94bd84d9 Fix for windows 2025-06-12 16:24:49 +07:00
slava
0d80a4c11e Fix for windows 2025-06-11 19:14:54 +07:00
slava
29ac3c473f Fix for windows 2025-06-11 18:16:31 +07:00
slava
6988794657 Refactor ADB utilities to use dynamic adb paths and streamline processes.
Updated all ADB operations to use the dynamically determined adb path for better platform compatibility. Consolidated redundant process-handling code using a new `startProcess` helper method to simplify and standardize command execution. Enhanced `getScreenDumpAsXml` and related functions to improve modularity and maintainability.
2025-06-11 18:13:22 +07:00
slava
b8b2b38f8a Add ADB utilities with platform-specific support
Introduce ADB utilities for executing commands like taking XML dumps from connected devices. Refactor the `takeXmlDump` method to dynamically determine the ADB path based on the platform. Include ADB tools and necessary files for both Windows and macOS in the build system.
2025-06-11 16:21:13 +07:00
slava
f70b46ce44 Add support for processing pending transactions
Refactored transaction processing to include pending status updates and added logic to handle "Ожидающие обработки" transactions. Updated relevant scripts, XML resources, and database handling for improved transaction tracking and status accuracy.
2025-06-11 15:28:37 +07:00
slava
6e9b7c2b6e Implement Event Type Handling and Status Updates
Added handling for event types (Balance, Phone) and respective processing logic in EventHandler. Introduced support for tracking and updating event statuses both locally and through network APIs. Enhanced data integrity and clarity by expanding the EventInfo model and associated database management.
2025-06-03 22:02:29 +07:00
slava
4fe6e399bc Refactor scripts and add enhanced error handling
Revised scripts for payments and history retrieval, introducing improved error management with screenshots for debugging. Added a new LoginAndCheckAccountsScript for account validation, streamlined multi-threading, and updated application logic for clarity and reliability.
2025-05-30 22:16:25 +07:00
slava
01aa8bca5e Daily commit 2025-05-28 13:28:31 +07:00
slava
0cebf8f1ff Fix for windows 2025-05-27 14:54:29 +07:00
slava
dd66adf455 Add bank management functionality with UI and DAO improvements
Introduce `BankSettingsWindow` for managing bank data, added `EditBankDialog` for editing bank details, and created reusable `PaddedItemDelegate` for list padding. Refactor DAO to support fetching multiple applications by device ID for improved data handling. Adjust existing components to integrate new bank-related features.
2025-05-27 14:25:33 +07:00
slava
c3fed927fc Remove macOS-specific Scrcpy embedding and cleanup debug logs
Removed Scrcpy embedding code and related configurations for macOS. Commented out excessive debug logging in multiple files to reduce console noise. Simplified CMakeLists.txt by unifying platform-specific sections for improved build system clarity.
2025-04-26 15:16:42 +07:00
slava
675fa7ca5f Fix scrcpy 2025-04-26 14:11:07 +07:00
slava
cf543917b4 Fix scrcpy 2025-04-26 13:50:05 +07:00
slava
b125c509b3 Fix database 2025-04-26 10:28:10 -07:00
slava
bf976f04c7 Refactor CMakeLists to conditionally include SCRCPY for macOS.
Moved the inclusion of SCRCPY and executable definition to within the APPLE conditional block. This ensures platform-specific sources are handled appropriately, improving configurability and avoiding redundancy.
2025-04-26 10:13:36 +07:00
slava
d52574c50f Merge remote-tracking branch 'origin/main' 2025-04-26 10:11:38 +07:00
slava
327b206fe8 Add ScrcpyWindow integration for screen mirroring
Introduced a ScrcpyWindow to enable integration of the scrcpy tool for device screen mirroring within the application. Platform-specific embedding is supported for macOS and Windows, with structure adjustments made in MainWindow and the build system (CMakeLists.txt).
2025-04-26 10:11:29 +07:00
608 changed files with 52058 additions and 20275 deletions

16
.gitignore vendored
View File

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

View File

@ -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
View 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`)

View File

@ -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;
}
}

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

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

View 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

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

View 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

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

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

View 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

View File

@ -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;
}

View File

@ -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";
}
}

View File

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

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

@ -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.
}

View File

@ -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
arc.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
arc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
assets/black/accounts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

123
assets/black/accounts.xml Normal file
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

View 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!&#10;veronica cintia heredia &#10;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&#10;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&#10;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&#10;21 de febrero de 2026 - 09:39&#10;$-1.857,&#10;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&#10;21 de febrero de 2026 - 09:38&#10;$-123,&#10;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&#10;21 de febrero de 2026 - 09:35&#10;$123,&#10;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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View 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 &#10;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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View 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&#10;ROCIO MICAELA MONTES&#10;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&#10;Gabriel Alejandro del Roio&#10;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&#10;Alexander Leonardo Nahuel Soto&#10;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&#10;MICAELA SOLEDAD VARGAS&#10;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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View 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 &#10;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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

97
assets/black/pin_code.xml Normal file
View 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&#10;código PIN&#10;Es un código de seguridad que te va a permitir operar con más seguridad&#10;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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View 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&#10;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

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

169
assets/black/side_menu.xml Normal file
View 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&#10;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>

View 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&#10;Una vez que tengas actividad en tu cuenta la&#10; 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View 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;10 de marzo de 2026 - 16:23&#10;$10.000,&#10;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&#10;21 de febrero de 2026 - 09:39&#10;$-1.857,&#10;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&#10;21 de febrero de 2026 - 09:38&#10;$-123,&#10;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&#10;21 de febrero de 2026 - 09:35&#10;$123,&#10;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&#10;21 de febrero de 2026 - 09:34&#10;$1.234,&#10;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&#10;21 de febrero de 2026 - 09:34&#10;$123,&#10;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&#10;15 de febrero de 2026 - 07:07&#10;$-56.030,&#10;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&#10;14 de febrero de 2026 - 23:59&#10;$-161.000,&#10;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&#10;14 de febrero de 2026 - 21:30&#10;$10.000,&#10;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&#10;14 de febrero de 2026 - 21:29&#10;$5.030,&#10;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 &#10;14 de febrero de 2026 - 21:28&#10;$16.000,&#10;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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

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

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