fixed scaner QR Android

This commit is contained in:
dranik
2026-05-09 17:11:29 +03:00
parent 2fa0ec81ad
commit f781bf6a23
18 changed files with 1397 additions and 111 deletions
+102 -40
View File
@@ -2,9 +2,11 @@
#include <QCoreApplication>
#include <QDataStream>
#include <QDateTime>
#include <QDebug>
#include <QIODevice>
#include <QMetaObject>
#include <QPointer>
#include <QRegularExpression>
#include <QTimer>
#include <QUuid>
@@ -96,6 +98,33 @@ bool tryDecodeLegacyChunkedPairingQrPayload(const QString &t, QString *outUuid)
*outUuid = u.toString(QUuid::WithoutBraces);
return true;
}
/**
* Extract a pairing session UUID from raw QR text without touching QObject / signals.
* Safe from CameraX / JNI threads while AmneziaActivity is stopped (Qt event loop may not run).
*/
QString extractPairingSessionUuidFromScanText(const QString &raw)
{
const QString t = raw.trimmed();
if (t.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) {
return {};
}
static const QRegularExpression reV4(QStringLiteral(
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}"));
const QRegularExpressionMatch m = reV4.match(t);
if (m.hasMatch()) {
return m.captured(0);
}
QString fromLegacy;
if (tryDecodeLegacyChunkedPairingQrPayload(t, &fromLegacy)) {
return fromLegacy;
}
const QUuid parsed = QUuid::fromString(t);
if (!parsed.isNull()) {
return parsed.toString(QUuid::WithoutBraces);
}
return {};
}
} // namespace
#if defined(Q_OS_ANDROID)
@@ -113,6 +142,15 @@ bool PairingUiController::iosNativePairingQrOverlayBuild() const
#endif
}
bool PairingUiController::androidNativePairingQrOverlayBuild() const
{
#if defined(Q_OS_ANDROID)
return true;
#else
return false;
#endif
}
PairingUiController::PairingUiController(PairingController *pairingController, ServersController *serversController,
SubscriptionController *subscriptionController,
SecureAppSettingsRepository *appSettingsRepository, QObject *parent)
@@ -173,7 +211,7 @@ void PairingUiController::openPairingQrScanner()
{
#if defined(Q_OS_ANDROID)
qInfo() << "[PairingUi] openPairingQrScanner (Android native activity)";
AndroidController::instance()->startQrReaderActivity();
AndroidController::instance()->startPairingQrReaderActivity();
#endif
}
@@ -319,34 +357,18 @@ bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw)
{
const QString t = raw.trimmed();
qInfo() << "[PairingUi] scan raw len=" << t.size();
if (t.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) {
qInfo() << "[PairingUi] scan rejected: looks like vpn:// bundle, not session UUID";
const QString uuid = extractPairingSessionUuidFromScanText(raw);
if (uuid.isEmpty()) {
if (t.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) {
qInfo() << "[PairingUi] scan rejected: looks like vpn:// bundle, not session UUID";
} else {
qInfo() << "[PairingUi] scan rejected: no session UUID recognized in payload";
}
return false;
}
static const QRegularExpression reV4(QStringLiteral(
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}"));
const QRegularExpressionMatch m = reV4.match(t);
if (m.hasMatch()) {
const QString uuid = m.captured(0);
qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "...";
emit pairingUuidFromScan(uuid);
return true;
}
QString fromLegacy;
if (tryDecodeLegacyChunkedPairingQrPayload(t, &fromLegacy)) {
qInfo() << "[PairingUi] scan accepted legacy chunked QR uuid=" << fromLegacy.left(13) << "...";
emit pairingUuidFromScan(fromLegacy);
return true;
}
const QUuid parsed = QUuid::fromString(t);
if (!parsed.isNull()) {
const QString canon = parsed.toString(QUuid::WithoutBraces);
qInfo() << "[PairingUi] scan accepted QUuid::fromString uuid=" << canon.left(13) << "...";
emit pairingUuidFromScan(canon);
return true;
}
qInfo() << "[PairingUi] scan rejected: no session UUID recognized in payload";
return false;
qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "...";
emit pairingUuidFromScan(uuid);
return true;
}
#if defined(Q_OS_ANDROID)
@@ -356,25 +378,65 @@ bool PairingUiController::tryConsumeAndroidQrScan(const QString &code)
qWarning() << "[PairingUi] tryConsumeAndroidQrScan: no controller (g_pairingUiForAndroidQr null)";
return false;
}
PairingUiController *const ctl = g_pairingUiForAndroidQr;
bool consumed = false;
const QString codeCopy = code;
QObject *const app = QCoreApplication::instance();
if (!app) {
// Parse on this thread: while CameraActivity is foreground, AmneziaActivity is stopped and the Qt
// event loop may not process BlockingQueuedConnection until the user returns — UI would lag behind.
if (extractPairingSessionUuidFromScanText(codeCopy).isEmpty()) {
return false;
}
// CameraActivity / ML Kit may invoke JNI from a non-Qt thread. Signals and QML must run on the Qt GUI thread.
QMetaObject::invokeMethod(
app,
[ctl, codeCopy, &consumed]() {
consumed = ctl->applyScannedTextAsPairingUuid(codeCopy);
},
Qt::BlockingQueuedConnection);
qInfo() << "[PairingUi] tryConsumeAndroidQrScan consumed=" << consumed << "rawLen=" << codeCopy.size();
return consumed;
PairingUiController *const ctl = g_pairingUiForAndroidQr;
QPointer<PairingUiController> ctlPtr(ctl);
QTimer::singleShot(0, ctl, [ctlPtr, codeCopy]() {
if (!ctlPtr) {
return;
}
ctlPtr->applyScannedTextAsPairingUuid(codeCopy);
});
qInfo() << "[PairingUi] tryConsumeAndroidQrScan: scheduled apply on Qt thread, rawLen=" << codeCopy.size();
return true;
}
void PairingUiController::notifyAndroidPairingQrCameraClosed()
{
if (g_pairingUiForAndroidQr) {
g_pairingUiForAndroidQr->suppressAndroidNativePairingReaderStarts(2000);
}
}
void PairingUiController::notifyAndroidPairingQrCameraUserDismissed()
{
if (!g_pairingUiForAndroidQr) {
return;
}
PairingUiController *const ctl = g_pairingUiForAndroidQr;
QPointer<PairingUiController> ptr(ctl);
QTimer::singleShot(0, ctl, [ptr]() {
if (!ptr) {
return;
}
emit ptr->pairingAndroidNativeQrScannerUserDismissed();
});
}
#endif
void PairingUiController::suppressAndroidNativePairingReaderStarts(int ms)
{
if (ms <= 0) {
return;
}
#if defined(Q_OS_ANDROID)
const qint64 now = QDateTime::currentMSecsSinceEpoch();
const qint64 until = now + ms;
if (until <= m_androidPairingReaderCooldownUntilEpochMs) {
return;
}
m_androidPairingReaderCooldownUntilEpochMs = until;
emit androidPairingReaderCooldownUntilEpochMsChanged();
#else
Q_UNUSED(ms);
#endif
}
QVariantList PairingUiController::tvQrCodes() const
{
QVariantList list;
@@ -40,6 +40,14 @@ class PairingUiController : public QObject
embeddedPairingQrCameraActiveChanged)
/** True only on iOS builds: use native UIWindow QR overlay (not Qt.platform.os, which can differ). */
Q_PROPERTY(bool iosNativePairingQrOverlayBuild READ iosNativePairingQrOverlayBuild CONSTANT)
/** True only on Android builds: full-screen CameraActivity pairing scanner; QML hides duplicate scan chrome. */
Q_PROPERTY(bool androidNativePairingQrOverlayBuild READ androidNativePairingQrOverlayBuild CONSTANT)
/**
* Epoch ms until which QML should not call openPairingQrScanner again (after native CameraActivity closes).
* Android pairing flow only; always 0 on other platforms.
*/
Q_PROPERTY(qint64 androidPairingReaderCooldownUntilEpochMs READ androidPairingReaderCooldownUntilEpochMs NOTIFY
androidPairingReaderCooldownUntilEpochMsChanged)
public:
PairingUiController(PairingController *pairingController, ServersController *serversController,
@@ -62,6 +70,7 @@ public:
int tvPairingUiPhase() const { return m_tvPairingUiPhase; }
bool embeddedPairingQrCameraActive() const { return m_embeddedPairingQrCameraActive; }
bool iosNativePairingQrOverlayBuild() const;
bool androidNativePairingQrOverlayBuild() const;
Q_INVOKABLE void setEmbeddedPairingQrCameraActive(bool active);
/** iOS: native dim strip height uses safe bottom + extraPt (see PageSettingsApiQrPairingSend scanDimBleedBottom). No-op elsewhere. */
Q_INVOKABLE void syncIosEmbeddedPairingQrNativeBottomExtra(int extraPt);
@@ -72,6 +81,10 @@ public:
*/
Q_INVOKABLE void refreshIosEmbeddedPairingQrChrome();
qint64 androidPairingReaderCooldownUntilEpochMs() const { return m_androidPairingReaderCooldownUntilEpochMs; }
/** Lengthens androidPairingReaderCooldownUntilEpochMs to at least now + ms (Android pairing; no-op elsewhere). */
Q_INVOKABLE void suppressAndroidNativePairingReaderStarts(int ms);
/**
* iOS: UIKit UIWindow QR scanner (see iosPairingQrOverlayWindow). Pass translated title/subtitle for native chrome.
* No-op on other platforms.
@@ -83,6 +96,10 @@ public:
#if defined(Q_OS_ANDROID)
static bool tryConsumeAndroidQrScan(const QString &code);
/** JNI from CameraActivity onDestroy: avoid reopening native reader while camera HAL is still releasing. */
static void notifyAndroidPairingQrCameraClosed();
/** JNI before CameraActivity finish when user pressed back — Qt should reopen native scan (QML shell has no preview). */
static void notifyAndroidPairingQrCameraUserDismissed();
#endif
public slots:
@@ -132,10 +149,13 @@ signals:
/** After requestPairingCameraAccess(): true if OS granted camera access. */
void pairingCameraAccessFinished(bool granted);
void embeddedPairingQrCameraActiveChanged();
void androidPairingReaderCooldownUntilEpochMsChanged();
/** iOS native overlay scanner: payload was not a pairing session UUID (toast in QML). */
void pairingSendQrScanRejectedInvalidPayload();
/** Native overlay back chevron tapped — dismiss scanner and close page from QML. */
void pairingIosNativeQrOverlayBackRequested();
/** Android CameraActivity: user pressed back — QML should exit pairing send (e.g. closePage), not reopen camera. */
void pairingAndroidNativeQrScannerUserDismissed();
private:
void setTvBusy(bool busy);
@@ -170,6 +190,7 @@ private:
quint64 m_phoneSessionGeneration { 0 };
bool m_embeddedPairingQrCameraActive = false;
qint64 m_androidPairingReaderCooldownUntilEpochMs = 0;
};
#endif // PAIRINGUICONTROLLER_H
@@ -25,9 +25,17 @@ PageType {
/** iOS-only: full-screen UIKit UIWindow scanner (PairingUiController.iosNativePairingQrOverlayBuild — not Qt.platform.os). */
readonly property bool useIosNativePairingQrOverlay: PairingUiController.iosNativePairingQrOverlayBuild
/** Android: full-screen CameraActivity — Qt cannot reliably composite CameraX under QML on some OEMs (e.g. Samsung). */
readonly property bool useAndroidNativePairingQrScanner: GC.isMobile() && Qt.platform.os === "android"
/** Android: iOS-like flow — titles and camera preview only in CameraActivity; QML hides duplicate scan chrome. */
readonly property bool useAndroidNativePairingQrOverlay: PairingUiController.androidNativePairingQrOverlayBuild
&& GC.isMobile()
&& Qt.platform.os === "android"
/** Let dimming draw into window chrome (status bar + tab bar) when camera underlay is active. */
readonly property bool extendScanDimToScreenEdges: GC.isMobile() && pairingWizardStep === 0
&& PairingUiController.embeddedPairingQrCameraActive
&& !root.useAndroidNativePairingQrOverlay
clip: !extendScanDimToScreenEdges
/** QQuickWindow (not Item); do not type as Item — breaks binding on Qt 6. */
@@ -140,6 +148,8 @@ PageType {
property bool awaitingCameraPermissionForScan: false
property bool waitingSettingsReturnForScan: false
property bool torchOn: false
/** Suppress double startActivity when StackView fires both Component.onCompleted and onVisibleChanged. */
property int _androidPairingReaderLastStartMs: 0
Timer {
id: pairingCameraKickTimer
@@ -171,9 +181,18 @@ PageType {
if (!root.visible) {
return
}
/** Confirm step (or transition to it): never reopen native / embedded scanner from stray taps or visibility. */
if (root.pairingWizardStep !== 0) {
return
}
if (addDeviceConfirmNavigationScheduled) {
return
}
console.warn("[PairingQrSend] startMobileScanner Qt.platform.os=", Qt.platform.os,
"iosNativePairingQrOverlayBuild=", PairingUiController.iosNativePairingQrOverlayBuild,
"useIosNativePairingQrOverlay=", root.useIosNativePairingQrOverlay)
"useIosNativePairingQrOverlay=", root.useIosNativePairingQrOverlay,
"androidNativePairingQrOverlayBuild=", PairingUiController.androidNativePairingQrOverlayBuild,
"useAndroidNativePairingQrOverlay=", root.useAndroidNativePairingQrOverlay)
if (!PairingUiController.isPairingCameraAccessGranted()) {
awaitingCameraPermissionForScan = true
PairingUiController.requestPairingCameraAccess()
@@ -186,6 +205,23 @@ PageType {
/** Do not run pairingCameraKickTimer here: restartCapture during first startRunning races the session (torch needs 23 taps). */
return
}
if (root.useAndroidNativePairingQrScanner) {
const coolUntil = PairingUiController.androidPairingReaderCooldownUntilEpochMs
if (Date.now() < coolUntil) {
console.warn("[PairingQrSend] startMobileScanner: skip (native camera cooldown), ms left=",
(coolUntil - Date.now()))
return
}
const now = Date.now()
if (now - _androidPairingReaderLastStartMs < 700) {
console.warn("[PairingQrSend] startMobileScanner: skip duplicate Android CameraActivity within",
(now - _androidPairingReaderLastStartMs), "ms")
return
}
_androidPairingReaderLastStartMs = now
PairingUiController.openPairingQrScanner()
return
}
PairingUiController.embeddedPairingQrCameraActive = true
if (root.useIosStyleNativeQrReader) {
// Session must start here, not only after pairingCameraKickTimer (220ms), otherwise
@@ -249,13 +285,15 @@ PageType {
onVisibleChanged: {
if (visible) {
addDeviceConfirmNavigationScheduled = false
/** Only reset confirm flag on scan step; clearing it on confirm breaks guards if visible flickers. */
if (pairingWizardStep === 0) {
addDeviceConfirmNavigationScheduled = false
Qt.callLater(startMobileScanner)
}
} else {
pairingCameraKickTimer.stop()
stopMobileScanner()
_androidPairingReaderLastStartMs = 0
pairingWizardStep = 0
waitingSettingsReturnForScan = false
if (!keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) {
@@ -268,6 +306,10 @@ PageType {
if (pairingWizardStep !== 0) {
stopMobileScanner()
} else if (root.visible) {
/**
* Android native: use Qt.callLater like iOS — a multi-second Timer delay left the QML scan chrome
* visible with an empty (black) viewport until CameraActivity opened.
*/
Qt.callLater(startMobileScanner)
}
}
@@ -348,10 +390,31 @@ PageType {
Item {
anchors.fill: parent
/** Brief Qt backdrop + back while CameraActivity is starting (native holds title/instructions like iOS overlay). */
Rectangle {
anchors.fill: parent
visible: pairingWizardStep === 0 && root.useAndroidNativePairingQrOverlay
color: AmneziaStyle.color.midnightBlack
z: 1
}
BackButtonType {
visible: pairingWizardStep === 0 && root.useAndroidNativePairingQrOverlay
anchors.top: parent.top
anchors.topMargin: PageController.safeAreaTopMargin
anchors.left: parent.left
width: parent.width
z: 2
backButtonFunction: function() {
PageController.closePage()
}
}
Item {
id: scanStep
anchors.fill: parent
visible: pairingWizardStep === 0
visible: pairingWizardStep === 0 && !root.useAndroidNativePairingQrOverlay
/** Extra guard: invisible alone can race one frame on some stacks; deny input off scan step. */
enabled: pairingWizardStep === 0 && !root.useAndroidNativePairingQrOverlay
readonly property real sqSz: Math.floor(Math.min(width, height) * 0.72)
readonly property real sqX: (width - sqSz) / 2
@@ -653,16 +716,16 @@ PageType {
anchors.leftMargin: 0
anchors.rightMargin: 0
visible: pairingWizardStep === 1
z: 10
spacing: 16
BackButtonType {
Layout.topMargin: 20 + PageController.safeAreaTopMargin
Layout.leftMargin: 0
backButtonFunction: function() {
PairingUiController.cancelAllPairingActivity()
pairingWizardStep = 0
addDeviceConfirmNavigationScheduled = false
Qt.callLater(startMobileScanner)
pairingWizardStep = 0
PairingUiController.cancelAllPairingActivity()
}
}
@@ -714,10 +777,9 @@ PageType {
text: qsTr("Cancel")
clickedFunc: function() {
PairingUiController.cancelAllPairingActivity()
pairingWizardStep = 0
addDeviceConfirmNavigationScheduled = false
Qt.callLater(startMobileScanner)
pairingWizardStep = 0
PairingUiController.cancelAllPairingActivity()
}
}
@@ -736,7 +798,9 @@ PageType {
}
awaitingCameraPermissionForScan = false
if (granted) {
startMobileScanner()
if (root.pairingWizardStep === 0) {
startMobileScanner()
}
} else {
waitingSettingsReturnForScan = true
showScanCameraDeniedDrawer()
@@ -750,9 +814,8 @@ PageType {
addDeviceConfirmNavigationScheduled = true
stopMobileScanner()
PairingUiController.pendingPhonePairingUuid = uuid
Qt.callLater(function() {
pairingWizardStep = 1
})
/** Immediate step switch so scan chrome is not hit-testable for another frame (avoids reopening CameraActivity). */
pairingWizardStep = 1
}
function onPairingSendQrScanRejectedInvalidPayload() {
@@ -771,5 +834,16 @@ PageType {
stopMobileScanner()
PageController.closePage()
}
/** Native CameraActivity back: leave pairing flow (same as iOS overlay back). Do NOT reopen scanner. */
function onPairingAndroidNativeQrScannerUserDismissed() {
if (!root.useAndroidNativePairingQrOverlay) {
return
}
stopMobileScanner()
PairingUiController.cancelAllPairingActivity()
addDeviceConfirmNavigationScheduled = false
PageController.closePage()
}
}
}