Compare commits

...
Sign in to create a new pull request.

13 commits

Author SHA1 Message Date
albexk
9ebefe79ca refactor: modify response json 2024-11-08 11:12:56 +03:00
albexk
d7581f29ba Merge branch 'dev' into feature/android-gpb
# Conflicts:
#	CMakeLists.txt
#	client/android/src/org/amnezia/vpn/AmneziaActivity.kt
2024-10-30 17:39:13 +03:00
albexk
2c6ae4214d Add methods to verify and purchase subscriptions 2024-10-30 17:35:33 +03:00
albexk
12eee2cd40 Merge branch 'dev' into feature/android-gpb
# Conflicts:
#	CMakeLists.txt
2024-10-03 19:52:38 +03:00
albexk
bad9327ffa Add method to get the list of offers 2024-10-03 19:47:39 +03:00
albexk
3bd6f704e9 Merge branch 'dev' into feature/android-gpb
# Conflicts:
#	CMakeLists.txt
2024-09-24 17:55:01 +03:00
albexk
58d1178416 Build script refactor to support AAB build for Google Play 2024-09-17 21:27:59 +03:00
albexk
e2dd5109f9 Add a method to detect Google Play build 2024-09-17 20:53:36 +03:00
albexk
582bd92d31 Add billing module, provide a separate build for Google Play 2024-09-17 20:51:02 +03:00
albexk
fe18472d07 Disable Qt debugger sleep 2024-09-17 20:44:39 +03:00
albexk
cb90881f24 Merge branch 'dev' into feature/android-gpb
# Conflicts:
#	CMakeLists.txt
2024-09-17 18:08:30 +03:00
albexk
8e95e6a2fd Bump Android version code to 59 2024-09-17 18:07:29 +03:00
albexk
73990bb07d Add Google Play Billing Library 2024-09-11 19:51:32 +03:00
18 changed files with 662 additions and 32 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -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
}
}
}
}

View file

@ -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<Pair<BillingResult, List<Purchase>?>?>(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<Unit>()
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<ProductDetails>? {
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<String>()
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<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("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<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() {
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

View file

@ -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)

View file

@ -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" }

View file

@ -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 = ""
}

View file

@ -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()
}
}

View file

@ -31,6 +31,7 @@ rootProject.buildFileName = "build.gradle.kts"
include(":qt")
include(":utils")
include(":billing")
include(":protocolApi")
include(":wireguard")
include(":awg")

View file

@ -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<Int, ActivityResultHandler>()
private val permissionRequestHandlers = mutableMapOf<Int, PermissionRequestHandler>()
@ -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 <T> blockingCall(
context: CoroutineContext = Dispatchers.Default,
block: suspend () -> T
) = runBlocking {
mainScope.async(context) { block() }.await()
}
companion object {
private fun actionCodeToString(actionCode: Int): String =
when (actionCode) {

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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;

View file

@ -287,6 +287,51 @@ bool AndroidController::requestAuthentication()
return result;
}
bool AndroidController::isPlay()
{
return callActivityMethod<jboolean>("isPlay", "()Z");
}
QJsonObject AndroidController::getSubscriptionPlans()
{
QJniObject subscriptionPlans = callActivityMethod<jstring>("getSubscriptionPlans", "()Ljava/lang/String;");
QJsonObject json = QJsonDocument::fromJson(subscriptionPlans.toString().toUtf8()).object();
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
jclass AndroidController::log;
jmethodID AndroidController::logDebug;

View file

@ -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);

View file

@ -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