Add methods to verify and purchase subscriptions

This commit is contained in:
albexk 2024-10-30 17:35:33 +03:00
parent 12eee2cd40
commit 2c6ae4214d
8 changed files with 306 additions and 79 deletions

View file

@ -12,10 +12,24 @@ import com.android.billingclient.api.BillingClient.BillingResponseCode.USER_CANC
import com.android.billingclient.api.BillingResult import com.android.billingclient.api.BillingResult
import org.amnezia.vpn.util.ErrorCode import org.amnezia.vpn.util.ErrorCode
internal class BillingException(billingResult: BillingResult) : Exception(billingResult.debugMessage) { internal class BillingException(
billingResult: BillingResult,
retryable: Boolean = false
) : Exception(billingResult.toString()) {
constructor(msg: String) : this(BillingResult.newBuilder()
.setResponseCode(9999)
.setDebugMessage(msg)
.build())
val errorCode: Int val errorCode: Int
val isCanceled = billingResult.responseCode == USER_CANCELED val isCanceled = billingResult.responseCode == USER_CANCELED
val isRetryable = retryable || billingResult.responseCode in setOf(
NETWORK_ERROR,
SERVICE_DISCONNECTED,
SERVICE_UNAVAILABLE,
ERROR
)
init { init {
when (billingResult.responseCode) { when (billingResult.responseCode) {

View file

@ -1,22 +1,30 @@
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.BillingClient.ProductType
import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode
import com.android.billingclient.api.BillingResult import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.GetBillingConfigParams import com.android.billingclient.api.GetBillingConfigParams
import com.android.billingclient.api.PendingPurchasesParams import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryProductDetailsParams.Product import com.android.billingclient.api.QueryProductDetailsParams.Product
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.Log
import org.json.JSONArray import org.json.JSONArray
@ -26,6 +34,7 @@ private const val TAG = "BillingProvider"
private const val RESULT_OK = 1 private const val RESULT_OK = 1
private const val RESULT_CANCELED = 0 private const val RESULT_CANCELED = 0
private const val RESULT_ERROR = -1 private const val RESULT_ERROR = -1
private const val PRODUCT_ID = "premium"
class BillingProvider(context: Context) : AutoCloseable { class BillingProvider(context: Context) : AutoCloseable {
@ -33,7 +42,7 @@ class BillingProvider(context: Context) : AutoCloseable {
private var subscriptionPurchases = MutableStateFlow<Pair<BillingResult, List<Purchase>?>?>(null) private var subscriptionPurchases = MutableStateFlow<Pair<BillingResult, List<Purchase>?>?>(null)
private val purchasesUpdatedListeners = PurchasesUpdatedListener { billingResult, purchases -> private val purchasesUpdatedListeners = PurchasesUpdatedListener { billingResult, purchases ->
Log.v(TAG, "PurchasesUpdatedListener: $billingResult") Log.v(TAG, "Purchases updated: $billingResult")
subscriptionPurchases.value = billingResult to purchases subscriptionPurchases.value = billingResult to purchases
} }
@ -47,7 +56,7 @@ class BillingProvider(context: Context) : AutoCloseable {
private suspend fun connect() { private suspend fun connect() {
if (billingClient.isReady) return if (billingClient.isReady) return
Log.v(TAG, "Connect to Google Play") Log.v(TAG, "Billing client connection")
val connection = CompletableDeferred<Unit>() val connection = CompletableDeferred<Unit>()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
billingClient.startConnection(object : BillingClientStateListener { billingClient.startConnection(object : BillingClientStateListener {
@ -69,29 +78,67 @@ class BillingProvider(context: Context) : AutoCloseable {
connection.await() connection.await()
} }
private suspend fun handleBillingApiCall(block: suspend () -> JSONObject): JSONObject = private suspend fun handleBillingApiCall(block: suspend () -> JSONObject): JSONObject {
try { val numberAttempts = 3
block() var attemptCount = 0
} catch (e: BillingException) { while (true) {
if (e.isCanceled) { try {
Log.w(TAG, "Billing canceled") return block()
JSONObject().put("result", RESULT_CANCELED) } catch (e: BillingException) {
} else { if (e.isCanceled) {
Log.e(TAG, "Billing error: $e") Log.w(TAG, "Billing canceled")
JSONObject() return JSONObject().put("result", RESULT_CANCELED)
.put("result", RESULT_ERROR) } else if (e.isRetryable && attemptCount < numberAttempts) {
.put("errorCode", e.errorCode) Log.d(TAG, "Retryable error: $e")
++attemptCount
delay(1000)
} else {
Log.e(TAG, "Billing error: $e")
return JSONObject()
.put("result", RESULT_ERROR)
.put("errorCode", e.errorCode)
}
} catch (_: CancellationException) {
Log.w(TAG, "Billing coroutine canceled")
return JSONObject().put("result", RESULT_CANCELED)
} }
} catch (_: CancellationException) {
Log.w(TAG, "Billing coroutine canceled")
JSONObject().put("result", RESULT_CANCELED)
} }
}
suspend fun getSubscriptionPlans(): JSONObject = handleBillingApiCall { suspend fun getSubscriptionPlans(): JSONObject {
Log.v(TAG, "Get subscription plans") Log.v(TAG, "Get subscription plans")
val productDetailsList = getProductDetails()
val resultJson = JSONObject().put("result", RESULT_OK)
val productArray = JSONArray().also { resultJson.put("products", it) }
productDetailsList?.forEach { productDetails ->
val product = JSONObject().also { productArray.put(it) }
.put("productId", productDetails.productId)
.put("name", productDetails.name)
val offers = JSONArray().also { product.put("offers", it) }
productDetails.subscriptionOfferDetails?.forEach { offerDetails ->
val offer = JSONObject().also { offers.put(it) }
.put("basePlanId", offerDetails.basePlanId)
.put("offerId", offerDetails.offerId)
.put("offerToken", offerDetails.offerToken)
val pricingPhases = JSONArray().also { offer.put("pricingPhases", it) }
offerDetails.pricingPhases.pricingPhaseList.forEach { phase ->
JSONObject().also { pricingPhases.put(it) }
.put("billingCycleCount", phase.billingCycleCount)
.put("billingPeriod", phase.billingPeriod)
.put("formatedPrice", phase.formattedPrice)
.put("recurrenceMode", phase.recurrenceMode)
}
}
}
return resultJson
}
private suspend fun getProductDetails(): List<ProductDetails>? {
Log.v(TAG, "Get product details")
val productDetailsParams = Product.newBuilder() val productDetailsParams = Product.newBuilder()
.setProductId("premium") .setProductId(PRODUCT_ID)
.setProductType(ProductType.SUBS) .setProductType(ProductType.SUBS)
.build() .build()
@ -103,40 +150,17 @@ class BillingProvider(context: Context) : AutoCloseable {
billingClient.queryProductDetails(queryProductDetailsParams) billingClient.queryProductDetails(queryProductDetailsParams)
} }
Log.v(TAG, "Query product details result: ${result.billingResult}")
if (!result.billingResult.isOk) { if (!result.billingResult.isOk) {
Log.e(TAG, "Failed to get subscription plans: ${result.billingResult}") Log.e(TAG, "Failed to get product details: ${result.billingResult}")
throw BillingException(result.billingResult) throw BillingException(result.billingResult)
} }
Log.v(TAG, "Subscription plans:\n${result.productDetailsList}") return result.productDetailsList
val resultJson = JSONObject().put("result", RESULT_OK)
val productArray = JSONArray().also { resultJson.put("products", it) }
result.productDetailsList?.forEach {
val product = JSONObject().also { productArray.put(it) }
product.put("productId", it.productId)
product.put("name", it.name)
val offers = JSONArray().also { product.put("offers", it) }
it.subscriptionOfferDetails?.forEach {
val offer = JSONObject().also { offers.put(it) }
offer.put("basePlanId", it.basePlanId)
offer.put("offerId", it.offerId)
offer.put("offerToken", it.offerToken)
val pricingPhases = JSONArray().also { offer.put("pricingPhases", it) }
it.pricingPhases.pricingPhaseList.forEach {
val pricingPhase = JSONObject().also { pricingPhases.put(it) }
pricingPhase.put("billingCycleCount", it.billingCycleCount)
pricingPhase.put("billingPeriod", it.billingPeriod)
pricingPhase.put("formatedPrice", it.formattedPrice)
pricingPhase.put("recurrenceMode", it.recurrenceMode)
}
}
}
resultJson
} }
suspend fun getCustomerCountryCode(): JSONObject = handleBillingApiCall { suspend fun getCustomerCountryCode(): JSONObject {
Log.v(TAG, "Get customer country code") Log.v(TAG, "Get customer country code")
val deferred = CompletableDeferred<String>() val deferred = CompletableDeferred<String>()
@ -153,22 +177,135 @@ class BillingProvider(context: Context) : AutoCloseable {
} }
val countryCode = deferred.await() val countryCode = deferred.await()
JSONObject() return JSONObject()
.put("result", RESULT_OK) .put("result", RESULT_OK)
.put("countryCode", countryCode) .put("countryCode", countryCode)
} }
suspend fun purchaseSubscription(activity: Activity, obfuscatedAccountId: String): JSONObject = suspend fun purchaseSubscription(
handleBillingApiCall { activity: Activity,
Log.v(TAG, "Purchase subscription") offerToken: String,
billingClient.launchBillingFlow(activity, BillingFlowParams.newBuilder() oldPurchaseToken: String? = null
.setObfuscatedAccountId(obfuscatedAccountId) ): JSONObject {
.build()) Log.v(TAG, "Purchase subscription")
JSONObject() Log.v(TAG, "Offer token: $offerToken")
oldPurchaseToken?.let { Log.v(TAG, "Old purchase token: $it") }
if (offerToken.isBlank()) throw BillingException("offerToken can not be empty")
val productDetails = getProductDetails()?.let {
it.filter { it.productId == PRODUCT_ID }
}?.firstOrNull() ?: throw BillingException("Product details not found")
Log.v(TAG, "Filtered product details:\n$productDetails")
val productDetail = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
val subscriptionUpdateParams = oldPurchaseToken?.let {
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
.setSubscriptionReplacementMode(ReplacementMode.WITHOUT_PRORATION)
.build()
} }
val billingResult = billingClient.launchBillingFlow(activity, BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetail))
.apply { subscriptionUpdateParams?.let { setSubscriptionUpdateParams(it) } }
.build())
Log.v(TAG, "Start billing flow result: $billingResult")
if (billingResult.responseCode == BillingResponseCode.ITEM_ALREADY_OWNED) {
Log.w(TAG, "Attempting to purchase already owned product")
val purchases = queryPurchases()
if (purchases.any { PRODUCT_ID in it.products }) throw BillingException(billingResult)
else throw BillingException(billingResult, retryable = true)
} else if (billingResult.responseCode == BillingResponseCode.ITEM_NOT_OWNED) {
Log.w(TAG, "Attempting to replace not owned product")
val purchases = queryPurchases()
if (purchases.all { PRODUCT_ID !in it.products }) throw BillingException(billingResult)
else throw BillingException(billingResult, retryable = true)
} else if (!billingResult.isOk) throw BillingException(billingResult)
subscriptionPurchases.firstOrNull { it != null }?.let { (billingResult, purchases) ->
if (!billingResult.isOk) throw BillingException(billingResult)
return JSONObject()
.put("result", RESULT_OK)
.put("purchases", processPurchases(purchases))
} ?: throw BillingException("Purchase failed")
}
private fun processPurchases(purchases: List<Purchase>?): JSONArray {
val purchaseArray = JSONArray()
purchases?.forEach { purchase ->
val purchaseJson = JSONObject().also { purchaseArray.put(it) }
.put("purchaseToken", purchase.purchaseToken)
.put("purchaseTime", purchase.purchaseTime)
.put("purchaseState", purchase.purchaseState)
.put("isAcknowledged", purchase.isAcknowledged)
.put("isAutoRenewing", purchase.isAutoRenewing)
.put("orderId", purchase.orderId)
.put("productIds", JSONArray(purchase.products))
purchase.pendingPurchaseUpdate?.let { purchaseUpdate ->
JSONObject()
.put("purchaseToken", purchaseUpdate.purchaseToken)
.put("productIds", JSONArray(purchaseUpdate.products))
}.also { purchaseJson.put("pendingPurchaseUpdate", it) }
}
return purchaseArray
}
suspend fun acknowledge(purchaseToken: String): JSONObject {
Log.v(TAG, "Acknowledge purchase: $purchaseToken")
val result = withContext(Dispatchers.IO) {
billingClient.acknowledgePurchase(
AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchaseToken)
.build()
)
}
Log.v(TAG, "Acknowledge purchase result: $result")
if (result.responseCode == BillingResponseCode.ITEM_NOT_OWNED) {
Log.w(TAG, "Attempting to acknowledge not owned product")
val purchases = queryPurchases()
if (purchases.all { PRODUCT_ID !in it.products }) throw BillingException(result)
else throw BillingException(result, retryable = true)
} else if (!result.isOk && result.responseCode != BillingResponseCode.ITEM_ALREADY_OWNED) {
throw BillingException(result)
}
return JSONObject().put("result", RESULT_OK)
}
suspend fun getPurchases(): JSONObject {
Log.v(TAG, "Get purchases")
val purchases = queryPurchases()
return JSONObject()
.put("result", RESULT_OK)
.put("purchases", processPurchases(purchases))
}
private suspend fun queryPurchases(): List<Purchase> {
Log.v(TAG, "Query purchases")
val result = withContext(Dispatchers.IO) {
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder().setProductType(ProductType.SUBS).build()
)
}
Log.v(TAG, "Query purchases result: ${result.billingResult}")
if (!result.billingResult.isOk) throw BillingException(result.billingResult)
return result.purchasesList
}
override fun close() { override fun close() {
Log.d(TAG, "Close billing client connection") Log.v(TAG, "Close billing client connection")
billingClient.endConnection() billingClient.endConnection()
} }

View file

@ -6,5 +6,8 @@ import android.content.Context
class BillingPaymentRepository(@Suppress("UNUSED_PARAMETER") context: Context) : BillingRepository { class BillingPaymentRepository(@Suppress("UNUSED_PARAMETER") context: Context) : BillingRepository {
override suspend fun getCountryCode(): String = "" override suspend fun getCountryCode(): String = ""
override suspend fun getSubscriptionPlans(): String = "" override suspend fun getSubscriptionPlans(): String = ""
override suspend fun purchaseSubscription(activity: Activity): String = "" override suspend fun purchaseSubscription(activity: Activity, offerToken: String): String = ""
override suspend fun upgradeSubscription(activity: Activity, offerToken: String, oldPurchaseToken: String): String = ""
override suspend fun acknowledge(purchaseToken: String): String = ""
override suspend fun queryPurchases(): String = ""
} }

View file

@ -14,7 +14,21 @@ class BillingPaymentRepository(private val context: Context) : BillingRepository
getSubscriptionPlans() getSubscriptionPlans()
} }
override suspend fun purchaseSubscription(activity: Activity): String = withBillingProvider(context) { override suspend fun purchaseSubscription(activity: Activity, offerToken: String): String =
purchaseSubscription(activity, "obfuscatedAccountId") withBillingProvider(context) {
purchaseSubscription(activity, offerToken)
}
override suspend fun upgradeSubscription(activity: Activity, offerToken: String, oldPurchaseToken: String): String =
withBillingProvider(context) {
purchaseSubscription(activity, offerToken, oldPurchaseToken)
}
override suspend fun acknowledge(purchaseToken: String): String = withBillingProvider(context) {
acknowledge(purchaseToken)
}
override suspend fun queryPurchases(): String = withBillingProvider(context) {
getPurchases()
} }
} }

View file

@ -29,6 +29,7 @@ import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.io.IOException import java.io.IOException
import kotlin.LazyThreadSafetyMode.NONE import kotlin.LazyThreadSafetyMode.NONE
import kotlin.coroutines.CoroutineContext
import kotlin.text.RegexOption.IGNORE_CASE import kotlin.text.RegexOption.IGNORE_CASE
import AppListProvider import AppListProvider
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
@ -39,7 +40,6 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.amnezia.vpn.protocol.getStatistics import org.amnezia.vpn.protocol.getStatistics
import org.amnezia.vpn.protocol.getStatus import org.amnezia.vpn.protocol.getStatus
import org.amnezia.vpn.qt.QtAndroidController import org.amnezia.vpn.qt.QtAndroidController
@ -648,15 +648,9 @@ class AmneziaActivity : QtActivity() {
@Suppress("unused") @Suppress("unused")
fun getAppList(): String { fun getAppList(): String {
Log.v(TAG, "Get app list") Log.v(TAG, "Get app list")
var appList = "" return blockingCall(Dispatchers.IO) {
runBlocking { AppListProvider.getAppList(packageManager, packageName)
mainScope.launch {
withContext(Dispatchers.IO) {
appList = AppListProvider.getAppList(packageManager, packageName)
}
}.join()
} }
return appList
} }
@Suppress("unused") @Suppress("unused")
@ -728,26 +722,51 @@ class AmneziaActivity : QtActivity() {
@Suppress("unused") @Suppress("unused")
fun getCountryCode(): String { fun getCountryCode(): String {
Log.v(TAG, "Get country code") Log.v(TAG, "Get country code")
return runBlocking { return blockingCall { billingRepository.getCountryCode() }
mainScope.async {
billingRepository.getCountryCode()
}.await()
}
} }
@Suppress("unused") @Suppress("unused")
fun getSubscriptionPlans(): String { fun getSubscriptionPlans(): String {
Log.v(TAG, "Get subscription plans") Log.v(TAG, "Get subscription plans")
return runBlocking { return blockingCall { billingRepository.getSubscriptionPlans() }
mainScope.async { }
billingRepository.getSubscriptionPlans()
}.await() @Suppress("unused")
fun purchaseSubscription(offerToken: String): String {
Log.v(TAG, "Purchase subscription")
return blockingCall { billingRepository.purchaseSubscription(this@AmneziaActivity, offerToken) }
}
@Suppress("unused")
fun upgradeSubscription(offerToken: String, oldPurchaseToken: String): String {
Log.v(TAG, "Upgrade subscription")
return blockingCall {
billingRepository.upgradeSubscription(this@AmneziaActivity, offerToken, oldPurchaseToken)
} }
} }
@Suppress("unused")
fun acknowledgePurchase(purchaseToken: String): String {
Log.v(TAG, "Acknowledge purchase")
return blockingCall { billingRepository.acknowledge(purchaseToken) }
}
@Suppress("unused")
fun queryPurchases(): String {
Log.v(TAG, "Query purchases")
return blockingCall { billingRepository.queryPurchases() }
}
/** /**
* Utils methods * Utils methods
*/ */
private fun <T> blockingCall(
context: CoroutineContext = Dispatchers.Default,
block: suspend () -> T
) = runBlocking {
mainScope.async(context) { block() }.await()
}
companion object { companion object {
private fun actionCodeToString(actionCode: Int): String = private fun actionCodeToString(actionCode: Int): String =
when (actionCode) { when (actionCode) {

View file

@ -5,5 +5,8 @@ import android.app.Activity
interface BillingRepository { interface BillingRepository {
suspend fun getCountryCode(): String suspend fun getCountryCode(): String
suspend fun getSubscriptionPlans(): String suspend fun getSubscriptionPlans(): String
suspend fun purchaseSubscription(activity: Activity): String suspend fun purchaseSubscription(activity: Activity, offerToken: String): String
suspend fun upgradeSubscription(activity: Activity, offerToken: String, oldPurchaseToken: String): String
suspend fun acknowledge(purchaseToken: String): String
suspend fun queryPurchases(): String
} }

View file

@ -299,6 +299,39 @@ QJsonObject AndroidController::getSubscriptionPlans()
return json; return json;
} }
QJsonObject AndroidController::purchaseSubscription(const QString &offerToken)
{
QJniObject result = callActivityMethod<jstring, jstring>("purchaseSubscription", "(Ljava/lang/String;)Ljava/lang/String;",
QJniObject::fromString(offerToken).object<jstring>());
QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object();
return json;
}
QJsonObject AndroidController::upgradeSubscription(const QString &offerToken, const QString &oldPurchaseToken)
{
QJniObject result = callActivityMethod<jstring, jstring, jstring>("upgradeSubscription",
"(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
QJniObject::fromString(offerToken).object<jstring>(),
QJniObject::fromString(oldPurchaseToken).object<jstring>());
QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object();
return json;
}
QJsonObject AndroidController::acknowledgePurchase(const QString &purchaseToken)
{
QJniObject result = callActivityMethod<jstring, jstring>("acknowledgePurchase", "(Ljava/lang/String;)Ljava/lang/String;",
QJniObject::fromString(purchaseToken).object<jstring>());
QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object();
return json;
}
QJsonObject AndroidController::queryPurchases()
{
QJniObject result = callActivityMethod<jstring>("queryPurchases", "()Ljava/lang/String;");
QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object();
return json;
}
// Moving log processing to the Android side // Moving log processing to the Android side
jclass AndroidController::log; jclass AndroidController::log;
jmethodID AndroidController::logDebug; jmethodID AndroidController::logDebug;

View file

@ -50,6 +50,10 @@ public:
bool requestAuthentication(); bool requestAuthentication();
bool isPlay(); bool isPlay();
QJsonObject getSubscriptionPlans(); QJsonObject getSubscriptionPlans();
QJsonObject purchaseSubscription(const QString &offerToken);
QJsonObject upgradeSubscription(const QString &offerToken, const QString &oldPurchaseToken);
QJsonObject acknowledgePurchase(const QString &purchaseToken);
QJsonObject queryPurchases();
static bool initLogging(); static bool initLogging();
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message); static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);