Acknowledge subscriptions

pull/1451/head
Alex Baker 5 years ago
parent 775b5b56ca
commit 7e5151cd03

@ -2,6 +2,7 @@ package org.tasks.billing
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules import dagger.hilt.android.testing.UninstallModules
import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -28,6 +29,7 @@ class InventoryTest : InjectingTestCase() {
withPurchases(monthly01) withPurchases(monthly01)
assertTrue(inventory.hasPro) assertTrue(inventory.hasPro)
assertEquals(1, inventory.subscription.value?.subscriptionPrice)
} }
@Test @Test
@ -59,7 +61,17 @@ class InventoryTest : InjectingTestCase() {
} }
private fun withPurchases(vararg purchases: String) { private fun withPurchases(vararg purchases: String) {
preferences.setPurchases(purchases.toHashSet()) val asPurchases =
purchases
.map(::JSONObject)
.map {
com.android.billingclient.api.Purchase(
it.getString("zza"),
it.getString("zzb")
)
}
.map(::Purchase)
preferences.setPurchases(asPurchases)
runOnMainSync { runOnMainSync {
inventory = Inventory( inventory = Inventory(
context, context,
@ -72,9 +84,9 @@ class InventoryTest : InjectingTestCase() {
} }
companion object { companion object {
private const val annual03 = """{"mOriginalJson":"{\"orderId\":\"GPA.3372-3222-8630-38485\",\"packageName\":\"org.tasks\",\"productId\":\"annual_03\",\"purchaseTime\":1603917413542,\"purchaseState\":0,\"purchaseToken\":\"ciogggalpbohflhmjamciehl.AO-J1OyjBpBOnwKCOBSN0Cil7yM65ZYnkn9nGqZO1n3nsHIF_LHv0ZuQqNnThB_JuCt-s9wBij1PyOCoP8axqVILXSiavcyPmg\",\"autoRenewing\":true}","mParsedJson":{"nameValuePairs":{"orderId":"GPA.3372-3222-8630-38485","packageName":"org.tasks","productId":"annual_03","purchaseTime":1603917413542,"purchaseState":0,"purchaseToken":"ciogggalpbohflhmjamciehl.AO-J1OyjBpBOnwKCOBSN0Cil7yM65ZYnkn9nGqZO1n3nsHIF_LHv0ZuQqNnThB_JuCt-s9wBij1PyOCoP8axqVILXSiavcyPmg","autoRenewing":true}},"mSignature":"Od2ulMjFethYNdA1rTm7AvNMyfFgefCaZhtBeYuTHlMB/XbEd/m9noRlKWMnShFthnQyw97CfrB86aaB52OSWm9pGkPzaRtOJPyL8BJHP9LEjXHOQIQ2Nx9zRF30+EWgV4O0IyeL/o5eUvTQRNnfyUXFdJQRLiKTblQojO6mTCX2fA6lTAntjJpbTbYGuYZjg782gX5HvmwQN5CJu7ZVZCH9AmsnAqZgb7h+MXhquQjv0L4pDVDp3dyDDwgpCAvSRy3550ZANPfNGsQpPr9Iv9IGoK0/INZRrq63VEEAz2mBGkzJgyQUYVtT6AylvNrqdo0w17hs0MLfsj6dwvSlYw\u003d\u003d"}""" private const val annual03 = """{"zza":"{\"orderId\":\"GPA.3372-3222-8630-38485\",\"packageName\":\"org.tasks\",\"productId\":\"annual_03\",\"purchaseTime\":1603917413542,\"purchaseState\":0,\"purchaseToken\":\"ciogggalpbohflhmjamciehl.AO-J1OyjBpBOnwKCOBSN0Cil7yM65ZYnkn9nGqZO1n3nsHIF_LHv0ZuQqNnThB_JuCt-s9wBij1PyOCoP8axqVILXSiavcyPmg\",\"autoRenewing\":true}","mParsedJson":{"nameValuePairs":{"orderId":"GPA.3372-3222-8630-38485","packageName":"org.tasks","productId":"annual_03","purchaseTime":1603917413542,"purchaseState":0,"purchaseToken":"ciogggalpbohflhmjamciehl.AO-J1OyjBpBOnwKCOBSN0Cil7yM65ZYnkn9nGqZO1n3nsHIF_LHv0ZuQqNnThB_JuCt-s9wBij1PyOCoP8axqVILXSiavcyPmg","autoRenewing":true}},"zzb":"Od2ulMjFethYNdA1rTm7AvNMyfFgefCaZhtBeYuTHlMB/XbEd/m9noRlKWMnShFthnQyw97CfrB86aaB52OSWm9pGkPzaRtOJPyL8BJHP9LEjXHOQIQ2Nx9zRF30+EWgV4O0IyeL/o5eUvTQRNnfyUXFdJQRLiKTblQojO6mTCX2fA6lTAntjJpbTbYGuYZjg782gX5HvmwQN5CJu7ZVZCH9AmsnAqZgb7h+MXhquQjv0L4pDVDp3dyDDwgpCAvSRy3550ZANPfNGsQpPr9Iv9IGoK0/INZRrq63VEEAz2mBGkzJgyQUYVtT6AylvNrqdo0w17hs0MLfsj6dwvSlYw\u003d\u003d"}"""
private const val monthly01 = """{"mOriginalJson":"{\"orderId\":\"GPA.3369-0544-4429-52590\",\"packageName\":\"org.tasks\",\"productId\":\"monthly_01\",\"purchaseTime\":1603912474316,\"purchaseState\":0,\"purchaseToken\":\"iibbhlkglfjcgdebphiklajb.AO-J1OyJd2kCytLMfT8Vszibf_E99ffLha5cHgOM8o3gYPKy1kD8nIZh0hcEEyOPe7fsdFJrR1-gtvg8WKLFNJoCdqrerJ2Z6Q\",\"autoRenewing\":true}","mParsedJson":{"nameValuePairs":{"orderId":"GPA.3369-0544-4429-52590","packageName":"org.tasks","productId":"monthly_01","purchaseTime":1603912474316,"purchaseState":0,"purchaseToken":"iibbhlkglfjcgdebphiklajb.AO-J1OyJd2kCytLMfT8Vszibf_E99ffLha5cHgOM8o3gYPKy1kD8nIZh0hcEEyOPe7fsdFJrR1-gtvg8WKLFNJoCdqrerJ2Z6Q","autoRenewing":true}},"mSignature":"UK7fdCY61QownZW8jDLB1myUKf1llFh9rj5I7P8V03AgdA6LGpEUiCvMvCqHfMGpY3VewmawezqiCUdGWGr+UgS+6QHEuFjpO8L+E36JUDqlU9uoGrTsXLI1gXQNQElGJ71DrKlFBbyyBHSeGWnzijcq4DyyHQzpmsqijxfs0KGjkta2TiOCtyxS+YA569xaGi6lcLGTyMEe7wS5bcjdfwFir0uVtCP+iqjoEd3kt4/03l9BEJYgf8eBxI0vrm4O+jYDJu8gGMTSQZiSqb0wN4sq8D9ksV+BcI4az6LVa1d6nuD+ob0Woe0/P2uoXG8nTEZJnrAZjkG6q8736HP6rw\u003d\u003d"}""" private const val monthly01 = """{"zza":"{\"orderId\":\"GPA.3369-0544-4429-52590\",\"packageName\":\"org.tasks\",\"productId\":\"monthly_01\",\"purchaseTime\":1603912474316,\"purchaseState\":0,\"purchaseToken\":\"iibbhlkglfjcgdebphiklajb.AO-J1OyJd2kCytLMfT8Vszibf_E99ffLha5cHgOM8o3gYPKy1kD8nIZh0hcEEyOPe7fsdFJrR1-gtvg8WKLFNJoCdqrerJ2Z6Q\",\"autoRenewing\":true}","mParsedJson":{"nameValuePairs":{"orderId":"GPA.3369-0544-4429-52590","packageName":"org.tasks","productId":"monthly_01","purchaseTime":1603912474316,"purchaseState":0,"purchaseToken":"iibbhlkglfjcgdebphiklajb.AO-J1OyJd2kCytLMfT8Vszibf_E99ffLha5cHgOM8o3gYPKy1kD8nIZh0hcEEyOPe7fsdFJrR1-gtvg8WKLFNJoCdqrerJ2Z6Q","autoRenewing":true}},"zzb":"UK7fdCY61QownZW8jDLB1myUKf1llFh9rj5I7P8V03AgdA6LGpEUiCvMvCqHfMGpY3VewmawezqiCUdGWGr+UgS+6QHEuFjpO8L+E36JUDqlU9uoGrTsXLI1gXQNQElGJ71DrKlFBbyyBHSeGWnzijcq4DyyHQzpmsqijxfs0KGjkta2TiOCtyxS+YA569xaGi6lcLGTyMEe7wS5bcjdfwFir0uVtCP+iqjoEd3kt4/03l9BEJYgf8eBxI0vrm4O+jYDJu8gGMTSQZiSqb0wN4sq8D9ksV+BcI4az6LVa1d6nuD+ob0Woe0/P2uoXG8nTEZJnrAZjkG6q8736HP6rw\u003d\u003d"}"""
private const val monthly03 = """{"mOriginalJson":"{\"orderId\":\"GPA.3348-6247-8527-38213\",\"packageName\":\"org.tasks\",\"productId\":\"monthly_03\",\"purchaseTime\":1603912730414,\"purchaseState\":0,\"purchaseToken\":\"cmomnojdllomadpoinoabbkd.AO-J1OypdY4iXbMrF21L6Evn3wZSccwiBq-d55G1BVcrkwuH69zOuqb35yZnVynEb9KEvnQvgYQsUpv1AD5749iU-eDo4TRV5A\",\"autoRenewing\":true}","mParsedJson":{"nameValuePairs":{"orderId":"GPA.3348-6247-8527-38213","packageName":"org.tasks","productId":"monthly_03","purchaseTime":1603912730414,"purchaseState":0,"purchaseToken":"cmomnojdllomadpoinoabbkd.AO-J1OypdY4iXbMrF21L6Evn3wZSccwiBq-d55G1BVcrkwuH69zOuqb35yZnVynEb9KEvnQvgYQsUpv1AD5749iU-eDo4TRV5A","autoRenewing":true}},"mSignature":"FkkW5FPw2elWnenIoQT7U5BnL2prcuK0GJEaHKtObPujSGRfJWFfThe3yuQ0w9AuTO0EDbm7LbJI44AiVJmpva3Iz3U2np2eNBuUAJIw9eECvQjEvuYk6Vq7LIgJwEsTyA8xRwjLJm+R1mmMWOxURmvDVBgDTHCOJsdUI9s52CSTQf2Ek+XABHugrMJudO43LzDuV2sP9mCqXUnSLbBXe3zZKyhhuz7gD+/5yavkRsPOVcZnsJetdxEmnrip8JEvgtHAvciPkvSD/fYeXdAlY2HiQWK/S0/I+yRaCEK8V+Um78ibbYc4Ng5NcXDm44nTv3F6jQEzYy4qRv/ohmwEQg\u003d\u003d"}""" private const val monthly03 = """{"zza":"{\"orderId\":\"GPA.3348-6247-8527-38213\",\"packageName\":\"org.tasks\",\"productId\":\"monthly_03\",\"purchaseTime\":1603912730414,\"purchaseState\":0,\"purchaseToken\":\"cmomnojdllomadpoinoabbkd.AO-J1OypdY4iXbMrF21L6Evn3wZSccwiBq-d55G1BVcrkwuH69zOuqb35yZnVynEb9KEvnQvgYQsUpv1AD5749iU-eDo4TRV5A\",\"autoRenewing\":true}","mParsedJson":{"nameValuePairs":{"orderId":"GPA.3348-6247-8527-38213","packageName":"org.tasks","productId":"monthly_03","purchaseTime":1603912730414,"purchaseState":0,"purchaseToken":"cmomnojdllomadpoinoabbkd.AO-J1OypdY4iXbMrF21L6Evn3wZSccwiBq-d55G1BVcrkwuH69zOuqb35yZnVynEb9KEvnQvgYQsUpv1AD5749iU-eDo4TRV5A","autoRenewing":true}},"zzb":"FkkW5FPw2elWnenIoQT7U5BnL2prcuK0GJEaHKtObPujSGRfJWFfThe3yuQ0w9AuTO0EDbm7LbJI44AiVJmpva3Iz3U2np2eNBuUAJIw9eECvQjEvuYk6Vq7LIgJwEsTyA8xRwjLJm+R1mmMWOxURmvDVBgDTHCOJsdUI9s52CSTQf2Ek+XABHugrMJudO43LzDuV2sP9mCqXUnSLbBXe3zZKyhhuz7gD+/5yavkRsPOVcZnsJetdxEmnrip8JEvgtHAvciPkvSD/fYeXdAlY2HiQWK/S0/I+yRaCEK8V+Um78ibbYc4Ng5NcXDm44nTv3F6jQEzYy4qRv/ohmwEQg\u003d\u003d"}"""
private const val annual03Cancelled = """{"mOriginalJson":"{\"orderId\":\"GPA.3372-3222-8630-38485\",\"packageName\":\"org.tasks\",\"productId\":\"annual_03\",\"purchaseTime\":1603917413542,\"purchaseState\":0,\"purchaseToken\":\"ciogggalpbohflhmjamciehl.AO-J1OyjBpBOnwKCOBSN0Cil7yM65ZYnkn9nGqZO1n3nsHIF_LHv0ZuQqNnThB_JuCt-s9wBij1PyOCoP8axqVILXSiavcyPmg\",\"autoRenewing\":false}","mParsedJson":{"nameValuePairs":{"orderId":"GPA.3372-3222-8630-38485","packageName":"org.tasks","productId":"annual_03","purchaseTime":1603917413542,"purchaseState":0,"purchaseToken":"ciogggalpbohflhmjamciehl.AO-J1OyjBpBOnwKCOBSN0Cil7yM65ZYnkn9nGqZO1n3nsHIF_LHv0ZuQqNnThB_JuCt-s9wBij1PyOCoP8axqVILXSiavcyPmg","autoRenewing":false}},"mSignature":"jL+2qRv0LtCutoJ86NWaInbx/9/kIWbxXRKYkou74TBjwu9KZ89EpJY632ImEy2xfLd8DHuVuWOcZY646I29Ny2E4HYNAsQEg2du4NRXEHZvu+py4Mi212KF8S2EPNdZCor1wiOJ0zRVBiRAtiCfqxHjQdfKn7FpDiHFrUhMu1huEAxJ0Xrnvxcmkouizw3wzKnAvI+O75LIWWZHCy+1o7s285cSKtQoztVY/nHInJLxV6dk93lAivOlEox+VCLU978lUvv45Rue50fMzS2CRsVFmRt9/yTP8RCiQKzGC/pyHtqNj/ceCrDi4VV8JPhsPd4NUaKk82Oq1xmGXtzEcQ\u003d\u003d"}""" private const val annual03Cancelled = """{"zza":"{\"orderId\":\"GPA.3372-3222-8630-38485\",\"packageName\":\"org.tasks\",\"productId\":\"annual_03\",\"purchaseTime\":1603917413542,\"purchaseState\":0,\"purchaseToken\":\"ciogggalpbohflhmjamciehl.AO-J1OyjBpBOnwKCOBSN0Cil7yM65ZYnkn9nGqZO1n3nsHIF_LHv0ZuQqNnThB_JuCt-s9wBij1PyOCoP8axqVILXSiavcyPmg\",\"autoRenewing\":false}","mParsedJson":{"nameValuePairs":{"orderId":"GPA.3372-3222-8630-38485","packageName":"org.tasks","productId":"annual_03","purchaseTime":1603917413542,"purchaseState":0,"purchaseToken":"ciogggalpbohflhmjamciehl.AO-J1OyjBpBOnwKCOBSN0Cil7yM65ZYnkn9nGqZO1n3nsHIF_LHv0ZuQqNnThB_JuCt-s9wBij1PyOCoP8axqVILXSiavcyPmg","autoRenewing":false}},"zzb":"jL+2qRv0LtCutoJ86NWaInbx/9/kIWbxXRKYkou74TBjwu9KZ89EpJY632ImEy2xfLd8DHuVuWOcZY646I29Ny2E4HYNAsQEg2du4NRXEHZvu+py4Mi212KF8S2EPNdZCor1wiOJ0zRVBiRAtiCfqxHjQdfKn7FpDiHFrUhMu1huEAxJ0Xrnvxcmkouizw3wzKnAvI+O75LIWWZHCy+1o7s285cSKtQoztVY/nHInJLxV6dk93lAivOlEox+VCLU978lUvv45Rue50fMzS2CRsVFmRt9/yTP8RCiQKzGC/pyHtqNj/ceCrDi4VV8JPhsPd4NUaKk82Oq1xmGXtzEcQ\u003d\u003d"}"""
} }
} }

@ -3,10 +3,16 @@ package org.tasks.billing
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.jobs.WorkManager
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
class BillingClientImpl(context: Context, inventory: Inventory, firebase: Firebase) : BillingClient { class BillingClientImpl(
override suspend fun queryPurchases() {} context: Context,
inventory: Inventory,
firebase: Firebase,
workManager: WorkManager
) : BillingClient {
override suspend fun queryPurchases(throwError: Boolean) {}
override suspend fun initiatePurchaseFlow( override suspend fun initiatePurchaseFlow(
activity: Activity, activity: Activity,
sku: String, sku: String,
@ -14,6 +20,8 @@ class BillingClientImpl(context: Context, inventory: Inventory, firebase: Fireba
oldPurchase: Purchase? oldPurchase: Purchase?
) {} ) {}
override suspend fun acknowledge(purchase: Purchase) {}
override suspend fun consume(sku: String) {} override suspend fun consume(sku: String) {}
companion object { companion object {

@ -17,4 +17,8 @@ class Purchase(json: String?) {
val isTasksSubscription = false val isTasksSubscription = false
val purchaseToken = "" val purchaseToken = ""
val needsAcknowledgement = false
val isPurchased = true
} }

@ -3,14 +3,12 @@ package org.tasks.analytics
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.android.billingclient.api.BillingResult
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.ktx.remoteConfigSettings import com.google.firebase.remoteconfig.ktx.remoteConfigSettings
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.R import org.tasks.R
import org.tasks.billing.BillingClientImpl.Companion.responseCodeString
import org.tasks.jobs.WorkManager import org.tasks.jobs.WorkManager
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import timber.log.Timber import timber.log.Timber
@ -33,11 +31,13 @@ class Firebase @Inject constructor(
crashlytics?.recordException(t) crashlytics?.recordException(t)
} }
fun reportIabResult(response: BillingResult, sku: String?) { fun reportIabResult(result: String, sku: String, state: String) {
analytics?.logEvent(FirebaseAnalytics.Event.ECOMMERCE_PURCHASE, Bundle().apply { logEvent(
putString(FirebaseAnalytics.Param.ITEM_ID, sku) R.string.event_purchase_result,
putString(FirebaseAnalytics.Param.SUCCESS, response.responseCodeString) R.string.param_sku to sku,
}) R.string.param_result to result,
R.string.param_state to state,
)
} }
fun updateRemoteConfig() { fun updateRemoteConfig() {

@ -2,12 +2,14 @@ package org.tasks.billing
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient.* import com.android.billingclient.api.BillingClient.*
import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.ProrationMode import com.android.billingclient.api.BillingFlowParams.ProrationMode
import com.android.billingclient.api.BillingResult import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ConsumeParams import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.Purchase.PurchaseState
import com.android.billingclient.api.Purchase.PurchasesResult import com.android.billingclient.api.Purchase.PurchasesResult
import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.SkuDetailsParams import com.android.billingclient.api.SkuDetailsParams
@ -19,14 +21,19 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.analytics.Firebase import org.tasks.analytics.Firebase
import org.tasks.billing.Purchase.Companion.isPurchased
import org.tasks.billing.Purchase.Companion.needsAcknowledgement
import org.tasks.jobs.WorkManager
import timber.log.Timber import timber.log.Timber
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class BillingClientImpl( class BillingClientImpl(
@ApplicationContext context: Context?, @ApplicationContext context: Context?,
private val inventory: Inventory, private val inventory: Inventory,
private val firebase: Firebase private val firebase: Firebase,
private val workManager: WorkManager
) : BillingClient, PurchasesUpdatedListener { ) : BillingClient, PurchasesUpdatedListener {
private val billingClient = private val billingClient =
newBuilder(context!!) newBuilder(context!!)
@ -36,7 +43,7 @@ class BillingClientImpl(
private var connected = false private var connected = false
private var onPurchasesUpdated: OnPurchasesUpdated? = null private var onPurchasesUpdated: OnPurchasesUpdated? = null
override suspend fun queryPurchases() = try { override suspend fun queryPurchases(throwError: Boolean) = try {
executeServiceRequest { executeServiceRequest {
withContext(Dispatchers.IO + NonCancellable) { withContext(Dispatchers.IO + NonCancellable) {
val subs = billingClient.queryPurchases(SkuType.SUBS) val subs = billingClient.queryPurchases(SkuType.SUBS)
@ -52,7 +59,11 @@ class BillingClientImpl(
} }
} }
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
Timber.e(e.message) if (throwError) {
throw e
} else {
Timber.e(e.message)
}
} }
override fun onPurchasesUpdated( override fun onPurchasesUpdated(
@ -62,10 +73,15 @@ class BillingClientImpl(
if (success) { if (success) {
add(purchases ?: emptyList()) add(purchases ?: emptyList())
} }
workManager.updatePurchases()
onPurchasesUpdated?.onPurchasesUpdated(success) onPurchasesUpdated?.onPurchasesUpdated(success)
val skus = purchases?.joinToString(";") { it.sku } ?: "null" purchases?.forEach {
Timber.i("onPurchasesUpdated(${result.responseCodeString}, $skus)") firebase.reportIabResult(
firebase.reportIabResult(result, skus) result.responseCodeString,
it.sku,
it.purchaseState.purchaseStateString
)
}
} }
private fun add(purchases: List<com.android.billingclient.api.Purchase>) { private fun add(purchases: List<com.android.billingclient.api.Purchase>) {
@ -93,7 +109,6 @@ class BillingClientImpl(
val skuDetails = val skuDetails =
skuDetailsResult skuDetailsResult
.skuDetailsList .skuDetailsList
?.takeIf { it.isNotEmpty() }
?.firstOrNull() ?.firstOrNull()
?: throw IllegalStateException("Sku $sku not found") ?: throw IllegalStateException("Sku $sku not found")
val params = BillingFlowParams.newBuilder().setSkuDetails(skuDetails) val params = BillingFlowParams.newBuilder().setSkuDetails(skuDetails)
@ -109,6 +124,22 @@ class BillingClientImpl(
} }
} }
override suspend fun acknowledge(purchase: Purchase) {
if (purchase.needsAcknowledgement) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
withContext(Dispatchers.IO) {
suspendCoroutine<BillingResult> { cont ->
billingClient.acknowledgePurchase(params) {
Timber.d("acknowledge: ${it.responseCodeString} $purchase")
cont.resume(it)
}
}
}
}
}
private suspend fun connect(): BillingResult = private suspend fun connect(): BillingResult =
suspendCoroutine { cont -> suspendCoroutine { cont ->
billingClient.startConnection( billingClient.startConnection(
@ -154,6 +185,7 @@ class BillingClientImpl(
companion object { companion object {
const val TYPE_SUBS = SkuType.SUBS const val TYPE_SUBS = SkuType.SUBS
const val STATE_PURCHASED = PurchaseState.PURCHASED
private val PurchasesResult.success: Boolean private val PurchasesResult.success: Boolean
get() = responseCode == BillingResponseCode.OK get() = responseCode == BillingResponseCode.OK
@ -177,6 +209,14 @@ class BillingClientImpl(
else -> "UNKNOWN" else -> "UNKNOWN"
} }
val Int.purchaseStateString: String
get() = when (this) {
PurchaseState.UNSPECIFIED_STATE -> "UNSPECIFIED_STATE"
PurchaseState.PURCHASED -> "PURCHASED"
PurchaseState.PENDING -> "PENDING"
else -> this.toString()
}
private val PurchasesResult.responseCodeString: String private val PurchasesResult.responseCodeString: String
get() = billingResult.responseCodeString get() = billingResult.responseCodeString

@ -2,6 +2,7 @@ package org.tasks.billing
import com.android.billingclient.api.Purchase import com.android.billingclient.api.Purchase
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import org.tasks.billing.BillingClientImpl.Companion.STATE_PURCHASED
import java.util.regex.Pattern import java.util.regex.Pattern
class Purchase(private val purchase: Purchase) { class Purchase(private val purchase: Purchase) {
@ -37,6 +38,12 @@ class Purchase(private val purchase: Purchase) {
val isCanceled: Boolean val isCanceled: Boolean
get() = !purchase.isAutoRenewing get() = !purchase.isAutoRenewing
val needsAcknowledgement: Boolean
get() = purchase.needsAcknowledgement
val isPurchased: Boolean
get() = purchase.isPurchased
val subscriptionPrice: Int? val subscriptionPrice: Int?
get() { get() {
val matcher = PATTERN.matcher(sku) val matcher = PATTERN.matcher(sku)
@ -62,5 +69,11 @@ class Purchase(private val purchase: Purchase) {
companion object { companion object {
private val PATTERN = Pattern.compile("^(annual|monthly)_([0-3][0-9]|499)$") private val PATTERN = Pattern.compile("^(annual|monthly)_([0-3][0-9]|499)$")
val Purchase.isPurchased: Boolean
get() = purchaseState == STATE_PURCHASED
val Purchase.needsAcknowledgement: Boolean
get() = isPurchased && !isAcknowledged
} }
} }

@ -15,7 +15,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.billing.BillingClient
import org.tasks.billing.Inventory import org.tasks.billing.Inventory
import org.tasks.caldav.CaldavSynchronizer import org.tasks.caldav.CaldavSynchronizer
import org.tasks.files.FileHelper import org.tasks.files.FileHelper
@ -46,7 +45,6 @@ class Tasks : Application(), Configuration.Provider {
@Inject lateinit var workManager: Lazy<WorkManager> @Inject lateinit var workManager: Lazy<WorkManager>
@Inject lateinit var refreshScheduler: Lazy<RefreshScheduler> @Inject lateinit var refreshScheduler: Lazy<RefreshScheduler>
@Inject lateinit var geofenceApi: Lazy<GeofenceApi> @Inject lateinit var geofenceApi: Lazy<GeofenceApi>
@Inject lateinit var billingClient: Lazy<BillingClient>
@Inject lateinit var appWidgetManager: Lazy<AppWidgetManager> @Inject lateinit var appWidgetManager: Lazy<AppWidgetManager>
@Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var contentObserver: Lazy<OpenTaskContentObserver> @Inject lateinit var contentObserver: Lazy<OpenTaskContentObserver>
@ -86,10 +84,10 @@ class Tasks : Application(), Configuration.Provider {
scheduleBackup() scheduleBackup()
scheduleConfigRefresh() scheduleConfigRefresh()
OpenTaskContentObserver.registerObserver(context, contentObserver.get()) OpenTaskContentObserver.registerObserver(context, contentObserver.get())
updatePurchases()
} }
geofenceApi.get().registerAll() geofenceApi.get().registerAll()
FileHelper.delete(context, preferences.cacheDirectory) FileHelper.delete(context, preferences.cacheDirectory)
billingClient.get().queryPurchases()
appWidgetManager.get().reconfigureWidgets() appWidgetManager.get().reconfigureWidgets()
CaldavSynchronizer.registerFactories() CaldavSynchronizer.registerFactories()
} }

@ -3,7 +3,7 @@ package org.tasks.billing
import android.app.Activity import android.app.Activity
interface BillingClient { interface BillingClient {
suspend fun queryPurchases() suspend fun queryPurchases(throwError: Boolean = false)
suspend fun consume(sku: String) suspend fun consume(sku: String)
suspend fun initiatePurchaseFlow( suspend fun initiatePurchaseFlow(
activity: Activity, activity: Activity,
@ -11,4 +11,5 @@ interface BillingClient {
skuType: String, skuType: String,
oldPurchase: Purchase? = null oldPurchase: Purchase? = null
) )
suspend fun acknowledge(purchase: Purchase)
} }

@ -24,7 +24,7 @@ class Inventory @Inject constructor(
private val localBroadcastManager: LocalBroadcastManager, private val localBroadcastManager: LocalBroadcastManager,
private val caldavDao: CaldavDao private val caldavDao: CaldavDao
) { ) {
private val purchases: MutableMap<String, Purchase> = HashMap() val purchases: MutableMap<String, Purchase> = HashMap()
val subscription = MutableLiveData<Purchase?>() val subscription = MutableLiveData<Purchase?>()
var hasTasksAccount = false var hasTasksAccount = false

@ -66,7 +66,11 @@ class PurchaseActivity : InjectingAppCompatActivity(), OnPurchasesUpdated {
localBroadcastManager.registerPurchaseReceiver(purchaseReceiver) localBroadcastManager.registerPurchaseReceiver(purchaseReceiver)
lifecycleScope.launch { lifecycleScope.launch {
billingClient.queryPurchases() try {
billingClient.queryPurchases(throwError = true)
} catch (e: Exception) {
toast(e.message)
}
} }
} }

@ -12,6 +12,7 @@ import org.tasks.billing.BillingClient
import org.tasks.billing.BillingClientImpl import org.tasks.billing.BillingClientImpl
import org.tasks.billing.Inventory import org.tasks.billing.Inventory
import org.tasks.data.* import org.tasks.data.*
import org.tasks.jobs.WorkManager
import org.tasks.locale.Locale import org.tasks.locale.Locale
import org.tasks.notifications.NotificationDao import org.tasks.notifications.NotificationDao
import javax.inject.Singleton import javax.inject.Singleton
@ -96,6 +97,10 @@ class ApplicationModule {
fun getPrincipalDao(db: Database) = db.principalDao fun getPrincipalDao(db: Database) = db.principalDao
@Provides @Provides
fun getBillingClient(@ApplicationContext context: Context, inventory: Inventory, firebase: Firebase): BillingClient fun getBillingClient(
= BillingClientImpl(context, inventory, firebase) @ApplicationContext context: Context,
inventory: Inventory,
firebase: Firebase,
workManager: WorkManager,
): BillingClient = BillingClientImpl(context, inventory, firebase, workManager)
} }

@ -0,0 +1,40 @@
package org.tasks.jobs
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.tasks.analytics.Firebase
import org.tasks.billing.BillingClient
import org.tasks.billing.Inventory
import org.tasks.injection.BaseWorker
@HiltWorker
class UpdatePurchaseWork @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
firebase: Firebase,
private val inventory: Inventory,
private val billingClient: BillingClient,
) : BaseWorker(context, workerParams, firebase) {
override suspend fun run(): Result {
try {
billingClient.queryPurchases(throwError = true)
} catch (e: Exception) {
return Result.retry()
}
inventory.purchases.values
.filter { it.needsAcknowledgement }
.takeIf { it.isNotEmpty() }
?.forEach { billingClient.acknowledge(it) }
?.apply { billingClient.queryPurchases() }
return with(inventory.purchases.values) {
when {
any { it.needsAcknowledgement } -> Result.retry()
all { it.isPurchased } -> Result.success()
else -> Result.retry()
}
}
}
}

@ -45,6 +45,8 @@ interface WorkManager {
fun cancelNotifications() fun cancelNotifications()
fun updatePurchases()
companion object { companion object {
val REMOTE_CONFIG_INTERVAL_HOURS = if (BuildConfig.DEBUG) 1 else 12.toLong() val REMOTE_CONFIG_INTERVAL_HOURS = if (BuildConfig.DEBUG) 1 else 12.toLong()
const val MAX_CLEANUP_LENGTH = 500 const val MAX_CLEANUP_LENGTH = 500
@ -63,5 +65,6 @@ interface WorkManager {
const val TAG_BACKGROUND_SYNC_OPENTASKS = "tag_background_sync_opentasks" const val TAG_BACKGROUND_SYNC_OPENTASKS = "tag_background_sync_opentasks"
const val TAG_REMOTE_CONFIG = "tag_remote_config" const val TAG_REMOTE_CONFIG = "tag_remote_config"
const val TAG_MIGRATE_LOCAL = "tag_migrate_local" const val TAG_MIGRATE_LOCAL = "tag_migrate_local"
const val TAG_UPDATE_PURCHASES = "tag_update_purchases"
} }
} }

@ -44,6 +44,7 @@ import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETEBASE
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETESYNC import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_ETESYNC
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_GOOGLE_TASKS import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_GOOGLE_TASKS
import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_OPENTASK import org.tasks.jobs.WorkManager.Companion.TAG_SYNC_OPENTASK
import org.tasks.jobs.WorkManager.Companion.TAG_UPDATE_PURCHASES
import org.tasks.notifications.Throttle import org.tasks.notifications.Throttle
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.time.DateTimeUtils import org.tasks.time.DateTimeUtils
@ -254,6 +255,9 @@ class WorkManagerImpl constructor(
alarmManager.cancel(notificationPendingIntent) alarmManager.cancel(notificationPendingIntent)
} }
override fun updatePurchases() =
enqueueUnique(TAG_UPDATE_PURCHASES, UpdatePurchaseWork::class.java)
@SuppressLint("EnqueueWork") @SuppressLint("EnqueueWork")
private fun enqueueUnique(key: String, c: Class<out Worker?>, time: Long = 0) { private fun enqueueUnique(key: String, c: Class<out Worker?>, time: Long = 0) {
val delay = time - DateUtilities.now() val delay = time - DateUtilities.now()

@ -19,6 +19,7 @@ import org.tasks.billing.PurchaseActivity
import org.tasks.caldav.BaseCaldavAccountSettingsActivity import org.tasks.caldav.BaseCaldavAccountSettingsActivity
import org.tasks.data.CaldavAccount import org.tasks.data.CaldavAccount
import org.tasks.data.GoogleTaskAccount import org.tasks.data.GoogleTaskAccount
import org.tasks.extensions.Context.toast
import org.tasks.injection.InjectingPreferenceFragment import org.tasks.injection.InjectingPreferenceFragment
import org.tasks.preferences.IconPreference import org.tasks.preferences.IconPreference
import org.tasks.preferences.MainPreferences import org.tasks.preferences.MainPreferences
@ -59,7 +60,14 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
findPreference(R.string.refresh_purchases).setOnPreferenceClickListener { findPreference(R.string.refresh_purchases).setOnPreferenceClickListener {
lifecycleScope.launch { lifecycleScope.launch {
billingClient.queryPurchases() try {
billingClient.queryPurchases(throwError = true)
if (inventory.subscription.value == null) {
activity?.toast(R.string.no_google_play_subscription)
}
} catch (e: Exception) {
activity?.toast(e.message)
}
} }
false false
} }

@ -431,6 +431,10 @@
<string name="param_user_no_churn">user_no_churn</string> <string name="param_user_no_churn">user_no_churn</string>
<string name="param_user_pro">user_pro</string> <string name="param_user_pro">user_pro</string>
<string name="param_click">click</string> <string name="param_click">click</string>
<string name="event_purchase_result">billing_flow_result</string>
<string name="param_sku">sku</string>
<string name="param_result">result</string>
<string name="param_state">state</string>
<string name="event_todoagenda">cp_todoagenda</string> <string name="event_todoagenda">cp_todoagenda</string>
<string name="event_astrid2taskprovider">cp_astrid2taskprovider</string> <string name="event_astrid2taskprovider">cp_astrid2taskprovider</string>
<string name="event_sync_add_account">sync_add_account</string> <string name="event_sync_add_account">sync_add_account</string>

Loading…
Cancel
Save