1
0
forked from BRT/arc

Refactor payment confirmation logic: add locateConfirmButton with prioritized search (color → OCR → geometry), enhance tap precision

This commit is contained in:
slava 2026-06-05 14:56:41 +05:00
parent e760f2bb5f
commit 9f23991c61
3 changed files with 153 additions and 31 deletions

View File

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

View File

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

View File

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