Migrate to the (required) new billing library

This commit is contained in:
Jonas Lochmann 2021-03-15 01:00:00 +01:00
parent f535cb4767
commit b71009d1cb
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
23 changed files with 410 additions and 663 deletions

View file

@ -203,7 +203,8 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp-tls:4.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:3.8.1'
googleApiImplementation 'org.solovyev.android:checkout:1.2.1'
googleApiImplementation "com.android.billingclient:billing:3.0.3"
googleApiImplementation "com.android.billingclient:billing-ktx:3.0.3"
implementation('io.socket:socket.io-client:1.0.0') {
exclude group: 'org.json', module: 'json'

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,7 +17,6 @@ package io.timelimit.android
import android.app.Application
import com.jakewharton.threetenabp.AndroidThreeTen
import org.solovyev.android.checkout.Billing
class Application : Application() {
override fun onCreate() {
@ -25,8 +24,4 @@ class Application : Application() {
AndroidThreeTen.init(this)
}
val billing = Billing(this, object: Billing.DefaultConfiguration() {
override fun getPublicKey() = BuildConfig.googlePlayKey
})
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -13,23 +13,17 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.extensions
package org.solovyev.android.checkout
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import java.lang.RuntimeException
import android.content.Intent
object ActivityCheckout: Checkout() {
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
// do nothing
fun BillingResult.assertSuccess() {
if (this.responseCode != BillingClient.BillingResponseCode.OK) {
throw BillingClientException("error during processing billing request: ${this.debugMessage}")
}
fun start() {
// do nothing
}
fun createPurchaseFlow(listener: RequestListener<Purchase>) {
// do nothing
}
val purchaseFlow = PurchaseFlow
}
open class BillingClientException(message: String): RuntimeException(message)
class BillingNotSupportedException(): BillingClientException("billing not supported")

View file

@ -1,125 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.extensions
import org.solovyev.android.checkout.*
import java.io.Closeable
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
suspend fun Checkout.startAsync(): CheckoutStartResponse {
val checkout = this
var resumed = false
return suspendCoroutine<CheckoutStartResponse> {
continuation ->
checkout.start(object: Checkout.EmptyListener() {
override fun onReady(requests: BillingRequests, product: String, billingSupported: Boolean) {
if (!resumed) {
resumed = true
continuation.resume(CheckoutStartResponse(
requests = requests,
product = product,
billingSupported = billingSupported,
checkout = checkout
))
}
}
})
}
}
suspend fun Checkout.waitUntilReady(): CheckoutStartResponse {
val checkout = this
var resumed = false
return suspendCoroutine {
continuation ->
checkout.whenReady(object: Checkout.EmptyListener() {
override fun onReady(requests: BillingRequests, product: String, billingSupported: Boolean) {
if (!resumed) {
resumed = true
continuation.resume(CheckoutStartResponse(
requests = requests,
product = product,
billingSupported = billingSupported,
checkout = checkout
))
}
}
})
}
}
suspend fun BillingRequests.getSkusAsync(product: String, skus: List<String>): Skus {
val requests = this
return suspendCoroutine {
continuation ->
requests.getSkus(product, skus, object: RequestListener<Skus> {
override fun onError(response: Int, e: Exception) {
continuation.resumeWithException(e)
}
override fun onSuccess(result: Skus) {
continuation.resume(result)
}
})
}
}
suspend fun BillingRequests.consumeAsync(token: String) {
val requests = this
suspendCoroutine<Any> {
continuation ->
requests.consume(token, object: RequestListener<Any> {
override fun onError(response: Int, e: java.lang.Exception) {
continuation.resumeWithException(e)
}
override fun onSuccess(result: Any) {
continuation.resume(result)
}
})
}
}
suspend fun Inventory.loadAsync(request: Inventory.Request) = suspendCoroutine<Inventory.Products> { continuation ->
this.load(request, object: Inventory.Callback {
override fun onLoaded(products: Inventory.Products) {
continuation.resume(products)
}
})
}
class CheckoutStartResponse (
val requests: BillingRequests,
val product: String,
val billingSupported: Boolean,
private val checkout: Checkout
): Closeable {
override fun close() {
checkout.stop()
}
}

View file

@ -18,6 +18,7 @@ package io.timelimit.android.ui
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@ -30,6 +31,7 @@ import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import io.timelimit.android.Application
import io.timelimit.android.R
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.livedata.ignoreUnchanged
@ -50,8 +52,6 @@ import io.timelimit.android.ui.payment.ActivityPurchaseModel
import io.timelimit.android.ui.setup.SetupTermsFragment
import io.timelimit.android.ui.setup.parent.SetupParentModeFragment
import io.timelimit.android.ui.util.SyncStatusModel
import org.solovyev.android.checkout.ActivityCheckout
import org.solovyev.android.checkout.Checkout
class MainActivity : AppCompatActivity(), ActivityViewModelHolder {
companion object {
@ -62,15 +62,10 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder {
private val currentNavigatorFragment = MutableLiveData<Fragment?>()
private val application: Application by lazy { getApplication() as Application }
private val checkout: ActivityCheckout by lazy { Checkout.forActivity(this, application.billing) }
private val syncModel: SyncStatusModel by lazy {
ViewModelProviders.of(this).get(SyncStatusModel::class.java)
}
val purchaseModel: ActivityPurchaseModel by lazy {
ViewModelProviders.of(this).get(ActivityPurchaseModel::class.java).apply {
setActivityCheckout(checkout)
}
}
val purchaseModel: ActivityPurchaseModel by viewModels()
override var ignoreStop: Boolean = false
override val showPasswordRecovery: Boolean = true
@ -238,12 +233,6 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder {
)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
checkout.onActivityResult(requestCode, resultCode, data)
}
override fun getActivityViewModel(): ActivityViewModel {
return ViewModelProviders.of(this).get(ActivityViewModel::class.java)
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -15,18 +15,17 @@
*/
package io.timelimit.android.ui.payment
import android.app.Activity
import android.app.Application
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import com.android.billingclient.api.*
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.extensions.consumeAsync
import io.timelimit.android.extensions.startAsync
import io.timelimit.android.extensions.waitUntilReady
import io.timelimit.android.livedata.castDown
import io.timelimit.android.extensions.*
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.mergeLiveData
import io.timelimit.android.livedata.setTemporarily
@ -34,25 +33,26 @@ import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.network.CanDoPurchaseStatus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.solovyev.android.checkout.*
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class ActivityPurchaseModel(application: Application): AndroidViewModel(application) {
companion object {
private const val LOG_TAG = "ActivityPurchaseModel"
}
private val billing = (application as io.timelimit.android.Application).billing
private val logic = DefaultAppLogic.with(application)
private val lock = Mutex()
private val clientMutex = Mutex()
private val processMutex = Mutex()
private var _billingClient: BillingClient? = null
private val isWorkingInternal = MutableLiveData<Boolean>().apply { value = false }
private val hadErrorInternal = MutableLiveData<Boolean>().apply { value = false }
private val processPurchaseSuccessInternal = MutableLiveData<Boolean>().apply { value = false }
val isWorking = isWorkingInternal.castDown()
val hadError = hadErrorInternal.castDown()
val processPurchaseSuccess = processPurchaseSuccessInternal.castDown()
val status = mergeLiveData(isWorking, hadError, processPurchaseSuccess).map {
val status = mergeLiveData(isWorkingInternal, hadErrorInternal, processPurchaseSuccessInternal).map {
(working, error, success) ->
if (success != null && success) {
@ -70,120 +70,207 @@ class ActivityPurchaseModel(application: Application): AndroidViewModel(applicat
processPurchaseSuccessInternal.value = false
}
private var activityCheckout: ActivityCheckout? = null
fun setActivityCheckout(checkout: ActivityCheckout) {
checkout.start()
checkout.createPurchaseFlow(object: RequestListener<Purchase> {
override fun onError(response: Int, e: Exception) {
// ignored
private suspend fun <R> initAndUseClient(block: suspend (client: BillingClient) -> R): R {
clientMutex.withLock {
if (_billingClient == null) {
_billingClient = BillingClient.newBuilder(getApplication())
.enablePendingPurchases()
.setListener(purchaseUpdatedListener)
.build()
}
val initBillingClient = _billingClient!!
suspendCoroutine<Unit?> { continuation ->
initBillingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
try {
billingResult.assertSuccess()
continuation.resume(null)
} catch (ex: BillingClientException) {
_billingClient = null
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "error during connecting", ex)
}
continuation.resumeWithException(BillingNotSupportedException())
}
}
override fun onBillingServiceDisconnected() {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "client disconnected")
}
override fun onSuccess(result: Purchase) {
if (PurchaseIds.BUY_SKUS.contains(result.sku)) {
runAsync {
lock.withLock {
isWorkingInternal.setTemporarily(true).use { _ ->
handlePurchase(result)
}
}
clientMutex.withLock {
if (_billingClient === initBillingClient) { _billingClient = null }
}
}
}
})
activityCheckout = checkout
}
fun forgetActivityCheckout() {
activityCheckout?.stop()
activityCheckout = null
return block(initBillingClient)
}
}
suspend fun querySkus(skuIds: List<String>): List<SkuDetails> = initAndUseClient { client ->
val (billingResult, data) = client.querySkuDetails(
SkuDetailsParams.newBuilder()
.setSkusList(skuIds)
.setType(BillingClient.SkuType.INAPP)
.build()
)
billingResult.assertSuccess()
data ?: throw BillingClientException("empty response")
}
suspend fun queryPurchases() = initAndUseClient { client ->
val response = client.queryPurchases(BillingClient.SkuType.INAPP)
response.billingResult.assertSuccess()
response.purchasesList!!.filter {
it.purchaseState == Purchase.PurchaseState.PURCHASED
}
}
private suspend fun queryAndProcessPurchases() {
processMutex.withLock {
isWorkingInternal.setTemporarily(true).use {
try {
initAndUseClient { client ->
val result = client.queryPurchases(BillingClient.SkuType.INAPP)
result.billingResult.assertSuccess()
for (purchase in result.purchasesList!!) {
handlePurchase(purchase, client)
}
}
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "queryAndProcessPurchases() failed", ex)
}
hadErrorInternal.value = true
}
}
}
}
fun queryAndProcessPurchasesAsync() {
runAsync { queryAndProcessPurchases() }
}
private val purchaseUpdatedListener: PurchasesUpdatedListener = object: PurchasesUpdatedListener {
override fun onPurchasesUpdated(p0: BillingResult, p1: MutableList<Purchase>?) {
runAsync {
lock.withLock {
isWorkingInternal.setTemporarily(true).use { _ ->
val checkout = activityCheckout
if (checkout != null) {
val inventory = checkout.makeInventory()
inventory.load(
Inventory.Request.create()
.loadAllPurchases(),
object: Inventory.Callback {
override fun onLoaded(products: Inventory.Products) {
products[ProductTypes.IN_APP].purchases.forEach { purchase ->
if (PurchaseIds.BUY_SKUS.contains(purchase.sku)) {
runAsync {
handlePurchase(purchase)
}
}
}
}
}
)
}
}
}
}
}
private suspend fun handlePurchase(purchase: Purchase) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "handlePurchase()")
}
processMutex.withLock {
isWorkingInternal.setTemporarily(true).use {
initAndUseClient { client ->
try {
p0.assertSuccess()
for (purchase in p1!!) {
handlePurchase(purchase, client)
}
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "onPurchasesUpdated() failed", ex)
}
hadErrorInternal.value = true
}
}
}
}
}
}
}
fun forgetActivityCheckout() {
runAsync {
clientMutex.withLock {
_billingClient?.endConnection()
_billingClient = null
}
}
}
private suspend fun handlePurchase(purchase: Purchase, billingClient: BillingClient) {
if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED || purchase.isAcknowledged) {
// we are not interested in it
return
}
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "handlePurchase($purchase)")
}
if (PurchaseIds.SAL_SKUS.contains(purchase.sku)) {
// just acknowledge
billingClient.acknowledgePurchase(
AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
).assertSuccess()
} else if (PurchaseIds.BUY_SKUS.contains(purchase.sku)) {
// send and consume
val server = logic.serverLogic.getServerConfigCoroutine()
if (server.hasAuthToken) {
server.api.finishPurchaseByGooglePlay(
receipt = purchase.data,
receipt = purchase.originalJson,
signature = purchase.signature,
deviceAuthToken = server.deviceAuthToken
)
}
billingClient.consumePurchase(
ConsumeParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
)
processPurchaseSuccessInternal.value = true
consumePurchaseAsync(purchase)
} catch (ex: Exception) {
hadErrorInternal.value = true
} else {
Log.w(LOG_TAG, "purchase for the premium version but no server available")
}
} else {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "server rejected purchase", ex)
Log.d(LOG_TAG, "don't know how to handle ${purchase.sku}")
}
}
}
private fun consumePurchaseAsync(purchase: Purchase) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "consumePurchaseAsync()")
}
fun startPurchase(sku: String, checkAtBackend: Boolean, activity: Activity) {
runAsync {
lock.withLock {
try {
Checkout.forApplication(billing).startAsync().use {
it.requests.consumeAsync(purchase.token)
}
val skuDetails = querySkus(listOf(sku)).single()
if (skuDetails.sku != sku) throw IllegalStateException()
startPurchase(skuDetails, checkAtBackend, activity)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "consumePurchaseAsync() failed", ex)
}
Log.d(LOG_TAG, "could not start purchase", ex)
}
Toast.makeText(getApplication(), R.string.error_general, Toast.LENGTH_SHORT).show()
}
}
}
fun startPurchase(sku: String, checkAtBackend: Boolean) {
fun startPurchase(skuDetails: SkuDetails, checkAtBackend: Boolean, activity: Activity) {
runAsync {
lock.withLock {
isWorkingInternal.setTemporarily(true).use {
_ ->
initAndUseClient { client ->
try {
if (checkAtBackend) {
val server = logic.serverLogic.getServerConfigCoroutine()
@ -191,7 +278,7 @@ class ActivityPurchaseModel(application: Application): AndroidViewModel(applicat
if (!server.hasAuthToken) {
Toast.makeText(getApplication(), R.string.error_general, Toast.LENGTH_SHORT).show()
return@runAsync
return@initAndUseClient
}
if (!(server.api.canDoPurchase(server.deviceAuthToken) is CanDoPurchaseStatus.Yes)) {
@ -199,21 +286,12 @@ class ActivityPurchaseModel(application: Application): AndroidViewModel(applicat
}
}
// start the purchase
val activityCheckout = activityCheckout
if (activityCheckout == null) {
Toast.makeText(getApplication(), R.string.error_general, Toast.LENGTH_SHORT).show()
return@runAsync
}
activityCheckout.waitUntilReady().requests.purchase(
ProductTypes.IN_APP,
sku,
null,
activityCheckout.purchaseFlow
)
client.launchBillingFlow(
activity,
BillingFlowParams.newBuilder()
.setSkuDetails(skuDetails)
.build()
).assertSuccess()
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "could not start purchase", ex)
@ -224,7 +302,6 @@ class ActivityPurchaseModel(application: Application): AndroidViewModel(applicat
}
}
}
}
}
enum class ActivityPurchaseModelStatus {

View file

@ -20,9 +20,9 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import io.timelimit.android.R
import io.timelimit.android.databinding.FragmentPurchaseBinding
import io.timelimit.android.livedata.liveDataFromNullableValue
@ -32,9 +32,7 @@ import io.timelimit.android.ui.main.FragmentWithCustomTitle
class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
private val activityModel: ActivityPurchaseModel by lazy { (activity as MainActivity).purchaseModel }
private val model: PurchaseModel by lazy {
ViewModelProviders.of(this).get(PurchaseModel::class.java)
}
private val model: PurchaseModel by viewModels()
companion object {
private const val PAGE_BUY = 0
@ -49,22 +47,30 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
if (savedInstanceState == null) {
activityModel.resetProcessPurchaseSuccess()
}
model.retry(activityModel)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = FragmentPurchaseBinding.inflate(inflater, container, false)
var processingPurchaseError = false
mergeLiveData(activityModel.status, model.status).observe(this, Observer {
mergeLiveData(activityModel.status, model.status).observe(viewLifecycleOwner, {
status ->
val (activityStatus, fragmentStatus) = status!!
if (activityStatus != ActivityPurchaseModelStatus.Idle) {
if (fragmentStatus != null) {
when (fragmentStatus) {
PurchaseFragmentPreparing -> binding.flipper.displayedChild = PAGE_WAIT
is PurchaseFragmentReady -> {
when (activityStatus) {
null -> binding.flipper.displayedChild = PAGE_WAIT
ActivityPurchaseModelStatus.Working -> binding.flipper.displayedChild = PAGE_WAIT
ActivityPurchaseModelStatus.Idle -> throw IllegalStateException()
ActivityPurchaseModelStatus.Idle -> {
binding.flipper.displayedChild = PAGE_BUY
binding.priceData = fragmentStatus
}
ActivityPurchaseModelStatus.Error -> {
binding.flipper.displayedChild = PAGE_ERROR
@ -74,12 +80,6 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
}
ActivityPurchaseModelStatus.Done -> binding.flipper.displayedChild = PAGE_DONE
}.let { }
} else if (fragmentStatus != null) {
when (fragmentStatus) {
PurchaseFragmentPreparing -> binding.flipper.displayedChild = PAGE_WAIT
is PurchaseFragmentReady -> {
binding.flipper.displayedChild = PAGE_BUY
binding.priceData = fragmentStatus
}
is PurchaseFragmentError -> {
binding.flipper.displayedChild = PAGE_ERROR
@ -110,16 +110,16 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
if (processingPurchaseError) {
activityModel.queryAndProcessPurchasesAsync()
} else {
model.retry()
model.retry(activityModel)
}
}
override fun buyForOneMonth() {
activityModel.startPurchase(PurchaseIds.SKU_MONTH, checkAtBackend = true)
activityModel.startPurchase(PurchaseIds.SKU_MONTH, checkAtBackend = true, activity = requireActivity())
}
override fun buyForOneYear() {
activityModel.startPurchase(PurchaseIds.SKU_YEAR, checkAtBackend = true)
activityModel.startPurchase(PurchaseIds.SKU_YEAR, checkAtBackend = true, activity = requireActivity())
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,5 +20,5 @@ object PurchaseIds {
const val SKU_MONTH = "premium_month_2018"
val BUY_SKUS = listOf(SKU_YEAR, SKU_MONTH)
val SLA_SKUS = listOf("sal100", "sal500", "sal1000", "sal2000")
val SAL_SKUS = listOf("sal100", "sal500", "sal1000", "sal2000")
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -21,36 +21,28 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.BuildConfig
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.extensions.getSkusAsync
import io.timelimit.android.extensions.startAsync
import io.timelimit.android.extensions.BillingNotSupportedException
import io.timelimit.android.livedata.castDown
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.network.CanDoPurchaseStatus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.solovyev.android.checkout.Checkout
import org.solovyev.android.checkout.ProductTypes
class PurchaseModel(application: Application): AndroidViewModel(application) {
private val logic: AppLogic by lazy { DefaultAppLogic.with(application) }
private val application: io.timelimit.android.Application by lazy { application as io.timelimit.android.Application }
private val statusInternal = MutableLiveData<PurchaseFragmentStatus>()
private val lock = Mutex()
val status = statusInternal.castDown()
init {
prepare()
}
fun retry() {
if (this.status.value is PurchaseFragmentRecoverableError) {
prepare()
fun retry(activityPurchaseModel: ActivityPurchaseModel) {
if (this.status.value is PurchaseFragmentRecoverableError || this.status.value == null) {
prepare(activityPurchaseModel)
}
}
private fun prepare() {
private fun prepare(activityPurchaseModel: ActivityPurchaseModel) {
runAsync {
lock.withLock {
try {
@ -70,30 +62,21 @@ class PurchaseModel(application: Application): AndroidViewModel(application) {
if (canDoPurchase.publicKey?.contentEquals(Base64.decode(BuildConfig.googlePlayKey, 0)) == false) {
statusInternal.value = PurchaseFragmentServerHasDifferentPublicKey
} else {
val checkout = Checkout.forApplication(application.billing)
checkout.startAsync().use {
if (!it.billingSupported) {
statusInternal.value = PurchaseFragmentErrorBillingNotSupportedByDevice
} else {
val skus = it.requests.getSkusAsync(
ProductTypes.IN_APP,
PurchaseIds.BUY_SKUS
)
val skus = activityPurchaseModel.querySkus(PurchaseIds.BUY_SKUS)
statusInternal.value = PurchaseFragmentReady(
monthPrice = skus.getSku(PurchaseIds.SKU_MONTH)?.price.toString(),
yearPrice = skus.getSku(PurchaseIds.SKU_YEAR)?.price.toString()
monthPrice = skus.find { it.sku == PurchaseIds.SKU_MONTH }?.price.toString(),
yearPrice = skus.find { it.sku == PurchaseIds.SKU_YEAR }?.price.toString()
)
}
}
}
} else if (canDoPurchase == CanDoPurchaseStatus.NotDueToOldPurchase) {
statusInternal.value = PurchaseFragmentExistingPaymentError
} else {
statusInternal.value = PurchaseFragmentServerRejectedError
}
}
} catch (ex: BillingNotSupportedException) {
statusInternal.value = PurchaseFragmentErrorBillingNotSupportedByDevice
} catch (ex: Exception) {
statusInternal.value = PurchaseFragmentNetworkError
}

View file

@ -20,9 +20,9 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import io.timelimit.android.R
import io.timelimit.android.databinding.StayAwesomeFragmentBinding
import io.timelimit.android.databinding.StayAwesomeFragmentItemBinding
@ -31,13 +31,11 @@ import io.timelimit.android.ui.MainActivity
import io.timelimit.android.ui.main.FragmentWithCustomTitle
class StayAwesomeFragment : Fragment(), FragmentWithCustomTitle {
val model: StayAwesomeModel by lazy {
ViewModelProviders.of(this).get(StayAwesomeModel::class.java)
}
private val model: StayAwesomeModel by viewModels()
private val activityModel get() = (activity as MainActivity).purchaseModel
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = StayAwesomeFragmentBinding.inflate(inflater, container, false)
val activityModel = (activity as MainActivity).purchaseModel
model.status.observe(viewLifecycleOwner, Observer { status ->
when (status!!) {
@ -62,7 +60,7 @@ class StayAwesomeFragment : Fragment(), FragmentWithCustomTitle {
if (!item.bought) {
view.card.setOnClickListener {
activityModel.startPurchase(item.id, checkAtBackend = false)
activityModel.startPurchase(item.id, checkAtBackend = false, requireActivity())
}
}
@ -82,7 +80,7 @@ class StayAwesomeFragment : Fragment(), FragmentWithCustomTitle {
override fun onResume() {
super.onResume()
model.load()
model.load(activityModel)
}
override fun getCustomTitle(): LiveData<String?> = liveDataFromNullableValue("${getString(R.string.about_sal)} < ${getString(R.string.main_tab_overview)}")

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,21 +20,13 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import io.timelimit.android.BuildConfig
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.extensions.getSkusAsync
import io.timelimit.android.extensions.loadAsync
import io.timelimit.android.extensions.startAsync
import io.timelimit.android.livedata.castDown
import org.solovyev.android.checkout.Checkout
import org.solovyev.android.checkout.Inventory
import org.solovyev.android.checkout.ProductTypes
class StayAwesomeModel(application: Application): AndroidViewModel(application) {
private val application: io.timelimit.android.Application by lazy { application as io.timelimit.android.Application }
private val statusInternal = MutableLiveData<StayAwesomeStatus>()
val status = statusInternal.castDown()
fun load() {
fun load(activityPurchaseModel: ActivityPurchaseModel) {
runAsync {
statusInternal.value = LoadingStayAwesomeStatus
@ -44,41 +36,27 @@ class StayAwesomeModel(application: Application): AndroidViewModel(application)
return@runAsync
}
val checkout = Checkout.forApplication(application.billing)
checkout.startAsync().use {
if (!it.billingSupported) {
statusInternal.value = NotSupportedByDeviceStayAwesomeStatus
} else {
val skus = it.requests.getSkusAsync(
ProductTypes.IN_APP,
PurchaseIds.SLA_SKUS
)
val inventory = checkout.makeInventory()
val products = inventory.loadAsync(Inventory.Request.create().loadAllPurchases())
val purchasedSkus = products[ProductTypes.IN_APP].purchases.map { it.sku }.toSet()
try {
val skus = activityPurchaseModel.querySkus(PurchaseIds.SAL_SKUS)
val purchases = activityPurchaseModel.queryPurchases()
statusInternal.value = ReadyStayAwesomeStatus(
PurchaseIds.SLA_SKUS.map { skuId ->
val sku = skus.getSku(skuId)
PurchaseIds.SAL_SKUS.map { skuId ->
val sku = skus.find { it.sku == skuId }
StayAwesomeItem(
id = skuId,
title = sku?.description ?: skuId,
price = sku?.price ?: "???",
bought = purchasedSkus.contains(skuId)
bought = purchases.find { it.sku == skuId } != null
)
}
)
} catch (ex: Exception) {
statusInternal.value = NotSupportedByDeviceStayAwesomeStatus
}
}
}
}
init {
load()
}
}
sealed class StayAwesomeStatus

View file

@ -92,8 +92,8 @@
(<a href="https://github.com/KeepSafe/TapTargetView/blob/master/LICENSE">Apache License, Version 2.0</a>
\n<a href="https://github.com/google/flexbox-layout">Flexbox Layout</a>
(<a href="https://github.com/google/flexbox-layout/blob/master/LICENSE">Apache License, Version 2.0</a>)
\n<a href="https://github.com/serso/android-checkout">Checkout</a>
(<a href="https://github.com/serso/android-checkout/blob/master/LICENSE.txt">Apache License, Version 2.0</a>)
\nGoogle Play Billing Library (only in builds in the Play Store)
(<a href="https://developer.android.com/google/play/billing/release-notes"> Android Software Development Kit License Agreement</a>)
\n<a href="https://github.com/square/okhttp">OkHttp</a>
(<a href="https://github.com/square/okhttp/blob/master/LICENSE.txt">Apache License, Version 2.0</a>)
\n<a href="https://github.com/socketio/socket.io-client-java">Socket.io-Java-Client</a>

View file

@ -0,0 +1,132 @@
/*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.android.billingclient.api
import android.app.Activity
import android.app.Application
import io.timelimit.android.async.Threads
object BillingClient {
fun newBuilder(application: Application) = Builder
fun startConnection(listener: BillingClientStateListener) {
Threads.mainThreadHandler.post { listener.onBillingSetupFinished(BillingResult) }
}
fun endConnection() {}
fun querySkuDetails(param: SkuDetailsParams) = QuerySkuDetailsResult.instance
fun launchBillingFlow(activity: Activity, params: BillingFlowParams) = BillingResult
fun acknowledgePurchase(params: AcknowledgePurchaseParams) = BillingResult
fun consumePurchase(params: ConsumeParams) = BillingResult
fun queryPurchases(type: String) = QueryPurchasesResult
object BillingResponseCode {
const val OK = 0
const val ERR = 1
}
object SkuType {
const val INAPP = ""
}
object Builder {
fun enablePendingPurchases() = this
fun setListener(listener: PurchasesUpdatedListener) = this
fun build() = BillingClient
}
}
object BillingResult {
const val responseCode = BillingClient.BillingResponseCode.ERR
const val debugMessage = "only mock linked"
}
object SkuDetails {
const val sku = ""
const val price = ""
const val description = ""
}
object SkuDetailsParams {
fun newBuilder() = Builder
object Builder {
fun setSkusList(list: List<String>) = this
fun setType(type: String) = this
fun build() = SkuDetailsParams
}
}
object Purchase {
const val purchaseState = PurchaseState.PURCHASED
const val isAcknowledged = true
const val sku = ""
const val purchaseToken = ""
const val originalJson = ""
const val signature = ""
object PurchaseState {
const val PURCHASED = 0
}
}
object AcknowledgePurchaseParams {
fun newBuilder() = Builder
object Builder {
fun setPurchaseToken(token: String) = this
fun build() = AcknowledgePurchaseParams
}
}
object ConsumeParams {
fun newBuilder() = Builder
object Builder {
fun setPurchaseToken(token: String) = this
fun build() = ConsumeParams
}
}
object BillingFlowParams {
fun newBuilder() = Builder
object Builder {
fun setSkuDetails(details: SkuDetails) = this
fun build() = BillingFlowParams
}
}
object QueryPurchasesResult {
val billingResult = BillingResult
val purchasesList: List<Purchase>? = emptyList()
}
data class QuerySkuDetailsResult(val billingResult: BillingResult, val details: List<SkuDetails>?) {
companion object {
val instance = QuerySkuDetailsResult(BillingResult, emptyList())
}
}
interface BillingClientStateListener {
fun onBillingSetupFinished(billingResult: BillingResult)
fun onBillingServiceDisconnected()
}
interface PurchasesUpdatedListener {
fun onPurchasesUpdated(p0: BillingResult, p1: MutableList<Purchase>?)
}

View file

@ -1,24 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
import android.app.Application
class Billing(application: Application, config: DefaultConfiguration) {
abstract class DefaultConfiguration {
abstract fun getPublicKey(): String
}
}

View file

@ -1,37 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
import io.timelimit.android.async.Threads
object BillingRequests {
fun consume(token: String, listener: RequestListener<Any>) {
Threads.mainThreadHandler.post {
listener.onSuccess(0)
}
}
fun getSkus(product: String, skus: List<String>, listener: RequestListener<Skus>) {
Threads.mainThreadHandler.post {
listener.onSuccess(Skus)
}
}
fun purchase(productType: String, sku: String, something: Unit?, purchaseFlow: PurchaseFlow) {
// do nothing
}
}

View file

@ -1,50 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
import android.app.Activity
import io.timelimit.android.async.Threads
open class Checkout {
companion object {
private val instance = Checkout()
fun forActivity(activity: Activity, billing: Billing) = ActivityCheckout
fun forApplication(billing: Billing) = instance
}
fun start(listener: EmptyListener) {
Threads.mainThreadHandler.post {
listener.onReady(
billingSupported = false,
product = "",
requests = BillingRequests
)
}
}
fun whenReady(listener: EmptyListener) = start(listener)
fun stop() {
// nothing to do
}
fun makeInventory() = Inventory
abstract class EmptyListener {
abstract fun onReady(requests: BillingRequests, product: String, billingSupported: Boolean)
}
}

View file

@ -1,44 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
import io.timelimit.android.async.Threads
object Inventory {
object Products {
object Type {
val purchases = emptyList<Purchase>()
}
operator fun get(type: String) = Type
}
object Request {
fun create() = this
fun loadAllPurchases() = this
}
interface Callback {
fun onLoaded(products: Inventory.Products)
}
fun load(request: Inventory.Request, callback: Callback) {
Threads.mainThreadHandler.post {
callback.onLoaded(Products)
}
}
}

View file

@ -1,21 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
object ProductTypes {
const val IN_APP = ""
}

View file

@ -1,18 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
data class Purchase (val data: String, val signature: String, val token: String, val sku: String)

View file

@ -1,19 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
object PurchaseFlow

View file

@ -1,22 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
interface RequestListener<T> {
fun onSuccess(result: T)
fun onError(response: Int, e: Exception)
}

View file

@ -1,19 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
data class Sku(val description: String, val price: String)

View file

@ -1,21 +0,0 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.solovyev.android.checkout
object Skus {
fun getSku(skuId: String): Sku? = null
}