diff --git a/client/android/billing/src/main/kotlin/BillingException.kt b/client/android/billing/src/main/kotlin/BillingException.kt index 6180872f..f1ebd519 100644 --- a/client/android/billing/src/main/kotlin/BillingException.kt +++ b/client/android/billing/src/main/kotlin/BillingException.kt @@ -12,10 +12,24 @@ import com.android.billingclient.api.BillingClient.BillingResponseCode.USER_CANC import com.android.billingclient.api.BillingResult 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 isCanceled = billingResult.responseCode == USER_CANCELED + val isRetryable = retryable || billingResult.responseCode in setOf( + NETWORK_ERROR, + SERVICE_DISCONNECTED, + SERVICE_UNAVAILABLE, + ERROR + ) init { when (billingResult.responseCode) { diff --git a/client/android/billing/src/main/kotlin/BillingProvider.kt b/client/android/billing/src/main/kotlin/BillingProvider.kt index a432d17c..59fe5cab 100644 --- a/client/android/billing/src/main/kotlin/BillingProvider.kt +++ b/client/android/billing/src/main/kotlin/BillingProvider.kt @@ -1,22 +1,30 @@ import android.app.Activity import android.content.Context +import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.BillingClientStateListener 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.GetBillingConfigParams import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.QueryProductDetailsParams 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.queryPurchasesAsync import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.withContext import org.amnezia.vpn.util.Log import org.json.JSONArray @@ -26,6 +34,7 @@ private const val TAG = "BillingProvider" private const val RESULT_OK = 1 private const val RESULT_CANCELED = 0 private const val RESULT_ERROR = -1 +private const val PRODUCT_ID = "premium" class BillingProvider(context: Context) : AutoCloseable { @@ -33,7 +42,7 @@ class BillingProvider(context: Context) : AutoCloseable { private var subscriptionPurchases = MutableStateFlow?>?>(null) private val purchasesUpdatedListeners = PurchasesUpdatedListener { billingResult, purchases -> - Log.v(TAG, "PurchasesUpdatedListener: $billingResult") + Log.v(TAG, "Purchases updated: $billingResult") subscriptionPurchases.value = billingResult to purchases } @@ -47,7 +56,7 @@ class BillingProvider(context: Context) : AutoCloseable { private suspend fun connect() { if (billingClient.isReady) return - Log.v(TAG, "Connect to Google Play") + Log.v(TAG, "Billing client connection") val connection = CompletableDeferred() withContext(Dispatchers.IO) { billingClient.startConnection(object : BillingClientStateListener { @@ -69,29 +78,67 @@ class BillingProvider(context: Context) : AutoCloseable { connection.await() } - private suspend fun handleBillingApiCall(block: suspend () -> JSONObject): JSONObject = - try { - block() - } catch (e: BillingException) { - if (e.isCanceled) { - Log.w(TAG, "Billing canceled") - JSONObject().put("result", RESULT_CANCELED) - } else { - Log.e(TAG, "Billing error: $e") - JSONObject() - .put("result", RESULT_ERROR) - .put("errorCode", e.errorCode) + private suspend fun handleBillingApiCall(block: suspend () -> JSONObject): JSONObject { + val numberAttempts = 3 + var attemptCount = 0 + while (true) { + try { + return block() + } catch (e: BillingException) { + if (e.isCanceled) { + Log.w(TAG, "Billing canceled") + return JSONObject().put("result", RESULT_CANCELED) + } else if (e.isRetryable && attemptCount < numberAttempts) { + 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") + 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? { + Log.v(TAG, "Get product details") + val productDetailsParams = Product.newBuilder() - .setProductId("premium") + .setProductId(PRODUCT_ID) .setProductType(ProductType.SUBS) .build() @@ -103,40 +150,17 @@ class BillingProvider(context: Context) : AutoCloseable { billingClient.queryProductDetails(queryProductDetailsParams) } + Log.v(TAG, "Query product details result: ${result.billingResult}") + 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) } - Log.v(TAG, "Subscription plans:\n${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 + return result.productDetailsList } - suspend fun getCustomerCountryCode(): JSONObject = handleBillingApiCall { + suspend fun getCustomerCountryCode(): JSONObject { Log.v(TAG, "Get customer country code") val deferred = CompletableDeferred() @@ -153,22 +177,135 @@ class BillingProvider(context: Context) : AutoCloseable { } val countryCode = deferred.await() - JSONObject() + return JSONObject() .put("result", RESULT_OK) .put("countryCode", countryCode) } - suspend fun purchaseSubscription(activity: Activity, obfuscatedAccountId: String): JSONObject = - handleBillingApiCall { - Log.v(TAG, "Purchase subscription") - billingClient.launchBillingFlow(activity, BillingFlowParams.newBuilder() - .setObfuscatedAccountId(obfuscatedAccountId) - .build()) - JSONObject() + suspend fun purchaseSubscription( + activity: Activity, + offerToken: String, + oldPurchaseToken: String? = null + ): JSONObject { + Log.v(TAG, "Purchase subscription") + 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?): 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 { + 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() { - Log.d(TAG, "Close billing client connection") + Log.v(TAG, "Close billing client connection") billingClient.endConnection() } diff --git a/client/android/oss/org/amnezia/vpn/BillingPaymentRepository.kt b/client/android/oss/org/amnezia/vpn/BillingPaymentRepository.kt index c5c934fc..8b97c447 100644 --- a/client/android/oss/org/amnezia/vpn/BillingPaymentRepository.kt +++ b/client/android/oss/org/amnezia/vpn/BillingPaymentRepository.kt @@ -6,5 +6,8 @@ import android.content.Context class BillingPaymentRepository(@Suppress("UNUSED_PARAMETER") context: Context) : BillingRepository { override suspend fun getCountryCode(): 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 = "" } diff --git a/client/android/play/org/amnezia/vpn/BillingPaymentRepository.kt b/client/android/play/org/amnezia/vpn/BillingPaymentRepository.kt index 3b81fddc..ce866452 100644 --- a/client/android/play/org/amnezia/vpn/BillingPaymentRepository.kt +++ b/client/android/play/org/amnezia/vpn/BillingPaymentRepository.kt @@ -14,7 +14,21 @@ class BillingPaymentRepository(private val context: Context) : BillingRepository getSubscriptionPlans() } - override suspend fun purchaseSubscription(activity: Activity): String = withBillingProvider(context) { - purchaseSubscription(activity, "obfuscatedAccountId") + override suspend fun purchaseSubscription(activity: Activity, offerToken: String): String = + 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() } } diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index 66af05e0..b923e2c7 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -29,6 +29,7 @@ import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import java.io.IOException import kotlin.LazyThreadSafetyMode.NONE +import kotlin.coroutines.CoroutineContext import kotlin.text.RegexOption.IGNORE_CASE import AppListProvider import kotlinx.coroutines.CompletableDeferred @@ -39,7 +40,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import org.amnezia.vpn.protocol.getStatistics import org.amnezia.vpn.protocol.getStatus import org.amnezia.vpn.qt.QtAndroidController @@ -648,15 +648,9 @@ class AmneziaActivity : QtActivity() { @Suppress("unused") fun getAppList(): String { Log.v(TAG, "Get app list") - var appList = "" - runBlocking { - mainScope.launch { - withContext(Dispatchers.IO) { - appList = AppListProvider.getAppList(packageManager, packageName) - } - }.join() + return blockingCall(Dispatchers.IO) { + AppListProvider.getAppList(packageManager, packageName) } - return appList } @Suppress("unused") @@ -728,26 +722,51 @@ class AmneziaActivity : QtActivity() { @Suppress("unused") fun getCountryCode(): String { Log.v(TAG, "Get country code") - return runBlocking { - mainScope.async { - billingRepository.getCountryCode() - }.await() - } + return blockingCall { billingRepository.getCountryCode() } } @Suppress("unused") fun getSubscriptionPlans(): String { Log.v(TAG, "Get subscription plans") - return runBlocking { - mainScope.async { - billingRepository.getSubscriptionPlans() - }.await() + return blockingCall { billingRepository.getSubscriptionPlans() } + } + + @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 */ + private fun blockingCall( + context: CoroutineContext = Dispatchers.Default, + block: suspend () -> T + ) = runBlocking { + mainScope.async(context) { block() }.await() + } + companion object { private fun actionCodeToString(actionCode: Int): String = when (actionCode) { diff --git a/client/android/src/org/amnezia/vpn/BillingRepository.kt b/client/android/src/org/amnezia/vpn/BillingRepository.kt index 5aac147a..3ae24cd3 100644 --- a/client/android/src/org/amnezia/vpn/BillingRepository.kt +++ b/client/android/src/org/amnezia/vpn/BillingRepository.kt @@ -5,5 +5,8 @@ import android.app.Activity interface BillingRepository { suspend fun getCountryCode(): 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 } diff --git a/client/platforms/android/android_controller.cpp b/client/platforms/android/android_controller.cpp index f5cd82a5..ed668d2a 100644 --- a/client/platforms/android/android_controller.cpp +++ b/client/platforms/android/android_controller.cpp @@ -299,6 +299,39 @@ QJsonObject AndroidController::getSubscriptionPlans() return json; } +QJsonObject AndroidController::purchaseSubscription(const QString &offerToken) +{ + QJniObject result = callActivityMethod("purchaseSubscription", "(Ljava/lang/String;)Ljava/lang/String;", + QJniObject::fromString(offerToken).object()); + QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object(); + return json; +} + +QJsonObject AndroidController::upgradeSubscription(const QString &offerToken, const QString &oldPurchaseToken) +{ + QJniObject result = callActivityMethod("upgradeSubscription", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + QJniObject::fromString(offerToken).object(), + QJniObject::fromString(oldPurchaseToken).object()); + QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object(); + return json; +} + +QJsonObject AndroidController::acknowledgePurchase(const QString &purchaseToken) +{ + QJniObject result = callActivityMethod("acknowledgePurchase", "(Ljava/lang/String;)Ljava/lang/String;", + QJniObject::fromString(purchaseToken).object()); + QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object(); + return json; +} + +QJsonObject AndroidController::queryPurchases() +{ + QJniObject result = callActivityMethod("queryPurchases", "()Ljava/lang/String;"); + QJsonObject json = QJsonDocument::fromJson(result.toString().toUtf8()).object(); + return json; +} + // Moving log processing to the Android side jclass AndroidController::log; jmethodID AndroidController::logDebug; diff --git a/client/platforms/android/android_controller.h b/client/platforms/android/android_controller.h index 958aa02d..ee2f4c17 100644 --- a/client/platforms/android/android_controller.h +++ b/client/platforms/android/android_controller.h @@ -50,6 +50,10 @@ public: bool requestAuthentication(); bool isPlay(); 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 void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);