diff --git a/app/src/androidTestGoogleplay/java/org/tasks/billing/InventoryTest.kt b/app/src/androidTestGoogleplay/java/org/tasks/billing/InventoryTest.kt index b436e821a..8369d53a9 100644 --- a/app/src/androidTestGoogleplay/java/org/tasks/billing/InventoryTest.kt +++ b/app/src/androidTestGoogleplay/java/org/tasks/billing/InventoryTest.kt @@ -2,6 +2,7 @@ package org.tasks.billing import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -28,6 +29,7 @@ class InventoryTest : InjectingTestCase() { withPurchases(monthly01) assertTrue(inventory.hasPro) + assertEquals(1, inventory.subscription.value?.subscriptionPrice) } @Test @@ -59,7 +61,17 @@ class InventoryTest : InjectingTestCase() { } 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 { inventory = Inventory( context, @@ -72,9 +84,9 @@ class InventoryTest : InjectingTestCase() { } 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 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 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 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 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 = """{"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 = """{"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 = """{"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"}""" } } \ No newline at end of file diff --git a/app/src/generic/java/org/tasks/billing/BillingClientImpl.kt b/app/src/generic/java/org/tasks/billing/BillingClientImpl.kt index 59fc9ed1b..0d20470b6 100644 --- a/app/src/generic/java/org/tasks/billing/BillingClientImpl.kt +++ b/app/src/generic/java/org/tasks/billing/BillingClientImpl.kt @@ -3,10 +3,16 @@ package org.tasks.billing import android.app.Activity import android.content.Context import org.tasks.analytics.Firebase +import org.tasks.jobs.WorkManager @Suppress("UNUSED_PARAMETER") -class BillingClientImpl(context: Context, inventory: Inventory, firebase: Firebase) : BillingClient { - override suspend fun queryPurchases() {} +class BillingClientImpl( + context: Context, + inventory: Inventory, + firebase: Firebase, + workManager: WorkManager +) : BillingClient { + override suspend fun queryPurchases(throwError: Boolean) {} override suspend fun initiatePurchaseFlow( activity: Activity, sku: String, @@ -14,6 +20,8 @@ class BillingClientImpl(context: Context, inventory: Inventory, firebase: Fireba oldPurchase: Purchase? ) {} + override suspend fun acknowledge(purchase: Purchase) {} + override suspend fun consume(sku: String) {} companion object { diff --git a/app/src/generic/java/org/tasks/billing/Purchase.kt b/app/src/generic/java/org/tasks/billing/Purchase.kt index 3c805b0ed..53b44586d 100644 --- a/app/src/generic/java/org/tasks/billing/Purchase.kt +++ b/app/src/generic/java/org/tasks/billing/Purchase.kt @@ -17,4 +17,8 @@ class Purchase(json: String?) { val isTasksSubscription = false val purchaseToken = "" + + val needsAcknowledgement = false + + val isPurchased = true } \ No newline at end of file diff --git a/app/src/googleplay/java/org/tasks/analytics/Firebase.kt b/app/src/googleplay/java/org/tasks/analytics/Firebase.kt index ce236dcc6..e6927e2ba 100644 --- a/app/src/googleplay/java/org/tasks/analytics/Firebase.kt +++ b/app/src/googleplay/java/org/tasks/analytics/Firebase.kt @@ -3,14 +3,12 @@ package org.tasks.analytics import android.content.Context import android.os.Bundle import androidx.annotation.StringRes -import com.android.billingclient.api.BillingResult import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.google.firebase.remoteconfig.ktx.remoteConfigSettings import dagger.hilt.android.qualifiers.ApplicationContext import org.tasks.R -import org.tasks.billing.BillingClientImpl.Companion.responseCodeString import org.tasks.jobs.WorkManager import org.tasks.preferences.Preferences import timber.log.Timber @@ -33,11 +31,13 @@ class Firebase @Inject constructor( crashlytics?.recordException(t) } - fun reportIabResult(response: BillingResult, sku: String?) { - analytics?.logEvent(FirebaseAnalytics.Event.ECOMMERCE_PURCHASE, Bundle().apply { - putString(FirebaseAnalytics.Param.ITEM_ID, sku) - putString(FirebaseAnalytics.Param.SUCCESS, response.responseCodeString) - }) + fun reportIabResult(result: String, sku: String, state: String) { + logEvent( + R.string.event_purchase_result, + R.string.param_sku to sku, + R.string.param_result to result, + R.string.param_state to state, + ) } fun updateRemoteConfig() { diff --git a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt index aab6407a7..5bcbec08e 100644 --- a/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt +++ b/app/src/googleplay/java/org/tasks/billing/BillingClientImpl.kt @@ -2,12 +2,14 @@ package org.tasks.billing 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.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams.ProrationMode import com.android.billingclient.api.BillingResult 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.PurchasesUpdatedListener import com.android.billingclient.api.SkuDetailsParams @@ -19,14 +21,19 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import org.tasks.BuildConfig 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 kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine class BillingClientImpl( @ApplicationContext context: Context?, private val inventory: Inventory, - private val firebase: Firebase + private val firebase: Firebase, + private val workManager: WorkManager ) : BillingClient, PurchasesUpdatedListener { private val billingClient = newBuilder(context!!) @@ -36,7 +43,7 @@ class BillingClientImpl( private var connected = false private var onPurchasesUpdated: OnPurchasesUpdated? = null - override suspend fun queryPurchases() = try { + override suspend fun queryPurchases(throwError: Boolean) = try { executeServiceRequest { withContext(Dispatchers.IO + NonCancellable) { val subs = billingClient.queryPurchases(SkuType.SUBS) @@ -52,7 +59,11 @@ class BillingClientImpl( } } } catch (e: IllegalStateException) { - Timber.e(e.message) + if (throwError) { + throw e + } else { + Timber.e(e.message) + } } override fun onPurchasesUpdated( @@ -62,10 +73,15 @@ class BillingClientImpl( if (success) { add(purchases ?: emptyList()) } + workManager.updatePurchases() onPurchasesUpdated?.onPurchasesUpdated(success) - val skus = purchases?.joinToString(";") { it.sku } ?: "null" - Timber.i("onPurchasesUpdated(${result.responseCodeString}, $skus)") - firebase.reportIabResult(result, skus) + purchases?.forEach { + firebase.reportIabResult( + result.responseCodeString, + it.sku, + it.purchaseState.purchaseStateString + ) + } } private fun add(purchases: List) { @@ -93,7 +109,6 @@ class BillingClientImpl( val skuDetails = skuDetailsResult .skuDetailsList - ?.takeIf { it.isNotEmpty() } ?.firstOrNull() ?: throw IllegalStateException("Sku $sku not found") 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 { cont -> + billingClient.acknowledgePurchase(params) { + Timber.d("acknowledge: ${it.responseCodeString} $purchase") + cont.resume(it) + } + } + } + } + } + private suspend fun connect(): BillingResult = suspendCoroutine { cont -> billingClient.startConnection( @@ -154,6 +185,7 @@ class BillingClientImpl( companion object { const val TYPE_SUBS = SkuType.SUBS + const val STATE_PURCHASED = PurchaseState.PURCHASED private val PurchasesResult.success: Boolean get() = responseCode == BillingResponseCode.OK @@ -177,6 +209,14 @@ class BillingClientImpl( 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 get() = billingResult.responseCodeString diff --git a/app/src/googleplay/java/org/tasks/billing/Purchase.kt b/app/src/googleplay/java/org/tasks/billing/Purchase.kt index a5aa13f15..d82453eac 100644 --- a/app/src/googleplay/java/org/tasks/billing/Purchase.kt +++ b/app/src/googleplay/java/org/tasks/billing/Purchase.kt @@ -2,6 +2,7 @@ package org.tasks.billing import com.android.billingclient.api.Purchase import com.google.gson.GsonBuilder +import org.tasks.billing.BillingClientImpl.Companion.STATE_PURCHASED import java.util.regex.Pattern class Purchase(private val purchase: Purchase) { @@ -37,6 +38,12 @@ class Purchase(private val purchase: Purchase) { val isCanceled: Boolean get() = !purchase.isAutoRenewing + val needsAcknowledgement: Boolean + get() = purchase.needsAcknowledgement + + val isPurchased: Boolean + get() = purchase.isPurchased + val subscriptionPrice: Int? get() { val matcher = PATTERN.matcher(sku) @@ -62,5 +69,11 @@ class Purchase(private val purchase: Purchase) { companion object { 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 } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/Tasks.kt b/app/src/main/java/org/tasks/Tasks.kt index d9fd7ea5b..3ce8bd45d 100644 --- a/app/src/main/java/org/tasks/Tasks.kt +++ b/app/src/main/java/org/tasks/Tasks.kt @@ -15,7 +15,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.tasks.billing.BillingClient import org.tasks.billing.Inventory import org.tasks.caldav.CaldavSynchronizer import org.tasks.files.FileHelper @@ -46,7 +45,6 @@ class Tasks : Application(), Configuration.Provider { @Inject lateinit var workManager: Lazy @Inject lateinit var refreshScheduler: Lazy @Inject lateinit var geofenceApi: Lazy - @Inject lateinit var billingClient: Lazy @Inject lateinit var appWidgetManager: Lazy @Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var contentObserver: Lazy @@ -86,10 +84,10 @@ class Tasks : Application(), Configuration.Provider { scheduleBackup() scheduleConfigRefresh() OpenTaskContentObserver.registerObserver(context, contentObserver.get()) + updatePurchases() } geofenceApi.get().registerAll() FileHelper.delete(context, preferences.cacheDirectory) - billingClient.get().queryPurchases() appWidgetManager.get().reconfigureWidgets() CaldavSynchronizer.registerFactories() } diff --git a/app/src/main/java/org/tasks/billing/BillingClient.kt b/app/src/main/java/org/tasks/billing/BillingClient.kt index 47fb5de5a..c8501b96d 100644 --- a/app/src/main/java/org/tasks/billing/BillingClient.kt +++ b/app/src/main/java/org/tasks/billing/BillingClient.kt @@ -3,7 +3,7 @@ package org.tasks.billing import android.app.Activity interface BillingClient { - suspend fun queryPurchases() + suspend fun queryPurchases(throwError: Boolean = false) suspend fun consume(sku: String) suspend fun initiatePurchaseFlow( activity: Activity, @@ -11,4 +11,5 @@ interface BillingClient { skuType: String, oldPurchase: Purchase? = null ) + suspend fun acknowledge(purchase: Purchase) } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/billing/Inventory.kt b/app/src/main/java/org/tasks/billing/Inventory.kt index b446aa7c8..bb67870e8 100644 --- a/app/src/main/java/org/tasks/billing/Inventory.kt +++ b/app/src/main/java/org/tasks/billing/Inventory.kt @@ -24,7 +24,7 @@ class Inventory @Inject constructor( private val localBroadcastManager: LocalBroadcastManager, private val caldavDao: CaldavDao ) { - private val purchases: MutableMap = HashMap() + val purchases: MutableMap = HashMap() val subscription = MutableLiveData() var hasTasksAccount = false diff --git a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt index bf87a2025..26d489e0f 100644 --- a/app/src/main/java/org/tasks/billing/PurchaseActivity.kt +++ b/app/src/main/java/org/tasks/billing/PurchaseActivity.kt @@ -66,7 +66,11 @@ class PurchaseActivity : InjectingAppCompatActivity(), OnPurchasesUpdated { localBroadcastManager.registerPurchaseReceiver(purchaseReceiver) lifecycleScope.launch { - billingClient.queryPurchases() + try { + billingClient.queryPurchases(throwError = true) + } catch (e: Exception) { + toast(e.message) + } } } diff --git a/app/src/main/java/org/tasks/injection/ApplicationModule.kt b/app/src/main/java/org/tasks/injection/ApplicationModule.kt index 6aeb1a89f..a6ab60df6 100644 --- a/app/src/main/java/org/tasks/injection/ApplicationModule.kt +++ b/app/src/main/java/org/tasks/injection/ApplicationModule.kt @@ -12,6 +12,7 @@ import org.tasks.billing.BillingClient import org.tasks.billing.BillingClientImpl import org.tasks.billing.Inventory import org.tasks.data.* +import org.tasks.jobs.WorkManager import org.tasks.locale.Locale import org.tasks.notifications.NotificationDao import javax.inject.Singleton @@ -96,6 +97,10 @@ class ApplicationModule { fun getPrincipalDao(db: Database) = db.principalDao @Provides - fun getBillingClient(@ApplicationContext context: Context, inventory: Inventory, firebase: Firebase): BillingClient - = BillingClientImpl(context, inventory, firebase) + fun getBillingClient( + @ApplicationContext context: Context, + inventory: Inventory, + firebase: Firebase, + workManager: WorkManager, + ): BillingClient = BillingClientImpl(context, inventory, firebase, workManager) } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/jobs/UpdatePurchaseWork.kt b/app/src/main/java/org/tasks/jobs/UpdatePurchaseWork.kt new file mode 100644 index 000000000..c19ed815f --- /dev/null +++ b/app/src/main/java/org/tasks/jobs/UpdatePurchaseWork.kt @@ -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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/jobs/WorkManager.kt b/app/src/main/java/org/tasks/jobs/WorkManager.kt index 752297d0a..4711f5b12 100644 --- a/app/src/main/java/org/tasks/jobs/WorkManager.kt +++ b/app/src/main/java/org/tasks/jobs/WorkManager.kt @@ -45,6 +45,8 @@ interface WorkManager { fun cancelNotifications() + fun updatePurchases() + companion object { val REMOTE_CONFIG_INTERVAL_HOURS = if (BuildConfig.DEBUG) 1 else 12.toLong() 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_REMOTE_CONFIG = "tag_remote_config" const val TAG_MIGRATE_LOCAL = "tag_migrate_local" + const val TAG_UPDATE_PURCHASES = "tag_update_purchases" } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt b/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt index 3cdfa70a0..0e558788a 100644 --- a/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt +++ b/app/src/main/java/org/tasks/jobs/WorkManagerImpl.kt @@ -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_GOOGLE_TASKS 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.preferences.Preferences import org.tasks.time.DateTimeUtils @@ -254,6 +255,9 @@ class WorkManagerImpl constructor( alarmManager.cancel(notificationPendingIntent) } + override fun updatePurchases() = + enqueueUnique(TAG_UPDATE_PURCHASES, UpdatePurchaseWork::class.java) + @SuppressLint("EnqueueWork") private fun enqueueUnique(key: String, c: Class, time: Long = 0) { val delay = time - DateUtilities.now() diff --git a/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt b/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt index bc5c326e2..87572eaa3 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/MainSettingsFragment.kt @@ -19,6 +19,7 @@ import org.tasks.billing.PurchaseActivity import org.tasks.caldav.BaseCaldavAccountSettingsActivity import org.tasks.data.CaldavAccount import org.tasks.data.GoogleTaskAccount +import org.tasks.extensions.Context.toast import org.tasks.injection.InjectingPreferenceFragment import org.tasks.preferences.IconPreference import org.tasks.preferences.MainPreferences @@ -59,7 +60,14 @@ class MainSettingsFragment : InjectingPreferenceFragment() { findPreference(R.string.refresh_purchases).setOnPreferenceClickListener { 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 } diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 8c81aa3ec..15f380f3e 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -431,6 +431,10 @@ user_no_churn user_pro click + billing_flow_result + sku + result + state cp_todoagenda cp_astrid2taskprovider sync_add_account