diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 64a4986d..a6666b7c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -418,13 +418,13 @@ jobs: 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 --apk all --build-platform ${{ env.ANDROID_BUILD_PLATFORM }} + run: ./deploy/build_android.sh --aab --play --apk all --build-platform ${{ env.ANDROID_BUILD_PLATFORM }} - name: 'Upload x86_64 apk' uses: actions/upload-artifact@v4 with: name: AmneziaVPN-android-x86_64 - path: deploy/build/AmneziaVPN-x86_64-release.apk + path: deploy/build/AmneziaVPN-oss-x86_64-release.apk compression-level: 0 retention-days: 7 @@ -432,7 +432,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: AmneziaVPN-android-x86 - path: deploy/build/AmneziaVPN-x86-release.apk + path: deploy/build/AmneziaVPN-oss-x86-release.apk compression-level: 0 retention-days: 7 @@ -440,7 +440,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: AmneziaVPN-android-arm64-v8a - path: deploy/build/AmneziaVPN-arm64-v8a-release.apk + path: deploy/build/AmneziaVPN-oss-arm64-v8a-release.apk compression-level: 0 retention-days: 7 @@ -448,7 +448,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: AmneziaVPN-android-armeabi-v7a - path: deploy/build/AmneziaVPN-armeabi-v7a-release.apk + path: deploy/build/AmneziaVPN-oss-armeabi-v7a-release.apk compression-level: 0 retention-days: 7 @@ -456,7 +456,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: AmneziaVPN-android - path: deploy/build/AmneziaVPN-release.aab + path: deploy/build/AmneziaVPN-play-release.aab compression-level: 0 retention-days: 7 diff --git a/client/android/billing/build.gradle.kts b/client/android/billing/build.gradle.kts new file mode 100644 index 00000000..b95d5aaf --- /dev/null +++ b/client/android/billing/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id(libs.plugins.android.library.get().pluginId) + id(libs.plugins.kotlin.android.get().pluginId) +} + +kotlin { + jvmToolchain(17) +} + +android { + namespace = "org.amnezia.vpn.billing" +} + +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..0094669c --- /dev/null +++ b/client/android/billing/src/main/kotlin/BillingException.kt @@ -0,0 +1,65 @@ +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, + retryable: Boolean = false +) : Exception(billingResult.toString()) { + + constructor(msg: String) : this(BillingResult.newBuilder() + .setResponseCode(DEVELOPER_ERROR) + .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) { + 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..4893eb30 --- /dev/null +++ b/client/android/billing/src/main/kotlin/BillingProvider.kt @@ -0,0 +1,320 @@ +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.ErrorCode +import org.amnezia.vpn.util.Log +import org.json.JSONArray +import org.json.JSONObject + +private const val TAG = "BillingProvider" +private const val PRODUCT_ID = "premium" + +class BillingProvider(context: Context) : AutoCloseable { + + private var billingClient: BillingClient + private var subscriptionPurchases = MutableStateFlow?>?>(null) + + private val purchasesUpdatedListeners = PurchasesUpdatedListener { billingResult, purchases -> + Log.v(TAG, "Purchases updated: $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, "Billing client connection") + 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 { + 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("responseCode", ErrorCode.BillingCanceled) + } 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("responseCode", e.errorCode) + } + } catch (_: CancellationException) { + Log.w(TAG, "Billing coroutine canceled") + return JSONObject().put("responseCode", ErrorCode.BillingCanceled) + } + } + } + + suspend fun getSubscriptionPlans(): JSONObject { + Log.v(TAG, "Get subscription plans") + + val productDetailsList = getProductDetails() + val resultJson = JSONObject().put("responseCode", ErrorCode.NoError) + 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(PRODUCT_ID) + .setProductType(ProductType.SUBS) + .build() + + val queryProductDetailsParams = QueryProductDetailsParams.newBuilder() + .setProductList(listOf(productDetailsParams)) + .build() + + val result = withContext(Dispatchers.IO) { + billingClient.queryProductDetails(queryProductDetailsParams) + } + + Log.v(TAG, "Query product details result: ${result.billingResult}") + + if (!result.billingResult.isOk) { + Log.e(TAG, "Failed to get product details: ${result.billingResult}") + throw BillingException(result.billingResult) + } + + return result.productDetailsList + } + + suspend fun getCustomerCountryCode(): JSONObject { + 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() + + return JSONObject() + .put("responseCode", ErrorCode.NoError) + .put("countryCode", countryCode) + } + + 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("responseCode", ErrorCode.NoError) + .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("responseCode", ErrorCode.NoError) + } + + suspend fun getPurchases(): JSONObject { + Log.v(TAG, "Get purchases") + val purchases = queryPurchases() + return JSONObject() + .put("responseCode", ErrorCode.NoError) + .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.v(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/build.gradle.kts b/client/android/build.gradle.kts index 3c742621..f22702ab 100644 --- a/client/android/build.gradle.kts +++ b/client/android/build.gradle.kts @@ -20,6 +20,7 @@ android { namespace = "org.amnezia.vpn" buildFeatures { + buildConfig = true viewBinding = true } @@ -41,17 +42,6 @@ android { resourceConfigurations += listOf("en", "ru", "b+zh+Hans") } - sourceSets { - getByName("main") { - manifest.srcFile("AndroidManifest.xml") - java.setSrcDirs(listOf("src")) - res.setSrcDirs(listOf("res")) - // androyddeployqt creates the folders below - assets.setSrcDirs(listOf("assets")) - jniLibs.setSrcDirs(listOf("libs")) - } - } - signingConfigs { register("release") { storeFile = providers.environmentVariable("ANDROID_KEYSTORE_PATH").orNull?.let { file(it) } @@ -77,6 +67,36 @@ android { } } + flavorDimensions += "billing" + + productFlavors { + create("oss") { + dimension = "billing" + } + create("play") { + dimension = "billing" + } + } + + sourceSets { + getByName("main") { + manifest.srcFile("AndroidManifest.xml") + java.setSrcDirs(listOf("src")) + res.setSrcDirs(listOf("res")) + // androyddeployqt creates the folders below + assets.setSrcDirs(listOf("assets")) + jniLibs.setSrcDirs(listOf("libs")) + } + + getByName("oss") { + java.setSrcDirs(listOf("oss")) + } + + getByName("play") { + java.setSrcDirs(listOf("play")) + } + } + splits { abi { isEnable = true @@ -122,4 +142,9 @@ dependencies { implementation(libs.google.mlkit) implementation(libs.androidx.datastore) implementation(libs.androidx.biometric) + + playImplementation(project(":billing")) } + +fun DependencyHandler.playImplementation(dependency: Any): Dependency? = + add("playImplementation", dependency) diff --git a/client/android/gradle/libs.versions.toml b/client/android/gradle/libs.versions.toml index c6fa1907..80340808 100644 --- a/client/android/gradle/libs.versions.toml +++ b/client/android/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.5.2" kotlin = "1.9.24" +android-billing = "7.0.0" androidx-core = "1.13.1" androidx-activity = "1.9.1" androidx-annotation = "1.8.2" @@ -14,6 +15,7 @@ kotlinx-serialization = "1.6.3" google-mlkit = "17.3.0" [libraries] +android-billing = { module = "com.android.billingclient:billing-ktx", version.ref = "android-billing" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } 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..8b97c447 --- /dev/null +++ b/client/android/oss/org/amnezia/vpn/BillingPaymentRepository.kt @@ -0,0 +1,13 @@ +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, 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 new file mode 100644 index 00000000..ce866452 --- /dev/null +++ b/client/android/play/org/amnezia/vpn/BillingPaymentRepository.kt @@ -0,0 +1,34 @@ +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, 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/settings.gradle.kts b/client/android/settings.gradle.kts index 68426ec8..4955e163 100644 --- a/client/android/settings.gradle.kts +++ b/client/android/settings.gradle.kts @@ -31,6 +31,7 @@ rootProject.buildFileName = "build.gradle.kts" include(":qt") include(":utils") +include(":billing") include(":protocolApi") include(":wireguard") include(":awg") diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index b2c2ff71..41443f6e 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -30,6 +30,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 @@ -40,7 +41,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 @@ -71,6 +71,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() @@ -180,6 +181,7 @@ class AmneziaActivity : QtActivity() { registerBroadcastReceivers() intent?.let(::processIntent) runBlocking { vpnProto = proto.await() } + billingRepository = BillingPaymentRepository(applicationContext) } private fun loadLibs() { @@ -647,15 +649,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") @@ -721,6 +717,47 @@ class AmneziaActivity : QtActivity() { } } + @Suppress("unused") + fun isPlay(): Boolean = BuildConfig.FLAVOR == "play" + + @Suppress("unused") + fun getCountryCode(): String { + Log.v(TAG, "Get country code") + return blockingCall { billingRepository.getCountryCode() } + } + + @Suppress("unused") + fun getSubscriptionPlans(): String { + Log.v(TAG, "Get subscription plans") + 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() } + } + // workaround for a bug in Qt that causes the mouse click event not to be handled // also disable right-click, as it causes the application to crash private var lastButtonState = 0 @@ -784,6 +821,13 @@ class AmneziaActivity : QtActivity() { /** * 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/AmneziaApplication.kt b/client/android/src/org/amnezia/vpn/AmneziaApplication.kt index 8b066056..b1c8f908 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaApplication.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaApplication.kt @@ -1,5 +1,6 @@ package org.amnezia.vpn +import android.system.Os import androidx.camera.camera2.Camera2Config import androidx.camera.core.CameraSelector import androidx.camera.core.CameraXConfig @@ -12,6 +13,9 @@ private const val TAG = "AmneziaApplication" class AmneziaApplication : QtApplication(), CameraXConfig.Provider { override fun onCreate() { + if (BuildConfig.DEBUG) { + Os.setenv("QT_ANDROID_DEBUGGER_MAIN_THREAD_SLEEP_MS", "0", true) + } super.onCreate() Prefs.init(this) Log.init(this) 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..3ae24cd3 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/BillingRepository.kt @@ -0,0 +1,12 @@ +package org.amnezia.vpn + +import android.app.Activity + +interface BillingRepository { + suspend fun getCountryCode(): String + suspend fun getSubscriptionPlans(): 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/android/utils/src/main/kotlin/ErrorCode.kt b/client/android/utils/src/main/kotlin/ErrorCode.kt new file mode 100644 index 00000000..5626cabf --- /dev/null +++ b/client/android/utils/src/main/kotlin/ErrorCode.kt @@ -0,0 +1,14 @@ +package org.amnezia.vpn.util + +// keep synchronized with client/core/defs.h error_code_ns::ErrorCode +object ErrorCode { + const val NoError = 0 + + const val BillingCanceled = 1300 + const val BillingError = 1301 + const val BillingGooglePlayError = 1302 + const val BillingUnavailable = 1303 + const val SubscriptionAlreadyOwned = 1304 + const val SubscriptionUnavailable = 1305 + const val BillingNetworkError = 1306 +} diff --git a/client/core/defs.h b/client/core/defs.h index d00d347b..ed4c4d7a 100644 --- a/client/core/defs.h +++ b/client/core/defs.h @@ -116,7 +116,16 @@ namespace amnezia PermissionsError = 1202, UnspecifiedError = 1203, FatalError = 1204, - AbortError = 1205 + AbortError = 1205, + + // Billing errors + BillingCanceled = 1300, + BillingError = 1301, + BillingGooglePlayError = 1302, + BillingUnavailable = 1303, + SubscriptionAlreadyOwned = 1304, + SubscriptionUnavailable = 1305, + BillingNetworkError = 1306, }; Q_ENUM_NS(ErrorCode) } diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index 49534606..4de4f506 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -72,6 +72,15 @@ 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::BillingCanceled): errorMessage = QObject::tr("Transaction was canceled by the user"); break; + 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 2790eb1b..ed668d2a 100644 --- a/client/platforms/android/android_controller.cpp +++ b/client/platforms/android/android_controller.cpp @@ -287,6 +287,51 @@ bool AndroidController::requestAuthentication() return result; } +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; +} + +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 759c9c3f..ee2f4c17 100644 --- a/client/platforms/android/android_controller.h +++ b/client/platforms/android/android_controller.h @@ -48,6 +48,12 @@ public: bool isNotificationPermissionGranted(); void requestNotificationPermission(); 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); diff --git a/deploy/build_android.sh b/deploy/build_android.sh index 3a739789..21260c2e 100755 --- a/deploy/build_android.sh +++ b/deploy/build_android.sh @@ -23,6 +23,7 @@ Options: By default, the latest available platform is used -m, --move Move the build result to the root of the build directory -f, --fdroid Build for F-Droid + -p, --play Build AAB for Google Play -h, --help Display this help EOT @@ -30,7 +31,7 @@ EOT BUILD_TYPE="release" -opts=$(getopt -l debug,aab,apk:,build-platform:,move,fdroid,help -o "dua:b:mfh" -- "$@") +opts=$(getopt -l debug,aab,apk:,build-platform:,move,fdroid,play,help -o "dua:b:mfph" -- "$@") eval set -- "$opts" while true; do case "$1" in @@ -40,6 +41,7 @@ while true; do -b | --build-platform) ANDROID_BUILD_PLATFORM=$2; shift 2;; -m | --move) MOVE_RESULT=1; shift;; -f | --fdroid) FDROID=1; shift;; + -p | --play) PLAY=1; shift;; -h | --help) usage; exit 0;; --) shift; break;; esac @@ -149,11 +151,17 @@ if [ -v FDROID ]; then BUILD_TYPE="fdroid" fi +if [ -v PLAY ]; then + AAB_FLAVOR="play" +else + AAB_FLAVOR="oss" +fi + if [ -v AAB ]; then - gradle_opts+=(bundle"${BUILD_TYPE^}") + gradle_opts+=(bundle"${AAB_FLAVOR^}${BUILD_TYPE^}") fi if [ -v ABIS ]; then - gradle_opts+=(assemble"${BUILD_TYPE^}") + gradle_opts+=(assembleOss"${BUILD_TYPE^}") fi $OUT_APP_DIR/android-build/gradlew \ @@ -164,7 +172,7 @@ $OUT_APP_DIR/android-build/gradlew \ if [[ -v CI || -v MOVE_RESULT ]]; then echo "Moving APK/AAB..." if [ -v AAB ]; then - mv -u $OUT_APP_DIR/android-build/build/outputs/bundle/$BUILD_TYPE/AmneziaVPN-$BUILD_TYPE.aab \ + mv -u $OUT_APP_DIR/android-build/build/outputs/bundle/$AAB_FLAVOR"${BUILD_TYPE^}"/AmneziaVPN-$AAB_FLAVOR-$BUILD_TYPE.aab \ $PROJECT_DIR/deploy/build/ fi @@ -181,7 +189,7 @@ if [[ -v CI || -v MOVE_RESULT ]]; then IFS=';' read -r -a abi_array <<< "$ABIS" for ABI in "${abi_array[@]}" do - mv -u $OUT_APP_DIR/android-build/build/outputs/apk/$BUILD_TYPE/AmneziaVPN-$ABI-$suffix.apk \ + mv -u $OUT_APP_DIR/android-build/build/outputs/apk/oss/$BUILD_TYPE/AmneziaVPN-oss-$ABI-$suffix.apk \ $PROJECT_DIR/deploy/build/ done fi