Add method to get the list of offers

This commit is contained in:
albexk 2024-10-03 19:47:39 +03:00
parent 3bd6f704e9
commit bad9327ffa
14 changed files with 336 additions and 16 deletions

View file

@ -12,6 +12,7 @@ android {
} }
dependencies { dependencies {
compileOnly(project(":utils"))
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.kotlinx.coroutines) implementation(libs.kotlinx.coroutines)
implementation(libs.android.billing) implementation(libs.android.billing)

View file

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

View file

@ -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<Pair<BillingResult, List<Purchase>?>?>(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<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 =
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<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()
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

View file

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

View file

@ -1,7 +0,0 @@
package org.amnezia.vpn
class BillingProvider {
fun type(): String {
return "OSS"
}
}

View file

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

View file

@ -1,7 +0,0 @@
package org.amnezia.vpn
class BillingProvider {
fun type(): String {
return "PLAY"
}
}

View file

@ -70,6 +70,7 @@ class AmneziaActivity : QtActivity() {
private var isInBoundState = false private var isInBoundState = false
private var notificationStateReceiver: BroadcastReceiver? = null private var notificationStateReceiver: BroadcastReceiver? = null
private lateinit var vpnServiceMessenger: IpcMessenger private lateinit var vpnServiceMessenger: IpcMessenger
private lateinit var billingRepository: BillingRepository
private val actionResultHandlers = mutableMapOf<Int, ActivityResultHandler>() private val actionResultHandlers = mutableMapOf<Int, ActivityResultHandler>()
private val permissionRequestHandlers = mutableMapOf<Int, PermissionRequestHandler>() private val permissionRequestHandlers = mutableMapOf<Int, PermissionRequestHandler>()
@ -157,7 +158,6 @@ class AmneziaActivity : QtActivity() {
* Activity overloaded methods * Activity overloaded methods
*/ */
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Log.d(TAG, "Billing provider: ${BillingProvider().type()}")
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(TAG, "Create Amnezia activity: $intent") Log.d(TAG, "Create Amnezia activity: $intent")
loadLibs() loadLibs()
@ -180,6 +180,7 @@ class AmneziaActivity : QtActivity() {
registerBroadcastReceivers() registerBroadcastReceivers()
intent?.let(::processIntent) intent?.let(::processIntent)
runBlocking { vpnProto = proto.await() } runBlocking { vpnProto = proto.await() }
billingRepository = BillingPaymentRepository(applicationContext)
} }
private fun loadLibs() { private fun loadLibs() {
@ -724,6 +725,26 @@ class AmneziaActivity : QtActivity() {
@Suppress("unused") @Suppress("unused")
fun isPlay(): Boolean = BuildConfig.FLAVOR == "play" 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 * Utils methods
*/ */

View file

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

View file

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

View file

@ -114,7 +114,15 @@ namespace amnezia
PermissionsError = 1202, PermissionsError = 1202,
UnspecifiedError = 1203, UnspecifiedError = 1203,
FatalError = 1204, FatalError = 1204,
AbortError = 1205 AbortError = 1205,
// Billing errors
BillingError = 1300,
BillingGooglePlayError = 1301,
BillingUnavailable = 1302,
SubscriptionAlreadyOwned = 1303,
SubscriptionUnavailable = 1304,
BillingNetworkError = 1305,
}; };
Q_ENUM_NS(ErrorCode) Q_ENUM_NS(ErrorCode)
} }

View file

@ -70,6 +70,14 @@ QString errorString(ErrorCode code) {
case(ErrorCode::FatalError): errorMessage = QObject::tr("QFile error: A fatal error occurred"); break; 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; 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): case(ErrorCode::InternalError):
default: default:
errorMessage = QObject::tr("Internal error"); break; errorMessage = QObject::tr("Internal error"); break;

View file

@ -292,6 +292,13 @@ bool AndroidController::isPlay()
return callActivityMethod<jboolean>("isPlay", "()Z"); 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;
}
// Moving log processing to the Android side // Moving log processing to the Android side
jclass AndroidController::log; jclass AndroidController::log;
jmethodID AndroidController::logDebug; jmethodID AndroidController::logDebug;

View file

@ -49,6 +49,7 @@ public:
void requestNotificationPermission(); void requestNotificationPermission();
bool requestAuthentication(); bool requestAuthentication();
bool isPlay(); bool isPlay();
QJsonObject getSubscriptionPlans();
static bool initLogging(); static bool initLogging();
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message); static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);