mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-23 02:00:20 +07:00
feat: add purchase to UI
This commit is contained in:
@@ -662,13 +662,55 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: ./deploy/build_android.sh --aab --play --apk all --build-platform ${{ env.ANDROID_BUILD_PLATFORM }}
|
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'
|
- name: 'Rename Android APKs'
|
||||||
run: |
|
run: |
|
||||||
cd deploy/build
|
cd deploy/build
|
||||||
mv AmneziaVPN-x86_64-release.apk AmneziaVPN_${VERSION}_android9+_x86_64.apk
|
mv AmneziaVPN-oss-x86_64-release.apk AmneziaVPN_${VERSION}_android9+_x86_64.apk
|
||||||
mv AmneziaVPN-x86-release.apk AmneziaVPN_${VERSION}_android9+_x86.apk
|
mv AmneziaVPN-oss-x86-release.apk AmneziaVPN_${VERSION}_android9+_x86.apk
|
||||||
mv AmneziaVPN-arm64-v8a-release.apk AmneziaVPN_${VERSION}_android9+_arm64-v8a.apk
|
mv AmneziaVPN-oss-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-armeabi-v7a-release.apk AmneziaVPN_${VERSION}_android9+_armeabi-v7a.apk
|
||||||
cd ../..
|
cd ../..
|
||||||
|
|
||||||
- name: 'Upload x86_64 apk'
|
- name: 'Upload x86_64 apk'
|
||||||
@@ -703,7 +745,7 @@ jobs:
|
|||||||
compression-level: 0
|
compression-level: 0
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: 'Upload aab'
|
- name: 'Upload Play AAB'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: AmneziaVPN-android
|
name: AmneziaVPN-android
|
||||||
@@ -711,6 +753,14 @@ jobs:
|
|||||||
compression-level: 0
|
compression-level: 0
|
||||||
retention-days: 7
|
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:
|
Extra:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
message("Client android ${CMAKE_ANDROID_ARCH_ABI} build")
|
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(APP_ANDROID_MIN_SDK 28)
|
||||||
set(ANDROID_PLATFORM "android-${APP_ANDROID_MIN_SDK}" CACHE STRING
|
set(ANDROID_PLATFORM "android-${APP_ANDROID_MIN_SDK}" CACHE STRING
|
||||||
"The minimum API level supported by the application or library" FORCE)
|
"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
|
file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/3rd-prebuilt/3rd-prebuilt/xray/android/libxray.aar
|
||||||
DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/android/xray/libXray)
|
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,
|
ApiUpdateRequestError = 1111,
|
||||||
ApiSubscriptionExpiredError = 1112,
|
ApiSubscriptionExpiredError = 1112,
|
||||||
ApiPurchaseError = 1113,
|
ApiPurchaseError = 1113,
|
||||||
|
ApiNoPurchasesToRestore = 1114,
|
||||||
|
|
||||||
// QFile errors
|
// QFile errors
|
||||||
OpenError = 1200,
|
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::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::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::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
|
// QFile errors
|
||||||
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
|
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.
|
// - 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 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 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) \
|
#define __vmess_checker__func(key, values) \
|
||||||
{ \
|
{ \
|
||||||
|
|||||||
@@ -11,9 +11,14 @@
|
|||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QEventLoop>
|
#include <QEventLoop>
|
||||||
|
#include <QFutureWatcher>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
|
||||||
#include "platforms/ios/ios_controller.h"
|
#include "platforms/ios/ios_controller.h"
|
||||||
|
#ifdef Q_OS_ANDROID
|
||||||
|
#include "platforms/android/android_controller.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace
|
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
|
#endif
|
||||||
|
|
||||||
m_apiServicesModel->updateModel(data);
|
m_apiServicesModel->updateModel(data);
|
||||||
@@ -436,25 +521,19 @@ bool ApiConfigsController::fillAvailableServices()
|
|||||||
|
|
||||||
bool ApiConfigsController::importService()
|
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 (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) {
|
||||||
if (isIosOrMacOsNe) {
|
#if defined(Q_OS_IOS) || defined(MACOS_NE) || defined(Q_OS_ANDROID)
|
||||||
importSerivceFromAppStore();
|
importSerivceFromPaymentMarket();
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
importServiceFromGateway();
|
|
||||||
return true;
|
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)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
bool purchaseOk = false;
|
bool purchaseOk = false;
|
||||||
@@ -511,12 +590,112 @@ bool ApiConfigsController::importSerivceFromAppStore()
|
|||||||
return false;
|
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()));
|
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
|
||||||
#endif
|
#endif
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ApiConfigsController::restoreSerivceFromAppStore()
|
bool ApiConfigsController::restoreSerivceFromPaymentMarket()
|
||||||
{
|
{
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
const QString premiumServiceType = QStringLiteral("amnezia-premium");
|
||||||
@@ -639,6 +818,131 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
|
|||||||
qInfo().noquote() << "[IAP] Skipped" << duplicateCount
|
qInfo().noquote() << "[IAP] Skipped" << duplicateCount
|
||||||
<< "duplicate restored transactions for original transaction IDs already processed";
|
<< "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
|
#endif
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -944,16 +1248,16 @@ QString ApiConfigsController::getVpnKey()
|
|||||||
|
|
||||||
ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase)
|
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();
|
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
|
||||||
QString key = responseObject.value(QStringLiteral("key")).toString();
|
QString key = responseObject.value(QStringLiteral("key")).toString();
|
||||||
if (key.isEmpty()) {
|
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;
|
return ErrorCode::ApiPurchaseError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_serversModel->hasServerWithVpnKey(key)) {
|
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;
|
return ErrorCode::ApiConfigAlreadyAdded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,7 +1271,7 @@ ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &respo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (configString.isEmpty()) {
|
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;
|
return ErrorCode::ApiPurchaseError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ public slots:
|
|||||||
|
|
||||||
bool fillAvailableServices();
|
bool fillAvailableServices();
|
||||||
bool importService();
|
bool importService();
|
||||||
bool importSerivceFromAppStore();
|
bool importSerivceFromPaymentMarket();
|
||||||
bool restoreSerivceFromAppStore();
|
bool restoreSerivceFromPaymentMarket();
|
||||||
bool importServiceFromGateway();
|
bool importServiceFromGateway();
|
||||||
bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
|
||||||
bool reloadServiceConfig = false);
|
bool reloadServiceConfig = false);
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const
|
|||||||
}
|
}
|
||||||
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
#if defined(Q_OS_IOS) || defined(MACOS_NE)
|
||||||
return tr("%1 $").arg(price);
|
return tr("%1 $").arg(price);
|
||||||
|
#elif defined(Q_OS_ANDROID)
|
||||||
|
return price;
|
||||||
#else
|
#else
|
||||||
return tr("%1 $/month").arg(price);
|
return tr("%1 $/month").arg(price);
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -103,14 +103,22 @@ PageType {
|
|||||||
Layout.leftMargin: 16
|
Layout.leftMargin: 16
|
||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
|
|
||||||
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium"
|
visible: ((Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium") ||
|
||||||
|
(Qt.platform.os === "android" && ApiServicesModel.getSelectedServiceType() === "amnezia-premium")
|
||||||
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
textFormat: Text.PlainText
|
textFormat: Text.PlainText
|
||||||
color: AmneziaStyle.color.mutedGray
|
color: AmneziaStyle.color.mutedGray
|
||||||
font.pixelSize: 12
|
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.")
|
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 {
|
BasicButtonType {
|
||||||
@@ -145,7 +153,8 @@ PageType {
|
|||||||
Layout.rightMargin: 16
|
Layout.rightMargin: 16
|
||||||
Layout.bottomMargin: 32
|
Layout.bottomMargin: 32
|
||||||
|
|
||||||
visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium"
|
visible: ((Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium") ||
|
||||||
|
(Qt.platform.os === "android" && ApiServicesModel.getSelectedServiceType() === "amnezia-premium")
|
||||||
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
textFormat: Text.RichText
|
textFormat: Text.RichText
|
||||||
@@ -153,7 +162,9 @@ PageType {
|
|||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
|
|
||||||
text: {
|
text: {
|
||||||
var termsUrl = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
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")
|
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,10 +358,10 @@ PageType {
|
|||||||
property string title: qsTr("Restore purchases")
|
property string title: qsTr("Restore purchases")
|
||||||
property string description: qsTr("")
|
property string description: qsTr("")
|
||||||
property string imageSource: "qrc:/images/controls/refresh-cw.svg"
|
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() {
|
property var handler: function() {
|
||||||
PageController.showBusyIndicator(true)
|
PageController.showBusyIndicator(true)
|
||||||
ApiConfigsController.restoreSerivceFromAppStore()
|
ApiConfigsController.restoreSerivceFromPaymentMarket()
|
||||||
PageController.showBusyIndicator(false)
|
PageController.showBusyIndicator(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user