diff --git a/client/android/billing/build.gradle.kts b/client/android/billing/build.gradle.kts index a3bc6765..b95d5aaf 100644 --- a/client/android/billing/build.gradle.kts +++ b/client/android/billing/build.gradle.kts @@ -12,6 +12,7 @@ android { } dependencies { + compileOnly(project(":utils")) implementation(libs.androidx.core) implementation(libs.kotlinx.coroutines) implementation(libs.android.billing) diff --git a/client/android/billing/src/main/kotlin/BillingException.kt b/client/android/billing/src/main/kotlin/BillingException.kt new file mode 100644 index 00000000..6180872f --- /dev/null +++ b/client/android/billing/src/main/kotlin/BillingException.kt @@ -0,0 +1,51 @@ +import com.android.billingclient.api.BillingClient.BillingResponseCode.BILLING_UNAVAILABLE +import com.android.billingclient.api.BillingClient.BillingResponseCode.DEVELOPER_ERROR +import com.android.billingclient.api.BillingClient.BillingResponseCode.ERROR +import com.android.billingclient.api.BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED +import com.android.billingclient.api.BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED +import com.android.billingclient.api.BillingClient.BillingResponseCode.ITEM_NOT_OWNED +import com.android.billingclient.api.BillingClient.BillingResponseCode.ITEM_UNAVAILABLE +import com.android.billingclient.api.BillingClient.BillingResponseCode.NETWORK_ERROR +import com.android.billingclient.api.BillingClient.BillingResponseCode.SERVICE_DISCONNECTED +import com.android.billingclient.api.BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE +import com.android.billingclient.api.BillingClient.BillingResponseCode.USER_CANCELED +import com.android.billingclient.api.BillingResult +import org.amnezia.vpn.util.ErrorCode + +internal class BillingException(billingResult: BillingResult) : Exception(billingResult.debugMessage) { + + val errorCode: Int + val isCanceled = billingResult.responseCode == USER_CANCELED + + init { + when (billingResult.responseCode) { + ERROR -> { + errorCode = ErrorCode.BillingGooglePlayError + } + + BILLING_UNAVAILABLE, SERVICE_DISCONNECTED, SERVICE_UNAVAILABLE -> { + errorCode = ErrorCode.BillingUnavailable + } + + DEVELOPER_ERROR, FEATURE_NOT_SUPPORTED, ITEM_NOT_OWNED -> { + errorCode = ErrorCode.BillingError + } + + ITEM_ALREADY_OWNED -> { + errorCode = ErrorCode.SubscriptionAlreadyOwned + } + + ITEM_UNAVAILABLE -> { + errorCode = ErrorCode.SubscriptionUnavailable + } + + NETWORK_ERROR -> { + errorCode = ErrorCode.BillingNetworkError + } + + else -> { + errorCode = ErrorCode.BillingError + } + } + } +} diff --git a/client/android/billing/src/main/kotlin/BillingProvider.kt b/client/android/billing/src/main/kotlin/BillingProvider.kt new file mode 100644 index 00000000..a432d17c --- /dev/null +++ b/client/android/billing/src/main/kotlin/BillingProvider.kt @@ -0,0 +1,187 @@ +import android.app.Activity +import android.content.Context +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.BillingResult +import com.android.billingclient.api.GetBillingConfigParams +import com.android.billingclient.api.PendingPurchasesParams +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.queryProductDetails +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import org.amnezia.vpn.util.Log +import org.json.JSONArray +import org.json.JSONObject + +private const val TAG = "BillingProvider" +private const val RESULT_OK = 1 +private const val RESULT_CANCELED = 0 +private const val RESULT_ERROR = -1 + +class BillingProvider(context: Context) : AutoCloseable { + + private var billingClient: BillingClient + private var subscriptionPurchases = MutableStateFlow?>?>(null) + + private val purchasesUpdatedListeners = PurchasesUpdatedListener { billingResult, purchases -> + Log.v(TAG, "PurchasesUpdatedListener: $billingResult") + subscriptionPurchases.value = billingResult to purchases + } + + init { + billingClient = BillingClient.newBuilder(context) + .setListener(purchasesUpdatedListeners) + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .build() + } + + private suspend fun connect() { + if (billingClient.isReady) return + + Log.v(TAG, "Connect to Google Play") + val connection = CompletableDeferred() + withContext(Dispatchers.IO) { + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + Log.v(TAG, "Billing setup finished: $billingResult") + if (billingResult.isOk) { + connection.complete(Unit) + } else { + Log.e(TAG, "Billing setup failed: $billingResult") + connection.completeExceptionally(BillingException(billingResult)) + } + } + + override fun onBillingServiceDisconnected() { + Log.w(TAG, "Billing service disconnected") + } + }) + } + 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) + } + } catch (_: CancellationException) { + Log.w(TAG, "Billing coroutine canceled") + JSONObject().put("result", RESULT_CANCELED) + } + + suspend fun getSubscriptionPlans(): JSONObject = handleBillingApiCall { + Log.v(TAG, "Get subscription plans") + + val productDetailsParams = Product.newBuilder() + .setProductId("premium") + .setProductType(ProductType.SUBS) + .build() + + val queryProductDetailsParams = QueryProductDetailsParams.newBuilder() + .setProductList(listOf(productDetailsParams)) + .build() + + val result = withContext(Dispatchers.IO) { + billingClient.queryProductDetails(queryProductDetailsParams) + } + + if (!result.billingResult.isOk) { + Log.e(TAG, "Failed to get subscription plans: ${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 + } + + suspend fun getCustomerCountryCode(): JSONObject = handleBillingApiCall { + Log.v(TAG, "Get customer country code") + + val deferred = CompletableDeferred() + withContext(Dispatchers.IO) { + billingClient.getBillingConfigAsync(GetBillingConfigParams.newBuilder().build(), + { billingResult, billingConfig -> + Log.v(TAG, "Billing config: $billingResult, ${billingConfig?.countryCode}") + if (billingResult.isOk) { + deferred.complete(billingConfig?.countryCode ?: "") + } else { + deferred.completeExceptionally(BillingException(billingResult)) + } + }) + } + val countryCode = deferred.await() + + 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() + } + + override fun close() { + Log.d(TAG, "Close billing client connection") + billingClient.endConnection() + } + + companion object { + suspend fun withBillingProvider(context: Context, block: suspend BillingProvider.() -> JSONObject): String = + BillingProvider(context).use { bp -> + bp.handleBillingApiCall { + bp.connect() + bp.block() + }.toString() + } + } +} + +internal val BillingResult.isOk: Boolean + get() = responseCode == BillingResponseCode.OK diff --git a/client/android/oss/org/amnezia/vpn/BillingPaymentRepository.kt b/client/android/oss/org/amnezia/vpn/BillingPaymentRepository.kt new file mode 100644 index 00000000..c5c934fc --- /dev/null +++ b/client/android/oss/org/amnezia/vpn/BillingPaymentRepository.kt @@ -0,0 +1,10 @@ +package org.amnezia.vpn + +import android.app.Activity +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 = "" +} diff --git a/client/android/oss/org/amnezia/vpn/BillingProvider.kt b/client/android/oss/org/amnezia/vpn/BillingProvider.kt deleted file mode 100644 index 80274312..00000000 --- a/client/android/oss/org/amnezia/vpn/BillingProvider.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.amnezia.vpn - -class BillingProvider { - fun type(): String { - return "OSS" - } -} diff --git a/client/android/play/org/amnezia/vpn/BillingPaymentRepository.kt b/client/android/play/org/amnezia/vpn/BillingPaymentRepository.kt new file mode 100644 index 00000000..3b81fddc --- /dev/null +++ b/client/android/play/org/amnezia/vpn/BillingPaymentRepository.kt @@ -0,0 +1,20 @@ +package org.amnezia.vpn + +import android.app.Activity +import android.content.Context +import BillingProvider.Companion.withBillingProvider + +class BillingPaymentRepository(private val context: Context) : BillingRepository { + + override suspend fun getCountryCode(): String = withBillingProvider(context) { + getCustomerCountryCode() + } + + override suspend fun getSubscriptionPlans(): String = withBillingProvider(context) { + getSubscriptionPlans() + } + + override suspend fun purchaseSubscription(activity: Activity): String = withBillingProvider(context) { + purchaseSubscription(activity, "obfuscatedAccountId") + } +} diff --git a/client/android/play/org/amnezia/vpn/BillingProvider.kt b/client/android/play/org/amnezia/vpn/BillingProvider.kt deleted file mode 100644 index 4d55c583..00000000 --- a/client/android/play/org/amnezia/vpn/BillingProvider.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.amnezia.vpn - -class BillingProvider { - fun type(): String { - return "PLAY" - } -} diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index a8be6b2c..66af05e0 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -70,6 +70,7 @@ class AmneziaActivity : QtActivity() { private var isInBoundState = false private var notificationStateReceiver: BroadcastReceiver? = null private lateinit var vpnServiceMessenger: IpcMessenger + private lateinit var billingRepository: BillingRepository private val actionResultHandlers = mutableMapOf() private val permissionRequestHandlers = mutableMapOf() @@ -157,7 +158,6 @@ class AmneziaActivity : QtActivity() { * Activity overloaded methods */ override fun onCreate(savedInstanceState: Bundle?) { - Log.d(TAG, "Billing provider: ${BillingProvider().type()}") super.onCreate(savedInstanceState) Log.d(TAG, "Create Amnezia activity: $intent") loadLibs() @@ -180,6 +180,7 @@ class AmneziaActivity : QtActivity() { registerBroadcastReceivers() intent?.let(::processIntent) runBlocking { vpnProto = proto.await() } + billingRepository = BillingPaymentRepository(applicationContext) } private fun loadLibs() { @@ -724,6 +725,26 @@ class AmneziaActivity : QtActivity() { @Suppress("unused") fun isPlay(): Boolean = BuildConfig.FLAVOR == "play" + @Suppress("unused") + fun getCountryCode(): String { + Log.v(TAG, "Get country code") + return runBlocking { + mainScope.async { + billingRepository.getCountryCode() + }.await() + } + } + + @Suppress("unused") + fun getSubscriptionPlans(): String { + Log.v(TAG, "Get subscription plans") + return runBlocking { + mainScope.async { + billingRepository.getSubscriptionPlans() + }.await() + } + } + /** * Utils methods */ diff --git a/client/android/src/org/amnezia/vpn/BillingRepository.kt b/client/android/src/org/amnezia/vpn/BillingRepository.kt new file mode 100644 index 00000000..5aac147a --- /dev/null +++ b/client/android/src/org/amnezia/vpn/BillingRepository.kt @@ -0,0 +1,9 @@ +package org.amnezia.vpn + +import android.app.Activity + +interface BillingRepository { + suspend fun getCountryCode(): String + suspend fun getSubscriptionPlans(): String + suspend fun purchaseSubscription(activity: Activity): String +} diff --git a/client/android/utils/src/main/kotlin/ErrorCode.kt b/client/android/utils/src/main/kotlin/ErrorCode.kt new file mode 100644 index 00000000..14b32903 --- /dev/null +++ b/client/android/utils/src/main/kotlin/ErrorCode.kt @@ -0,0 +1,11 @@ +package org.amnezia.vpn.util + +// keep synchronized with client/core/defs.h error_code_ns::ErrorCode +object ErrorCode { + const val BillingError = 1300 + const val BillingGooglePlayError = 1301 + const val BillingUnavailable = 1302 + const val SubscriptionAlreadyOwned = 1303 + const val SubscriptionUnavailable = 1304 + const val BillingNetworkError = 1305 +} diff --git a/client/core/defs.h b/client/core/defs.h index ebc07f4b..62e469c9 100644 --- a/client/core/defs.h +++ b/client/core/defs.h @@ -114,7 +114,15 @@ namespace amnezia PermissionsError = 1202, UnspecifiedError = 1203, FatalError = 1204, - AbortError = 1205 + AbortError = 1205, + + // Billing errors + BillingError = 1300, + BillingGooglePlayError = 1301, + BillingUnavailable = 1302, + SubscriptionAlreadyOwned = 1303, + SubscriptionUnavailable = 1304, + BillingNetworkError = 1305, }; Q_ENUM_NS(ErrorCode) } diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index 8c16d786..f880ef3e 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -70,6 +70,14 @@ QString errorString(ErrorCode code) { case(ErrorCode::FatalError): errorMessage = QObject::tr("QFile error: A fatal error occurred"); break; case(ErrorCode::AbortError): errorMessage = QObject::tr("QFile error: The operation was aborted"); break; + // Billing errors + case(ErrorCode::BillingError): errorMessage = QObject::tr("Billing error"); break; + case(ErrorCode::BillingGooglePlayError): errorMessage = QObject::tr("Internal Google Play error, please try again later"); break; + case(ErrorCode::BillingUnavailable): errorMessage = QObject::tr("Billing is unavailable, please try again later"); break; + case(ErrorCode::SubscriptionAlreadyOwned): errorMessage = QObject::tr("You already own this subscription"); break; + case(ErrorCode::SubscriptionUnavailable): errorMessage = QObject::tr("The requested subscription is not available for purchase"); break; + case(ErrorCode::BillingNetworkError): errorMessage = QObject::tr("A network error occurred during the operation, please check the Internet connection"); break; + case(ErrorCode::InternalError): default: errorMessage = QObject::tr("Internal error"); break; diff --git a/client/platforms/android/android_controller.cpp b/client/platforms/android/android_controller.cpp index ece67d1c..f5cd82a5 100644 --- a/client/platforms/android/android_controller.cpp +++ b/client/platforms/android/android_controller.cpp @@ -292,6 +292,13 @@ bool AndroidController::isPlay() return callActivityMethod("isPlay", "()Z"); } +QJsonObject AndroidController::getSubscriptionPlans() +{ + QJniObject subscriptionPlans = callActivityMethod("getSubscriptionPlans", "()Ljava/lang/String;"); + QJsonObject json = QJsonDocument::fromJson(subscriptionPlans.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 b5ab45a5..958aa02d 100644 --- a/client/platforms/android/android_controller.h +++ b/client/platforms/android/android_controller.h @@ -49,6 +49,7 @@ public: void requestNotificationPermission(); bool requestAuthentication(); bool isPlay(); + QJsonObject getSubscriptionPlans(); static bool initLogging(); static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);