mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-22 02:01:08 +07:00
fixed open Qr QML & add check error code & add test
This commit is contained in:
@@ -204,6 +204,10 @@ list(APPEND SOURCES ${CMAKE_CURRENT_LIST_DIR}/main.cpp)
|
||||
target_link_libraries(${PROJECT} PRIVATE ${LIBS})
|
||||
target_compile_definitions(${PROJECT} PRIVATE "MZ_$<UPPER_CASE:${MZ_PLATFORM_NAME}>")
|
||||
|
||||
if(AMNEZIA_LOCAL_GATEWAY)
|
||||
target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_LOCAL_GATEWAY)
|
||||
endif()
|
||||
|
||||
target_sources(${PROJECT} PRIVATE ${SOURCES} ${HEADERS} ${RESOURCES} ${QRC} ${I18NQRC})
|
||||
|
||||
# Finalize the executable so Qt can gather/deploy QML modules and plugins correctly (Android needs this).
|
||||
|
||||
@@ -16,11 +16,16 @@ namespace
|
||||
{
|
||||
constexpr auto kGenerateQrEndpoint = "%1api/v1/generate_qr";
|
||||
constexpr auto kScanQrEndpoint = "%1api/v1/scan_qr";
|
||||
constexpr qsizetype kPairingMaxQrUuidChars = 128;
|
||||
constexpr qsizetype kPairingMaxVpnConfigChars = 256 * 1024;
|
||||
constexpr qsizetype kPairingMaxApiKeyChars = 8192;
|
||||
|
||||
bool isLocalGatewayHost(const QString &gatewayUrl)
|
||||
{
|
||||
return gatewayUrl.contains(QStringLiteral("127.0.0.1"), Qt::CaseInsensitive)
|
||||
|| gatewayUrl.contains(QStringLiteral("localhost"), Qt::CaseInsensitive);
|
||||
|| gatewayUrl.contains(QStringLiteral("localhost"), Qt::CaseInsensitive)
|
||||
|| gatewayUrl.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)
|
||||
|| gatewayUrl.contains(QStringLiteral("::1"), Qt::CaseInsensitive);
|
||||
}
|
||||
|
||||
ErrorCode applyGatewayOrOpenApiGenerateError(const QJsonObject &obj, PairingController::QrPairingConfigPayload &outPayload)
|
||||
@@ -118,6 +123,20 @@ ErrorCode PairingController::parseScanQrResponseBody(const QByteArray &responseB
|
||||
return interpretScanQrJson(obj);
|
||||
}
|
||||
|
||||
ErrorCode PairingController::validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey)
|
||||
{
|
||||
if (qrUuid.size() > kPairingMaxQrUuidChars) {
|
||||
return ErrorCode::ApiConfigEmptyError;
|
||||
}
|
||||
if (vpnConfig.size() > kPairingMaxVpnConfigChars) {
|
||||
return ErrorCode::ApiPairingPayloadTooLargeError;
|
||||
}
|
||||
if (apiKey.size() > kPairingMaxApiKeyChars) {
|
||||
return ErrorCode::ApiPairingPayloadTooLargeError;
|
||||
}
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
PairingController::PairingController(SecureAppSettingsRepository *appSettingsRepository)
|
||||
: m_appSettingsRepository(appSettingsRepository)
|
||||
{
|
||||
@@ -187,6 +206,11 @@ ErrorCode PairingController::completePairing(const QString &qrUuid, const QStrin
|
||||
return ErrorCode::ApiConfigEmptyError;
|
||||
}
|
||||
|
||||
const ErrorCode fieldErr = validatePairingScanFields(qrUuid, vpnConfig, apiKey);
|
||||
if (fieldErr != ErrorCode::NoError) {
|
||||
return fieldErr;
|
||||
}
|
||||
|
||||
GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(), m_appSettingsRepository->isDevGatewayEnv(),
|
||||
apiDefs::requestTimeoutMsecs, m_appSettingsRepository->isStrictKillSwitchEnabled());
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ public:
|
||||
static amnezia::ErrorCode parseGenerateQrResponseBody(const QByteArray &responseBody, QrPairingConfigPayload &outPayload);
|
||||
static amnezia::ErrorCode parseScanQrResponseBody(const QByteArray &responseBody);
|
||||
|
||||
/** Length bounds before `scan_qr` (avoids huge JSON / abuse). */
|
||||
static amnezia::ErrorCode validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey);
|
||||
|
||||
amnezia::ErrorCode startPairing(const QString &qrUuid, QrPairingConfigPayload &outPayload);
|
||||
amnezia::ErrorCode completePairing(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo,
|
||||
const QJsonArray &supportedProtocols, const QString &apiKey);
|
||||
|
||||
@@ -312,6 +312,83 @@ ErrorCode SubscriptionController::importTrialFromGateway(const QString &userCoun
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode SubscriptionController::importServerFromQrPairingResponse(const QString &vpnConfigKey, const QJsonObject &serviceInfo,
|
||||
const QJsonArray &supportedProtocols,
|
||||
ServerConfig &serverConfig, int *duplicateServerIndex)
|
||||
{
|
||||
if (vpnConfigKey.isEmpty()) {
|
||||
return ErrorCode::ApiConfigEmptyError;
|
||||
}
|
||||
|
||||
QString normalizedKey = vpnConfigKey;
|
||||
normalizedKey.replace(QStringLiteral("vpn://"), QString());
|
||||
|
||||
for (int i = 0; i < m_serversRepository->serversCount(); ++i) {
|
||||
ServerConfig existingServerConfig = m_serversRepository->server(i);
|
||||
QString existingVpnKey;
|
||||
if (existingServerConfig.isApiV1()) {
|
||||
const ApiV1ServerConfig *apiV1 = existingServerConfig.as<ApiV1ServerConfig>();
|
||||
existingVpnKey = apiV1 ? apiV1->vpnKey() : QString();
|
||||
} else if (existingServerConfig.isApiV2()) {
|
||||
const ApiV2ServerConfig *apiV2 = existingServerConfig.as<ApiV2ServerConfig>();
|
||||
existingVpnKey = apiV2 ? apiV2->vpnKey() : QString();
|
||||
}
|
||||
existingVpnKey.replace(QStringLiteral("vpn://"), QString());
|
||||
if (!existingVpnKey.isEmpty() && existingVpnKey == normalizedKey) {
|
||||
if (duplicateServerIndex) {
|
||||
*duplicateServerIndex = i;
|
||||
}
|
||||
return ErrorCode::ApiConfigAlreadyAdded;
|
||||
}
|
||||
}
|
||||
|
||||
QByteArray configString =
|
||||
QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
|
||||
QByteArray configUncompressed = qUncompress(configString);
|
||||
if (!configUncompressed.isEmpty()) {
|
||||
configString = configUncompressed;
|
||||
}
|
||||
if (configString.isEmpty()) {
|
||||
return ErrorCode::ApiConfigEmptyError;
|
||||
}
|
||||
|
||||
QJsonObject serverJson = QJsonDocument::fromJson(configString).object();
|
||||
if (serverJson.isEmpty()) {
|
||||
return ErrorCode::ApiConfigEmptyError;
|
||||
}
|
||||
|
||||
if (serverJson.value(configKey::configVersion).toInt() != apiDefs::ConfigSource::AmneziaGateway) {
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
QJsonObject apiConfig = serverJson.value(apiDefs::key::apiConfig).toObject();
|
||||
if (!serviceInfo.isEmpty()) {
|
||||
apiConfig.insert(apiDefs::key::serviceInfo, serviceInfo);
|
||||
}
|
||||
if (!supportedProtocols.isEmpty()) {
|
||||
apiConfig.insert(apiDefs::key::supportedProtocols, supportedProtocols);
|
||||
}
|
||||
serverJson[apiDefs::key::apiConfig] = apiConfig;
|
||||
|
||||
ServerConfig serverConfigModel = ServerConfig::fromJson(serverJson);
|
||||
if (!serverConfigModel.isApiV2()) {
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
ApiV2ServerConfig *apiV2 = serverConfigModel.as<ApiV2ServerConfig>();
|
||||
if (apiV2 && apiV2->apiConfig.vpnKey.isEmpty()) {
|
||||
QString fullKey = vpnConfigKey.trimmed();
|
||||
if (!fullKey.startsWith(QStringLiteral("vpn://"))) {
|
||||
fullKey = QStringLiteral("vpn://") + fullKey;
|
||||
}
|
||||
apiV2->apiConfig.vpnKey = fullKey;
|
||||
}
|
||||
|
||||
m_serversRepository->addServer(serverConfigModel);
|
||||
serverConfig = serverConfigModel;
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode SubscriptionController::importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType,
|
||||
const QString &serviceProtocol, const ProtocolData &protocolData,
|
||||
const QString &transactionId, bool isTestPurchase,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef SUBSCRIPTIONCONTROLLER_H
|
||||
#define SUBSCRIPTIONCONTROLLER_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QByteArray>
|
||||
#include <QFuture>
|
||||
@@ -57,6 +58,11 @@ public:
|
||||
const QString &serviceProtocol, const QString &email,
|
||||
ServerConfig &serverConfig);
|
||||
|
||||
/** Decode premium API (vpn://) bundle from QR pairing TV response, merge gateway fields, add server. */
|
||||
ErrorCode importServerFromQrPairingResponse(const QString &vpnConfigKey, const QJsonObject &serviceInfo,
|
||||
const QJsonArray &supportedProtocols, ServerConfig &serverConfig,
|
||||
int *duplicateServerIndex = nullptr);
|
||||
|
||||
ErrorCode importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType,
|
||||
const QString &serviceProtocol, const ProtocolData &protocolData,
|
||||
const QString &transactionId, bool isTestPurchase,
|
||||
|
||||
@@ -86,6 +86,18 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef AMNEZIA_LOCAL_GATEWAY
|
||||
{
|
||||
const QUrl gatewayUrl(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl);
|
||||
const QString host = gatewayUrl.host().toLower();
|
||||
if (host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1")) {
|
||||
encRequestData.isPlaintextLocalGateway = true;
|
||||
encRequestData.requestBody = QJsonDocument(apiPayload).toJson();
|
||||
return encRequestData;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
QSimpleCrypto::QBlockCipher blockCipher;
|
||||
encRequestData.key = blockCipher.generatePrivateSalt(32);
|
||||
encRequestData.iv = blockCipher.generatePrivateSalt(32);
|
||||
@@ -176,6 +188,16 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
|
||||
|
||||
reply->deleteLater();
|
||||
|
||||
if (encRequestData.isPlaintextLocalGateway) {
|
||||
const auto errorCode =
|
||||
apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, encryptedResponseBody);
|
||||
if (errorCode) {
|
||||
return errorCode;
|
||||
}
|
||||
responseBody = encryptedResponseBody;
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
auto decryptionResult =
|
||||
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
|
||||
|
||||
@@ -223,7 +245,8 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString &endpoint, const QJsonObject apiPayload)
|
||||
QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString &endpoint, const QJsonObject &apiPayload,
|
||||
QNetworkReply **activeReplyOut)
|
||||
{
|
||||
auto promise = QSharedPointer<QPromise<QPair<ErrorCode, QByteArray>>>::create();
|
||||
promise->start();
|
||||
@@ -236,6 +259,9 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
|
||||
}
|
||||
|
||||
QNetworkReply *reply = amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody);
|
||||
if (activeReplyOut) {
|
||||
*activeReplyOut = reply;
|
||||
}
|
||||
|
||||
auto sslErrors = QSharedPointer<QList<QSslError>>::create();
|
||||
|
||||
@@ -249,6 +275,18 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
|
||||
|
||||
reply->deleteLater();
|
||||
|
||||
if (encRequestData.isPlaintextLocalGateway) {
|
||||
const auto errorCode = apiUtils::checkNetworkReplyErrors(*sslErrors, replyErrorString, replyError, httpStatusCode,
|
||||
encryptedResponseBody);
|
||||
if (errorCode) {
|
||||
promise->addResult(qMakePair(errorCode, QByteArray()));
|
||||
} else {
|
||||
promise->addResult(qMakePair(ErrorCode::NoError, encryptedResponseBody));
|
||||
}
|
||||
promise->finish();
|
||||
return;
|
||||
}
|
||||
|
||||
auto decryptionResult =
|
||||
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ public:
|
||||
const bool isStrictKillSwitchEnabled, QObject *parent = nullptr);
|
||||
|
||||
amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody);
|
||||
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject apiPayload);
|
||||
/** If \a activeReplyOut is non-null, the underlying QNetworkReply is written for abort/cancel (not owned by caller). */
|
||||
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject &apiPayload,
|
||||
QNetworkReply **activeReplyOut = nullptr);
|
||||
|
||||
private:
|
||||
struct EncryptedRequestData
|
||||
@@ -36,6 +38,7 @@ private:
|
||||
QByteArray iv;
|
||||
QByteArray salt;
|
||||
amnezia::ErrorCode errorCode;
|
||||
bool isPlaintextLocalGateway = false;
|
||||
};
|
||||
|
||||
struct DecryptionResult
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
using namespace amnezia;
|
||||
|
||||
namespace {
|
||||
#ifdef AMNEZIA_LOCAL_GATEWAY
|
||||
// Prefer 127.0.0.1 with local mock (tools/local_gateway listens on 0.0.0.0:8080); avoids LAN/IPv6 ambiguity in dev.
|
||||
constexpr char gatewayEndpoint[] = "http://127.0.0.1:8080/";
|
||||
#else
|
||||
constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/";
|
||||
#endif
|
||||
}
|
||||
|
||||
SecureAppSettingsRepository::SecureAppSettingsRepository(SecureQSettings* settings, QObject *parent)
|
||||
@@ -246,6 +251,15 @@ void SecureAppSettingsRepository::setAppsSplitTunnelingEnabled(bool enabled)
|
||||
QString SecureAppSettingsRepository::getGatewayEndpoint(bool isTestPurchase) const
|
||||
{
|
||||
if (isTestPurchase) {
|
||||
// App Store / sandbox subscriptions set isTestPurchase; the stock rule swaps the base URL to
|
||||
// DEV_AGW_ENDPOINT. For tools/local_gateway (127.0.0.1 / localhost) that sends encrypted
|
||||
// traffic to the wrong host, decryption fails, shouldBypassProxy pulls S3 — crash or "Send failed".
|
||||
const QString &base = m_gatewayEndpoint;
|
||||
if (base.contains(QStringLiteral("127.0.0.1"), Qt::CaseInsensitive)
|
||||
|| base.contains(QStringLiteral("localhost"), Qt::CaseInsensitive)
|
||||
|| base.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)) {
|
||||
return m_gatewayEndpoint;
|
||||
}
|
||||
return QString(DEV_AGW_ENDPOINT);
|
||||
}
|
||||
return m_gatewayEndpoint;
|
||||
|
||||
@@ -103,6 +103,7 @@ namespace amnezia
|
||||
ApiPairingConflictError = 1118,
|
||||
ApiPairingRateLimitedError = 1119,
|
||||
ApiPairingServiceUnavailableError = 1120,
|
||||
ApiPairingPayloadTooLargeError = 1121,
|
||||
|
||||
// QFile errors
|
||||
OpenError = 1200,
|
||||
|
||||
@@ -87,6 +87,7 @@ QString errorString(ErrorCode code) {
|
||||
case (ErrorCode::ApiPairingConflictError): errorMessage = QObject::tr("This QR code has already been used"); break;
|
||||
case (ErrorCode::ApiPairingRateLimitedError): errorMessage = QObject::tr("Too many requests. Please try again later"); break;
|
||||
case (ErrorCode::ApiPairingServiceUnavailableError): errorMessage = QObject::tr("Service temporarily unavailable. Please try again later"); break;
|
||||
case (ErrorCode::ApiPairingPayloadTooLargeError): errorMessage = QObject::tr("QR pairing data is too large to send"); break;
|
||||
|
||||
// QFile errors
|
||||
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "android_controller.h"
|
||||
#include "android_utils.h"
|
||||
#include "ui/controllers/importUiController.h"
|
||||
#include "ui/controllers/api/pairingUiController.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -538,7 +539,11 @@ bool AndroidController::decodeQrCode(JNIEnv *env, jobject thiz, jstring data)
|
||||
{
|
||||
Q_UNUSED(thiz);
|
||||
|
||||
return ImportUiController::decodeQrCode(AndroidUtils::convertJString(env, data));
|
||||
const QString code = AndroidUtils::convertJString(env, data);
|
||||
if (PairingUiController::tryConsumeAndroidQrScan(code)) {
|
||||
return true;
|
||||
}
|
||||
return ImportUiController::decodeQrCode(code);
|
||||
}
|
||||
// static
|
||||
void AndroidController::onImeInsetsChanged(JNIEnv *env, jobject thiz, jint heightDp)
|
||||
|
||||
@@ -140,6 +140,15 @@ target_link_libraries(test_self_hosted_server_setup PRIVATE
|
||||
test_common
|
||||
)
|
||||
|
||||
add_executable(test_pairing_parsers
|
||||
testPairingParsers.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(test_pairing_parsers PRIVATE
|
||||
Qt6::Test
|
||||
test_common
|
||||
)
|
||||
|
||||
enable_testing()
|
||||
add_test(NAME ImportExportTest COMMAND test_import_export)
|
||||
add_test(NAME MultipleImportsTest COMMAND test_multiple_imports)
|
||||
@@ -153,3 +162,4 @@ add_test(NAME ComplexOperationsTest COMMAND test_complex_operations)
|
||||
add_test(NAME SettingsSignalsTest COMMAND test_settings_signals)
|
||||
add_test(NAME UiServersModelAndControllerTest COMMAND test_ui_servers_model_and_controller)
|
||||
add_test(NAME SelfHostedServerSetupTest COMMAND test_self_hosted_server_setup)
|
||||
add_test(NAME PairingParsersTest COMMAND test_pairing_parsers)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSignalSpy>
|
||||
#include <QTest>
|
||||
|
||||
#include "core/controllers/api/pairingController.h"
|
||||
#include "ui/controllers/api/pairingUiController.h"
|
||||
#include "core/utils/constants/apiKeys.h"
|
||||
|
||||
using namespace amnezia;
|
||||
|
||||
class TestPairingParsers : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void generateQr_success_extractsConfigAndMeta()
|
||||
{
|
||||
PairingController::QrPairingConfigPayload out;
|
||||
QJsonObject o;
|
||||
o[apiDefs::key::config] = QStringLiteral("vpn://dummy");
|
||||
o[apiDefs::key::serviceInfo] = QJsonObject { { QStringLiteral("is_ad_visible"), false } };
|
||||
o[apiDefs::key::supportedProtocols] = QJsonArray { QStringLiteral("awg") };
|
||||
const QByteArray body = QJsonDocument(o).toJson();
|
||||
|
||||
QCOMPARE(PairingController::parseGenerateQrResponseBody(body, out), ErrorCode::NoError);
|
||||
QCOMPARE(out.config, QStringLiteral("vpn://dummy"));
|
||||
QCOMPARE(out.supportedProtocols.size(), 1);
|
||||
}
|
||||
|
||||
void generateQr_http408()
|
||||
{
|
||||
PairingController::QrPairingConfigPayload out;
|
||||
QJsonObject o;
|
||||
o[QStringLiteral("http_status")] = 408;
|
||||
o[QStringLiteral("message")] = QStringLiteral("Request Timeout");
|
||||
const QByteArray body = QJsonDocument(o).toJson();
|
||||
|
||||
QCOMPARE(PairingController::parseGenerateQrResponseBody(body, out), ErrorCode::ApiConfigTimeoutError);
|
||||
QVERIFY(out.config.isEmpty());
|
||||
}
|
||||
|
||||
void generateQr_http429()
|
||||
{
|
||||
PairingController::QrPairingConfigPayload out;
|
||||
QJsonObject o;
|
||||
o[QStringLiteral("http_status")] = 429;
|
||||
o[QStringLiteral("message")] = QStringLiteral("Too Many Requests");
|
||||
const QByteArray body = QJsonDocument(o).toJson();
|
||||
|
||||
QCOMPARE(PairingController::parseGenerateQrResponseBody(body, out), ErrorCode::ApiPairingRateLimitedError);
|
||||
}
|
||||
|
||||
void scanQr_messageOk()
|
||||
{
|
||||
QJsonObject o;
|
||||
o[QStringLiteral("message")] = QStringLiteral("OK");
|
||||
const QByteArray body = QJsonDocument(o).toJson();
|
||||
|
||||
QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::NoError);
|
||||
}
|
||||
|
||||
void scanQr_http403()
|
||||
{
|
||||
QJsonObject o;
|
||||
o[QStringLiteral("http_status")] = 403;
|
||||
const QByteArray body = QJsonDocument(o).toJson();
|
||||
|
||||
QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiPairingForbiddenError);
|
||||
}
|
||||
|
||||
void scanQr_http409()
|
||||
{
|
||||
QJsonObject o;
|
||||
o[QStringLiteral("http_status")] = 409;
|
||||
const QByteArray body = QJsonDocument(o).toJson();
|
||||
|
||||
QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiPairingConflictError);
|
||||
}
|
||||
|
||||
void scanQr_notFoundMessage()
|
||||
{
|
||||
QJsonObject o;
|
||||
o[QStringLiteral("message")] = QStringLiteral("Session not found");
|
||||
const QByteArray body = QJsonDocument(o).toJson();
|
||||
|
||||
QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiNotFoundError);
|
||||
}
|
||||
|
||||
void validateScanFields_oversizedVpnKey()
|
||||
{
|
||||
QString vpnKey;
|
||||
vpnKey.fill(QLatin1Char('x'), 256 * 1024 + 1);
|
||||
QCOMPARE(PairingController::validatePairingScanFields(QStringLiteral("ab"), vpnKey, QStringLiteral("k")),
|
||||
ErrorCode::ApiPairingPayloadTooLargeError);
|
||||
}
|
||||
|
||||
void validateScanFields_uuidTooLong()
|
||||
{
|
||||
QString uuid(200, QLatin1Char('a'));
|
||||
QCOMPARE(PairingController::validatePairingScanFields(uuid, QStringLiteral("vpn://a"), QStringLiteral("k")),
|
||||
ErrorCode::ApiConfigEmptyError);
|
||||
}
|
||||
|
||||
void pairingUi_applyScanned_extractsUuid_emitsSignal()
|
||||
{
|
||||
PairingUiController ctl(nullptr, nullptr, nullptr, nullptr);
|
||||
QSignalSpy spy(&ctl, &PairingUiController::pairingUuidFromScan);
|
||||
const QString u = QStringLiteral("123e4567-e89b-12d3-a456-426614174000");
|
||||
QVERIFY(ctl.applyScannedTextAsPairingUuid(QStringLiteral("prefix ") + u + QStringLiteral(" suffix")));
|
||||
QCOMPARE(spy.count(), 1);
|
||||
QCOMPARE(spy.first().first().toString(), u);
|
||||
}
|
||||
|
||||
void pairingUi_applyScanned_rejectsVpnKey()
|
||||
{
|
||||
PairingUiController ctl(nullptr, nullptr, nullptr, nullptr);
|
||||
QSignalSpy spy(&ctl, &PairingUiController::pairingUuidFromScan);
|
||||
QVERIFY(!ctl.applyScannedTextAsPairingUuid(QStringLiteral("vpn://AAAA")));
|
||||
QCOMPARE(spy.count(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestPairingParsers)
|
||||
#include "testPairingParsers.moc"
|
||||
@@ -1,7 +1,13 @@
|
||||
#include "pairingUiController.h"
|
||||
|
||||
#include <QRegularExpression>
|
||||
#include <QTimer>
|
||||
#include <QUuid>
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
#include "platforms/android/android_controller.h"
|
||||
#endif
|
||||
|
||||
#include "core/controllers/gatewayController.h"
|
||||
#include "core/models/serverConfig.h"
|
||||
#include "core/models/api/apiV2ServerConfig.h"
|
||||
@@ -14,8 +20,33 @@ namespace
|
||||
{
|
||||
constexpr auto kGenerateQrPath = "%1api/v1/generate_qr";
|
||||
constexpr auto kScanQrPath = "%1api/v1/scan_qr";
|
||||
constexpr int kPairingRetryMaxAttempts = 3;
|
||||
|
||||
bool isPairingRetriableError(ErrorCode code)
|
||||
{
|
||||
switch (code) {
|
||||
case ErrorCode::ApiPairingRateLimitedError:
|
||||
case ErrorCode::ApiPairingServiceUnavailableError:
|
||||
case ErrorCode::ApiConfigDownloadError:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int pairingRetryDelayMs(int zeroBasedAttempt)
|
||||
{
|
||||
constexpr int baseMs = 500;
|
||||
return baseMs * (1 << zeroBasedAttempt);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
namespace {
|
||||
PairingUiController *g_pairingUiForAndroidQr = nullptr;
|
||||
}
|
||||
#endif
|
||||
|
||||
PairingUiController::PairingUiController(PairingController *pairingController, ServersController *serversController,
|
||||
SubscriptionController *subscriptionController,
|
||||
SecureAppSettingsRepository *appSettingsRepository, QObject *parent)
|
||||
@@ -25,8 +56,63 @@ PairingUiController::PairingUiController(PairingController *pairingController, S
|
||||
m_subscriptionController(subscriptionController),
|
||||
m_appSettingsRepository(appSettingsRepository)
|
||||
{
|
||||
#if defined(Q_OS_ANDROID)
|
||||
g_pairingUiForAndroidQr = this;
|
||||
#endif
|
||||
}
|
||||
|
||||
PairingUiController::~PairingUiController()
|
||||
{
|
||||
#if defined(Q_OS_ANDROID)
|
||||
if (g_pairingUiForAndroidQr == this) {
|
||||
g_pairingUiForAndroidQr = nullptr;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void PairingUiController::setTvPairingUiPhase(int phase)
|
||||
{
|
||||
if (m_tvPairingUiPhase == phase) {
|
||||
return;
|
||||
}
|
||||
m_tvPairingUiPhase = phase;
|
||||
emit tvPairingUiPhaseChanged();
|
||||
}
|
||||
|
||||
void PairingUiController::openPairingQrScanner()
|
||||
{
|
||||
#if defined(Q_OS_ANDROID)
|
||||
AndroidController::instance()->startQrReaderActivity();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw)
|
||||
{
|
||||
const QString t = raw.trimmed();
|
||||
if (t.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) {
|
||||
return false;
|
||||
}
|
||||
static const QRegularExpression re(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 = re.match(t);
|
||||
if (!m.hasMatch()) {
|
||||
return false;
|
||||
}
|
||||
const QString uuid = m.captured(0);
|
||||
emit pairingUuidFromScan(uuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
bool PairingUiController::tryConsumeAndroidQrScan(const QString &code)
|
||||
{
|
||||
if (!g_pairingUiForAndroidQr) {
|
||||
return false;
|
||||
}
|
||||
return g_pairingUiForAndroidQr->applyScannedTextAsPairingUuid(code);
|
||||
}
|
||||
#endif
|
||||
|
||||
QVariantList PairingUiController::tvQrCodes() const
|
||||
{
|
||||
QVariantList list;
|
||||
@@ -93,6 +179,18 @@ void PairingUiController::resetTvQrDisplay()
|
||||
emit tvSessionUuidChanged();
|
||||
}
|
||||
|
||||
QString PairingUiController::tvFailureMessage(ErrorCode code) const
|
||||
{
|
||||
switch (code) {
|
||||
case ErrorCode::ApiConfigTimeoutError:
|
||||
return tr("QR session expired. Tap Start to show a new QR code.");
|
||||
case ErrorCode::ApiConfigAlreadyAdded:
|
||||
return tr("This configuration is already on the device.");
|
||||
default:
|
||||
return tr("Pairing failed");
|
||||
}
|
||||
}
|
||||
|
||||
void PairingUiController::startTvQrSession()
|
||||
{
|
||||
if (!m_pairingController || !m_appSettingsRepository) {
|
||||
@@ -108,6 +206,9 @@ void PairingUiController::startTvQrSession()
|
||||
m_tvWatcher.clear();
|
||||
}
|
||||
|
||||
++m_tvSessionGeneration;
|
||||
const quint64 generation = m_tvSessionGeneration;
|
||||
|
||||
m_tvSessionUuid = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
const QByteArray qrPayload = m_tvSessionUuid.toUtf8();
|
||||
m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeries(qrPayload);
|
||||
@@ -118,6 +219,19 @@ void PairingUiController::startTvQrSession()
|
||||
emit tvStatusMessageChanged();
|
||||
|
||||
setTvBusy(true);
|
||||
setTvPairingUiPhase(1);
|
||||
|
||||
dispatchTvGenerateQrAttempt(generation, 0);
|
||||
}
|
||||
|
||||
void PairingUiController::dispatchTvGenerateQrAttempt(quint64 generation, int retryAttempt)
|
||||
{
|
||||
if (!m_pairingController || !m_appSettingsRepository) {
|
||||
return;
|
||||
}
|
||||
if (generation != m_tvSessionGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool isTestPurchase = false;
|
||||
auto gatewayController = QSharedPointer<GatewayController>::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase),
|
||||
@@ -126,12 +240,15 @@ void PairingUiController::startTvQrSession()
|
||||
m_appSettingsRepository->isStrictKillSwitchEnabled());
|
||||
|
||||
const QJsonObject payload = m_pairingController->buildGenerateQrPayload(m_tvSessionUuid);
|
||||
const QFuture<QPair<ErrorCode, QByteArray>> future = gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload);
|
||||
QNetworkReply *replyRaw = nullptr;
|
||||
const QFuture<QPair<ErrorCode, QByteArray>> future =
|
||||
gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload, &replyRaw);
|
||||
m_tvNetworkReply = replyRaw;
|
||||
|
||||
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
|
||||
m_tvWatcher = watcher;
|
||||
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::finished, this,
|
||||
[this, gatewayController, watcher]() {
|
||||
[this, gatewayController, watcher, generation, retryAttempt]() {
|
||||
Q_UNUSED(gatewayController);
|
||||
const auto result = watcher->result();
|
||||
watcher->deleteLater();
|
||||
@@ -139,33 +256,67 @@ void PairingUiController::startTvQrSession()
|
||||
m_tvWatcher.clear();
|
||||
}
|
||||
|
||||
setTvBusy(false);
|
||||
|
||||
if (result.first != ErrorCode::NoError) {
|
||||
m_tvStatusMessage = tr("Pairing failed");
|
||||
emit tvStatusMessageChanged();
|
||||
emit errorOccurred(result.first);
|
||||
if (generation != m_tvSessionGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_tvNetworkReply.clear();
|
||||
|
||||
PairingController::QrPairingConfigPayload out;
|
||||
const ErrorCode parseErr = PairingController::parseGenerateQrResponseBody(result.second, out);
|
||||
if (parseErr != ErrorCode::NoError) {
|
||||
m_tvStatusMessage = tr("Pairing failed");
|
||||
ErrorCode logicalErr = result.first;
|
||||
if (logicalErr == ErrorCode::NoError) {
|
||||
logicalErr = PairingController::parseGenerateQrResponseBody(result.second, out);
|
||||
}
|
||||
|
||||
if (logicalErr == ErrorCode::NoError) {
|
||||
ServerConfig importedConfig;
|
||||
const ErrorCode impErr = m_subscriptionController->importServerFromQrPairingResponse(
|
||||
out.config, out.serviceInfo, out.supportedProtocols, importedConfig);
|
||||
Q_UNUSED(importedConfig);
|
||||
setTvBusy(false);
|
||||
if (impErr != ErrorCode::NoError) {
|
||||
setTvPairingUiPhase(2);
|
||||
m_tvStatusMessage = tvFailureMessage(impErr);
|
||||
emit tvStatusMessageChanged();
|
||||
emit errorOccurred(impErr);
|
||||
resetTvQrDisplay();
|
||||
return;
|
||||
}
|
||||
resetTvQrDisplay();
|
||||
m_tvStatusMessage = tr("Configuration received");
|
||||
emit tvStatusMessageChanged();
|
||||
emit errorOccurred(parseErr);
|
||||
emit tvPairingConfigReceived();
|
||||
setTvPairingUiPhase(0);
|
||||
return;
|
||||
}
|
||||
|
||||
m_tvStatusMessage = tr("Configuration received");
|
||||
if (isPairingRetriableError(logicalErr) && retryAttempt + 1 < kPairingRetryMaxAttempts) {
|
||||
const int delayMs = pairingRetryDelayMs(retryAttempt);
|
||||
QTimer::singleShot(delayMs, this, [this, generation, retryAttempt]() {
|
||||
if (generation != m_tvSessionGeneration) {
|
||||
return;
|
||||
}
|
||||
dispatchTvGenerateQrAttempt(generation, retryAttempt + 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTvBusy(false);
|
||||
setTvPairingUiPhase(logicalErr == ErrorCode::ApiConfigTimeoutError ? 3 : 2);
|
||||
m_tvStatusMessage = tvFailureMessage(logicalErr);
|
||||
emit tvStatusMessageChanged();
|
||||
emit tvPairingConfigReceived();
|
||||
emit errorOccurred(logicalErr);
|
||||
});
|
||||
watcher->setFuture(future);
|
||||
}
|
||||
|
||||
void PairingUiController::cancelTvQrSession()
|
||||
{
|
||||
++m_tvSessionGeneration;
|
||||
if (m_tvNetworkReply) {
|
||||
m_tvNetworkReply->abort();
|
||||
}
|
||||
m_tvNetworkReply.clear();
|
||||
if (m_tvWatcher) {
|
||||
m_tvWatcher->disconnect();
|
||||
m_tvWatcher->deleteLater();
|
||||
@@ -175,6 +326,26 @@ void PairingUiController::cancelTvQrSession()
|
||||
m_tvStatusMessage.clear();
|
||||
emit tvStatusMessageChanged();
|
||||
resetTvQrDisplay();
|
||||
setTvPairingUiPhase(0);
|
||||
}
|
||||
|
||||
void PairingUiController::cancelAllPairingActivity()
|
||||
{
|
||||
++m_phoneSessionGeneration;
|
||||
if (m_phoneNetworkReply) {
|
||||
m_phoneNetworkReply->abort();
|
||||
}
|
||||
m_phoneNetworkReply.clear();
|
||||
if (m_phoneWatcher) {
|
||||
m_phoneWatcher->disconnect();
|
||||
m_phoneWatcher->deleteLater();
|
||||
m_phoneWatcher.clear();
|
||||
}
|
||||
setPhoneBusy(false);
|
||||
m_phoneStatusMessage.clear();
|
||||
emit phoneStatusMessageChanged();
|
||||
|
||||
cancelTvQrSession();
|
||||
}
|
||||
|
||||
void PairingUiController::submitPhonePairing(const QString &qrUuid, int serverIndex)
|
||||
@@ -224,29 +395,50 @@ void PairingUiController::submitPhonePairing(const QString &qrUuid, int serverIn
|
||||
return;
|
||||
}
|
||||
|
||||
const ErrorCode fieldErr = PairingController::validatePairingScanFields(trimmedUuid, vpnKey, apiKey);
|
||||
if (fieldErr != ErrorCode::NoError) {
|
||||
emit errorOccurred(fieldErr);
|
||||
return;
|
||||
}
|
||||
|
||||
++m_phoneSessionGeneration;
|
||||
const quint64 phoneGeneration = m_phoneSessionGeneration;
|
||||
|
||||
m_phoneStatusMessage = tr("Sending…");
|
||||
emit phoneStatusMessageChanged();
|
||||
setPhoneBusy(true);
|
||||
|
||||
runPhonePairingRequest(trimmedUuid, apiV2->apiConfig.isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey);
|
||||
dispatchPhoneScanQrAttempt(trimmedUuid, apiV2->apiConfig.isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey,
|
||||
phoneGeneration, 0);
|
||||
}
|
||||
|
||||
void PairingUiController::runPhonePairingRequest(const QString &qrUuid, const bool isTestPurchase, const QString &vpnKey,
|
||||
const QJsonObject &serviceInfo, const QJsonArray &supportedProtocols,
|
||||
const QString &apiKey)
|
||||
void PairingUiController::dispatchPhoneScanQrAttempt(const QString &qrUuid, const bool isTestPurchase, const QString &vpnKey,
|
||||
const QJsonObject &serviceInfo, const QJsonArray &supportedProtocols,
|
||||
const QString &apiKey, quint64 generation, int retryAttempt)
|
||||
{
|
||||
if (!m_pairingController || !m_appSettingsRepository) {
|
||||
return;
|
||||
}
|
||||
if (generation != m_phoneSessionGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto gatewayController = QSharedPointer<GatewayController>::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase),
|
||||
m_appSettingsRepository->isDevGatewayEnv(isTestPurchase),
|
||||
apiDefs::requestTimeoutMsecs,
|
||||
m_appSettingsRepository->isStrictKillSwitchEnabled());
|
||||
|
||||
const QJsonObject payload = m_pairingController->buildScanQrPayload(qrUuid, vpnKey, serviceInfo, supportedProtocols, apiKey);
|
||||
const QFuture<QPair<ErrorCode, QByteArray>> future = gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload);
|
||||
QNetworkReply *replyRaw = nullptr;
|
||||
const QFuture<QPair<ErrorCode, QByteArray>> future =
|
||||
gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload, &replyRaw);
|
||||
m_phoneNetworkReply = replyRaw;
|
||||
|
||||
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
|
||||
m_phoneWatcher = watcher;
|
||||
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::finished, this,
|
||||
[this, gatewayController, watcher]() {
|
||||
[this, gatewayController, watcher, generation, retryAttempt, qrUuid, isTestPurchase, vpnKey, serviceInfo,
|
||||
supportedProtocols, apiKey]() {
|
||||
Q_UNUSED(gatewayController);
|
||||
const auto result = watcher->result();
|
||||
watcher->deleteLater();
|
||||
@@ -254,26 +446,42 @@ void PairingUiController::runPhonePairingRequest(const QString &qrUuid, const bo
|
||||
m_phoneWatcher.clear();
|
||||
}
|
||||
|
||||
if (generation != m_phoneSessionGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_phoneNetworkReply.clear();
|
||||
|
||||
ErrorCode logicalErr = result.first;
|
||||
if (logicalErr == ErrorCode::NoError) {
|
||||
logicalErr = PairingController::parseScanQrResponseBody(result.second);
|
||||
}
|
||||
|
||||
if (logicalErr == ErrorCode::NoError) {
|
||||
setPhoneBusy(false);
|
||||
m_phoneStatusMessage = tr("Sent successfully");
|
||||
emit phoneStatusMessageChanged();
|
||||
emit phonePairingSucceeded();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPairingRetriableError(logicalErr) && retryAttempt + 1 < kPairingRetryMaxAttempts) {
|
||||
const int delayMs = pairingRetryDelayMs(retryAttempt);
|
||||
QTimer::singleShot(delayMs, this, [this, qrUuid, isTestPurchase, vpnKey, serviceInfo, supportedProtocols,
|
||||
apiKey, generation, retryAttempt]() {
|
||||
if (generation != m_phoneSessionGeneration) {
|
||||
return;
|
||||
}
|
||||
dispatchPhoneScanQrAttempt(qrUuid, isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey,
|
||||
generation, retryAttempt + 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPhoneBusy(false);
|
||||
|
||||
if (result.first != ErrorCode::NoError) {
|
||||
m_phoneStatusMessage = tr("Send failed");
|
||||
emit phoneStatusMessageChanged();
|
||||
emit errorOccurred(result.first);
|
||||
return;
|
||||
}
|
||||
|
||||
const ErrorCode parseErr = PairingController::parseScanQrResponseBody(result.second);
|
||||
if (parseErr != ErrorCode::NoError) {
|
||||
m_phoneStatusMessage = tr("Send failed");
|
||||
emit phoneStatusMessageChanged();
|
||||
emit errorOccurred(parseErr);
|
||||
return;
|
||||
}
|
||||
|
||||
m_phoneStatusMessage = tr("Sent successfully");
|
||||
m_phoneStatusMessage = tr("Send failed");
|
||||
emit phoneStatusMessageChanged();
|
||||
emit phonePairingSucceeded();
|
||||
emit errorOccurred(logicalErr);
|
||||
});
|
||||
watcher->setFuture(future);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define PAIRINGUICONTROLLER_H
|
||||
|
||||
#include <QFutureWatcher>
|
||||
#include <QNetworkReply>
|
||||
#include <QObject>
|
||||
#include <QVariantList>
|
||||
#include <QPointer>
|
||||
@@ -26,11 +27,14 @@ class PairingUiController : public QObject
|
||||
|
||||
Q_PROPERTY(bool phonePairingBusy READ phonePairingBusy NOTIFY phonePairingBusyChanged)
|
||||
Q_PROPERTY(QString phoneStatusMessage READ phoneStatusMessage NOTIFY phoneStatusMessageChanged)
|
||||
/** TV flow for QA: 0=idle, 1=waitingForPeer, 2=error, 3=sessionExpired */
|
||||
Q_PROPERTY(int tvPairingUiPhase READ tvPairingUiPhase NOTIFY tvPairingUiPhaseChanged)
|
||||
|
||||
public:
|
||||
PairingUiController(PairingController *pairingController, ServersController *serversController,
|
||||
SubscriptionController *subscriptionController, SecureAppSettingsRepository *appSettingsRepository,
|
||||
QObject *parent = nullptr);
|
||||
~PairingUiController() override;
|
||||
|
||||
QVariantList tvQrCodes() const;
|
||||
int tvQrCodesCount() const;
|
||||
@@ -40,14 +44,27 @@ public:
|
||||
|
||||
bool phonePairingBusy() const;
|
||||
QString phoneStatusMessage() const;
|
||||
int tvPairingUiPhase() const { return m_tvPairingUiPhase; }
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
static bool tryConsumeAndroidQrScan(const QString &code);
|
||||
#endif
|
||||
|
||||
public slots:
|
||||
void startTvQrSession();
|
||||
void cancelTvQrSession();
|
||||
/** TV receive + phone send: call when leaving QR pairing (back / pop) so long-poll state does not stick. */
|
||||
void cancelAllPairingActivity();
|
||||
|
||||
/** Sends the current premium/free API config from \a serverIndex to the gateway for the given \a qrUuid. */
|
||||
void submitPhonePairing(const QString &qrUuid, int serverIndex);
|
||||
|
||||
/** Android: system camera activity. iOS: toggle camera from QML. */
|
||||
void openPairingQrScanner();
|
||||
|
||||
/** If \a raw contains a session UUID (not vpn://), emits pairingUuidFromScan and returns true. */
|
||||
bool applyScannedTextAsPairingUuid(const QString &raw);
|
||||
|
||||
signals:
|
||||
void errorOccurred(amnezia::ErrorCode errorCode);
|
||||
void tvQrCodesChanged();
|
||||
@@ -60,12 +77,18 @@ signals:
|
||||
void tvPairingConfigReceived();
|
||||
void phonePairingSucceeded();
|
||||
|
||||
void pairingUuidFromScan(const QString &uuid);
|
||||
void tvPairingUiPhaseChanged();
|
||||
|
||||
private:
|
||||
void setTvBusy(bool busy);
|
||||
void setPhoneBusy(bool busy);
|
||||
void resetTvQrDisplay();
|
||||
void runPhonePairingRequest(const QString &qrUuid, bool isTestPurchase, const QString &vpnKey, const QJsonObject &serviceInfo,
|
||||
const QJsonArray &supportedProtocols, const QString &apiKey);
|
||||
QString tvFailureMessage(amnezia::ErrorCode code) const;
|
||||
void dispatchTvGenerateQrAttempt(quint64 generation, int retryAttempt);
|
||||
void dispatchPhoneScanQrAttempt(const QString &qrUuid, bool isTestPurchase, const QString &vpnKey, const QJsonObject &serviceInfo,
|
||||
const QJsonArray &supportedProtocols, const QString &apiKey, quint64 generation, int retryAttempt);
|
||||
void setTvPairingUiPhase(int phase);
|
||||
|
||||
PairingController *m_pairingController {};
|
||||
ServersController *m_serversController {};
|
||||
@@ -77,10 +100,15 @@ private:
|
||||
bool m_tvPairingBusy = false;
|
||||
QString m_tvStatusMessage;
|
||||
QPointer<QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>>> m_tvWatcher;
|
||||
QPointer<QNetworkReply> m_tvNetworkReply;
|
||||
quint64 m_tvSessionGeneration { 0 };
|
||||
int m_tvPairingUiPhase { 0 };
|
||||
|
||||
bool m_phonePairingBusy = false;
|
||||
QString m_phoneStatusMessage;
|
||||
QPointer<QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>>> m_phoneWatcher;
|
||||
QPointer<QNetworkReply> m_phoneNetworkReply;
|
||||
quint64 m_phoneSessionGeneration { 0 };
|
||||
};
|
||||
|
||||
#endif // PAIRINGUICONTROLLER_H
|
||||
|
||||
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import QRCodeReader 1.0
|
||||
import PageEnum 1.0
|
||||
import Style 1.0
|
||||
|
||||
@@ -14,6 +15,18 @@ PageType {
|
||||
id: root
|
||||
|
||||
property int qrImageIndex: 0
|
||||
property bool pairingCameraOpen: false
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onVisibleChanged() {
|
||||
if (!root.visible) {
|
||||
pairingQrReader.stopReading()
|
||||
root.pairingCameraOpen = false
|
||||
PairingUiController.cancelAllPairingActivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FlickableType {
|
||||
anchors.fill: parent
|
||||
@@ -74,8 +87,9 @@ PageType {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
defaultColor: "transparent"
|
||||
text: qsTr("Cancel receive")
|
||||
// Do not use defaultColor: transparent here: when enabled, BasicButtonType paints that
|
||||
// as the idle background, so midnightBlack label sits on the page — invisible until hover.
|
||||
enabled: PairingUiController.tvPairingBusy
|
||||
clickedFunc: function() {
|
||||
PairingUiController.cancelTvQrSession()
|
||||
@@ -91,21 +105,29 @@ PageType {
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Image {
|
||||
id: qrImage
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
// SVG QR from qrCodeUtils has a tiny viewBox (~45px); without a sized container + sourceSize it stays small.
|
||||
Item {
|
||||
id: qrBox
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
Layout.topMargin: 8
|
||||
implicitHeight: width
|
||||
visible: PairingUiController.tvQrCodesCount > 0
|
||||
width: Math.min(220, parent.width - 32)
|
||||
height: width
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: PairingUiController.tvQrCodesCount > 0 ? PairingUiController.tvQrCodes[root.qrImageIndex] : ""
|
||||
|
||||
MouseArea {
|
||||
Image {
|
||||
id: qrImage
|
||||
anchors.fill: parent
|
||||
enabled: PairingUiController.tvQrCodesCount > 1
|
||||
onClicked: {
|
||||
root.qrImageIndex = (root.qrImageIndex + 1) % PairingUiController.tvQrCodesCount
|
||||
fillMode: Image.PreserveAspectFit
|
||||
sourceSize: Qt.size(2048, 2048)
|
||||
source: PairingUiController.tvQrCodesCount > 0 ? PairingUiController.tvQrCodes[root.qrImageIndex] : ""
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: PairingUiController.tvQrCodesCount > 1
|
||||
onClicked: {
|
||||
root.qrImageIndex = (root.qrImageIndex + 1) % PairingUiController.tvQrCodesCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,12 +152,70 @@ PageType {
|
||||
textField.placeholderText: qsTr("Paste UUID from TV QR")
|
||||
}
|
||||
|
||||
BasicButtonType {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
visible: Qt.platform.os === "android" || Qt.platform.os === "ios"
|
||||
text: {
|
||||
if (Qt.platform.os === "ios" && root.pairingCameraOpen) {
|
||||
return qsTr("Hide camera")
|
||||
}
|
||||
return qsTr("Scan QR code")
|
||||
}
|
||||
enabled: !PairingUiController.phonePairingBusy
|
||||
clickedFunc: function() {
|
||||
if (Qt.platform.os === "android") {
|
||||
PairingUiController.openPairingQrScanner()
|
||||
} else {
|
||||
root.pairingCameraOpen = !root.pairingCameraOpen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: cameraSlot
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: (root.pairingCameraOpen && Qt.platform.os === "ios") ? 220 : 0
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
visible: Layout.preferredHeight > 0
|
||||
clip: true
|
||||
|
||||
// QRCodeReader is a QObject (not Item): no anchors; preview rect via setCameraSize like PageSetupWizardQrReader.
|
||||
QRCodeReader {
|
||||
id: pairingQrReader
|
||||
|
||||
onCodeReaded: function(code) {
|
||||
if (PairingUiController.applyScannedTextAsPairingUuid(code)) {
|
||||
pairingQrReader.stopReading()
|
||||
root.pairingCameraOpen = false
|
||||
PageController.showNotificationMessage(qsTr("Session ID filled from QR"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (!visible) {
|
||||
pairingQrReader.stopReading()
|
||||
return
|
||||
}
|
||||
if (Qt.platform.os === "ios") {
|
||||
Qt.callLater(function() {
|
||||
var p = cameraSlot.mapToItem(root, 0, 0)
|
||||
pairingQrReader.setCameraSize(Qt.rect(p.x, p.y, cameraSlot.width, cameraSlot.height))
|
||||
pairingQrReader.startReading()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BasicButtonType {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
text: PairingUiController.phonePairingBusy ? qsTr("Sending…") : qsTr("Send from current subscription")
|
||||
enabled: !PairingUiController.tvPairingBusy && !PairingUiController.phonePairingBusy
|
||||
enabled: !PairingUiController.phonePairingBusy
|
||||
clickedFunc: function() {
|
||||
PairingUiController.submitPhonePairing(uuidField.textField.text, ServersUiController.getProcessedServerIndex())
|
||||
}
|
||||
@@ -160,6 +240,11 @@ PageType {
|
||||
root.qrImageIndex = 0
|
||||
}
|
||||
|
||||
function onTvSessionUuidChanged() {
|
||||
root.qrImageIndex = 0
|
||||
uuidField.textField.text = PairingUiController.tvSessionUuid
|
||||
}
|
||||
|
||||
function onTvPairingConfigReceived() {
|
||||
PageController.showNotificationMessage(qsTr("Configuration received from gateway"))
|
||||
}
|
||||
@@ -167,5 +252,9 @@ PageType {
|
||||
function onPhonePairingSucceeded() {
|
||||
PageController.showNotificationMessage(qsTr("Configuration sent"))
|
||||
}
|
||||
|
||||
function onPairingUuidFromScan(uuid) {
|
||||
uuidField.textField.text = uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user