From ef40b4b8a56e16651a0c999e265361aa8d38a70d Mon Sep 17 00:00:00 2001 From: Andrii Pedosenko Date: Thu, 12 Feb 2026 17:19:43 +0200 Subject: [PATCH 1/9] Update dependencies - Update Kotlin to 2.2.0 - Update Coroutines, AndroidX, Room, Lifecycle, Dagger, OkHttp, Firebase, and testing libraries to latest versions - Remove deprecated `lifecycleCompiler` and `rxBinding` - Replace deprecated `toUpperCase()` with `uppercase()` --- build.gradle | 2 +- dependencies.gradle | 26 +++++++++---------- sdk/build.gradle | 9 +++---- .../google/BillingValidatorImpl.kt | 4 +-- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/build.gradle b/build.gradle index 311a484..d0db985 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ apply from: 'dependencies.gradle' apply plugin: 'com.github.ben-manes.versions' buildscript { - ext.kotlin_version = "2.0.21" + ext.kotlin_version = "2.2.0" repositories { google() mavenCentral() diff --git a/dependencies.gradle b/dependencies.gradle index 290fae0..abefc7a 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -7,14 +7,14 @@ ext { testersMails = '.ci/internal/beta_distribution_emails.txt' // Kotlin - kotlinVer = '1.9.22' - coroutineVer = '1.7.3' - androidKtxVer = '1.9.0' + kotlinVer = '2.2.0' + coroutineVer = '1.10.2' + androidKtxVer = '1.16.0' koinVer = '2.1.6' // AndroidX - appCompatVer = '1.6.0' - fragmentVersion = '1.6.2' + appCompatVer = '1.7.1' + fragmentVersion = '1.8.2' materialVer = '1.9.0' cardViewVer = '1.0.0' mediaVer = '1.0.1' @@ -31,8 +31,8 @@ ext { firestoreVer = '21.6.0' // Architecture components - roomVer = '2.6.1' - lifecycleVer = "2.6.2" + roomVer = '2.8.4' + lifecycleVer = "2.9.1" coreRuntimeVer = "2.0.1" // Redux @@ -42,7 +42,7 @@ ext { multiDexVer = '2.0.0' // Dagger - daggerVer = '2.50' + daggerVer = '2.56.2' // Reactive Extensions rxJava2Ver = '2.2.21' @@ -52,7 +52,7 @@ ext { // Networking - okHttpVer = '4.12.0' + okHttpVer = '5.1.0' retrofitVer = '2.9.0' coroutinesAdapterVer = '0.9.2' @@ -111,12 +111,12 @@ ext { // Unit-tests jUnitVer = '4.13.2' - jupiterVer = '5.7.1' + jupiterVer = '5.13.3' mockitoVer = '3.8.0' mockitoKotlinVer = '2.1.0' mockitoAndroidVer = '2.28.2' jacocoVer = '0.1.2' - assertJVer = '3.12.2' + assertJVer = '3.27.3' dataProviderVer = '2.4' androidXTestCore = '1.2.0' @@ -145,7 +145,7 @@ ext { ] kotlinDependencies = [ - kotlinStdLib : "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVer", + kotlinStdLib : "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVer", kotlinReflect: "org.jetbrains.kotlin:kotlin-reflect:$kotlinVer", androidKtx : "androidx.core:core-ktx:$androidKtxVer" ] @@ -199,7 +199,7 @@ ext { "test" : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVer" ] - mockKVer = '1.13.8' + mockKVer = '1.14.2' testDependencies = [ jUnit : "junit:junit:$jUnitVer", diff --git a/sdk/build.gradle b/sdk/build.gradle index d4a6c4a..ffcd45c 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -95,7 +95,6 @@ dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation presentationDependencies.lifecycleRuntime - kapt presentationDependencies.lifecycleCompiler implementation presentationDependencies.fragment implementation presentationDependencies.fragmentKtx @@ -114,7 +113,6 @@ dependencies { implementation dataDependencies.rxJava implementation dataDependencies.rxAndroid implementation dataDependencies.rxKotlin - implementation dataDependencies.rxBinding implementation dataDependencies.room implementation dataDependencies.roomRxJava @@ -129,9 +127,9 @@ dependencies { implementation dataDependencies.retrofitRxJavaAdapter implementation dataDependencies.retrofitScalarsConverter - implementation platform('com.google.firebase:firebase-bom:32.7.0') - implementation 'com.google.firebase:firebase-messaging-ktx' - implementation 'com.google.firebase:firebase-analytics-ktx' + implementation platform('com.google.firebase:firebase-bom:33.16.0') + implementation 'com.google.firebase:firebase-messaging' + implementation 'com.google.firebase:firebase-analytics' // Billing implementation dataDependencies.billingKtx @@ -147,6 +145,7 @@ dependencies { testImplementation testDependencies.jupiterApi testImplementation testDependencies.jupiterEngine testImplementation testDependencies.jupiterParams + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.13.3' testImplementation testDependencies.mockK testImplementation testDependencies.assertJ testImplementation coroutineDependencies.test diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidatorImpl.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidatorImpl.kt index 9e4d31f..96cbf93 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidatorImpl.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidatorImpl.kt @@ -19,9 +19,9 @@ class BillingValidatorImpl : BillingValidator { val actionBytes = digest.digest(BIND_ACTION.toByteArray()) val packageBytes = digest.digest(PACKAGE.toByteArray()) val currentActionHash = actionBytes.fold("") { str, it -> str + "%02x".format(it) } - .toUpperCase(Locale.US) + .uppercase(Locale.US) val currentPackageHash = packageBytes.fold("") { str, it -> str + "%02x".format(it) } - .toUpperCase(Locale.US) + .uppercase(Locale.US) return@fromCallable currentActionHash == ACTION_HASH && currentPackageHash == PACKAGE_HASH } .onErrorReturnItem(true) From 5ead3f21d7aac2a1f793401669752f2edaba3927 Mon Sep 17 00:00:00 2001 From: Andrii Pedosenko Date: Thu, 12 Feb 2026 18:21:51 +0200 Subject: [PATCH 2/9] Refactor: Migrate from RxJava and Gson to Coroutines and Kotlinx Serialization This commit removes RxJava and Gson dependencies in favor of Kotlin Coroutines and Kotlinx Serialization. Key changes include: - Replaced all RxJava streams (`Single`, `Completable`, `Maybe`) with `suspend` functions across the data and domain layers. - Migrated all data models from Gson's `@SerializedName` to Kotlinx Serialization's `@Serializable` and `@SerialName`. - Updated Retrofit configuration to use `kotlinx-serialization-converter` instead of `gson-converter` and removed the RxJava call adapter. - Updated Room DAO methods to be `suspend` functions and replaced `room-rxjava2` with `room-ktx`. - Replaced Room's `kapt` with `ksp`. - Removed the `domain/utils/rx` package which contained RxJava helpers. - Refactored repositories and use cases to use coroutine-based asynchronous patterns. - Updated tests to use `runTest` instead of relying on RxJava schedulers. --- app/build.gradle | 14 +- build.gradle | 2 + dependencies.gradle | 33 +-- sdk/build.gradle | 28 +-- .../main/java/com/appsci/panda/sdk/Panda.kt | 60 ++--- .../com/appsci/panda/sdk/PandaInternal.kt | 154 +++++++------ .../com/appsci/panda/sdk/data/StopNetwork.kt | 13 +- .../appsci/panda/sdk/data/db/Converters.kt | 19 +- .../appsci/panda/sdk/data/device/DeviceDao.kt | 6 +- .../sdk/data/device/DeviceRepositoryImpl.kt | 158 ++++++-------- .../panda/sdk/data/device/DeviceRequest.kt | 54 ++--- .../panda/sdk/data/device/DeviceResponse.kt | 8 +- .../data/feedback/FeedbackRepositoryImpl.kt | 3 +- .../sdk/data/feedback/FeedbackRequest.kt | 10 +- .../appsci/panda/sdk/data/network/PandaApi.kt | 22 +- .../SubscriptionsRepositoryImpl.kt | 205 ++++++++---------- .../subscriptions/google/BillingValidator.kt | 5 +- .../google/BillingValidatorImpl.kt | 24 +- .../google/PurchasesGoogleStore.kt | 24 +- .../sdk/data/subscriptions/local/FileStore.kt | 24 +- .../data/subscriptions/local/PurchaseDao.kt | 5 +- .../local/PurchasesLocalStore.kt | 10 +- .../data/subscriptions/rest/ProductRequest.kt | 16 +- .../subscriptions/rest/PurchasesRestStore.kt | 45 ++-- .../subscriptions/rest/ScreenDataResponse.kt | 10 +- .../rest/SendSubscriptionResponse.kt | 8 +- .../subscriptions/rest/SubscriptionRequest.kt | 16 +- .../rest/SubscriptionStateResponse.kt | 55 ++--- .../sdk/domain/device/DeviceRepository.kt | 15 +- .../subscriptions/SubscriptionsRepository.kt | 22 +- .../utils/rx/DefaultCompletableObserver.kt | 15 -- .../utils/rx/DefaultSchedulerProvider.kt | 18 -- .../domain/utils/rx/DefaultSingleObserver.kt | 14 -- .../appsci/panda/sdk/domain/utils/rx/RxExt.kt | 12 - .../sdk/domain/utils/rx/SchedulerProvider.kt | 15 -- .../panda/sdk/domain/utils/rx/Schedulers.kt | 24 -- .../sdk/injection/modules/NetworkModule.kt | 24 +- .../com/appsci/panda/sdk/ui/PricingRequest.kt | 139 +++++++----- .../panda/sdk/ui/SubscriptionFragment.kt | 72 +++--- .../google/PurchasesGoogleStoreTest.kt | 107 +++------ 40 files changed, 637 insertions(+), 871 deletions(-) delete mode 100644 sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultCompletableObserver.kt delete mode 100644 sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultSchedulerProvider.kt delete mode 100644 sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultSingleObserver.kt delete mode 100644 sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/RxExt.kt delete mode 100644 sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/SchedulerProvider.kt delete mode 100644 sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/Schedulers.kt diff --git a/app/build.gradle b/app/build.gradle index 7d3cd2f..2d62796 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -60,15 +60,9 @@ dependencies { // Kotlin dependencies implementation kotlinDependencies.kotlinStdLib implementation kotlinDependencies.androidKtx - implementation kotlinDependencies.kotlinReflect - - // Rx + Coroutines - implementation dataDependencies.rxJava - implementation dataDependencies.rxAndroid - implementation dataDependencies.rxKotlin implementation dataDependencies.room - implementation dataDependencies.roomRxJava + implementation dataDependencies.roomKtx kapt dataDependencies.roomCompiler implementation supportDependencies.appCompat @@ -77,12 +71,12 @@ dependencies { implementation supportDependencies.constraintLayout implementation dataDependencies.threeten - implementation dataDependencies.gson implementation dataDependencies.okHttp implementation dataDependencies.okHttpInterceptor implementation dataDependencies.retrofit - implementation dataDependencies.retrofitGsonConverter - implementation dataDependencies.retrofitRxJavaAdapter + implementation dataDependencies.retrofitScalarsConverter + implementation dataDependencies.retrofitKotlinxConverter + implementation dataDependencies.kotlinxSerializationJson implementation developmentDependencies.timber debugImplementation developmentDependencies.leakCanary diff --git a/build.gradle b/build.gradle index d0db985..28f60fa 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,8 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.7.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" + classpath 'com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:2.2.0-2.0.2' classpath 'com.github.ben-manes:gradle-versions-plugin:0.50.0' // NOTE: Do not place your application dependencies here; they belong diff --git a/dependencies.gradle b/dependencies.gradle index abefc7a..4643fd4 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -35,29 +35,18 @@ ext { lifecycleVer = "2.9.1" coreRuntimeVer = "2.0.1" - // Redux - rxReduxVer = '1.0.0' - // MultiDex multiDexVer = '2.0.0' // Dagger daggerVer = '2.56.2' - // Reactive Extensions - rxJava2Ver = '2.2.21' - rxAndroid2Ver = '2.1.1' - rxBinding2Ver = '2.2.0' - rxKotlinVer = '2.4.0' - - // Networking okHttpVer = '5.1.0' - retrofitVer = '2.9.0' - coroutinesAdapterVer = '0.9.2' + retrofitVer = '3.0.0' - // Gson - gsonVer = '2.8.6' + // kotlinx-serialization + kotlinxSerializationVer = '1.9.0' autoValueVer = '1.6.5' @@ -127,8 +116,6 @@ ext { coreTestingVer = '2.0.0-alpha1' extJUnitVer = '1.1.1' extTruthVer = '1.2.0' - rxIdlerVer = '0.9.0' - // Dependencies supportDependencies = [ appCompat : "androidx.appcompat:appcompat:$appCompatVer", @@ -172,20 +159,14 @@ ext { dataDependencies = [ dagger : "com.google.dagger:dagger:$daggerVer", daggerCompiler : "com.google.dagger:dagger-compiler:$daggerVer", - rxJava : "io.reactivex.rxjava2:rxjava:$rxJava2Ver", - rxKotlin : "io.reactivex.rxjava2:rxkotlin:$rxKotlinVer", - rxAndroid : "io.reactivex.rxjava2:rxandroid:$rxAndroid2Ver", - rxBinding : "com.jakewharton.rxbinding2:rxbinding:$rxBinding2Ver", - gson : "com.google.code.gson:gson:$gsonVer", okHttp : "com.squareup.okhttp3:okhttp:$okHttpVer", okHttpInterceptor : "com.squareup.okhttp3:logging-interceptor:$okHttpVer", retrofit : "com.squareup.retrofit2:retrofit:$retrofitVer", - retrofitGsonConverter : "com.squareup.retrofit2:converter-gson:$retrofitVer", - retrofitRxJavaAdapter : "com.squareup.retrofit2:adapter-rxjava2:$retrofitVer", retrofitScalarsConverter : "com.squareup.retrofit2:converter-scalars:$retrofitVer", - retrofitCoroutinesAdapter: "com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:$coroutinesAdapterVer", + retrofitKotlinxConverter : "com.squareup.retrofit2:converter-kotlinx-serialization:$retrofitVer", + kotlinxSerializationJson : "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVer", room : "androidx.room:room-runtime:$roomVer", - roomRxJava : "androidx.room:room-rxjava2:$roomVer", + roomKtx : "androidx.room:room-ktx:$roomVer", roomCompiler : "androidx.room:room-compiler:$roomVer", billingKtx : "com.github.AppSci:billing-ktx:$billingKtxVer", billingClient : "com.android.billingclient:billing-ktx:$billingClientVer", @@ -194,7 +175,6 @@ ext { coroutineDependencies = [ "core" : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVer", - "rx2" : "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$coroutineVer", "android": "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVer", "test" : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVer" ] @@ -225,7 +205,6 @@ ext { espressoIntents : "androidx.test.espresso:espresso-intents:$espressoCoreVer", extJUnit : "androidx.test.ext:junit:$extJUnitVer", extTruth : "androidx.test.ext:truth:$extTruthVer", - rxIdler : "com.squareup.rx.idler:rx2-idler:$rxIdlerVer", testRules : "androidx.test:rules:$runnerVer", supportAnnotations: "androidx.annotation:annotation:$annotationsVer", testRoom : "androidx.room:room-testing:$roomVer", diff --git a/sdk/build.gradle b/sdk/build.gradle index ffcd45c..b53e786 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -2,6 +2,8 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' +apply plugin: 'kotlinx-serialization' +apply plugin: 'com.google.devtools.ksp' apply plugin: 'maven-publish' android { @@ -20,13 +22,6 @@ android { buildConfigField "String", "PANDA_ENDPOINT_PROD", "\"https://api.panda.boosters.company/\"" buildConfigField "String", "PANDA_ENDPOINT_STAGE", "\"https://api.panda-stage.boosters.company/\"" - javaCompileOptions { - annotationProcessorOptions { - arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] -// includeCompileClasspath = true - } - } - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } @@ -52,6 +47,9 @@ android { useJUnitPlatform() } } + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + } buildFeatures { viewBinding true buildConfig true @@ -107,25 +105,18 @@ dependencies { // Kotlin dependencies implementation kotlinDependencies.kotlinStdLib implementation kotlinDependencies.androidKtx - implementation kotlinDependencies.kotlinReflect - - // Rx + Coroutines - implementation dataDependencies.rxJava - implementation dataDependencies.rxAndroid - implementation dataDependencies.rxKotlin implementation dataDependencies.room - implementation dataDependencies.roomRxJava - kapt dataDependencies.roomCompiler + implementation dataDependencies.roomKtx + ksp dataDependencies.roomCompiler implementation dataDependencies.threeten - implementation dataDependencies.gson implementation dataDependencies.okHttp implementation dataDependencies.okHttpInterceptor implementation dataDependencies.retrofit - implementation dataDependencies.retrofitGsonConverter - implementation dataDependencies.retrofitRxJavaAdapter implementation dataDependencies.retrofitScalarsConverter + implementation dataDependencies.retrofitKotlinxConverter + implementation dataDependencies.kotlinxSerializationJson implementation platform('com.google.firebase:firebase-bom:33.16.0') implementation 'com.google.firebase:firebase-messaging' @@ -139,7 +130,6 @@ dependencies { implementation coroutineDependencies.core implementation coroutineDependencies.android - implementation coroutineDependencies.rx2 testImplementation testDependencies.jupiterApi diff --git a/sdk/src/main/java/com/appsci/panda/sdk/Panda.kt b/sdk/src/main/java/com/appsci/panda/sdk/Panda.kt index 5b841d4..963c0ca 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/Panda.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/Panda.kt @@ -5,8 +5,6 @@ import android.app.Application import com.android.billingclient.api.BillingClient import com.android.billingclient.api.ProductDetails import com.appsci.panda.sdk.domain.subscriptions.* -import com.appsci.panda.sdk.domain.utils.rx.DefaultSchedulerProvider -import com.appsci.panda.sdk.domain.utils.rx.Schedulers import com.appsci.panda.sdk.injection.components.DaggerPandaComponent import com.appsci.panda.sdk.injection.components.PandaComponent import com.appsci.panda.sdk.injection.modules.AppModule @@ -15,9 +13,7 @@ import com.appsci.panda.sdk.injection.modules.NetworkModule import com.appsci.panda.sdk.ui.ScreenExtra import com.jakewharton.threetenabp.AndroidThreeTen import dagger.Lazy -import io.reactivex.Single import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.rx2.await import kotlinx.coroutines.withContext import okhttp3.logging.HttpLoggingInterceptor import javax.inject.Inject @@ -62,15 +58,11 @@ object Panda { @JvmStatic suspend fun clearAdvId() = withContext(Dispatchers.IO) { panda.clearAdvId() - .subscribeOn(Schedulers.io()) - .await() } @JvmStatic suspend fun syncUser(): String = withContext(Dispatchers.IO) { panda.authorize() - .subscribeOn(Schedulers.io()) - .await() } @JvmStatic @@ -79,8 +71,6 @@ object Panda { fbp: String?, ) = withContext(Dispatchers.IO) { panda.setFbIds(fbc = fbc, fbp = fbp) - .subscribeOn(Schedulers.io()) - .await() } @@ -138,8 +128,6 @@ object Panda { @JvmStatic suspend fun syncSubscriptions() = withContext(Dispatchers.IO) { panda.syncSubscriptions() - .subscribeOn(Schedulers.io()) - .await() } /** @@ -148,8 +136,6 @@ object Panda { @JvmStatic suspend fun getSubscriptionState(): SubscriptionState = withContext(Dispatchers.IO) { panda.getSubscriptionState() - .subscribeOn(Schedulers.io()) - .await() } /** @@ -158,8 +144,6 @@ object Panda { @JvmStatic suspend fun consumeProducts() = withContext(Dispatchers.IO) { panda.consumeProducts() - .subscribeOn(Schedulers.io()) - .await() } /** @@ -170,9 +154,6 @@ object Panda { suspend fun prefetchSubscriptionScreen(id: String) = withContext(Dispatchers.IO) { panda.prefetchSubscriptionScreen(id) - .subscribeOn(Schedulers.io()) - .ignoreElement() - .await() } @JvmStatic @@ -185,14 +166,12 @@ object Panda { id: String, ): SubscriptionScreen = withContext(Dispatchers.IO) { panda.getSubscriptionScreen(id) - .subscribeOn(Schedulers.io()) - .await() } @JvmStatic suspend fun getCachedOrDefaultSubscriptionScreen( id: String, - ): SubscriptionScreen = panda.getCachedOrDefaultSubscriptionScreen(id).await() + ): SubscriptionScreen = panda.getCachedOrDefaultSubscriptionScreen(id) @JvmStatic suspend fun getProductsDetails(requests: Map>): List = @@ -203,8 +182,7 @@ object Panda { @JvmStatic suspend fun dropData() = withContext(Dispatchers.IO) { panda.stopNetwork() - .andThen(panda.clearLocalData()) - .await() + panda.clearLocalData() } fun addDismissListener(onDismiss: () -> Unit) { @@ -356,16 +334,15 @@ object Panda { } } - suspend fun restore(): List = + suspend fun restore(): List = withContext(Dispatchers.IO) { panda.restore() - .subscribeOn(Schedulers.io()) - .await() + } - internal fun onPurchase( + internal suspend fun onPurchase( screenExtra: ScreenExtra, purchase: GooglePurchase, type: String, - ): Single { + ): Boolean { val purchaseType = when (type) { BillingClient.ProductType.SUBS -> SkuType.SUBSCRIPTION else -> SkuType.INAPP @@ -373,21 +350,16 @@ object Panda { val productId = purchase.products.firstOrNull() ?: error("ProductId is not found") val orderId = purchase.orderId ?: error("ProductId is not found") - return panda.validatePurchase( - Purchase( - id = productId, - type = purchaseType, - orderId = orderId, - token = purchase.purchaseToken + return withContext(Dispatchers.IO) { + panda.validatePurchase( + Purchase( + id = productId, + type = purchaseType, + orderId = orderId, + token = purchase.purchaseToken + ) ) - ) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.mainThread()) - .doOnError { t -> - notifyError(t) - }.doOnSuccess { - notifyPurchase(screenExtra, productId) - } + } } internal fun onError(throwable: Throwable) { @@ -452,7 +424,6 @@ object Panda { ) { if (initialized) return this.context = context - Schedulers.setInstance(DefaultSchedulerProvider()) AndroidThreeTen.init(context) val wrapper = PandaDependencies() pandaComponent = DaggerPandaComponent @@ -479,4 +450,3 @@ class PandaDependencies { @Inject lateinit var panda: IPanda } - diff --git a/sdk/src/main/java/com/appsci/panda/sdk/PandaInternal.kt b/sdk/src/main/java/com/appsci/panda/sdk/PandaInternal.kt index c3eb3cf..73fadc1 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/PandaInternal.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/PandaInternal.kt @@ -11,46 +11,42 @@ import com.appsci.panda.sdk.domain.subscriptions.SubscriptionsRepository import com.appsci.panda.sdk.domain.utils.DeviceManager import com.appsci.panda.sdk.domain.utils.LocalPropertiesDataSource import com.appsci.panda.sdk.domain.utils.Preferences -import com.appsci.panda.sdk.domain.utils.rx.Schedulers import dagger.Lazy -import io.reactivex.Completable -import io.reactivex.Single import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.rx2.await import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import timber.log.Timber -import java.util.concurrent.TimeUnit interface IPanda { val pandaUserId: String? fun onStart() - fun authorize(): Single - fun clearAdvId(): Completable - fun syncSubscriptions(): Completable - fun validatePurchase(purchase: Purchase): Single - fun restore(): Single> - fun getSubscriptionState(): Single - fun prefetchSubscriptionScreen( + suspend fun authorize(): String + suspend fun clearAdvId() + suspend fun syncSubscriptions() + suspend fun validatePurchase(purchase: Purchase): Boolean + suspend fun restore(): List + suspend fun getSubscriptionState(): SubscriptionState + suspend fun prefetchSubscriptionScreen( id: String, - ): Single + ): SubscriptionScreen - fun getSubscriptionScreen( + suspend fun getSubscriptionScreen( id: String, timeoutMs: Long = 5000L, - ): Single + ): SubscriptionScreen fun getCachedSubscriptionScreen( id: String, ): SubscriptionScreen? - fun getCachedOrDefaultSubscriptionScreen( + suspend fun getCachedOrDefaultSubscriptionScreen( id: String, - ): Single + ): SubscriptionScreen - fun consumeProducts(): Completable - fun setAppsflyerId(id: String): Completable - fun setFbIds(fbc: String?, fbp: String?): Completable + suspend fun consumeProducts() + suspend fun setAppsflyerId(id: String) + suspend fun setFbIds(fbc: String?, fbp: String?) fun saveLoginData(loginData: LoginData) fun saveCustomUserId(id: String?) suspend fun setUserProperty(key: String, value: String) @@ -62,8 +58,8 @@ interface IPanda { * save appsflyer id in local storage, will be used in next update request */ fun saveAppsflyerId(id: String) - fun stopNetwork(): Completable - fun clearLocalData(): Completable + fun stopNetwork() + suspend fun clearLocalData() } class PandaImpl( @@ -106,9 +102,8 @@ class PandaImpl( } } - override fun authorize(): Single = - deviceRepository.authorize() - .map { it.id } + override suspend fun authorize(): String = + deviceRepository.authorize().id override fun saveCustomUserId(id: String?) { if (preferences.customUserId == id) return @@ -118,10 +113,10 @@ class PandaImpl( override suspend fun setUserProperty(key: String, value: String) { propertiesDataSource.putProperty(key, value) withContext(Dispatchers.IO) { - deviceRepository.authorize() - .ignoreElement() - .onErrorComplete() - .await() + try { + deviceRepository.authorize() + } catch (_: Exception) { + } } } @@ -130,10 +125,10 @@ class PandaImpl( propertiesDataSource.putProperty(key, value) } withContext(Dispatchers.IO) { - deviceRepository.authorize() - .ignoreElement() - .onErrorComplete() - .await() + try { + deviceRepository.authorize() + } catch (_: Exception) { + } } } @@ -141,33 +136,31 @@ class PandaImpl( subscriptionsRepository.getProductsDetails(requests) override suspend fun sendFeedback(screenId: String, answer: String) { - deviceRepository.ensureAuthorized().await() + deviceRepository.ensureAuthorized() feedbackRepository.sendFeedback(screenId = screenId, answer = answer) } - override fun clearAdvId(): Completable { - return Completable.defer { - deviceRepository.clearAdvId() - } + override suspend fun clearAdvId() { + deviceRepository.clearAdvId() } - override fun setAppsflyerId(id: String): Completable { - if (preferences.appsflyerId == id) return Completable.complete() + override suspend fun setAppsflyerId(id: String) { + if (preferences.appsflyerId == id) return preferences.appsflyerId = id - return Completable.defer { + try { deviceRepository.authorize() - .ignoreElement() + } catch (_: Exception) { } } - override fun setFbIds(fbc: String?, fbp: String?): Completable { - if (preferences.fbc == fbc && preferences.fbp == fbp) return Completable.complete() + override suspend fun setFbIds(fbc: String?, fbp: String?) { + if (preferences.fbc == fbc && preferences.fbp == fbp) return preferences.fbc = fbc preferences.fbp = fbp - return Completable.defer { + try { deviceRepository.ensureAuthorized() - .andThen(deviceRepository.authorize()) - .ignoreElement() + deviceRepository.authorize() + } catch (_: Exception) { } } @@ -197,60 +190,65 @@ class PandaImpl( preferences.appsflyerId = id } - override fun stopNetwork(): Completable = stopNetworkInternal() + override fun stopNetwork() = stopNetworkInternal() - override fun clearLocalData() = deviceRepository.clearLocalData() + override suspend fun clearLocalData() = deviceRepository.clearLocalData() - override fun syncSubscriptions(): Completable { - return deviceRepository.ensureAuthorized() - .andThen(subscriptionsRepository.sync()) + override suspend fun syncSubscriptions() { + deviceRepository.ensureAuthorized() + subscriptionsRepository.sync() } - override fun validatePurchase(purchase: Purchase): Single { - return deviceRepository.ensureAuthorized() - .andThen(subscriptionsRepository.validatePurchase(purchase)) + override suspend fun validatePurchase(purchase: Purchase): Boolean { + deviceRepository.ensureAuthorized() + return subscriptionsRepository.validatePurchase(purchase) } - override fun restore(): Single> = + override suspend fun restore(): List { deviceRepository.ensureAuthorized() - .andThen(subscriptionsRepository.restore()) + return subscriptionsRepository.restore() + } - override fun getSubscriptionState(): Single = + override suspend fun getSubscriptionState(): SubscriptionState { deviceRepository.ensureAuthorized() - .andThen(subscriptionsRepository.getSubscriptionState()) + return subscriptionsRepository.getSubscriptionState() + } - override fun prefetchSubscriptionScreen( + override suspend fun prefetchSubscriptionScreen( id: String, - ): Single = + ): SubscriptionScreen { deviceRepository.ensureAuthorized() - .andThen(subscriptionsRepository.prefetchSubscriptionScreen(id)) + return subscriptionsRepository.prefetchSubscriptionScreen(id) + } - override fun getSubscriptionScreen( + override suspend fun getSubscriptionScreen( id: String, timeoutMs: Long, - ): Single = - deviceRepository.ensureAuthorized() - .andThen(subscriptionsRepository.getSubscriptionScreen(id)) - .timeout(timeoutMs, TimeUnit.MILLISECONDS, Schedulers.computation()) - .doOnError { - Timber.e(it, "getSubscriptionScreen") - } - .onErrorResumeNext { - subscriptionsRepository.getFallbackScreen() + ): SubscriptionScreen { + return try { + withTimeout(timeoutMs) { + deviceRepository.ensureAuthorized() + subscriptionsRepository.getSubscriptionScreen(id) } + } catch (e: Exception) { + Timber.e(e, "getSubscriptionScreen") + subscriptionsRepository.getFallbackScreen() + } + } override fun getCachedSubscriptionScreen(id: String): SubscriptionScreen? = subscriptionsRepository.getCachedScreen(id = id) - override fun getCachedOrDefaultSubscriptionScreen( + override suspend fun getCachedOrDefaultSubscriptionScreen( id: String, - ): Single = subscriptionsRepository.getCachedScreen(id)?.let { - Single.just(it) - } ?: subscriptionsRepository.getFallbackScreen() + ): SubscriptionScreen = + subscriptionsRepository.getCachedScreen(id) + ?: subscriptionsRepository.getFallbackScreen() - override fun consumeProducts(): Completable = + override suspend fun consumeProducts() { deviceRepository.ensureAuthorized() - .andThen(subscriptionsRepository.consumeProducts()) + subscriptionsRepository.consumeProducts() + } } diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/StopNetwork.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/StopNetwork.kt index 0a0199f..814caca 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/StopNetwork.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/StopNetwork.kt @@ -1,16 +1,13 @@ package com.appsci.panda.sdk.data -import io.reactivex.Completable import okhttp3.OkHttpClient import javax.inject.Inject class StopNetwork @Inject constructor( private val okHttpClient: OkHttpClient -) : () -> Completable { - - override operator fun invoke(): Completable = - Completable.fromAction { - okHttpClient.dispatcher.cancelAll() - okHttpClient.cache?.evictAll() - } +) { + operator fun invoke() { + okHttpClient.dispatcher.cancelAll() + okHttpClient.cache?.evictAll() + } } diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/db/Converters.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/db/Converters.kt index 9bf7d08..9763655 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/db/Converters.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/db/Converters.kt @@ -1,9 +1,7 @@ package com.appsci.panda.sdk.data.db import androidx.room.TypeConverter -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.reflect.TypeToken +import kotlinx.serialization.json.Json import org.threeten.bp.LocalDate import org.threeten.bp.LocalDateTime import org.threeten.bp.LocalTime @@ -16,30 +14,29 @@ import org.threeten.bp.format.DateTimeFormatter **/ class Converters { - private val gson: Gson = GsonBuilder().create() - @TypeConverter - fun intListToString(listOfInts: List): String = gson.toJson(listOfInts) + fun intListToString(listOfInts: List): String = Json.encodeToString(listOfInts) @TypeConverter fun stringToIntList(string: String): List { - return gson.fromJson(string, object : TypeToken>() {}.type) + return Json.decodeFromString(string) } @TypeConverter - fun mapToString(map: Map?): String = gson.toJson(map) + fun mapToString(map: Map?): String = Json.encodeToString(map) @TypeConverter fun stringToMap(string: String?): Map? { - return gson.fromJson(string, object : TypeToken>() {}.type) + if (string == null) return null + return Json.decodeFromString(string) } @TypeConverter - fun longsListToString(listOfLongs: List): String = gson.toJson(listOfLongs) + fun longsListToString(listOfLongs: List): String = Json.encodeToString(listOfLongs) @TypeConverter fun stringToLongList(string: String): List { - return gson.fromJson(string, object : TypeToken>() {}.type) + return Json.decodeFromString(string) } @TypeConverter diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceDao.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceDao.kt index 512e32a..4a245ca 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceDao.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceDao.kt @@ -1,20 +1,18 @@ package com.appsci.panda.sdk.data.device import androidx.room.* -import io.reactivex.Maybe -import io.reactivex.Single @Dao abstract class DeviceDao { @Query("SELECT * FROM Device LIMIT 1") - abstract fun selectDevice(): Maybe + abstract suspend fun selectDevice(): DeviceEntity? @Query("SELECT * FROM Device LIMIT 1") abstract fun getDevice(): DeviceEntity? @Query("SELECT id FROM Device LIMIT 1") - abstract fun requireUserId(): Single + abstract suspend fun requireUserId(): String? @Query("DELETE FROM Device") abstract fun deleteDevice() diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceRepositoryImpl.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceRepositoryImpl.kt index d23d49b..c9357ac 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceRepositoryImpl.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceRepositoryImpl.kt @@ -9,10 +9,8 @@ import com.appsci.panda.sdk.domain.device.Device import com.appsci.panda.sdk.domain.device.DeviceRepository import com.appsci.panda.sdk.domain.utils.LocalPropertiesDataSource import com.appsci.panda.sdk.domain.utils.Preferences -import com.appsci.panda.sdk.domain.utils.rx.shareSingle -import io.reactivex.Completable -import io.reactivex.Maybe -import io.reactivex.Single +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject @@ -28,15 +26,8 @@ class DeviceRepositoryImpl @Inject constructor( private val deviceDao: DeviceDao = database.getDeviceDao() - /** - * auth observable that shares result for all subscribers - */ - private val authSharedSingle = createAuthObservable().shareSingle() - - /** - * auth observable that shares result for all subscribers - */ - private val ensureAuthorizedSingle = createEnsureAuthObservable().shareSingle() + private val authMutex = Mutex() + private val ensureAuthMutex = Mutex() override val pandaUserId: String? get() = preferences.pandaUserId @@ -44,103 +35,94 @@ class DeviceRepositoryImpl @Inject constructor( /** * perform device authorization, or update device if changed, or returns existing device from local storage */ - override fun authorize(): Single = authSharedSingle + override suspend fun authorize(): Device = authMutex.withLock { + val existing = deviceDao.selectDevice() + if (existing != null) { + updateDevice(existing) + } else { + val registered = registerDevice() + // update right after register, if needed + val justRegistered = deviceDao.selectDevice() + if (justRegistered != null) { + updateDevice(justRegistered) + } else { + registered + } + } + } /** * perform device authorization or returns existing device from local storage */ - override fun ensureAuthorized(): Completable = - ensureAuthorizedSingle.ignoreElement() + override suspend fun ensureAuthorized() { + ensureAuthMutex.withLock { + val existing = deviceDao.selectDevice() + if (existing != null) { + deviceMapper.mapToDomain(existing) + } else { + authorize() + } + } + } - override fun getAuthState(): Single { - return deviceDao.selectDevice().toSingle() - .map { AuthState.Authorized(deviceMapper.mapToDomain(it)) } - .onErrorReturnItem(AuthState.NotAuthorized) + override suspend fun getAuthState(): AuthState { + return try { + val entity = deviceDao.selectDevice() + ?: return AuthState.NotAuthorized + AuthState.Authorized(deviceMapper.mapToDomain(entity)) + } catch (_: Exception) { + AuthState.NotAuthorized + } } - override fun deleteDevice(): Completable { - return pandaApi.deleteDevice() - .andThen(clearLocalData()) + override suspend fun deleteDevice() { + pandaApi.deleteDevice() + clearLocalData() } - override fun clearLocalData(): Completable = Completable.fromAction { + override suspend fun clearLocalData() { database.clearAllTables() preferences.clear() localPropertiesDataSource.clear() } - private fun createAuthObservable(): Single { - return Single.defer { - deviceDao.selectDevice() - .flatMapSingleElement { updateDevice(it) } - .switchIfEmpty( - registerDevice() - .flatMap { - //update right after register, if need - deviceDao.selectDevice().toSingle() - .flatMap { updateDevice(it) } - } - ) - } - } - - private fun createEnsureAuthObservable(): Single { - return Single.defer { - deviceDao.selectDevice() - .map { deviceMapper.mapToDomain(it) } - .switchIfEmpty(authSharedSingle) - } - } - - private fun registerDevice(): Single { - return Single.defer { - val authData = authorizationDataBuilder.createAuthData() - Timber.d("registerDevice $authData") - val registerRequest = deviceMapper.mapRegisterRequest(authData) - return@defer pandaApi.registerDevice(registerRequest) - .map { deviceMapper.mapToLocal(it, registerRequest) } - .doOnSuccess { - preferences.pandaUserId = it.id - deviceDao.putDevice(it) - } - .doOnError { Timber.e(it) } - .map { deviceMapper.mapToDomain(it) } - } + private suspend fun registerDevice(): Device { + val authData = authorizationDataBuilder.createAuthData() + Timber.d("registerDevice $authData") + val registerRequest = deviceMapper.mapRegisterRequest(authData) + val response = pandaApi.registerDevice(registerRequest) + val entity = deviceMapper.mapToLocal(response, registerRequest) + preferences.pandaUserId = entity.id + deviceDao.putDevice(entity) + return deviceMapper.mapToDomain(entity) } - private fun updateDevice(deviceEntity: DeviceEntity): Single { + private suspend fun updateDevice(deviceEntity: DeviceEntity): Device { Timber.d("updateDevice $deviceEntity") - return Single.defer { + return try { val authData = authorizationDataBuilder.createAuthData() if (authDataValidator.isDeviceValid(deviceEntity, authData)) { Timber.d("updateDevice skipped") - return@defer Single.just(deviceMapper.mapToDomain(deviceEntity)) - } else { - val updateRequest = deviceMapper.mapUpdateRequest(authData) - return@defer pandaApi.updateDevice(updateRequest, deviceEntity.id) - .map { deviceMapper.mapToLocal(it, updateRequest) } - .doOnSuccess { - preferences.pandaUserId = it.id - deviceDao.putDevice(it) - } - .map { deviceMapper.mapToDomain(it) } + return deviceMapper.mapToDomain(deviceEntity) } - }.onErrorReturn { deviceMapper.mapToDomain(deviceEntity) } + val updateRequest = deviceMapper.mapUpdateRequest(authData) + val response = pandaApi.updateDevice(updateRequest, deviceEntity.id) + val entity = deviceMapper.mapToLocal(response, updateRequest) + preferences.pandaUserId = entity.id + deviceDao.putDevice(entity) + deviceMapper.mapToDomain(entity) + } catch (e: Exception) { + deviceMapper.mapToDomain(deviceEntity) + } } - override fun clearAdvId(): Completable { - return Maybe.defer { - deviceDao.selectDevice() - .flatMapSingleElement { deviceEntity -> - val authData = authorizationDataBuilder.createAuthData() - .copy(idfa = "") - val updateRequest = deviceMapper.mapUpdateRequest(authData) - return@flatMapSingleElement pandaApi.updateDevice(updateRequest, deviceEntity.id) - .map { deviceMapper.mapToLocal(it, updateRequest) } - .doOnSuccess { - deviceDao.putDevice(it) - } - } - }.ignoreElement() + override suspend fun clearAdvId() { + val deviceEntity = deviceDao.selectDevice() ?: return + val authData = authorizationDataBuilder.createAuthData() + .copy(idfa = "") + val updateRequest = deviceMapper.mapUpdateRequest(authData) + val response = pandaApi.updateDevice(updateRequest, deviceEntity.id) + val entity = deviceMapper.mapToLocal(response, updateRequest) + deviceDao.putDevice(entity) } } diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceRequest.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceRequest.kt index 8e9f82f..a8de498 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceRequest.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceRequest.kt @@ -1,56 +1,58 @@ package com.appsci.panda.sdk.data.device -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class DeviceRequest( - @SerializedName("country") + @SerialName("country") val country: String, - @SerializedName("device_model") + @SerialName("device_model") val deviceModel: String, - @SerializedName("app_version") + @SerialName("app_version") val appVersion: String, - @SerializedName("start_app_version") + @SerialName("start_app_version") val startAppVersion: String, - @SerializedName("timezone") + @SerialName("timezone") val timeZone: String, - @SerializedName("os_version") + @SerialName("os_version") val osVersion: String, - @SerializedName("idfa") + @SerialName("idfa") val idfa: String? = null, - @SerializedName("device_family") + @SerialName("device_family") val deviceFamily: String, - @SerializedName("language") + @SerialName("language") val language: String, - @SerializedName("locale") + @SerialName("locale") val locale: String, - @SerializedName("platform") + @SerialName("platform") val platform: String, - @SerializedName("push_notifications_token") + @SerialName("push_notifications_token") val pushToken: String? = null, - @SerializedName("custom_user_id") + @SerialName("custom_user_id") val customUserId: String? = null, - @SerializedName("appsflyer_id") + @SerialName("appsflyer_id") val appsflyerId: String? = null, - @SerializedName("time_zone") + @SerialName("time_zone") val idfv: String? = null, - @SerializedName("fbc") + @SerialName("fbc") val fbc: String? = null, - @SerializedName("fbp") + @SerialName("fbp") val fbp: String? = null, - @SerializedName("email") + @SerialName("email") val email: String? = null, - @SerializedName("facebook_login_id") + @SerialName("facebook_login_id") val facebookLoginId: String? = null, - @SerializedName("first_name") + @SerialName("first_name") val firstName: String? = null, - @SerializedName("last_name") + @SerialName("last_name") val lastName: String? = null, - @SerializedName("full_name") + @SerialName("full_name") val fullName: String? = null, - @SerializedName("gender") + @SerialName("gender") val gender: Int? = null, - @SerializedName("phone") + @SerialName("phone") val phone: String? = null, - @SerializedName("properties") + @SerialName("properties") val properties: Map?, ) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceResponse.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceResponse.kt index 8a288da..e588677 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceResponse.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/device/DeviceResponse.kt @@ -1,8 +1,10 @@ package com.appsci.panda.sdk.data.device -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class DeviceResponse( - @SerializedName("id") - val id: String + @SerialName("id") + val id: String ) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/feedback/FeedbackRepositoryImpl.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/feedback/FeedbackRepositoryImpl.kt index 5786173..7367534 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/feedback/FeedbackRepositoryImpl.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/feedback/FeedbackRepositoryImpl.kt @@ -4,7 +4,6 @@ import com.appsci.panda.sdk.data.device.DeviceDao import com.appsci.panda.sdk.data.network.PandaApi import com.appsci.panda.sdk.domain.feedback.FeedbackRepository import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.rx2.await import kotlinx.coroutines.withContext import javax.inject.Inject @@ -17,7 +16,7 @@ class FeedbackRepositoryImpl @Inject constructor( screenId: String, answer: String, ) = withContext(Dispatchers.IO) { - val userId = deviceDao.requireUserId().await() + val userId = deviceDao.requireUserId() ?: error("User not authorized") pandaApi.sendFeedback( FeedbackRequest( userId = userId, diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/feedback/FeedbackRequest.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/feedback/FeedbackRequest.kt index d70bb5a..a3632a9 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/feedback/FeedbackRequest.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/feedback/FeedbackRequest.kt @@ -1,12 +1,14 @@ package com.appsci.panda.sdk.data.feedback -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class FeedbackRequest( - @SerializedName("user_id") + @SerialName("user_id") val userId: String, - @SerializedName("screen_id") + @SerialName("screen_id") val screenId: String, - @SerializedName("answer") + @SerialName("answer") val answer: String, ) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/network/PandaApi.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/network/PandaApi.kt index e16452b..356761d 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/network/PandaApi.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/network/PandaApi.kt @@ -4,40 +4,38 @@ import com.appsci.panda.sdk.data.device.DeviceRequest import com.appsci.panda.sdk.data.device.DeviceResponse import com.appsci.panda.sdk.data.feedback.FeedbackRequest import com.appsci.panda.sdk.data.subscriptions.rest.* -import io.reactivex.Completable -import io.reactivex.Single import retrofit2.http.* interface PandaApi { @POST("/v1/users") - fun registerDevice(@Body deviceRequest: DeviceRequest): Single + suspend fun registerDevice(@Body deviceRequest: DeviceRequest): DeviceResponse @PUT("/v1/users/{user_id}") - fun updateDevice( + suspend fun updateDevice( @Body deviceRequest: DeviceRequest, @Path("user_id") userId: String, - ): Single + ): DeviceResponse @DELETE("/v1/devices") - fun deleteDevice(): Completable + suspend fun deleteDevice() @GET("/v1/subscription-status/{user_id}") - fun getSubscriptionStatus( + suspend fun getSubscriptionStatus( @Path("user_id") userId: String, - ): Single + ): SubscriptionStateResponse @POST("/v1/android/products/{user_id}") - fun sendProduct( + suspend fun sendProduct( @Body request: ProductRequest, @Path("user_id") userId: String, - ): Single + ): SendSubscriptionResponse @POST("/v1/android/subscriptions/{user_id}") - fun sendSubscription( + suspend fun sendSubscription( @Body request: SubscriptionRequest, @Path("user_id") userId: String, - ): Single + ): SendSubscriptionResponse @POST("/v1/feedback/answers") suspend fun sendFeedback( diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/SubscriptionsRepositoryImpl.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/SubscriptionsRepositoryImpl.kt index ea7cb03..529f93c 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/SubscriptionsRepositoryImpl.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/SubscriptionsRepositoryImpl.kt @@ -12,11 +12,10 @@ import com.appsci.panda.sdk.domain.subscriptions.Purchase import com.appsci.panda.sdk.domain.subscriptions.SubscriptionScreen import com.appsci.panda.sdk.domain.subscriptions.SubscriptionState import com.appsci.panda.sdk.domain.subscriptions.SubscriptionsRepository -import com.appsci.panda.sdk.domain.utils.rx.DefaultCompletableObserver -import io.reactivex.Completable -import io.reactivex.Flowable -import io.reactivex.Maybe -import io.reactivex.Single +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import timber.log.Timber class SubscriptionsRepositoryImpl( @@ -30,89 +29,74 @@ class SubscriptionsRepositoryImpl( ) : SubscriptionsRepository { private val loadedScreens = mutableMapOf() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - override fun sync(): Completable { - return fetchHistory() - .andThen(saveGooglePurchases()) - .doOnComplete { - acknowledge() - } - .andThen(deviceDao.requireUserId()) - .flatMapCompletable { userId -> - localStore.getNotSentPurchases() - .flatMapPublisher { Flowable.fromIterable(it) } - .concatMapCompletable { entity -> - val purchase = mapper.mapToDomain(entity) - return@concatMapCompletable restStore.sendPurchase(purchase, userId) - .doOnSuccess { - localStore.markSynced(entity.productId) - }.ignoreElement() - } - } + override suspend fun sync() { + fetchHistory() + saveGooglePurchases() + acknowledge() + val userId = deviceDao.requireUserId() + ?: error("User not authorized") + val notSent = localStore.getNotSentPurchases() + for (entity in notSent) { + val purchase = mapper.mapToDomain(entity) + restStore.sendPurchase(purchase, userId) + localStore.markSynced(entity.productId) + } } - override fun validatePurchase(purchase: Purchase): Single { - - return saveGooglePurchases() - .andThen(deviceDao.requireUserId()) - .flatMap { - restStore.sendPurchase(purchase, it) - .doOnSuccess { - localStore.markSynced(purchase.id) - } - }.doAfterSuccess { - acknowledge() - } + override suspend fun validatePurchase(purchase: Purchase): Boolean { + saveGooglePurchases() + val userId = deviceDao.requireUserId() + ?: error("User not authorized") + val result = restStore.sendPurchase(purchase, userId) + localStore.markSynced(purchase.id) + acknowledge() + return result } - override fun restore(): Single> = + override suspend fun restore(): List { fetchHistory() - .andThen(saveGooglePurchases()) - .andThen(deviceDao.requireUserId()) - .flatMap { userId -> - googleStore.getPurchases() - .flatMapPublisher { Flowable.fromIterable(it) } - .flatMapMaybe { entity -> - val purchase = mapper.mapToDomain(entity) - return@flatMapMaybe restStore.sendPurchase(purchase, userId) - .doOnSuccess { - localStore.markSynced(entity.productId) - }.filter { it } - .map { entity.productId } - }.toList() + saveGooglePurchases() + val userId = deviceDao.requireUserId() + ?: error("User not authorized") + val purchases = googleStore.getPurchases() + val restoredIds = mutableListOf() + for (entity in purchases) { + val purchase = mapper.mapToDomain(entity) + val active = restStore.sendPurchase(purchase, userId) + localStore.markSynced(entity.productId) + if (active) { + restoredIds.add(entity.productId) } + } + return restoredIds + } - override fun consumeProducts(): Completable = + override suspend fun consumeProducts() { googleStore.consumeProducts() - .andThen(googleStore.fetchHistory()) + googleStore.fetchHistory() + } - override fun prefetchSubscriptionScreen( + override suspend fun prefetchSubscriptionScreen( id: String, - ): Single { - return loadSubscriptionScreen(id) - .map { - SubscriptionScreen( - id = it.id, - name = it.name, - screenHtml = it.screenHtml - ) - } + ): SubscriptionScreen { + val screenData = loadSubscriptionScreen(id) + return SubscriptionScreen( + id = screenData.id, + name = screenData.name, + screenHtml = screenData.screenHtml + ) } - override fun getSubscriptionScreen(id: String): Single { + override suspend fun getSubscriptionScreen(id: String): SubscriptionScreen { val cachedScreen = loadedScreens[id] - return (if (cachedScreen != null) { - Single.just(cachedScreen) - } else { - loadSubscriptionScreen(id) - }).map { - SubscriptionScreen( - id = it.id, - name = it.name, - screenHtml = it.screenHtml - ) - } - + val screenData = cachedScreen ?: loadSubscriptionScreen(id) + return SubscriptionScreen( + id = screenData.id, + name = screenData.name, + screenHtml = screenData.screenHtml + ) } override fun getCachedScreen(id: String): SubscriptionScreen? { @@ -125,7 +109,7 @@ class SubscriptionsRepositoryImpl( } } - override fun getCachedOrDefaultScreen(id: String): Single { + override suspend fun getCachedOrDefaultScreen(id: String): SubscriptionScreen { val cachedScreen = loadedScreens.values.firstOrNull { it.id == id }?.let { @@ -135,58 +119,59 @@ class SubscriptionsRepositoryImpl( screenHtml = it.screenHtml ) } - val cachedMaybe = cachedScreen?.let { - Maybe.just(it) - } ?: Maybe.empty() - return cachedMaybe - .switchIfEmpty(getFallbackScreen().toMaybe()) - .toSingle() + return cachedScreen ?: getFallbackScreen() } - override fun getFallbackScreen(): Single = + override suspend fun getFallbackScreen(): SubscriptionScreen = fileStore.getSubscriptionScreen() override suspend fun getProductsDetails(requests: Map>): List = googleStore.getProductsDetails(requests) - override fun getSubscriptionState(): Single = - deviceDao.requireUserId() - .flatMap { restStore.getSubscriptionState(it) } + override suspend fun getSubscriptionState(): SubscriptionState { + val userId = deviceDao.requireUserId() + ?: error("User not authorized") + return restStore.getSubscriptionState(userId) + } - override fun fetchHistory(): Completable { - return googleStore.fetchHistory() - .doOnComplete { - Timber.d("fetchHistory success") - }.doOnError { - Timber.e(it) - } + override suspend fun fetchHistory() { + try { + googleStore.fetchHistory() + Timber.d("fetchHistory success") + } catch (e: Exception) { + Timber.e(e) + } } private fun acknowledge() { - googleStore.acknowledge() - .subscribe(DefaultCompletableObserver()) + scope.launch { + try { + googleStore.acknowledge() + } catch (e: Exception) { + Timber.e(e) + } + } } - private fun saveGooglePurchases(): Completable { - - //active subscriptions from billing client - return googleStore.getPurchases() - .flatMap { purchases -> + private suspend fun saveGooglePurchases() { + val purchases = try { + val googlePurchases = googleStore.getPurchases() + try { intentValidator.validateIntent() - .toSingle { purchases } - .onErrorReturnItem(emptyList()) + googlePurchases + } catch (_: Exception) { + emptyList() } - .doOnSuccess { purchases -> - localStore.savePurchases(purchases) - }.ignoreElement() + } catch (_: Exception) { + emptyList() + } + localStore.savePurchases(purchases) } - private fun loadSubscriptionScreen(id: String): Single { + private suspend fun loadSubscriptionScreen(id: String): ScreenData { Timber.d("loadSubscriptionScreen $id") - return restStore.getSubscriptionScreen( - id = id, - ).doOnSuccess { - loadedScreens[id] = it - } + val screenData = restStore.getSubscriptionScreen(id = id) + loadedScreens[id] = screenData + return screenData } } diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidator.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidator.kt index 361eff9..f941df3 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidator.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidator.kt @@ -1,8 +1,5 @@ package com.appsci.panda.sdk.data.subscriptions.google -import io.reactivex.Completable - interface BillingValidator { - fun validateIntent(): Completable + suspend fun validateIntent() } - diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidatorImpl.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidatorImpl.kt index 96cbf93..0fc6969 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidatorImpl.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/BillingValidatorImpl.kt @@ -1,7 +1,5 @@ package com.appsci.panda.sdk.data.subscriptions.google -import io.reactivex.Completable -import io.reactivex.Single import java.security.MessageDigest import java.util.* @@ -13,8 +11,8 @@ class BillingValidatorImpl : BillingValidator { const val PACKAGE = "com.android.vending" } - override fun validateIntent(): Completable { - return Single.fromCallable { + override suspend fun validateIntent() { + try { val digest = MessageDigest.getInstance("SHA-256") val actionBytes = digest.digest(BIND_ACTION.toByteArray()) val packageBytes = digest.digest(PACKAGE.toByteArray()) @@ -22,16 +20,14 @@ class BillingValidatorImpl : BillingValidator { .uppercase(Locale.US) val currentPackageHash = packageBytes.fold("") { str, it -> str + "%02x".format(it) } .uppercase(Locale.US) - return@fromCallable currentActionHash == ACTION_HASH && currentPackageHash == PACKAGE_HASH + val isValid = currentActionHash == ACTION_HASH && currentPackageHash == PACKAGE_HASH + if (!isValid) { + throw InvalidIntentException(action = BIND_ACTION, packageName = PACKAGE) + } + } catch (e: InvalidIntentException) { + throw e + } catch (_: Exception) { + // Ignore errors, treat as valid (matching original onErrorReturnItem(true) behavior) } - .onErrorReturnItem(true) - .flatMapCompletable { - return@flatMapCompletable when (it) { - true -> Completable.complete() - false -> Completable.error( - InvalidIntentException(action = BIND_ACTION, packageName = PACKAGE)) - } - } - } } diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/PurchasesGoogleStore.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/PurchasesGoogleStore.kt index 4e1c115..94a515c 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/PurchasesGoogleStore.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/google/PurchasesGoogleStore.kt @@ -6,22 +6,18 @@ import com.appsci.panda.sdk.data.subscriptions.PurchasesMapper import com.appsci.panda.sdk.data.subscriptions.local.PurchaseEntity import com.appsci.panda.sdk.data.subscriptions.local.TYPE_PRODUCT import com.appsci.panda.sdk.data.subscriptions.local.TYPE_SUBSCRIPTION -import io.reactivex.Completable -import io.reactivex.Single import kotlinx.coroutines.* -import kotlinx.coroutines.rx2.rxCompletable -import kotlinx.coroutines.rx2.rxSingle import timber.log.Timber interface PurchasesGoogleStore { - fun getPurchases(): Single> + suspend fun getPurchases(): List - fun consumeProducts(): Completable + suspend fun consumeProducts() - fun fetchHistory(): Completable + suspend fun fetchHistory() - fun acknowledge(): Completable + suspend fun acknowledge() suspend fun getProductsDetails(requests: Map>): List @@ -32,7 +28,7 @@ class PurchasesGoogleStoreImpl( private val mapper: PurchasesMapper, ) : PurchasesGoogleStore { - override fun getPurchases(): Single> = rxSingle { + override suspend fun getPurchases(): List { val subs = billingKtx.getPurchases(BillingClient.ProductType.SUBS) val subscriptionEntities = mapper.mapFromBillingPurchases(subs, TYPE_SUBSCRIPTION) @@ -41,10 +37,10 @@ class PurchasesGoogleStoreImpl( val result = subscriptionEntities + productEntities Timber.d("getPurchases $result") - result + return result } - override fun consumeProducts(): Completable = rxCompletable { + override suspend fun consumeProducts() { val purchases = billingKtx.getPurchases(BillingClient.ProductType.INAPP) purchases.forEach { purchase -> billingKtx.consumeProduct( @@ -55,14 +51,12 @@ class PurchasesGoogleStoreImpl( } } - override fun fetchHistory(): Completable = rxCompletable { - // BillingKtx doesn't have getPurchaseHistory, use getPurchases instead - // This triggers a refresh of the purchases cache + override suspend fun fetchHistory() { billingKtx.getPurchases(BillingClient.ProductType.SUBS) billingKtx.getPurchases(BillingClient.ProductType.INAPP) } - override fun acknowledge(): Completable = rxCompletable { + override suspend fun acknowledge() { val subs = billingKtx.getPurchases(BillingClient.ProductType.SUBS) val inapp = billingKtx.getPurchases(BillingClient.ProductType.INAPP) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/FileStore.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/FileStore.kt index d99074d..70f20dd 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/FileStore.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/FileStore.kt @@ -2,27 +2,23 @@ package com.appsci.panda.sdk.data.subscriptions.local import android.content.Context import com.appsci.panda.sdk.domain.subscriptions.SubscriptionScreen -import io.reactivex.Single interface FileStore { - fun getSubscriptionScreen(): Single + suspend fun getSubscriptionScreen(): SubscriptionScreen } class FileStoreImpl( private val context: Context ) : FileStore { - override fun getSubscriptionScreen(): Single { - return Single.fromCallable { - context.assets.open("panda-index.html") - .bufferedReader() - .use { it.readText() } - }.map { - SubscriptionScreen( - id = "", - name = "", - screenHtml = it - ) - } + override suspend fun getSubscriptionScreen(): SubscriptionScreen { + val html = context.assets.open("panda-index.html") + .bufferedReader() + .use { it.readText() } + return SubscriptionScreen( + id = "", + name = "", + screenHtml = html + ) } } diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/PurchaseDao.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/PurchaseDao.kt index a82e63e..8a28604 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/PurchaseDao.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/PurchaseDao.kt @@ -1,16 +1,15 @@ package com.appsci.panda.sdk.data.subscriptions.local import androidx.room.* -import io.reactivex.Single @Dao abstract class PurchaseDao { @Query("SELECT * FROM Purchase") - abstract fun selectPurchases(): Single> + abstract suspend fun selectPurchases(): List @Query("SELECT * FROM Purchase WHERE synced!=1") - abstract fun selectNotSentPurchases(): Single> + abstract suspend fun selectNotSentPurchases(): List @Query("SELECT * FROM Purchase where productId=:productId") abstract fun selectPurchase(productId: String): PurchaseEntity? diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/PurchasesLocalStore.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/PurchasesLocalStore.kt index 85f3227..dcf8460 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/PurchasesLocalStore.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/local/PurchasesLocalStore.kt @@ -1,12 +1,10 @@ package com.appsci.panda.sdk.data.subscriptions.local -import io.reactivex.Single - interface PurchasesLocalStore { - fun getPurchases(): Single> + suspend fun getPurchases(): List - fun getNotSentPurchases(): Single> + suspend fun getNotSentPurchases(): List fun markSynced(id: String) @@ -15,10 +13,10 @@ interface PurchasesLocalStore { class PurchasesLocalStoreImpl(private val purchaseDao: PurchaseDao) : PurchasesLocalStore { - override fun getPurchases(): Single> = + override suspend fun getPurchases(): List = purchaseDao.selectPurchases() - override fun getNotSentPurchases(): Single> = + override suspend fun getNotSentPurchases(): List = purchaseDao.selectNotSentPurchases() override fun markSynced(id: String) { diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/ProductRequest.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/ProductRequest.kt index 5277383..3b3b050 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/ProductRequest.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/ProductRequest.kt @@ -1,12 +1,14 @@ package com.appsci.panda.sdk.data.subscriptions.rest -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class ProductRequest( - @SerializedName("product_id") - val productId: String, - @SerializedName("order_id") - val orderId: String, - @SerializedName("purchase_token") - val purchaseToken: String + @SerialName("product_id") + val productId: String, + @SerialName("order_id") + val orderId: String, + @SerialName("purchase_token") + val purchaseToken: String ) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/PurchasesRestStore.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/PurchasesRestStore.kt index 4cc7b21..01bb3fc 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/PurchasesRestStore.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/PurchasesRestStore.kt @@ -5,17 +5,15 @@ import com.appsci.panda.sdk.data.network.ScreenApi import com.appsci.panda.sdk.domain.subscriptions.Purchase import com.appsci.panda.sdk.domain.subscriptions.SkuType import com.appsci.panda.sdk.domain.subscriptions.SubscriptionState -import io.reactivex.Single -import kotlinx.coroutines.rx2.rxSingle import timber.log.Timber interface PurchasesRestStore { - fun sendPurchase(purchase: Purchase, userId: String): Single + suspend fun sendPurchase(purchase: Purchase, userId: String): Boolean - fun getSubscriptionState(userId: String): Single + suspend fun getSubscriptionState(userId: String): SubscriptionState - fun getSubscriptionScreen(id: String): Single + suspend fun getSubscriptionScreen(id: String): ScreenData } class PurchasesRestStoreImpl( @@ -23,8 +21,8 @@ class PurchasesRestStoreImpl( private val screenApi: ScreenApi, ) : PurchasesRestStore { - override fun sendPurchase(purchase: Purchase, userId: String): Single { - return when (purchase.type) { + override suspend fun sendPurchase(purchase: Purchase, userId: String): Boolean { + val response = when (purchase.type) { SkuType.SUBSCRIPTION -> pandaApi.sendSubscription( SubscriptionRequest( @@ -44,26 +42,23 @@ class PurchasesRestStoreImpl( ), userId = userId ) - }.map { it.active } + } + return response.active } - override fun getSubscriptionState(userId: String): Single = - pandaApi.getSubscriptionStatus(userId) - .map { SubscriptionState.map(it) } + override suspend fun getSubscriptionState(userId: String): SubscriptionState = + SubscriptionState.map(pandaApi.getSubscriptionStatus(userId)) - override fun getSubscriptionScreen( + override suspend fun getSubscriptionScreen( id: String, - ): Single = - rxSingle { - Timber.d("getSubscriptionScreen: $id") - val screenData = screenApi.getSubscriptionScreen(id) - val html = screenApi.getScreenHtml(screenData.htmlUrl) - ScreenData( - id = screenData.id, - name = screenData.name, - screenHtml = html, - ) - } - - + ): ScreenData { + Timber.d("getSubscriptionScreen: $id") + val screenData = screenApi.getSubscriptionScreen(id) + val html = screenApi.getScreenHtml(screenData.htmlUrl) + return ScreenData( + id = screenData.id, + name = screenData.name, + screenHtml = html, + ) + } } diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/ScreenDataResponse.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/ScreenDataResponse.kt index 0953971..ce1af52 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/ScreenDataResponse.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/ScreenDataResponse.kt @@ -1,13 +1,15 @@ package com.appsci.panda.sdk.data.subscriptions.rest -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class ScreenDataResponse( - @SerializedName("screen_html") + @SerialName("screen_html") val htmlUrl: String, - @SerializedName("name") + @SerialName("name") val name: String, - @SerializedName("id") + @SerialName("id") val id: String ) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SendSubscriptionResponse.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SendSubscriptionResponse.kt index 77ca306..8ad0b70 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SendSubscriptionResponse.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SendSubscriptionResponse.kt @@ -1,8 +1,10 @@ package com.appsci.panda.sdk.data.subscriptions.rest -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class SendSubscriptionResponse( - @SerializedName("active") - val active: Boolean + @SerialName("active") + val active: Boolean ) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SubscriptionRequest.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SubscriptionRequest.kt index 0a4364a..6f6cd17 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SubscriptionRequest.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SubscriptionRequest.kt @@ -1,12 +1,14 @@ package com.appsci.panda.sdk.data.subscriptions.rest -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class SubscriptionRequest( - @SerializedName("subscription_id") - val productId: String, - @SerializedName("order_id") - val orderId: String, - @SerializedName("purchase_token") - val purchaseToken: String + @SerialName("subscription_id") + val productId: String, + @SerialName("order_id") + val orderId: String, + @SerialName("purchase_token") + val purchaseToken: String ) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SubscriptionStateResponse.kt b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SubscriptionStateResponse.kt index 385777f..7677563 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SubscriptionStateResponse.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/data/subscriptions/rest/SubscriptionStateResponse.kt @@ -1,37 +1,40 @@ package com.appsci.panda.sdk.data.subscriptions.rest -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class SubscriptionStateResponse( - @SerializedName("state") - val state: String, - @SerializedName("subscriptions") - val subscriptions: SubscriptionsResponse + @SerialName("state") + val state: String, + @SerialName("subscriptions") + val subscriptions: SubscriptionsResponse ) +@Serializable data class SubscriptionsResponse( - @SerializedName("android") - val android: List?, - @SerializedName("ios") - val ios: List?, - @SerializedName("web") - val web: List? + @SerialName("android") + val android: List? = null, + @SerialName("ios") + val ios: List? = null, + @SerialName("web") + val web: List? = null ) +@Serializable data class SubscriptionResponse( - @SerializedName("order_id") - val orderId: String, - @SerializedName("subscription_id") - val subscriptionId: String, - @SerializedName("is_trial_period") - val isTrial: Boolean, - @SerializedName("product_id") - val productId: String, - @SerializedName("state") - val state: String, - @SerializedName("is_intro_offer") - val isIntroOffer: Boolean?, - @SerializedName("payment_type") - //can it be null? I don't know - val paymentType: String?, + @SerialName("order_id") + val orderId: String, + @SerialName("subscription_id") + val subscriptionId: String, + @SerialName("is_trial_period") + val isTrial: Boolean, + @SerialName("product_id") + val productId: String, + @SerialName("state") + val state: String, + @SerialName("is_intro_offer") + val isIntroOffer: Boolean? = null, + @SerialName("payment_type") + val paymentType: String? = null, ) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/domain/device/DeviceRepository.kt b/sdk/src/main/java/com/appsci/panda/sdk/domain/device/DeviceRepository.kt index 07d9315..2134fd1 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/domain/device/DeviceRepository.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/domain/device/DeviceRepository.kt @@ -1,21 +1,18 @@ package com.appsci.panda.sdk.domain.device -import io.reactivex.Completable -import io.reactivex.Single - interface DeviceRepository { val pandaUserId: String? - fun authorize(): Single + suspend fun authorize(): Device - fun clearAdvId(): Completable + suspend fun clearAdvId() - fun ensureAuthorized(): Completable + suspend fun ensureAuthorized() - fun getAuthState(): Single + suspend fun getAuthState(): AuthState - fun deleteDevice(): Completable + suspend fun deleteDevice() - fun clearLocalData(): Completable + suspend fun clearLocalData() } diff --git a/sdk/src/main/java/com/appsci/panda/sdk/domain/subscriptions/SubscriptionsRepository.kt b/sdk/src/main/java/com/appsci/panda/sdk/domain/subscriptions/SubscriptionsRepository.kt index fe97bfd..fc83f2a 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/domain/subscriptions/SubscriptionsRepository.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/domain/subscriptions/SubscriptionsRepository.kt @@ -1,41 +1,39 @@ package com.appsci.panda.sdk.domain.subscriptions import com.android.billingclient.api.ProductDetails -import io.reactivex.Completable -import io.reactivex.Single interface SubscriptionsRepository { /** * returns [SubscriptionStatus] based on purchases from billing and local store */ - fun getSubscriptionState(): Single + suspend fun getSubscriptionState(): SubscriptionState /** * Fetches purchases from billing and sends to rest store */ - fun sync(): Completable + suspend fun sync() - fun restore(): Single> + suspend fun restore(): List - fun validatePurchase(purchase: Purchase): Single + suspend fun validatePurchase(purchase: Purchase): Boolean /** * Consumes all available products and refreshes all purchases */ - fun consumeProducts(): Completable + suspend fun consumeProducts() - fun fetchHistory(): Completable + suspend fun fetchHistory() - fun prefetchSubscriptionScreen(id: String): Single + suspend fun prefetchSubscriptionScreen(id: String): SubscriptionScreen - fun getSubscriptionScreen(id: String): Single + suspend fun getSubscriptionScreen(id: String): SubscriptionScreen fun getCachedScreen(id: String): SubscriptionScreen? - fun getCachedOrDefaultScreen(id: String): Single + suspend fun getCachedOrDefaultScreen(id: String): SubscriptionScreen - fun getFallbackScreen(): Single + suspend fun getFallbackScreen(): SubscriptionScreen suspend fun getProductsDetails(requests: Map>): List } diff --git a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultCompletableObserver.kt b/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultCompletableObserver.kt deleted file mode 100644 index b48cf21..0000000 --- a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultCompletableObserver.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.appsci.panda.sdk.domain.utils.rx - -import io.reactivex.observers.DisposableCompletableObserver -import timber.log.Timber - -open class DefaultCompletableObserver : DisposableCompletableObserver() { - override fun onComplete() { - - } - - override fun onError(e: Throwable) { - Timber.e(e) - } - -} diff --git a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultSchedulerProvider.kt b/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultSchedulerProvider.kt deleted file mode 100644 index fcf2def..0000000 --- a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultSchedulerProvider.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.appsci.panda.sdk.domain.utils.rx - -import io.reactivex.Scheduler -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers - -class DefaultSchedulerProvider : SchedulerProvider { - - override fun io(): Scheduler = Schedulers.io() - - override fun mainThread(): Scheduler = AndroidSchedulers.mainThread() - - override fun computation(): Scheduler = Schedulers.computation() - - override fun newThread(): Scheduler = Schedulers.newThread() - - override fun trampoline(): Scheduler = Schedulers.trampoline() -} diff --git a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultSingleObserver.kt b/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultSingleObserver.kt deleted file mode 100644 index 1bbeb33..0000000 --- a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/DefaultSingleObserver.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.appsci.panda.sdk.domain.utils.rx - -import io.reactivex.observers.DisposableSingleObserver -import timber.log.Timber - -open class DefaultSingleObserver : DisposableSingleObserver() { - - override fun onSuccess(t: T & Any) { - } - - override fun onError(e: Throwable) { - Timber.e(e) - } -} diff --git a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/RxExt.kt b/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/RxExt.kt deleted file mode 100644 index a15a8b9..0000000 --- a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/RxExt.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.appsci.panda.sdk.domain.utils.rx - -import io.reactivex.Single - -fun Single.shareSingle(): Single { - return this.compose { - return@compose it.toFlowable() - .replay(1) - .refCount() - .singleOrError() - } -} diff --git a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/SchedulerProvider.kt b/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/SchedulerProvider.kt deleted file mode 100644 index 3fe9099..0000000 --- a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/SchedulerProvider.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.appsci.panda.sdk.domain.utils.rx - -import io.reactivex.Scheduler - -interface SchedulerProvider { - fun mainThread(): Scheduler - - fun io(): Scheduler - - fun computation(): Scheduler - - fun newThread(): Scheduler - - fun trampoline(): Scheduler -} diff --git a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/Schedulers.kt b/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/Schedulers.kt deleted file mode 100644 index 9d9b4d6..0000000 --- a/sdk/src/main/java/com/appsci/panda/sdk/domain/utils/rx/Schedulers.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.appsci.panda.sdk.domain.utils.rx - -import io.reactivex.Scheduler - -@Suppress("unused") -object Schedulers { - private lateinit var instance: SchedulerProvider - - fun setInstance(instance: SchedulerProvider) { - Schedulers.instance = instance - } - - @JvmStatic - fun io(): Scheduler = instance.io() - - @JvmStatic - fun mainThread(): Scheduler = instance.mainThread() - - fun computation(): Scheduler = instance.computation() - - fun newThread(): Scheduler = instance.newThread() - - fun trampoline(): Scheduler = instance.trampoline() -} diff --git a/sdk/src/main/java/com/appsci/panda/sdk/injection/modules/NetworkModule.kt b/sdk/src/main/java/com/appsci/panda/sdk/injection/modules/NetworkModule.kt index 933695c..9eb1976 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/injection/modules/NetworkModule.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/injection/modules/NetworkModule.kt @@ -7,15 +7,15 @@ import com.appsci.panda.sdk.data.network.HeaderInterceptor import com.appsci.panda.sdk.data.network.PandaApi import com.appsci.panda.sdk.data.network.ScreenApi import com.appsci.panda.sdk.domain.utils.DeviceManager -import com.google.gson.GsonBuilder import dagger.Module import dagger.Provides +import kotlinx.serialization.json.Json import okhttp3.Cache +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit -import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.converter.scalars.ScalarsConverterFactory import java.io.File import java.util.concurrent.TimeUnit @@ -37,6 +37,13 @@ class NetworkModule( private const val CLIENT_WRITE_TIMEOUT_SECONDS = 10L } + private val json = Json { + isLenient = true + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + @Provides @Singleton fun provideCache(context: Context): Cache { @@ -79,17 +86,13 @@ class NetworkModule( @Provides @Singleton fun providePandaApi(okHttpClient: OkHttpClient): PandaApi { - val gson = GsonBuilder() - .setLenient() - .create() return Retrofit.Builder() .baseUrl(if (debug) { BuildConfig.PANDA_ENDPOINT_STAGE } else { BuildConfig.PANDA_ENDPOINT_PROD }) - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .addConverterFactory(GsonConverterFactory.create(gson)) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .client(okHttpClient) .build() .create(PandaApi::class.java) @@ -98,13 +101,10 @@ class NetworkModule( @Provides @Singleton fun provideScreenApi(okHttpClient: OkHttpClient): ScreenApi { - val gson = GsonBuilder() - .setLenient() - .create() return Retrofit.Builder() .baseUrl("https://isengard.promova-tech.com/") .addConverterFactory(ScalarsConverterFactory.create()) - .addConverterFactory(GsonConverterFactory.create(gson)) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .client(okHttpClient) .build() .create(ScreenApi::class.java) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/ui/PricingRequest.kt b/sdk/src/main/java/com/appsci/panda/sdk/ui/PricingRequest.kt index 3e7ec8a..c6cb1ef 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/ui/PricingRequest.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/ui/PricingRequest.kt @@ -1,57 +1,66 @@ package com.appsci.panda.sdk.ui import com.android.billingclient.api.BillingClient -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.encodeToJsonElement +@Serializable data class ProductPricingRequest( - @SerializedName("id") - val id: String, - @SerializedName("type") - val type: String, + @SerialName("id") + val id: String, + @SerialName("type") + val type: String, ) interface ProductDetails +@Serializable data class SubscriptionDetails( - @SerializedName("productId") - val productId: String, - @SerializedName("type") - val type: String, - @SerializedName("pricingPhases") - val pricingPhases: List, + @SerialName("productId") + val productId: String, + @SerialName("type") + val type: String, + @SerialName("pricingPhases") + val pricingPhases: List, ) : ProductDetails +@Serializable data class InappDetails( - @SerializedName("productId") - val productId: String, - @SerializedName("type") - val type: String, - @SerializedName("oneTimePurchaseOfferDetail") - val oneTimePurchaseOfferDetail: OneTimePurchaseOfferDetails, + @SerialName("productId") + val productId: String, + @SerialName("type") + val type: String, + @SerialName("oneTimePurchaseOfferDetail") + val oneTimePurchaseOfferDetail: OneTimePurchaseOfferDetails, ) : ProductDetails +@Serializable data class PricingPhase( - @SerializedName("priceAmountMicros") - val priceAmountMicros: Long, - @SerializedName("priceCurrencyCode") - val priceCurrencyCode: String, - @SerializedName("formattedPrice") - val formattedPrice: String, - @SerializedName("billingPeriod") - val billingPeriod: String, - @SerializedName("recurrenceMode") - val recurrenceMode: Int, - @SerializedName("billingCycleCount") - val billingCycleCount: Int, + @SerialName("priceAmountMicros") + val priceAmountMicros: Long, + @SerialName("priceCurrencyCode") + val priceCurrencyCode: String, + @SerialName("formattedPrice") + val formattedPrice: String, + @SerialName("billingPeriod") + val billingPeriod: String, + @SerialName("recurrenceMode") + val recurrenceMode: Int, + @SerialName("billingCycleCount") + val billingCycleCount: Int, ) +@Serializable data class OneTimePurchaseOfferDetails( - @SerializedName("priceAmountMicros") - val priceAmountMicros: Long, - @SerializedName("priceCurrencyCode") - val priceCurrencyCode: String, - @SerializedName("formattedPrice") - val formattedPrice: String, + @SerialName("priceAmountMicros") + val priceAmountMicros: Long, + @SerialName("priceCurrencyCode") + val priceCurrencyCode: String, + @SerialName("formattedPrice") + val formattedPrice: String, ) fun List.toModels(): List { @@ -59,36 +68,54 @@ fun List.toModels(): List { val oneTimePurchaseOfferDetails = productDetails.oneTimePurchaseOfferDetails - ?: return@mapNotNull null + ?: return@mapNotNull null InappDetails( - productId = productDetails.productId, - type = productDetails.productType, - oneTimePurchaseOfferDetail = OneTimePurchaseOfferDetails( - priceAmountMicros = oneTimePurchaseOfferDetails.priceAmountMicros, - priceCurrencyCode = oneTimePurchaseOfferDetails.priceCurrencyCode, - formattedPrice = oneTimePurchaseOfferDetails.formattedPrice, - ) + productId = productDetails.productId, + type = productDetails.productType, + oneTimePurchaseOfferDetail = OneTimePurchaseOfferDetails( + priceAmountMicros = oneTimePurchaseOfferDetails.priceAmountMicros, + priceCurrencyCode = oneTimePurchaseOfferDetails.priceCurrencyCode, + formattedPrice = oneTimePurchaseOfferDetails.formattedPrice, + ) ) } + BillingClient.ProductType.SUBS -> { val subscriptionOfferDetails = productDetails.subscriptionOfferDetails?.first() - ?: return@mapNotNull null + ?: return@mapNotNull null SubscriptionDetails( - productId = productDetails.productId, - type = productDetails.productType, - pricingPhases = subscriptionOfferDetails.pricingPhases.pricingPhaseList.map { - PricingPhase( - priceAmountMicros = it.priceAmountMicros, - priceCurrencyCode = it.priceCurrencyCode, - formattedPrice = it.formattedPrice, - recurrenceMode = it.recurrenceMode, - billingPeriod = it.billingPeriod, - billingCycleCount = it.billingCycleCount, - ) - } + productId = productDetails.productId, + type = productDetails.productType, + pricingPhases = subscriptionOfferDetails.pricingPhases.pricingPhaseList.map { + PricingPhase( + priceAmountMicros = it.priceAmountMicros, + priceCurrencyCode = it.priceCurrencyCode, + formattedPrice = it.formattedPrice, + recurrenceMode = it.recurrenceMode, + billingPeriod = it.billingPeriod, + billingCycleCount = it.billingCycleCount, + ) + } ) } + else -> null } } } + +/** + * Encode list of ProductDetails to JSON string, preserving the same format as Gson. + * Each element is serialized based on its concrete type without a type discriminator. + */ +fun List.encodeToJson(): String { + val json = Json + val elements: List = map { detail -> + when (detail) { + is SubscriptionDetails -> json.encodeToJsonElement(detail) + is InappDetails -> json.encodeToJsonElement(detail) + else -> error("Unknown ProductDetails type") + } + } + return json.encodeToString(elements) +} diff --git a/sdk/src/main/java/com/appsci/panda/sdk/ui/SubscriptionFragment.kt b/sdk/src/main/java/com/appsci/panda/sdk/ui/SubscriptionFragment.kt index c882179..95c4c3a 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/ui/SubscriptionFragment.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/ui/SubscriptionFragment.kt @@ -28,11 +28,7 @@ import com.appsci.panda.sdk.databinding.PandaFragmentSubscriptionBinding import com.appsci.panda.sdk.domain.subscriptions.SubscriptionScreen import com.appsci.panda.sdk.domain.subscriptions.SubscriptionsRepository import com.appsci.panda.sdk.domain.utils.getStringOrNull -import com.appsci.panda.sdk.domain.utils.rx.DefaultSingleObserver -import com.appsci.panda.sdk.domain.utils.rx.Schedulers -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import io.reactivex.disposables.CompositeDisposable +import kotlinx.serialization.json.Json import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.catch @@ -52,8 +48,6 @@ class SubscriptionFragment : Fragment() { @Inject lateinit var subscriptionsRepository: SubscriptionsRepository - private val disposeOnDestroyView = CompositeDisposable() - private var _binding: PandaFragmentSubscriptionBinding? = null private val binding: PandaFragmentSubscriptionBinding get() = _binding!! @@ -314,16 +308,16 @@ class SubscriptionFragment : Fragment() { Timber.d("observeSuccess $update") binding.loading.root.visibility = View.VISIBLE val productId = purchase.products.firstOrNull() ?: "" - Panda.onPurchase(screenExtra, purchase, getType(productId)) - .doAfterTerminate { - binding.loading.root.visibility = View.GONE - } - .subscribe({ success -> - Timber.d("onPurchase success=$success") - }, { error -> - Panda.onError(error) - Timber.e(error) - }) + try { + val success = Panda.onPurchase(screenExtra, purchase, getType(productId)) + Timber.d("onPurchase success=$success") + notifyPurchase(screenExtra, productId) + } catch (error: Throwable) { + Panda.onError(error) + Timber.e(error) + } finally { + binding.loading.root.visibility = View.GONE + } } } is PurchasesUpdate.Failed -> { @@ -338,36 +332,36 @@ class SubscriptionFragment : Fragment() { } } - disposeOnDestroyView.add( - subscriptionsRepository.getCachedOrDefaultScreen(screenExtra.id) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.mainThread()) - .doOnSuccess { screen -> - binding.webView.loadDataWithBaseURL( - "file:///android_asset/", - screen.screenHtml, - null, - null, - null - ) + // Load cached or default screen + lifecycleScope.launch { + try { + val screen = withContext(Dispatchers.IO) { + subscriptionsRepository.getCachedOrDefaultScreen(screenExtra.id) } - .subscribeWith(DefaultSingleObserver())) + binding.webView.loadDataWithBaseURL( + "file:///android_asset/", + screen.screenHtml, + null, + null, + null + ) + } catch (e: Exception) { + Timber.e(e) + } + } Panda.screenShowed(screenExtra) } override fun onDestroyView() { _binding = null Panda.removePurchaseListener(onPurchaseListener) - disposeOnDestroyView.clear() super.onDestroyView() } private fun loadPricing(requestString: String) { - val gson = Gson() - val requests: Map> = gson.fromJson>( - requestString, - object : TypeToken>() {}.type, - ).groupBy { it.type } + val pricingRequests: List = Json.decodeFromString(requestString) + val requests: Map> = pricingRequests + .groupBy { it.type } .map { entry -> entry.key to entry.value.map { it.id } }.toMap() @@ -376,7 +370,7 @@ class SubscriptionFragment : Fragment() { runCatching { Panda.getProductsDetails(requests) }.onSuccess { - val json = gson.toJson(it.toModels()) + val json = it.toModels().encodeToJson() lifecycleScope.launchWhenStarted { binding.webView.evaluateJavascript("pricingLoaded($json);") { @@ -511,6 +505,10 @@ class SubscriptionFragment : Fragment() { } } + private fun notifyPurchase(screenExtra: ScreenExtra, productId: String) { + // Notify listeners about the successful purchase + } + private fun openExternalUrl(url: String) { Panda.onOpenExternal(screenExtra.id, url) try { diff --git a/sdk/src/test/java/com/appsci/panda/sdk/data/subscriptions/google/PurchasesGoogleStoreTest.kt b/sdk/src/test/java/com/appsci/panda/sdk/data/subscriptions/google/PurchasesGoogleStoreTest.kt index 9bdb0c3..77bb168 100644 --- a/sdk/src/test/java/com/appsci/panda/sdk/data/subscriptions/google/PurchasesGoogleStoreTest.kt +++ b/sdk/src/test/java/com/appsci/panda/sdk/data/subscriptions/google/PurchasesGoogleStoreTest.kt @@ -6,15 +6,10 @@ import com.appsci.panda.sdk.data.subscriptions.PurchasesMapper import com.appsci.panda.sdk.data.subscriptions.local.PurchaseEntity import com.appsci.panda.sdk.data.subscriptions.local.TYPE_PRODUCT import com.appsci.panda.sdk.data.subscriptions.local.TYPE_SUBSCRIPTION -import com.appsci.panda.sdk.domain.utils.rx.SchedulerProvider import io.mockk.* -import io.reactivex.Scheduler -import io.reactivex.plugins.RxJavaPlugins -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.* -import com.appsci.panda.sdk.domain.utils.rx.Schedulers as AppSchedulers /** * Tests for PurchasesGoogleStore - validates Google Play Billing integration. @@ -34,19 +29,6 @@ class PurchasesGoogleStoreTest { @BeforeEach fun setUp() { - // Override schedulers for synchronous testing - RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() } - RxJavaPlugins.setComputationSchedulerHandler { Schedulers.trampoline() } - - // Initialize the app's custom Schedulers with trampoline for testing - AppSchedulers.setInstance(object : SchedulerProvider { - override fun io(): Scheduler = Schedulers.trampoline() - override fun mainThread(): Scheduler = Schedulers.trampoline() - override fun computation(): Scheduler = Schedulers.trampoline() - override fun newThread(): Scheduler = Schedulers.trampoline() - override fun trampoline(): Scheduler = Schedulers.trampoline() - }) - billingKtx = mockk(relaxed = true) mapper = mockk(relaxed = true) store = PurchasesGoogleStoreImpl(billingKtx, mapper) @@ -54,7 +36,6 @@ class PurchasesGoogleStoreTest { @AfterEach fun tearDown() { - RxJavaPlugins.reset() clearAllMocks() } @@ -64,7 +45,7 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should fetch and combine subscriptions and in-app purchases") - fun fetchAndCombinePurchases() = runBlocking { + fun fetchAndCombinePurchases() = runTest { // Given val subscriptionPurchase = createMockPurchase("sub_monthly", "order_sub", "token_sub") val productPurchase = createMockPurchase("coins_100", "order_product", "token_product") @@ -83,7 +64,7 @@ class PurchasesGoogleStoreTest { listOf(productEntity) // When - val result = store.getPurchases().blockingGet() + val result = store.getPurchases() // Then assertThat(result).hasSize(2) @@ -96,7 +77,7 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should return empty list when no purchases exist") - fun returnEmptyWhenNoPurchases() = runBlocking { + fun returnEmptyWhenNoPurchases() = runTest { // Given coEvery { billingKtx.getPurchases(BillingClient.ProductType.SUBS) } returns emptyList() coEvery { billingKtx.getPurchases(BillingClient.ProductType.INAPP) } returns emptyList() @@ -105,7 +86,7 @@ class PurchasesGoogleStoreTest { every { mapper.mapFromBillingPurchases(emptyList(), TYPE_PRODUCT) } returns emptyList() // When - val result = store.getPurchases().blockingGet() + val result = store.getPurchases() // Then assertThat(result).isEmpty() @@ -113,21 +94,21 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should handle subscription fetch error gracefully") - fun handleSubscriptionError() { + fun handleSubscriptionError() = runTest { // Given val error = RuntimeException("Billing service unavailable") coEvery { billingKtx.getPurchases(BillingClient.ProductType.SUBS) } throws error // When/Then - store.getPurchases() - .test() - .await() - .assertError(error) + val thrown = assertThrows { + store.getPurchases() + } + assertThat(thrown).isEqualTo(error) } @Test @DisplayName("should handle in-app fetch error after successful subscription fetch") - fun handleInAppErrorAfterSubscriptionSuccess() { + fun handleInAppErrorAfterSubscriptionSuccess() = runTest { // Given val subscriptionPurchase = createMockPurchase("sub", "order", "token") val subscriptionEntity = createPurchaseEntity("sub", TYPE_SUBSCRIPTION) @@ -140,10 +121,10 @@ class PurchasesGoogleStoreTest { listOf(subscriptionEntity) // When/Then - store.getPurchases() - .test() - .await() - .assertError(error) + val thrown = assertThrows { + store.getPurchases() + } + assertThat(thrown).isEqualTo(error) } } @@ -153,7 +134,7 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should consume all in-app products") - fun consumeAllProducts() = runBlocking { + fun consumeAllProducts() = runTest { // Given val purchase1 = createMockPurchase("coins_100", "order_1", "token_1") val purchase2 = createMockPurchase("coins_500", "order_2", "token_2") @@ -164,9 +145,6 @@ class PurchasesGoogleStoreTest { // When store.consumeProducts() - .test() - .await() - .assertComplete() // Then - verify both products were consumed coVerify(exactly = 2) { billingKtx.consumeProduct(any()) } @@ -174,15 +152,12 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should complete successfully when no products to consume") - fun completeWhenNoProducts() = runBlocking { + fun completeWhenNoProducts() = runTest { // Given coEvery { billingKtx.getPurchases(BillingClient.ProductType.INAPP) } returns emptyList() - // When/Then + // When store.consumeProducts() - .test() - .await() - .assertComplete() // Verify consumeProduct was never called coVerify(exactly = 0) { billingKtx.consumeProduct(any()) } @@ -190,7 +165,7 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should propagate error when consumption fails") - fun propagateConsumptionError() { + fun propagateConsumptionError() = runTest { // Given val purchase = createMockPurchase("coins", "order", "token") val error = RuntimeException("Consumption failed") @@ -199,10 +174,10 @@ class PurchasesGoogleStoreTest { coEvery { billingKtx.consumeProduct(any()) } throws error // When/Then - store.consumeProducts() - .test() - .await() - .assertError(error) + val thrown = assertThrows { + store.consumeProducts() + } + assertThat(thrown).isEqualTo(error) } } @@ -212,7 +187,7 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should acknowledge only unacknowledged purchases") - fun acknowledgeUnacknowledgedOnly() = runBlocking { + fun acknowledgeUnacknowledgedOnly() = runTest { // Given val acknowledgedPurchase = createMockPurchase("sub_1", "order_1", "token_1", isAcknowledged = true) val unacknowledgedPurchase = createMockPurchase("sub_2", "order_2", "token_2", isAcknowledged = false) @@ -224,9 +199,6 @@ class PurchasesGoogleStoreTest { // When store.acknowledge() - .test() - .await() - .assertComplete() // Then - only unacknowledged purchase should be acknowledged coVerify(exactly = 1) { billingKtx.acknowledge(any()) } @@ -234,7 +206,7 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should acknowledge purchases from both subscriptions and in-app") - fun acknowledgeBothTypes() = runBlocking { + fun acknowledgeBothTypes() = runTest { // Given val subPurchase = createMockPurchase("sub", "order_sub", "token_sub", isAcknowledged = false) val productPurchase = createMockPurchase("coins", "order_product", "token_product", isAcknowledged = false) @@ -245,9 +217,6 @@ class PurchasesGoogleStoreTest { // When store.acknowledge() - .test() - .await() - .assertComplete() // Then - both should be acknowledged coVerify(exactly = 2) { billingKtx.acknowledge(any()) } @@ -255,18 +224,15 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should complete when all purchases already acknowledged") - fun completeWhenAllAcknowledged() = runBlocking { + fun completeWhenAllAcknowledged() = runTest { // Given val purchase = createMockPurchase("sub", "order", "token", isAcknowledged = true) coEvery { billingKtx.getPurchases(BillingClient.ProductType.SUBS) } returns listOf(purchase) coEvery { billingKtx.getPurchases(BillingClient.ProductType.INAPP) } returns emptyList() - // When/Then + // When store.acknowledge() - .test() - .await() - .assertComplete() coVerify(exactly = 0) { billingKtx.acknowledge(any()) } } @@ -278,16 +244,13 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should fetch purchases for both subscriptions and in-app products") - fun fetchBothPurchases() = runBlocking { + fun fetchBothPurchases() = runTest { // Given coEvery { billingKtx.getPurchases(BillingClient.ProductType.SUBS) } returns emptyList() coEvery { billingKtx.getPurchases(BillingClient.ProductType.INAPP) } returns emptyList() // When store.fetchHistory() - .test() - .await() - .assertComplete() // Then coVerify { billingKtx.getPurchases(BillingClient.ProductType.SUBS) } @@ -296,16 +259,16 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should propagate error from subscription fetch") - fun propagateSubscriptionError() { + fun propagateSubscriptionError() = runTest { // Given val error = RuntimeException("Fetch failed") coEvery { billingKtx.getPurchases(BillingClient.ProductType.SUBS) } throws error // When/Then - store.fetchHistory() - .test() - .await() - .assertError(error) + val thrown = assertThrows { + store.fetchHistory() + } + assertThat(thrown).isEqualTo(error) } } @@ -315,7 +278,7 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should fetch product details for subscription type") - fun fetchSubscriptionDetails() = runBlocking { + fun fetchSubscriptionDetails() = runTest { // Given val productDetails = createMockProductDetails("premium_monthly", BillingClient.ProductType.SUBS) val requests = mapOf(BillingClient.ProductType.SUBS to listOf("premium_monthly")) @@ -332,7 +295,7 @@ class PurchasesGoogleStoreTest { @Test @DisplayName("should return empty list for empty requests") - fun returnEmptyForEmptyRequests() = runBlocking { + fun returnEmptyForEmptyRequests() = runTest { // When val result = store.getProductsDetails(emptyMap()) From 46873fd0df12bb2c4bc88eea0cad698c70a0bc08 Mon Sep 17 00:00:00 2001 From: Andrii Pedosenko Date: Thu, 12 Feb 2026 18:30:59 +0200 Subject: [PATCH 3/9] Update Gradle to 8.11.1 and AGP to 8.9.1 --- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 28f60fa..408ab50 100644 --- a/build.gradle +++ b/build.gradle @@ -13,7 +13,7 @@ buildscript { gradlePluginPortal() } dependencies { - classpath 'com.android.tools.build:gradle:8.7.2' + classpath 'com.android.tools.build:gradle:8.9.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" classpath 'com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:2.2.0-2.0.2' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 303537c..3990c51 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Dec 27 16:22:23 EET 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 77ba0c4296fa73b0dd7ff504c031d648691f8150 Mon Sep 17 00:00:00 2001 From: Andrii Pedosenko Date: Thu, 12 Feb 2026 18:39:11 +0200 Subject: [PATCH 4/9] Skip purchases with null orderId or empty products list --- .../sdk/data/subscriptions/PurchasesMapperTest.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/sdk/src/test/java/com/appsci/panda/sdk/data/subscriptions/PurchasesMapperTest.kt b/sdk/src/test/java/com/appsci/panda/sdk/data/subscriptions/PurchasesMapperTest.kt index ecec9fd..36515ff 100644 --- a/sdk/src/test/java/com/appsci/panda/sdk/data/subscriptions/PurchasesMapperTest.kt +++ b/sdk/src/test/java/com/appsci/panda/sdk/data/subscriptions/PurchasesMapperTest.kt @@ -174,9 +174,9 @@ class PurchasesMapperTest { } @Test - @DisplayName("should handle null orderId (Billing v8)") + @DisplayName("should skip purchase with null orderId (Billing v8)") fun mapPurchaseWithNullOrderId() { - // Given - orderId can be null in Billing v8 + // Given - orderId can be null in Billing v8, such purchases should be skipped val billingPurchase = createMockBillingPurchase( productIds = listOf("product"), orderId = null, @@ -190,14 +190,13 @@ class PurchasesMapperTest { ) // Then - assertThat(result).hasSize(1) - assertThat(result.first().orderId).isEmpty() + assertThat(result).isEmpty() } @Test - @DisplayName("should handle empty products list") + @DisplayName("should skip purchase with empty products list") fun mapPurchaseWithEmptyProductsList() { - // Given - edge case for empty products list + // Given - purchases with no products should be skipped val billingPurchase = createMockBillingPurchase( productIds = emptyList(), orderId = "order", @@ -211,8 +210,7 @@ class PurchasesMapperTest { ) // Then - assertThat(result).hasSize(1) - assertThat(result.first().productId).isEmpty() + assertThat(result).isEmpty() } } From b4b75ccd58f8098a56b96e5659fa4d697a638b49 Mon Sep 17 00:00:00 2001 From: Andrii Pedosenko Date: Thu, 12 Feb 2026 18:43:16 +0200 Subject: [PATCH 5/9] Update version to 1.9.0-RC1 --- sdk/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/build.gradle b/sdk/build.gradle index b53e786..6889ac5 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -70,7 +70,7 @@ afterEvaluate { from components.release groupId = 'com.github.AppSci' artifactId = 'panda-sdk-android' - version = '1.8.0' + version = '1.9.0-RC1' } } From bf02f821070fc41fef0c81c9616b1790355a6510 Mon Sep 17 00:00:00 2001 From: Andrii Pedosenko Date: Wed, 18 Feb 2026 19:59:23 +0200 Subject: [PATCH 6/9] Update Billing KTX version to 1.1.0-RC2 --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index 4643fd4..7f38bc8 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -71,7 +71,7 @@ ext { // Billing Client billingClientVer = '8.0.0' - billingKtxVer = '1.0.0' + billingKtxVer = '1.1.0-RC2' // Developer-related timberVer = '5.0.1' From 31cfc90943bbabb599c0260263b85041c832aca1 Mon Sep 17 00:00:00 2001 From: Andrii Pedosenko Date: Wed, 18 Feb 2026 19:59:53 +0200 Subject: [PATCH 7/9] Update version to 1.9.0-RC2 --- sdk/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/build.gradle b/sdk/build.gradle index 6889ac5..85ea94e 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -70,7 +70,7 @@ afterEvaluate { from components.release groupId = 'com.github.AppSci' artifactId = 'panda-sdk-android' - version = '1.9.0-RC1' + version = '1.9.0-RC2' } } From 9ee99e18160d2e2cf9fe9e670b7a9758011e71f2 Mon Sep 17 00:00:00 2001 From: Andrii Pedosenko Date: Wed, 18 Feb 2026 20:11:00 +0200 Subject: [PATCH 8/9] Update BillingKtx instantiation --- .../panda/sdk/injection/modules/BillingModule.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/injection/modules/BillingModule.kt b/sdk/src/main/java/com/appsci/panda/sdk/injection/modules/BillingModule.kt index 1f12678..31858f7 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/injection/modules/BillingModule.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/injection/modules/BillingModule.kt @@ -18,8 +18,6 @@ import com.appsci.panda.sdk.data.subscriptions.rest.PurchasesRestStore import com.appsci.panda.sdk.data.subscriptions.rest.PurchasesRestStoreImpl import com.appsci.panda.sdk.domain.subscriptions.SubscriptionsRepository import com.appsci.billingktx.client.BillingKtx -import com.appsci.billingktx.client.BillingKtxImpl -import com.appsci.billingktx.connection.BillingKtxFactory import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -29,12 +27,10 @@ class BillingModule(private val context: Context) { @Provides @Singleton - fun provideBillingKtx(): BillingKtx = BillingKtxImpl( - BillingKtxFactory( - context = context, - enableOneTimeProducts = true, - enablePrepaidPlans = true, - ) + fun provideBillingKtx(): BillingKtx = BillingKtx( + context = context, + enableOneTimeProducts = true, + enablePrepaidPlans = true, ) @Provides From 6a5f8ace0c4da0c0084df48b5361f68bc01d3653 Mon Sep 17 00:00:00 2001 From: Andrii Pedosenko Date: Thu, 19 Feb 2026 11:32:35 +0200 Subject: [PATCH 9/9] Remove unused notifyPurchase method --- .../java/com/appsci/panda/sdk/ui/SubscriptionFragment.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sdk/src/main/java/com/appsci/panda/sdk/ui/SubscriptionFragment.kt b/sdk/src/main/java/com/appsci/panda/sdk/ui/SubscriptionFragment.kt index 95c4c3a..9a002f7 100644 --- a/sdk/src/main/java/com/appsci/panda/sdk/ui/SubscriptionFragment.kt +++ b/sdk/src/main/java/com/appsci/panda/sdk/ui/SubscriptionFragment.kt @@ -311,7 +311,6 @@ class SubscriptionFragment : Fragment() { try { val success = Panda.onPurchase(screenExtra, purchase, getType(productId)) Timber.d("onPurchase success=$success") - notifyPurchase(screenExtra, productId) } catch (error: Throwable) { Panda.onError(error) Timber.e(error) @@ -505,10 +504,6 @@ class SubscriptionFragment : Fragment() { } } - private fun notifyPurchase(screenExtra: ScreenExtra, productId: String) { - // Notify listeners about the successful purchase - } - private fun openExternalUrl(url: String) { Panda.onOpenExternal(screenExtra.id, url) try {