Add method to get the list of offers
This commit is contained in:
parent
3bd6f704e9
commit
bad9327ffa
14 changed files with 336 additions and 16 deletions
|
|
@ -12,6 +12,7 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(project(":utils"))
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
implementation(libs.android.billing)
|
||||
|
|
|
|||
51
client/android/billing/src/main/kotlin/BillingException.kt
Normal file
51
client/android/billing/src/main/kotlin/BillingException.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
client/android/billing/src/main/kotlin/BillingProvider.kt
Normal file
187
client/android/billing/src/main/kotlin/BillingProvider.kt
Normal 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
|
||||
|
|
@ -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 = ""
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package org.amnezia.vpn
|
||||
|
||||
class BillingProvider {
|
||||
fun type(): String {
|
||||
return "OSS"
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package org.amnezia.vpn
|
||||
|
||||
class BillingProvider {
|
||||
fun type(): String {
|
||||
return "PLAY"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Int, ActivityResultHandler>()
|
||||
private val permissionRequestHandlers = mutableMapOf<Int, PermissionRequestHandler>()
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
9
client/android/src/org/amnezia/vpn/BillingRepository.kt
Normal file
9
client/android/src/org/amnezia/vpn/BillingRepository.kt
Normal 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
|
||||
}
|
||||
11
client/android/utils/src/main/kotlin/ErrorCode.kt
Normal file
11
client/android/utils/src/main/kotlin/ErrorCode.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -292,6 +292,13 @@ 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;
|
||||
}
|
||||
|
||||
// Moving log processing to the Android side
|
||||
jclass AndroidController::log;
|
||||
jmethodID AndroidController::logDebug;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue