mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-21 02:01:03 +07:00
feat: add purchase to UI
This commit is contained in:
@@ -662,13 +662,55 @@ jobs:
|
||||
shell: bash
|
||||
run: ./deploy/build_android.sh --aab --play --apk all --build-platform ${{ env.ANDROID_BUILD_PLATFORM }}
|
||||
|
||||
- name: 'Build OSS AAB (in-app purchase)'
|
||||
env:
|
||||
ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }}
|
||||
QT_HOST_PATH: ${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/gcc_64
|
||||
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android.keystore
|
||||
ANDROID_KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }}
|
||||
ANDROID_KEYSTORE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }}
|
||||
shell: bash
|
||||
run: ./deploy/build_android.sh --aab --build-platform ${{ env.ANDROID_BUILD_PLATFORM }}
|
||||
|
||||
- name: 'Upload OSS x86_64 apk'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: AmneziaVPN-android-x86_64
|
||||
path: deploy/build/AmneziaVPN-oss-x86_64-release.apk
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
- name: 'Upload OSS x86 apk'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: AmneziaVPN-android-x86
|
||||
path: deploy/build/AmneziaVPN-oss-x86-release.apk
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
- name: 'Upload OSS arm64-v8a apk'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: AmneziaVPN-android-arm64-v8a
|
||||
path: deploy/build/AmneziaVPN-oss-arm64-v8a-release.apk
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
- name: 'Upload OSS armeabi-v7a apk'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: AmneziaVPN-android-armeabi-v7a
|
||||
path: deploy/build/AmneziaVPN-oss-armeabi-v7a-release.apk
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
- name: 'Rename Android APKs'
|
||||
run: |
|
||||
cd deploy/build
|
||||
mv AmneziaVPN-x86_64-release.apk AmneziaVPN_${VERSION}_android9+_x86_64.apk
|
||||
mv AmneziaVPN-x86-release.apk AmneziaVPN_${VERSION}_android9+_x86.apk
|
||||
mv AmneziaVPN-arm64-v8a-release.apk AmneziaVPN_${VERSION}_android9+_arm64-v8a.apk
|
||||
mv AmneziaVPN-armeabi-v7a-release.apk AmneziaVPN_${VERSION}_android9+_armeabi-v7a.apk
|
||||
mv AmneziaVPN-oss-x86_64-release.apk AmneziaVPN_${VERSION}_android9+_x86_64.apk
|
||||
mv AmneziaVPN-oss-x86-release.apk AmneziaVPN_${VERSION}_android9+_x86.apk
|
||||
mv AmneziaVPN-oss-arm64-v8a-release.apk AmneziaVPN_${VERSION}_android9+_arm64-v8a.apk
|
||||
mv AmneziaVPN-oss-armeabi-v7a-release.apk AmneziaVPN_${VERSION}_android9+_armeabi-v7a.apk
|
||||
cd ../..
|
||||
|
||||
- name: 'Upload x86_64 apk'
|
||||
@@ -703,7 +745,7 @@ jobs:
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
- name: 'Upload aab'
|
||||
- name: 'Upload Play AAB'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: AmneziaVPN-android
|
||||
@@ -711,6 +753,14 @@ jobs:
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
- name: 'Upload OSS AAB (in-app purchase)'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: AmneziaVPN-android-oss-aab
|
||||
path: deploy/build/AmneziaVPN-oss-release.aab
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
Extra:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
message("Client android ${CMAKE_ANDROID_ARCH_ABI} build")
|
||||
|
||||
# Option to build Play variant (with Google Play Billing) instead of OSS
|
||||
# When ON, adds target android_play_apk: cmake --build . --target android_play_apk
|
||||
option(ANDROID_BUILD_PLAY "Add android_play_apk target for Google Play Billing build" OFF)
|
||||
|
||||
set(APP_ANDROID_MIN_SDK 28)
|
||||
set(ANDROID_PLATFORM "android-${APP_ANDROID_MIN_SDK}" CACHE STRING
|
||||
"The minimum API level supported by the application or library" FORCE)
|
||||
@@ -57,3 +61,22 @@ endforeach()
|
||||
|
||||
file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/3rd-prebuilt/3rd-prebuilt/xray/android/libxray.aar
|
||||
DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/android/xray/libXray)
|
||||
|
||||
# Custom target to build Play variant (with Google Play Billing)
|
||||
# Enable with: cmake -DANDROID_BUILD_PLAY=ON ...
|
||||
# Then run: cmake --build <build_dir> --target android_play_apk
|
||||
# Note: Do a normal build first so androiddeployqt creates the android-build folder
|
||||
if(ANDROID_BUILD_PLAY)
|
||||
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||
set(_gradle_suffix "Debug")
|
||||
else()
|
||||
set(_gradle_suffix "Release")
|
||||
endif()
|
||||
set(_android_build_dir "${CMAKE_CURRENT_BINARY_DIR}/android-build-${PROJECT}")
|
||||
add_custom_target(android_play_apk
|
||||
COMMAND ./gradlew assemblePlay${_gradle_suffix} -DexplicitRun=1
|
||||
WORKING_DIRECTORY "${_android_build_dir}"
|
||||
COMMENT "Building Android Play variant (assemblePlay${_gradle_suffix})"
|
||||
DEPENDS ${PROJECT}
|
||||
)
|
||||
endif()
|
||||
|
||||
@@ -123,6 +123,7 @@ namespace amnezia
|
||||
ApiUpdateRequestError = 1111,
|
||||
ApiSubscriptionExpiredError = 1112,
|
||||
ApiPurchaseError = 1113,
|
||||
ApiNoPurchasesToRestore = 1114,
|
||||
|
||||
// QFile errors
|
||||
OpenError = 1200,
|
||||
|
||||
@@ -80,6 +80,15 @@ QString errorString(ErrorCode code) {
|
||||
case (ErrorCode::ApiUpdateRequestError): errorMessage = QObject::tr("Please update the application to use this feature"); break;
|
||||
case (ErrorCode::ApiSubscriptionExpiredError): errorMessage = QObject::tr("Your Amnezia Premium subscription has expired.\n Please check your email for renewal instructions.\n If you haven't received an email, please contact our support."); break;
|
||||
case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break;
|
||||
case (ErrorCode::ApiNoPurchasesToRestore):
|
||||
#if defined(Q_OS_ANDROID)
|
||||
errorMessage = QObject::tr("No purchases to restore. If you have an active subscription, make sure you're signed in with the same Google account used for the purchase.");
|
||||
#elif defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||
errorMessage = QObject::tr("No purchases to restore. If you have an active subscription, make sure you're signed in with the same Apple ID used for the purchase.");
|
||||
#else
|
||||
errorMessage = QObject::tr("No purchases to restore. If you have an active subscription, make sure you're signed in with the same account used for the purchase.");
|
||||
#endif
|
||||
break;
|
||||
|
||||
// QFile errors
|
||||
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
|
||||
|
||||
@@ -170,7 +170,7 @@ QJsonObject Deserialize(const QString &vmessStr, QString *alias, QString *errMes
|
||||
// - It can be empty, if so, if the key is not in the JSON, or the value is empty, report an error.
|
||||
// - Else if it contains one thing. if the key is not in the JSON, or the value is empty, use that one.
|
||||
// - Else if it contains many things, when the key IS in the JSON but not within the THINGS, use the first in the THINGS
|
||||
// - Else -------------------------------------------->>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> use the JSON value
|
||||
// - Else -------------------------------------------- use the JSON value
|
||||
//
|
||||
#define __vmess_checker__func(key, values) \
|
||||
{ \
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
#include <QClipboard>
|
||||
#include <QDebug>
|
||||
#include <QEventLoop>
|
||||
#include <QFutureWatcher>
|
||||
#include <QSet>
|
||||
#include <QtConcurrent>
|
||||
|
||||
#include "platforms/ios/ios_controller.h"
|
||||
#ifdef Q_OS_ANDROID
|
||||
#include "platforms/android/android_controller.h"
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -425,6 +430,86 @@ bool ApiConfigsController::fillAvailableServices()
|
||||
}
|
||||
}
|
||||
}
|
||||
#elif defined(Q_OS_ANDROID)
|
||||
// Get price from Google Play Billing
|
||||
auto androidController = AndroidController::instance();
|
||||
QJsonObject plansResult = androidController->getSubscriptionPlans();
|
||||
int responseCode = plansResult.value("responseCode").toInt(-1);
|
||||
|
||||
if (responseCode == 0) {
|
||||
QJsonArray products = plansResult.value("products").toArray();
|
||||
QString formattedPrice;
|
||||
int billingPeriodDays = 180;
|
||||
for (const QJsonValue &productValue : products) {
|
||||
QJsonObject product = productValue.toObject();
|
||||
if (product.value("productId").toString() == "premium") {
|
||||
QJsonArray offers = product.value("offers").toArray();
|
||||
if (!offers.isEmpty()) {
|
||||
QJsonObject firstOffer = offers.at(0).toObject();
|
||||
QJsonArray pricingPhases = firstOffer.value("pricingPhases").toArray();
|
||||
if (!pricingPhases.isEmpty()) {
|
||||
QJsonObject pricingPhase = pricingPhases.at(0).toObject();
|
||||
formattedPrice = pricingPhase.value("formatedPrice").toString();
|
||||
QString billingPeriod = pricingPhase.value("billingPeriod").toString();
|
||||
if (billingPeriod.contains("D")) {
|
||||
int idx = billingPeriod.indexOf("D");
|
||||
billingPeriodDays = billingPeriod.mid(1, idx - 1).toInt();
|
||||
} else if (billingPeriod.contains("M")) {
|
||||
int idx = billingPeriod.indexOf("M");
|
||||
int months = billingPeriod.mid(1, idx - 1).toInt();
|
||||
if (months > 0) billingPeriodDays = months * 30;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!formattedPrice.isEmpty()) {
|
||||
QJsonArray services = data.value("services").toArray();
|
||||
bool premiumFound = false;
|
||||
for (int i = 0; i < services.size(); ++i) {
|
||||
QJsonObject service = services[i].toObject();
|
||||
if (service.value(configKey::serviceType).toString() == serviceType::amneziaPremium) {
|
||||
QJsonObject serviceInfo = service.value(configKey::serviceInfo).toObject();
|
||||
serviceInfo["price"] = formattedPrice;
|
||||
service[configKey::serviceInfo] = serviceInfo;
|
||||
services[i] = service;
|
||||
data["services"] = services;
|
||||
premiumFound = true;
|
||||
qInfo() << "[Billing] Updated premium service price in data:" << formattedPrice;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!premiumFound) {
|
||||
// Gateway did not return premium; add it from billing data
|
||||
QString region = data.value(configKey::userCountryCode).toString();
|
||||
QJsonObject serviceInfo;
|
||||
serviceInfo["name"] = tr("Amnezia Premium");
|
||||
serviceInfo["price"] = formattedPrice;
|
||||
serviceInfo["region"] = region;
|
||||
serviceInfo["speed"] = "200";
|
||||
serviceInfo["timelimit"] = QString::number(billingPeriodDays);
|
||||
QJsonObject serviceDescription;
|
||||
serviceDescription["card_description"] = tr("Amnezia Premium is classic VPN for seamless work, downloading large files, and watching videos.");
|
||||
serviceDescription["description"] = serviceDescription["card_description"];
|
||||
serviceDescription["features"] = "";
|
||||
QJsonObject premiumService;
|
||||
premiumService[configKey::serviceType] = serviceType::amneziaPremium;
|
||||
premiumService[configKey::serviceProtocol] = "amnezia-premium";
|
||||
premiumService[configKey::serviceInfo] = serviceInfo;
|
||||
premiumService["service_description"] = serviceDescription;
|
||||
premiumService["available_countries"] = QJsonArray();
|
||||
premiumService["is_available"] = true;
|
||||
premiumService["store_endpoint"] = "";
|
||||
premiumService["subscription"] = QJsonObject();
|
||||
services.prepend(premiumService);
|
||||
data["services"] = services;
|
||||
qInfo() << "[Billing] Added premium service from billing (gateway did not return it)";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
qWarning() << "[Billing] Failed to fetch product price, responseCode:" << responseCode;
|
||||
}
|
||||
#endif
|
||||
|
||||
m_apiServicesModel->updateModel(data);
|
||||
@@ -436,25 +521,19 @@ bool ApiConfigsController::fillAvailableServices()
|
||||
|
||||
bool ApiConfigsController::importService()
|
||||
{
|
||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||
bool isIosOrMacOsNe = true;
|
||||
#else
|
||||
bool isIosOrMacOsNe = false;
|
||||
#endif
|
||||
|
||||
if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) {
|
||||
if (isIosOrMacOsNe) {
|
||||
importSerivceFromAppStore();
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
importServiceFromGateway();
|
||||
#if defined(Q_OS_IOS) || defined(MACOS_NE) || defined(Q_OS_ANDROID)
|
||||
importSerivceFromPaymentMarket();
|
||||
return true;
|
||||
#else
|
||||
return false; // premium only via App Store / Play
|
||||
#endif
|
||||
}
|
||||
return false;
|
||||
importServiceFromGateway();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ApiConfigsController::importSerivceFromAppStore()
|
||||
bool ApiConfigsController::importSerivceFromPaymentMarket()
|
||||
{
|
||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||
bool purchaseOk = false;
|
||||
@@ -511,12 +590,112 @@ bool ApiConfigsController::importSerivceFromAppStore()
|
||||
return false;
|
||||
}
|
||||
|
||||
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
||||
#elif defined(Q_OS_ANDROID)
|
||||
auto androidController = AndroidController::instance();
|
||||
QString purchaseToken;
|
||||
bool purchaseOk = false;
|
||||
|
||||
QFutureWatcher<QPair<bool, QString>> watcher;
|
||||
QEventLoop waitLoop;
|
||||
connect(&watcher, &QFutureWatcher<QPair<bool, QString>>::finished, &waitLoop, &QEventLoop::quit);
|
||||
|
||||
QFuture<QPair<bool, QString>> future = QtConcurrent::run([androidController]() {
|
||||
QJsonObject plansResult = androidController->getSubscriptionPlans();
|
||||
int responseCode = plansResult.value("responseCode").toInt(-1);
|
||||
if (responseCode != 0) {
|
||||
qWarning() << "[Billing] Failed to get subscription plans, responseCode:" << responseCode;
|
||||
return qMakePair(false, QString());
|
||||
}
|
||||
QJsonArray products = plansResult.value("products").toArray();
|
||||
QString offerToken;
|
||||
for (const QJsonValue &productValue : products) {
|
||||
QJsonObject product = productValue.toObject();
|
||||
if (product.value("productId").toString() == "premium") {
|
||||
QJsonArray offers = product.value("offers").toArray();
|
||||
if (!offers.isEmpty()) {
|
||||
QJsonObject firstOffer = offers.at(0).toObject();
|
||||
offerToken = firstOffer.value("offerToken").toString();
|
||||
qInfo() << "[Billing] Found offer token:" << offerToken;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (offerToken.isEmpty()) {
|
||||
qWarning() << "[Billing] No offer token found for premium subscription";
|
||||
return qMakePair(false, QString());
|
||||
}
|
||||
QJsonObject purchaseResult = androidController->purchaseSubscription(offerToken);
|
||||
responseCode = purchaseResult.value("responseCode").toInt(-1);
|
||||
if (responseCode != 0) {
|
||||
qWarning() << "[Billing] Purchase failed, responseCode:" << responseCode;
|
||||
return qMakePair(false, QString());
|
||||
}
|
||||
QJsonArray purchases = purchaseResult.value("purchases").toArray();
|
||||
if (purchases.isEmpty()) {
|
||||
qWarning() << "[Billing] Purchase succeeded but no purchases returned";
|
||||
return qMakePair(false, QString());
|
||||
}
|
||||
QJsonObject purchase = purchases.at(0).toObject();
|
||||
QString token = purchase.value("purchaseToken").toString();
|
||||
bool isAcknowledged = purchase.value("isAcknowledged").toBool();
|
||||
qInfo() << "[Billing] Purchase success. purchaseToken:" << token << "isAcknowledged:" << isAcknowledged;
|
||||
if (!isAcknowledged) {
|
||||
QJsonObject ackResult = androidController->acknowledgePurchase(token);
|
||||
if (ackResult.value("responseCode").toInt(-1) != 0) {
|
||||
qWarning() << "[Billing] Acknowledge failed";
|
||||
} else {
|
||||
qInfo() << "[Billing] Purchase acknowledged successfully";
|
||||
}
|
||||
}
|
||||
return qMakePair(true, token);
|
||||
});
|
||||
|
||||
watcher.setFuture(future);
|
||||
waitLoop.exec();
|
||||
|
||||
purchaseOk = watcher.result().first;
|
||||
purchaseToken = watcher.result().second;
|
||||
|
||||
if (!purchaseOk || purchaseToken.isEmpty()) {
|
||||
emit errorOccurred(ErrorCode::ApiPurchaseError);
|
||||
return false;
|
||||
}
|
||||
|
||||
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||
QString(APP_VERSION),
|
||||
m_settings->getAppLanguage().name().split("_").first(),
|
||||
m_settings->getInstallationUuid(true),
|
||||
m_apiServicesModel->getCountryCode(),
|
||||
"",
|
||||
m_apiServicesModel->getSelectedServiceType(),
|
||||
m_apiServicesModel->getSelectedServiceProtocol(),
|
||||
QJsonObject() };
|
||||
|
||||
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
||||
apiPayload[apiDefs::key::transactionId] = purchaseToken;
|
||||
bool isTestPurchase = false; // TODO: detect if this is a test purchase
|
||||
|
||||
ErrorCode errorCode;
|
||||
QByteArray responseBody;
|
||||
errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase);
|
||||
if (errorCode != ErrorCode::NoError) {
|
||||
emit errorOccurred(errorCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
errorCode = importServiceFromBilling(responseBody, isTestPurchase);
|
||||
if (errorCode != ErrorCode::NoError) {
|
||||
emit errorOccurred(errorCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ApiConfigsController::restoreSerivceFromAppStore()
|
||||
bool ApiConfigsController::restoreSerivceFromPaymentMarket()
|
||||
{
|
||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
||||
@@ -639,6 +818,131 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
|
||||
qInfo().noquote() << "[IAP] Skipped" << duplicateCount
|
||||
<< "duplicate restored transactions for original transaction IDs already processed";
|
||||
}
|
||||
#elif defined(Q_OS_ANDROID)
|
||||
// Android Google Play Billing restore implementation
|
||||
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
||||
|
||||
if (!fillAvailableServices()) {
|
||||
qWarning() << "[Billing] Unable to fetch services list before restore";
|
||||
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_apiServicesModel->rowCount() <= 0) {
|
||||
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure we have a valid premium selection for gateway requests
|
||||
bool premiumSelected = false;
|
||||
for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) {
|
||||
m_apiServicesModel->setServiceIndex(i);
|
||||
if (m_apiServicesModel->getSelectedServiceType() == premiumServiceType) {
|
||||
premiumSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!premiumSelected) {
|
||||
emit errorOccurred(ErrorCode::ApiServicesMissingError);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto androidController = AndroidController::instance();
|
||||
|
||||
// Query existing purchases
|
||||
QJsonObject purchasesResult = androidController->queryPurchases();
|
||||
int responseCode = purchasesResult.value("responseCode").toInt(-1);
|
||||
|
||||
if (responseCode != 0) {
|
||||
qWarning() << "[Billing] Failed to query purchases, responseCode:" << responseCode;
|
||||
emit errorOccurred(ErrorCode::ApiPurchaseError);
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonArray purchases = purchasesResult.value("purchases").toArray();
|
||||
|
||||
if (purchases.isEmpty()) {
|
||||
qInfo() << "[Billing] No purchases found to restore";
|
||||
emit errorOccurred(ErrorCode::ApiNoPurchasesToRestore);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasInstalledConfig = false;
|
||||
bool duplicateConfigAlreadyPresent = false;
|
||||
QSet<QString> processedTokens;
|
||||
|
||||
for (const QJsonValue &purchaseValue : purchases) {
|
||||
QJsonObject purchase = purchaseValue.toObject();
|
||||
QString purchaseToken = purchase.value("purchaseToken").toString();
|
||||
bool isAcknowledged = purchase.value("isAcknowledged").toBool();
|
||||
|
||||
if (purchaseToken.isEmpty()) {
|
||||
qWarning() << "[Billing] Skipping purchase without token";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processedTokens.contains(purchaseToken)) {
|
||||
continue;
|
||||
}
|
||||
processedTokens.insert(purchaseToken);
|
||||
|
||||
qInfo() << "[Billing] Restoring purchase. purchaseToken:" << purchaseToken
|
||||
<< "isAcknowledged:" << isAcknowledged;
|
||||
|
||||
// Acknowledge purchase if needed
|
||||
if (!isAcknowledged) {
|
||||
QJsonObject ackResult = androidController->acknowledgePurchase(purchaseToken);
|
||||
int ackResponseCode = ackResult.value("responseCode").toInt(-1);
|
||||
if (ackResponseCode != 0) {
|
||||
qWarning() << "[Billing] Acknowledge failed, responseCode:" << ackResponseCode;
|
||||
} else {
|
||||
qInfo() << "[Billing] Purchase acknowledged successfully";
|
||||
}
|
||||
}
|
||||
|
||||
// Send purchase token to gateway
|
||||
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
|
||||
QString(APP_VERSION),
|
||||
m_settings->getAppLanguage().name().split("_").first(),
|
||||
m_settings->getInstallationUuid(true),
|
||||
m_apiServicesModel->getCountryCode(),
|
||||
"",
|
||||
m_apiServicesModel->getSelectedServiceType(),
|
||||
m_apiServicesModel->getSelectedServiceProtocol(),
|
||||
QJsonObject() };
|
||||
|
||||
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
|
||||
apiPayload[apiDefs::key::transactionId] = purchaseToken;
|
||||
bool isTestPurchase = false; // TODO: detect if this is a test purchase
|
||||
|
||||
QByteArray responseBody;
|
||||
ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase);
|
||||
if (errorCode != ErrorCode::NoError) {
|
||||
qWarning() << "[Billing] Failed to restore purchase" << purchaseToken
|
||||
<< "errorCode =" << static_cast<int>(errorCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
ErrorCode installError = importServiceFromBilling(responseBody, isTestPurchase);
|
||||
if (installError == ErrorCode::ApiConfigAlreadyAdded) {
|
||||
duplicateConfigAlreadyPresent = true;
|
||||
qInfo() << "[Billing] Skipping restored purchase" << purchaseToken
|
||||
<< "because subscription config with the same vpn_key already exists";
|
||||
} else if (installError != ErrorCode::NoError) {
|
||||
qWarning() << "[Billing] Failed to process restored subscription response for purchase" << purchaseToken;
|
||||
} else {
|
||||
hasInstalledConfig = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasInstalledConfig) {
|
||||
const ErrorCode restoreError = duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError;
|
||||
emit errorOccurred(restoreError);
|
||||
return false;
|
||||
}
|
||||
|
||||
emit installServerFromApiFinished(tr("Subscription restored successfully."));
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
@@ -944,16 +1248,16 @@ QString ApiConfigsController::getVpnKey()
|
||||
|
||||
ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase)
|
||||
{
|
||||
#ifdef Q_OS_IOS
|
||||
#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID)
|
||||
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
|
||||
QString key = responseObject.value(QStringLiteral("key")).toString();
|
||||
if (key.isEmpty()) {
|
||||
qWarning().noquote() << "[IAP] Subscription response does not contain a key field";
|
||||
qWarning().noquote() << "[IAP/Billing] Subscription response does not contain a key field";
|
||||
return ErrorCode::ApiPurchaseError;
|
||||
}
|
||||
|
||||
if (m_serversModel->hasServerWithVpnKey(key)) {
|
||||
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
|
||||
qInfo().noquote() << "[IAP/Billing] Subscription config with the same vpn_key already exists";
|
||||
return ErrorCode::ApiConfigAlreadyAdded;
|
||||
}
|
||||
|
||||
@@ -967,7 +1271,7 @@ ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &respo
|
||||
}
|
||||
|
||||
if (configString.isEmpty()) {
|
||||
qWarning().noquote() << "[IAP] Subscription response config payload is empty";
|
||||
qWarning().noquote() << "[IAP/Billing] Subscription response config payload is empty";
|
||||
return ErrorCode::ApiPurchaseError;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ public slots:
|
||||
|
||||
bool fillAvailableServices();
|
||||
bool importService();
|
||||
bool importSerivceFromAppStore();
|
||||
bool restoreSerivceFromAppStore();
|
||||
bool importSerivceFromPaymentMarket();
|
||||
bool restoreSerivceFromPaymentMarket();
|
||||
bool importServiceFromGateway();
|
||||
bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
||||
bool reloadServiceConfig = false);
|
||||
|
||||
@@ -114,6 +114,8 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const
|
||||
}
|
||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||
return tr("%1 $").arg(price);
|
||||
#elif defined(Q_OS_ANDROID)
|
||||
return price;
|
||||
#else
|
||||
return tr("%1 $/month").arg(price);
|
||||
#endif
|
||||
|
||||
@@ -1,226 +1,237 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Dialogs
|
||||
|
||||
import PageEnum 1.0
|
||||
import Style 1.0
|
||||
|
||||
import "./"
|
||||
import "../Controls2"
|
||||
import "../Controls2/TextTypes"
|
||||
import "../Config"
|
||||
import "../Components"
|
||||
|
||||
PageType {
|
||||
id: root
|
||||
|
||||
BackButtonType {
|
||||
id: backButton
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin
|
||||
|
||||
onFocusChanged: {
|
||||
if (this.activeFocus) {
|
||||
listView.positionViewAtBeginning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListViewType {
|
||||
id: listView
|
||||
|
||||
anchors.top: backButton.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
|
||||
header: ColumnLayout {
|
||||
width: listView.width
|
||||
|
||||
BaseHeaderType {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 8
|
||||
Layout.rightMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
Layout.bottomMargin: 32
|
||||
|
||||
headerText: ApiServicesModel.getSelectedServiceData("name")
|
||||
descriptionText: ApiServicesModel.getSelectedServiceData("serviceDescription")
|
||||
}
|
||||
}
|
||||
|
||||
model: inputFields
|
||||
spacing: 0
|
||||
|
||||
delegate: ColumnLayout {
|
||||
width: listView.width
|
||||
|
||||
LabelWithImageType {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 16
|
||||
|
||||
imageSource: imagePath
|
||||
leftText: lText
|
||||
rightText: rText
|
||||
|
||||
visible: isVisible
|
||||
}
|
||||
}
|
||||
|
||||
footer: ColumnLayout {
|
||||
width: listView.width
|
||||
|
||||
spacing: 0
|
||||
|
||||
ParagraphTextType {
|
||||
Layout.fillWidth: true
|
||||
Layout.rightMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
|
||||
onLinkActivated: function(link) {
|
||||
Qt.openUrlExternally(link)
|
||||
}
|
||||
textFormat: Text.RichText
|
||||
text: {
|
||||
var text = ApiServicesModel.getSelectedServiceData("features")
|
||||
return text.replace("%1", LanguageModel.getCurrentSiteUrl("free")).replace("/free", "") // todo link should come from gateway
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
}
|
||||
}
|
||||
|
||||
ParagraphTextType {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
|
||||
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium"
|
||||
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
textFormat: Text.PlainText
|
||||
color: AmneziaStyle.color.mutedGray
|
||||
font.pixelSize: 12
|
||||
|
||||
text: qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.")
|
||||
}
|
||||
|
||||
BasicButtonType {
|
||||
id: continueButton
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 32
|
||||
Layout.bottomMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
|
||||
text: ApiServicesModel.getSelectedServiceType() === "amnezia-premium" ? qsTr("Subscribe Now") : qsTr("Connect")
|
||||
|
||||
clickedFunc: function() {
|
||||
PageController.showBusyIndicator(true)
|
||||
var result = ApiConfigsController.importService()
|
||||
PageController.showBusyIndicator(false)
|
||||
|
||||
if (!result) {
|
||||
var endpoint = ApiServicesModel.getStoreEndpoint()
|
||||
Qt.openUrlExternally(endpoint)
|
||||
PageController.closePage()
|
||||
PageController.closePage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParagraphTextType {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
Layout.bottomMargin: 32
|
||||
|
||||
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium"
|
||||
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
textFormat: Text.RichText
|
||||
color: AmneziaStyle.color.mutedGray
|
||||
font.pixelSize: 12
|
||||
|
||||
text: {
|
||||
var termsUrl = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
||||
var privacyUrl = LanguageModel.getCurrentSiteUrl("policy")
|
||||
return qsTr("By continuing, you agree to the <a href=\"%1\" style=\"color: #FBB26A;\">Terms of Use</a> and <a href=\"%2\" style=\"color: #FBB26A;\">Privacy Policy</a>").arg(termsUrl).arg(privacyUrl)
|
||||
}
|
||||
|
||||
onLinkActivated: function(link) {
|
||||
Qt.openUrlExternally(link)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property list<QtObject> inputFields: [
|
||||
region,
|
||||
price,
|
||||
timeLimit,
|
||||
speed,
|
||||
features
|
||||
]
|
||||
|
||||
QtObject {
|
||||
id: region
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/map-pin.svg"
|
||||
readonly property string lText: qsTr("For the region")
|
||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("region")
|
||||
property bool isVisible: true
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: price
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/tag.svg"
|
||||
readonly property string lText: qsTr("Price")
|
||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("price")
|
||||
property bool isVisible: true
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: timeLimit
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/history.svg"
|
||||
readonly property string lText: qsTr("Work period")
|
||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("timeLimit")
|
||||
property bool isVisible: rText !== ""
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: speed
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/gauge.svg"
|
||||
readonly property string lText: qsTr("Speed")
|
||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("speed")
|
||||
property bool isVisible: true
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: features
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/info.svg"
|
||||
readonly property string lText: qsTr("Features")
|
||||
readonly property string rText: ""
|
||||
property bool isVisible: true
|
||||
}
|
||||
}
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Dialogs
|
||||
|
||||
import PageEnum 1.0
|
||||
import Style 1.0
|
||||
|
||||
import "./"
|
||||
import "../Controls2"
|
||||
import "../Controls2/TextTypes"
|
||||
import "../Config"
|
||||
import "../Components"
|
||||
|
||||
PageType {
|
||||
id: root
|
||||
|
||||
BackButtonType {
|
||||
id: backButton
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin
|
||||
|
||||
onFocusChanged: {
|
||||
if (this.activeFocus) {
|
||||
listView.positionViewAtBeginning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListViewType {
|
||||
id: listView
|
||||
|
||||
anchors.top: backButton.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
|
||||
header: ColumnLayout {
|
||||
width: listView.width
|
||||
|
||||
BaseHeaderType {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 8
|
||||
Layout.rightMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
Layout.bottomMargin: 32
|
||||
|
||||
headerText: ApiServicesModel.getSelectedServiceData("name")
|
||||
descriptionText: ApiServicesModel.getSelectedServiceData("serviceDescription")
|
||||
}
|
||||
}
|
||||
|
||||
model: inputFields
|
||||
spacing: 0
|
||||
|
||||
delegate: ColumnLayout {
|
||||
width: listView.width
|
||||
|
||||
LabelWithImageType {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 16
|
||||
|
||||
imageSource: imagePath
|
||||
leftText: lText
|
||||
rightText: rText
|
||||
|
||||
visible: isVisible
|
||||
}
|
||||
}
|
||||
|
||||
footer: ColumnLayout {
|
||||
width: listView.width
|
||||
|
||||
spacing: 0
|
||||
|
||||
ParagraphTextType {
|
||||
Layout.fillWidth: true
|
||||
Layout.rightMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
|
||||
onLinkActivated: function(link) {
|
||||
Qt.openUrlExternally(link)
|
||||
}
|
||||
textFormat: Text.RichText
|
||||
text: {
|
||||
var text = ApiServicesModel.getSelectedServiceData("features")
|
||||
return text.replace("%1", LanguageModel.getCurrentSiteUrl("free")).replace("/free", "") // todo link should come from gateway
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
}
|
||||
}
|
||||
|
||||
ParagraphTextType {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
|
||||
visible: ((Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium") ||
|
||||
(Qt.platform.os === "android" && ApiServicesModel.getSelectedServiceType() === "amnezia-premium")
|
||||
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
textFormat: Text.PlainText
|
||||
color: AmneziaStyle.color.mutedGray
|
||||
font.pixelSize: 12
|
||||
|
||||
text: {
|
||||
if (Qt.platform.os === "ios" || IsMacOsNeBuild) {
|
||||
return qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.")
|
||||
} else if (Qt.platform.os === "android") {
|
||||
return qsTr("Charged to your Google Play account at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Google Play settings.")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
BasicButtonType {
|
||||
id: continueButton
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 32
|
||||
Layout.bottomMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
|
||||
text: ApiServicesModel.getSelectedServiceType() === "amnezia-premium" ? qsTr("Subscribe Now") : qsTr("Connect")
|
||||
|
||||
clickedFunc: function() {
|
||||
PageController.showBusyIndicator(true)
|
||||
var result = ApiConfigsController.importService()
|
||||
PageController.showBusyIndicator(false)
|
||||
|
||||
if (!result) {
|
||||
var endpoint = ApiServicesModel.getStoreEndpoint()
|
||||
Qt.openUrlExternally(endpoint)
|
||||
PageController.closePage()
|
||||
PageController.closePage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParagraphTextType {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 16
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
Layout.bottomMargin: 32
|
||||
|
||||
visible: ((Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium") ||
|
||||
(Qt.platform.os === "android" && ApiServicesModel.getSelectedServiceType() === "amnezia-premium")
|
||||
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
textFormat: Text.RichText
|
||||
color: AmneziaStyle.color.mutedGray
|
||||
font.pixelSize: 12
|
||||
|
||||
text: {
|
||||
var termsUrl = Qt.platform.os === "ios" || IsMacOsNeBuild ?
|
||||
"https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" :
|
||||
"https://play.google.com/intl/en_us/about/play-terms/"
|
||||
var privacyUrl = LanguageModel.getCurrentSiteUrl("policy")
|
||||
return qsTr("By continuing, you agree to the <a href=\"%1\" style=\"color: #FBB26A;\">Terms of Use</a> and <a href=\"%2\" style=\"color: #FBB26A;\">Privacy Policy</a>").arg(termsUrl).arg(privacyUrl)
|
||||
}
|
||||
|
||||
onLinkActivated: function(link) {
|
||||
Qt.openUrlExternally(link)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property list<QtObject> inputFields: [
|
||||
region,
|
||||
price,
|
||||
timeLimit,
|
||||
speed,
|
||||
features
|
||||
]
|
||||
|
||||
QtObject {
|
||||
id: region
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/map-pin.svg"
|
||||
readonly property string lText: qsTr("For the region")
|
||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("region")
|
||||
property bool isVisible: true
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: price
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/tag.svg"
|
||||
readonly property string lText: qsTr("Price")
|
||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("price")
|
||||
property bool isVisible: true
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: timeLimit
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/history.svg"
|
||||
readonly property string lText: qsTr("Work period")
|
||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("timeLimit")
|
||||
property bool isVisible: rText !== ""
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: speed
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/gauge.svg"
|
||||
readonly property string lText: qsTr("Speed")
|
||||
readonly property string rText: ApiServicesModel.getSelectedServiceData("speed")
|
||||
property bool isVisible: true
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: features
|
||||
|
||||
readonly property string imagePath: "qrc:/images/controls/info.svg"
|
||||
readonly property string lText: qsTr("Features")
|
||||
readonly property string rText: ""
|
||||
property bool isVisible: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,10 +358,10 @@ PageType {
|
||||
property string title: qsTr("Restore purchases")
|
||||
property string description: qsTr("")
|
||||
property string imageSource: "qrc:/images/controls/refresh-cw.svg"
|
||||
property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild
|
||||
property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild || Qt.platform.os === "android"
|
||||
property var handler: function() {
|
||||
PageController.showBusyIndicator(true)
|
||||
ApiConfigsController.restoreSerivceFromAppStore()
|
||||
ApiConfigsController.restoreSerivceFromPaymentMarket()
|
||||
PageController.showBusyIndicator(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,4 +193,4 @@ if [[ -v CI || -v MOVE_RESULT ]]; then
|
||||
$PROJECT_DIR/deploy/build/
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
Reference in New Issue
Block a user