From 9f23991c61c3280d2fe4e3df11e3e122521165d6 Mon Sep 17 00:00:00 2001 From: slava Date: Fri, 5 Jun 2026 14:56:41 +0500 Subject: [PATCH] =?UTF-8?q?Refactor=20payment=20confirmation=20logic:=20ad?= =?UTF-8?q?d=20`locateConfirmButton`=20with=20prioritized=20search=20(colo?= =?UTF-8?q?r=20=E2=86=92=20OCR=20=E2=86=92=20geometry),=20enhance=20tap=20?= =?UTF-8?q?precision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/dushanbe/PayByCardScript.cpp | 78 +++++++++++++++---------- android/ocr/OcrUtils.cpp | 85 ++++++++++++++++++++++++++++ android/ocr/OcrUtils.h | 21 +++++++ 3 files changed, 153 insertions(+), 31 deletions(-) diff --git a/android/dushanbe/PayByCardScript.cpp b/android/dushanbe/PayByCardScript.cpp index b9ee45b..c8e4c5a 100644 --- a/android/dushanbe/PayByCardScript.cpp +++ b/android/dushanbe/PayByCardScript.cpp @@ -97,6 +97,48 @@ static QString recognizeOperationId(const QByteArray &pngData) { return {}; } +// Координаты кнопки "Подтверждаю" на bottom sheet подтверждения платежа. +// Compose flatten-ит весь sheet в одну accessibility-ноду — отдельного узла +// кнопки в дампе нет, поэтому позицию ищем по скриншоту тремя способами по +// убыванию надёжности: +// 1) цвет — оранжевая sticky-кнопка RGB(255,117,40); не зависит от текста, +// контраста, языка и разрешения (самый стабильный путь); +// 2) OCR "Подтверждаю" — если вдруг тема/цвет кнопки иные; +// 3) геометрия — центр кнопки ≈ 6% высоты экрана над низом sheet'a; считаем +// относительно высоты, а не в абсолютных пикселях, чтобы работать на +// любом разрешении. +// `confirmNode` — нода sheet'a (на весь экран), `height` — высота экрана. +static QPoint locateConfirmButton(const QString &deviceId, + const Node &confirmNode, + int height) { + // Окно поиска — нижняя половина sheet'a, где живёт кнопка. Sheet-нода + // растянута на весь экран, поэтому y1/y2 сами по себе поиск не сужают. + const int yMin = (confirmNode.y1() + confirmNode.y2()) / 2; + const int yMax = confirmNode.y2(); + const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId); + + // 1) Цвет. + QPoint tap = OcrUtils::findColorButtonCenter(screen, 255, 117, 40, 55, yMin, yMax); + if (tap.x() >= 0) { + qDebug() << "[Dushanbe::PayByCard] confirm button via color at" << tap; + return tap; + } + + // 2) OCR. + tap = OcrUtils::findTextCenter(screen, QString::fromUtf8("Подтверждаю"), yMin, yMax); + if (tap.x() >= 0) { + qDebug() << "[Dushanbe::PayByCard] confirm button via OCR at" << tap; + return tap; + } + + // 3) Геометрия: кнопка у самого низа sheet'a. Целимся ВНИЗ к y2, а не вверх. + const int sheetH = confirmNode.y2() - confirmNode.y1(); + const int offset = qMax(sheetH * 6 / 100, height / 16); + tap = QPoint(confirmNode.x(), confirmNode.y2() - offset); + qDebug() << "[Dushanbe::PayByCard] confirm button geometry fallback at" << tap; + return tap; +} + namespace Dushanbe { PayByCardScript::PayByCardScript( @@ -383,23 +425,8 @@ void PayByCardScript::makePayment( QString::fromUtf8("Подтверждение платежа"), true); if (!confirmNode.isEmpty() && confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) { - // Compose flatten-ит весь bottom sheet в одну accessibility-кнопку, - // отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом - // по скриншоту. Если OCR не нашёл — fallback на геометрию (центр - // нижней четверти sheet, выше системного nav-бара). - // Compose отдаёт sheet как одну ноду на весь экран ([0,0][720,1640]), - // поэтому confirmNode.y1/y2 не сужают поиск — OCR сканирует весь - // скриншот и ловит ложные совпадения. Ограничиваем нижней половиной. - const int ocrYMin = (confirmNode.y1() + confirmNode.y2()) / 2; - const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId); - QPoint tap = OcrUtils::findTextCenter(screen, - QString::fromUtf8("Подтверждаю"), - ocrYMin, confirmNode.y2()); - if (tap.x() < 0) { - const int tapY = confirmNode.y2() - qMax(150, height / 14); - tap = QPoint(confirmNode.x(), tapY); - qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap; - } + // Позицию кнопки ищем: цвет → OCR → геометрия (см. locateConfirmButton). + const QPoint tap = locateConfirmButton(deviceId, confirmNode, height); qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap; AdbUtils::makeTap(deviceId, tap.x(), tap.y()); confirmed = true; @@ -487,20 +514,9 @@ void PayByCardScript::makePayment( ++confirmRetries; qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap" << confirmRetries << "/" << maxConfirmRetries; - const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2; - const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId); - QPoint tap = OcrUtils::findTextCenter(screen, - QString::fromUtf8("Подтверждаю"), - ocrYMin, retryConfirm.y2()); - if (tap.x() < 0) { - // Варьируем Y: первый retry — выше fallback на 100px, - // второй — ещё на 100px выше. Compose-кнопка действия - // обычно на y2 - 150..350; перебираем диапазон. - const int extra = 100 * confirmRetries; - const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra); - tap = QPoint(retryConfirm.x(), tapY); - qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap; - } + // Та же логика, что и на первом тапе: цвет → OCR → геометрия. + const QPoint tap = locateConfirmButton(deviceId, retryConfirm, height); + qDebug() << "[Dushanbe::PayByCard] Re-tapping 'Подтверждаю' at" << tap; AdbUtils::makeTap(deviceId, tap.x(), tap.y()); QThread::msleep(4000); continue; diff --git a/android/ocr/OcrUtils.cpp b/android/ocr/OcrUtils.cpp index 2819b3a..0751dac 100644 --- a/android/ocr/OcrUtils.cpp +++ b/android/ocr/OcrUtils.cpp @@ -202,4 +202,89 @@ QPoint findTextCenter(const QByteArray &png, return result; } +QPoint findColorButtonCenter(const QByteArray &png, + int targetR, + int targetG, + int targetB, + int tolerance, + int yMin, + int yMax) { + if (png.isEmpty()) return {-1, -1}; + + QElapsedTimer timer; + timer.start(); + + Pix *src = pixReadMem(reinterpret_cast(png.constData()), + static_cast(png.size())); + if (!src) { + qWarning() << "[OcrUtils] findColorButtonCenter: pixReadMem failed"; + return {-1, -1}; + } + // Приводим к 32bpp, чтобы pixGetRGBPixel работал независимо от исходной + // глубины (PNG со скриншота бывает 24/32 bpp). + Pix *pix = pixConvertTo32(src); + pixDestroy(&src); + if (!pix) return {-1, -1}; + + const int W = pixGetWidth(pix); + const int H = pixGetHeight(pix); + const int y0 = qBound(0, yMin, H); + const int y1 = (yMax > 0 && yMax <= H) ? yMax : H; + + // Для каждой строки считаем число пикселей целевого цвета и их X-диапазон. + QVector rowCount(H, 0), rowMinX(H, -1), rowMaxX(H, -1); + for (int y = y0; y < y1; ++y) { + int cnt = 0, mn = W, mx = -1; + for (int x = 0; x < W; ++x) { + l_int32 r, g, b; + pixGetRGBPixel(pix, x, y, &r, &g, &b); + if (qAbs(r - targetR) <= tolerance && + qAbs(g - targetG) <= tolerance && + qAbs(b - targetB) <= tolerance) { + ++cnt; + if (x < mn) mn = x; + if (x > mx) mx = x; + } + } + rowCount[y] = cnt; + rowMinX[y] = mn; + rowMaxX[y] = mx; + } + + // Кнопка — широкая сплошная полоса: ищем самую длинную серию строк, где + // заполнение целевым цветом ≥ 25% ширины. Мелкие цветные иконки в статус- + // баре такой порог не проходят. + const int minRowFill = qMax(1, W / 4); + int bestStart = -1, bestLen = 0, curStart = -1; + for (int y = y0; y <= y1; ++y) { + const bool filled = (y < y1) && (rowCount[y] >= minRowFill); + if (filled) { + if (curStart < 0) curStart = y; + } else if (curStart >= 0) { + const int len = y - curStart; + if (len > bestLen) { bestLen = len; bestStart = curStart; } + curStart = -1; + } + } + + // Полоса должна быть не тоньше ~1% высоты — отсекаем шум/тонкие разделители. + if (bestStart < 0 || bestLen < qMax(8, H / 100)) { + pixDestroy(&pix); + qDebug() << "[OcrUtils] findColorButtonCenter: no button band in" + << timer.elapsed() << "ms"; + return {-1, -1}; + } + + int xmin = W, xmax = -1; + for (int y = bestStart; y < bestStart + bestLen; ++y) { + if (rowMinX[y] >= 0) { xmin = qMin(xmin, rowMinX[y]); xmax = qMax(xmax, rowMaxX[y]); } + } + pixDestroy(&pix); + + const QPoint center((xmin + xmax) / 2, bestStart + bestLen / 2); + qDebug() << "[OcrUtils] findColorButtonCenter → (" << center.x() << "," + << center.y() << ") band h=" << bestLen << "in" << timer.elapsed() << "ms"; + return center; +} + } // namespace OcrUtils \ No newline at end of file diff --git a/android/ocr/OcrUtils.h b/android/ocr/OcrUtils.h index 97a57d9..7c0d192 100644 --- a/android/ocr/OcrUtils.h +++ b/android/ocr/OcrUtils.h @@ -19,4 +19,25 @@ QPoint findTextCenter(const QByteArray &png, int yMin = 0, int yMax = 0); +// Находит крупную сплошную кнопку заданного цвета (по умолчанию — оранжевая +// sticky-action кнопка Dushanbe "Подтверждаю"/"Оплатить", RGB(255,117,40)) и +// возвращает центр её bounding box в координатах экрана (= пиксели скриншота). +// +// В отличие от findTextCenter не зависит от текста, контраста и языка: OCR на +// белом-по-оранжевому ненадёжен, а сплошной цветной прямоугольник ловится +// стабильно. Алгоритм — поиск самой длинной вертикальной полосы строк, где +// доля пикселей целевого цвета превышает порог по ширине; это отсекает мелкие +// цветные иконки в статус-баре и ловит именно широкую кнопку. Координаты +// относительные по построению, поэтому работает на любом разрешении. +// +// `yMin`/`yMax` — необязательное окно по Y (координаты экрана), 0/0 — весь +// кадр. Возвращает QPoint(-1, -1), если подходящей полосы не найдено. +QPoint findColorButtonCenter(const QByteArray &png, + int targetR = 255, + int targetG = 117, + int targetB = 40, + int tolerance = 55, + int yMin = 0, + int yMax = 0); + } // namespace OcrUtils \ No newline at end of file