From 8a91ec24b1cd8afbfc48c49b24c35885bc6fbcd3 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:53:09 +0100 Subject: [PATCH] Adds a new :result module (#22) --- .github/workflows/main.yml | 10 +- .github/workflows/pull_request.yml | 6 +- build-logic/convention/.gitignore | 1 + build-logic/convention/build.gradle.kts | 31 ++ .../kobankat/buildlogic/ktx/VersionCatalog.kt | 17 + .../plugin/LibraryConventionPlugin.kt | 81 ++++ build-logic/gradle.properties | 3 + build-logic/settings.gradle.kts | 16 + build.gradle.kts | 51 ++- composeApp/build.gradle.kts | 2 +- core/build.gradle.kts | 94 +---- .../io/shortway/kobankat/ktx/Coroutines.kt | 12 +- gradle/libs.versions.toml | 2 + result/.gitignore | 1 + result/build.gradle.kts | 15 + .../io/shortway/kobankat/result/Result.kt | 375 ++++++++++++++++++ settings.gradle.kts | 2 + 17 files changed, 612 insertions(+), 107 deletions(-) create mode 100644 build-logic/convention/.gitignore create mode 100644 build-logic/convention/build.gradle.kts create mode 100644 build-logic/convention/src/main/kotlin/io/shortway/kobankat/buildlogic/ktx/VersionCatalog.kt create mode 100644 build-logic/convention/src/main/kotlin/io/shortway/kobankat/buildlogic/plugin/LibraryConventionPlugin.kt create mode 100644 build-logic/gradle.properties create mode 100644 build-logic/settings.gradle.kts create mode 100644 result/.gitignore create mode 100644 result/build.gradle.kts create mode 100644 result/src/commonMain/kotlin/io/shortway/kobankat/result/Result.kt diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 54405915..a664899b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,8 +41,10 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 - - name: Build with Gradle Wrapper - run: ./gradlew assemble + - name: Build libraries + run: ./gradlew assemble -x :composeApp:assemble + - name: Build sample + run: ./gradlew :composeApp:assembleDebug dependency-submission: needs: build @@ -87,11 +89,11 @@ jobs: uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 - name: Generate Dokkatoo site - run: ./gradlew dokkatooGenerate + run: ./gradlew :dokkatooGenerate - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v3.0.1 with: - path: 'core/build/dokka/html' + path: 'build/dokka/html' deploy-api-reference: needs: generate-api-reference diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index fae22aab..9f28f258 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -38,5 +38,7 @@ jobs: distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 - - name: Build with Gradle Wrapper - run: ./gradlew assemble + - name: Build libraries + run: ./gradlew assemble -x :composeApp:assemble + - name: Build sample + run: ./gradlew :composeApp:assembleDebug diff --git a/build-logic/convention/.gitignore b/build-logic/convention/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/build-logic/convention/.gitignore @@ -0,0 +1 @@ +/build diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 00000000..75c2106e --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,31 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `kotlin-dsl` +} + +group = "io.shortway.kobankat.buildlogic" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + compileOnly(libs.android.gradlePlugin) + compileOnly(libs.kotlin.gradlePlugin) +} + +gradlePlugin { + plugins { + register("Library") { + id = "kobankat.library" + implementationClass = "io.shortway.kobankat.buildlogic.plugin.LibraryConventionPlugin" + } + } +} diff --git a/build-logic/convention/src/main/kotlin/io/shortway/kobankat/buildlogic/ktx/VersionCatalog.kt b/build-logic/convention/src/main/kotlin/io/shortway/kobankat/buildlogic/ktx/VersionCatalog.kt new file mode 100644 index 00000000..caf1be35 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/io/shortway/kobankat/buildlogic/ktx/VersionCatalog.kt @@ -0,0 +1,17 @@ +package io.shortway.kobankat.buildlogic.ktx + +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionConstraint + +internal fun VersionCatalog.getVersion(alias: String): String = + findVersion(alias).get().version + +/** + * Uses the same logic as VersionFactory.doGetVersion(). + */ +internal val VersionConstraint.version: String + get() = requiredVersion.ifEmpty { + strictVersion.ifEmpty { + preferredVersion + } + } diff --git a/build-logic/convention/src/main/kotlin/io/shortway/kobankat/buildlogic/plugin/LibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/io/shortway/kobankat/buildlogic/plugin/LibraryConventionPlugin.kt new file mode 100644 index 00000000..56c54d77 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/io/shortway/kobankat/buildlogic/plugin/LibraryConventionPlugin.kt @@ -0,0 +1,81 @@ +package io.shortway.kobankat.buildlogic.plugin + +import com.android.build.gradle.LibraryExtension +import io.shortway.kobankat.buildlogic.ktx.getVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +/** + * A build convention plugin to be applied to all published library modules, to reduce duplication + * in build scripts. + */ +class LibraryConventionPlugin : Plugin { + + override fun apply(target: Project) = with(target) { + val libs = extensions.getByType().named("libs") + val javaVersion = libs.getVersion("java") + + with(pluginManager) { + apply("org.jetbrains.kotlin.multiplatform") + apply("com.android.library") + apply("dev.adamko.dokkatoo-html") + apply("io.gitlab.arturbosch.detekt") + apply("com.vanniktech.maven.publish") + apply("com.gradleup.nmcp") + } + + extensions.configure { + // Compilation targets: + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = javaVersion + } + } + + publishLibraryVariants("release") + } + iosX64() + iosArm64() + iosSimulatorArm64() + + // Compiler flags: + targets.all { + compilations.all { + compilerOptions.configure { + freeCompilerArgs.apply { + add("-Xexpect-actual-classes") + } + } + } + } + sourceSets.all { + languageSettings.apply { + if (name.lowercase().startsWith("ios")) { + optIn("kotlinx.cinterop.ExperimentalForeignApi") + } + } + } + + // Explicit API: + explicitApi() + } + + // Android library setup: + extensions.configure { + compileSdk = libs.getVersion("android-compileSdk").toInt() + defaultConfig { + minSdk = libs.getVersion("android-minSdk").toInt() + } + compileOptions { + sourceCompatibility(javaVersion) + targetCompatibility(javaVersion) + } + } + } + +} diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties new file mode 100644 index 00000000..c6cd2a7e --- /dev/null +++ b/build-logic/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configureondemand=true diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 00000000..ccb31ae4 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,16 @@ +@file:Suppress("UnstableApiUsage") + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") diff --git a/build.gradle.kts b/build.gradle.kts index fcccdf88..8e2208e4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,8 +2,10 @@ import com.vanniktech.maven.publish.MavenPublishBaseExtension import com.vanniktech.maven.publish.MavenPublishPlugin +import io.gitlab.arturbosch.detekt.Detekt import nmcp.NmcpExtension import nmcp.NmcpPlugin +import org.gradle.configurationcache.extensions.capitalized plugins { //trick: for the same plugin versions in all sub-modules @@ -12,7 +14,7 @@ plugins { alias(libs.plugins.jetbrains.compose).apply(false) alias(libs.plugins.kotlin.multiplatform).apply(false) alias(libs.plugins.kotlin.cocoapods).apply(false) - alias(libs.plugins.adamko.dokkatoo.html).apply(false) + alias(libs.plugins.adamko.dokkatoo.html) alias(libs.plugins.arturbosch.detekt).apply(false) alias(libs.plugins.vanniktech.mavenPublish).apply(false) alias(libs.plugins.gradleup.nmcp).apply(false) @@ -79,5 +81,52 @@ allprojects { } } } + + // Register a Detekt task for all published modules. + with(this@allprojects) { + projectDir + .resolve("src") + .listFiles { child -> child.isDirectory } + .orEmpty() + .also { sourceDirectories -> + tasks.registerDetektTask( + taskName = "detektAll", + taskDescription = "Runs Detekt on all source sets.", + reportName = "all", + sourceDirs = files(sourceDirectories) + ) + + sourceDirectories.forEach { sourceDir -> + val sourceSet = sourceDir.name + tasks.registerDetektTask( + taskName = "detekt${sourceSet.capitalized()}", + taskDescription = "Runs Detekt on the $sourceSet source set.", + reportName = "$name${sourceSet.capitalized()}", + sourceDirs = files(sourceDir) + ) + } + } + } } } + +dependencies { + dokkatoo(projects.core) + dokkatoo(projects.result) +} + +private fun TaskContainer.registerDetektTask( + taskName: String, + taskDescription: String, + reportName: String, + sourceDirs: ConfigurableFileCollection, +) = + register(taskName) { + description = taskDescription + setSource(sourceDirs.map { it.resolve("kotlin") }) + config = files("$rootDir/config/detekt/detekt.yml") + reports { + html.outputLocation = file("build/reports/detekt/$reportName.html") + xml.outputLocation = file("build/reports/detekt/$reportName.xml") + } + } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cc40c71a..7e71d4d4 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -42,6 +42,7 @@ kotlin { @OptIn(ExperimentalComposeLibrary::class) implementation(compose.components.resources) implementation(projects.core) + implementation(projects.result) } androidMain.dependencies { implementation(libs.androidx.compose.ui.tooling.preview) @@ -83,4 +84,3 @@ android { debugImplementation(libs.androidx.compose.ui.tooling) } } - diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 30608844..d4701a48 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,53 +1,10 @@ -import io.gitlab.arturbosch.detekt.Detekt -import org.gradle.configurationcache.extensions.capitalized - plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.android.library) + id("kobankat.library") alias(libs.plugins.kotlin.cocoapods) - alias(libs.plugins.adamko.dokkatoo.html) - alias(libs.plugins.arturbosch.detekt) - alias(libs.plugins.vanniktech.mavenPublish) - alias(libs.plugins.gradleup.nmcp) } kotlin { - explicitApi() - targets.all { - compilations.all { - compilerOptions.configure { - freeCompilerArgs.apply { - add("-Xexpect-actual-classes") - } - } - } - } - - androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = libs.versions.java.get() - } - } - - publishLibraryVariants("release") - } - - iosX64() - iosArm64() - iosSimulatorArm64() - sourceSets { - all { - languageSettings.apply { - if (name.lowercase().startsWith("ios")) { - optIn("kotlinx.cinterop.ExperimentalForeignApi") - } - } - } - commonMain.dependencies { - //put your multiplatform dependencies here - } commonTest.dependencies { implementation(libs.kotlin.test) } @@ -74,53 +31,4 @@ kotlin { android { namespace = "io.shortway.kobankat" - compileSdk = libs.versions.android.compileSdk.get().toInt() - defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInt() - } - compileOptions { - sourceCompatibility(libs.versions.java.get()) - targetCompatibility(libs.versions.java.get()) - } } - -file("src") - .listFiles { child -> child.isDirectory } - .orEmpty() - .also { sourceDirectories -> - registerDetektTask( - taskName = "detektAll", - taskDescription = "Runs Detekt on all source sets.", - reportName = "all", - sourceDirs = files(sourceDirectories) - ) - - sourceDirectories.forEach { sourceDir -> registerDetektTask(sourceDir) } - } - -private fun registerDetektTask(sourceDir: File) = - registerDetektTask(sourceDir.name) - -private fun registerDetektTask(sourceDir: String) = - registerDetektTask( - taskName = "detekt${sourceDir.capitalized()}", - taskDescription = "Runs Detekt on the $sourceDir source set.", - reportName = sourceDir, - sourceDirs = files("src/$sourceDir/kotlin") - ) - -private fun registerDetektTask( - taskName: String, - taskDescription: String, - reportName: String, - sourceDirs: ConfigurableFileCollection, -) = - tasks.register(taskName) { - description = taskDescription - setSource(sourceDirs) - config = files("$rootDir/config/detekt/detekt.yml") - reports { - html.outputLocation = file("build/reports/detekt/$reportName.html") - xml.outputLocation = file("build/reports/detekt/$reportName.xml") - } - } diff --git a/core/src/commonMain/kotlin/io/shortway/kobankat/ktx/Coroutines.kt b/core/src/commonMain/kotlin/io/shortway/kobankat/ktx/Coroutines.kt index d96c43fc..e14deaeb 100644 --- a/core/src/commonMain/kotlin/io/shortway/kobankat/ktx/Coroutines.kt +++ b/core/src/commonMain/kotlin/io/shortway/kobankat/ktx/Coroutines.kt @@ -158,7 +158,7 @@ public suspend fun Purchases.awaitPromotionalOffer( * @param replacementMode Play Store only, ignored otherwise. The replacement mode to use when * upgrading from another product. This field is ignored, unless [oldProductId] is non-null. * - * @throws PurchasesException in case of an error. + * @throws PurchasesTransactionException in case of an error. */ @Throws(PurchasesTransactionException::class, CancellationException::class) public suspend fun Purchases.awaitPurchase( @@ -209,7 +209,7 @@ public suspend fun Purchases.awaitPurchase( * @param replacementMode Play Store only, ignored otherwise. The replacement mode to use when * upgrading from another product. This field is ignored, unless [oldProductId] is non-null. * - * @throws PurchasesException in case of an error. + * @throws PurchasesTransactionException in case of an error. */ @Throws(PurchasesTransactionException::class, CancellationException::class) public suspend fun Purchases.awaitPurchase( @@ -249,7 +249,7 @@ public suspend fun Purchases.awaitPurchase( * @param replacementMode Play Store only, ignored otherwise. The replacement mode to use when * upgrading from another product. This field is ignored, unless [oldProductId] is non-null. * - * @throws PurchasesException in case of an error. + * @throws PurchasesTransactionException in case of an error. */ @Throws(PurchasesTransactionException::class, CancellationException::class) public suspend fun Purchases.awaitPurchase( @@ -283,7 +283,7 @@ public suspend fun Purchases.awaitPurchase( * @param promotionalOffer The [PromotionalOffer] to apply to this purchase. * [StoreTransaction] and updated [CustomerInfo]. * - * @throws PurchasesException in case of an error. + * @throws PurchasesTransactionException in case of an error. * * @see [awaitPromotionalOffer] */ @@ -315,7 +315,7 @@ public suspend fun Purchases.awaitPurchase( * @param promotionalOffer The [PromotionalOffer] to apply to this purchase. * [StoreTransaction] and updated [CustomerInfo]. * - * @throws PurchasesException in case of an error. + * @throws PurchasesTransactionException in case of an error. * * @see [awaitPromotionalOffer] */ @@ -408,4 +408,4 @@ public suspend fun Purchases.awaitCustomerInfo( onError = { continuation.resumeWithException(PurchasesException(it)) }, onSuccess = { continuation.resume(it) }, ) -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 636d83fb..8f5d142f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,9 +10,11 @@ revenuecat-android = "7.5.2" revenuecat-ios = "4.37.0" [libraries] +android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.8.2" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } revenuecat-android = { module = "com.revenuecat.purchases:purchases", version.ref = "revenuecat-android"} diff --git a/result/.gitignore b/result/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/result/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/result/build.gradle.kts b/result/build.gradle.kts new file mode 100644 index 00000000..be367533 --- /dev/null +++ b/result/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("kobankat.library") +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.core) + } + } +} + +android { + namespace = "io.shortway.kobankat.result" +} diff --git a/result/src/commonMain/kotlin/io/shortway/kobankat/result/Result.kt b/result/src/commonMain/kotlin/io/shortway/kobankat/result/Result.kt new file mode 100644 index 00000000..b7c8c662 --- /dev/null +++ b/result/src/commonMain/kotlin/io/shortway/kobankat/result/Result.kt @@ -0,0 +1,375 @@ +package io.shortway.kobankat.result + +import io.shortway.kobankat.CacheFetchPolicy +import io.shortway.kobankat.CustomerInfo +import io.shortway.kobankat.Offerings +import io.shortway.kobankat.Package +import io.shortway.kobankat.Purchases +import io.shortway.kobankat.PurchasesException +import io.shortway.kobankat.PurchasesTransactionException +import io.shortway.kobankat.appUserID +import io.shortway.kobankat.getCustomerInfo +import io.shortway.kobankat.getOfferings +import io.shortway.kobankat.getProducts +import io.shortway.kobankat.getPromotionalOffer +import io.shortway.kobankat.ktx.SuccessfulLogin +import io.shortway.kobankat.ktx.SuccessfulPurchase +import io.shortway.kobankat.ktx.awaitPromotionalOffer +import io.shortway.kobankat.ktx.awaitPurchase +import io.shortway.kobankat.logIn +import io.shortway.kobankat.logOut +import io.shortway.kobankat.models.GoogleReplacementMode +import io.shortway.kobankat.models.PromotionalOffer +import io.shortway.kobankat.models.StoreProduct +import io.shortway.kobankat.models.StoreProductDiscount +import io.shortway.kobankat.models.StoreTransaction +import io.shortway.kobankat.models.SubscriptionOption +import io.shortway.kobankat.purchase +import io.shortway.kobankat.restorePurchases +import io.shortway.kobankat.syncPurchases +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * This method will send all the purchases to the RevenueCat backend. Call this when using your own + * implementation for subscriptions anytime a sync is needed, such as when migrating existing users + * to RevenueCat. + * + * **Warning:** This function should only be called if you're migrating to RevenueCat or in observer + * mode. + * + * **Warning:** This function could take a relatively long time to execute, depending on the amount + * of purchases the user has. Consider that when waiting for this operation to complete. + * + * @return A [Result] containing [CustomerInfo] if successful, and [PurchasesException] in case of + * a failure. + */ +public suspend fun Purchases.awaitSyncPurchasesResult(): Result = + suspendCoroutine { continuation -> + syncPurchases( + onError = { continuation.resume(Result.failure(PurchasesException(it))) }, + onSuccess = { continuation.resume(Result.success(it)) } + ) + } + +/** + * Fetch the configured offerings for this users. Offerings allows you to configure your in-app + * products vis RevenueCat and greatly simplifies management. See + * [the guide](https://docs.revenuecat.com/offerings) for more info. + * + * Offerings will be fetched and cached on instantiation so that, by the time they are needed, + * your prices are loaded for your purchase flow. Time is money. + * + * @return A [Result] containing [Offerings] if successful, and [PurchasesException] in case of a + * failure. + */ +public suspend fun Purchases.awaitOfferingsResult(): Result = + suspendCoroutine { continuation -> + getOfferings( + onError = { continuation.resume(Result.failure(PurchasesException(it))) }, + onSuccess = { continuation.resume(Result.success(it)) } + ) + } + +/** + * Gets the [StoreProduct]s for the given list of product ids for all product types. + * + * @return A [Result] containing a list of [StoreProduct]s if successful, and [PurchasesException] + * in case of a failure. + */ +public suspend fun Purchases.awaitGetProductsResult( + productIds: List +): Result> = suspendCoroutine { continuation -> + getProducts( + productIds = productIds, + onError = { continuation.resume(Result.failure(PurchasesException(it))) }, + onSuccess = { continuation.resume(Result.success(it)) } + ) +} + +/** + * App Store only. Use this method to fetch a [PromotionalOffer] to use with [awaitPurchase]. + * + * @return A [Result] containing a [PromotionalOffer] if successful, and [PurchasesException] in + * case of a failure. + */ +public suspend fun Purchases.awaitPromotionalOfferResult( + discount: StoreProductDiscount, + storeProduct: StoreProduct, +): Result = suspendCoroutine { continuation -> + getPromotionalOffer( + discount = discount, + storeProduct = storeProduct, + onError = { continuation.resume(Result.failure(PurchasesException(it))) }, + onSuccess = { continuation.resume(Result.success(it)) } + ) +} + +/** + * Purchases [storeProduct]. + * On the Play Store, if [storeProduct] represents a subscription, upgrades from the subscription + * specified by [oldProductId] and chooses [storeProduct]'s default [SubscriptionOption]. + * + * The default [SubscriptionOption] logic: + * - Filters out offers with `"rc-ignore-offer"` tag + * - Uses [SubscriptionOption] WITH longest free trial or cheapest first phase + * - Falls back to use base plan + * + * If [storeProduct] represents a non-subscription, [oldProductId] and [replacementMode] will be + * ignored. + * + * @param storeProduct The [StoreProduct] you wish to purchase. + * @param isPersonalizedPrice Play Store only, ignored otherwise. Optional boolean indicates + * personalized pricing on products available for purchase in the EU. For compliance with EU + * regulations. User will see "This price has been customize for you" in the purchase dialog when + * true. See + * [developer.android.com](https://developer.android.com/google/play/billing/integrate#personalized-price) + * for more info. + * @param oldProductId Play Store only, ignored otherwise. If this purchase is an upgrade from + * another product, provide the previous product ID here. + * @param replacementMode Play Store only, ignored otherwise. The replacement mode to use when + * upgrading from another product. This field is ignored, unless [oldProductId] is non-null. + * + * @return A [Result] containing [SuccessfulPurchase] if successful, and + * [PurchasesTransactionException] in case of a failure. + */ +public suspend fun Purchases.awaitPurchaseResult( + storeProduct: StoreProduct, + isPersonalizedPrice: Boolean? = null, + oldProductId: String? = null, + replacementMode: GoogleReplacementMode = GoogleReplacementMode.WITHOUT_PRORATION, +): Result = suspendCoroutine { continuation -> + purchase( + storeProduct = storeProduct, + onError = { error, userCancelled -> + continuation.resume(Result.failure(PurchasesTransactionException(error, userCancelled))) + }, + onSuccess = { storeTransaction, customerInfo -> + continuation.resume(Result.success(SuccessfulPurchase(storeTransaction, customerInfo))) + }, + isPersonalizedPrice = isPersonalizedPrice, + oldProductId = oldProductId, + replacementMode = replacementMode, + ) +} + +/** + * Purchases [packageToPurchase]. + * On the Play Store, if [packageToPurchase] represents a subscription, upgrades from the + * subscription specified by [oldProductId] and chooses the default [SubscriptionOption] from + * [packageToPurchase]. + * + * The default [SubscriptionOption] logic: + * - Filters out offers with `"rc-ignore-offer"` tag + * - Uses [SubscriptionOption] WITH longest free trial or cheapest first phase + * - Falls back to use base plan + * + * If [packageToPurchase] represents a non-subscription, [oldProductId] and [replacementMode] will + * be ignored. + * + * @param packageToPurchase The [Package] you wish to purchase. + * @param isPersonalizedPrice Play Store only, ignored otherwise. Optional boolean indicates + * personalized pricing on products available for purchase in the EU. For compliance with EU + * regulations. User will see "This price has been customize for you" in the purchase dialog when + * true. See + * [developer.android.com](https://developer.android.com/google/play/billing/integrate#personalized-price) + * for more info. + * @param oldProductId Play Store only, ignored otherwise. If this purchase is an upgrade from + * another product, provide the previous product ID here. + * @param replacementMode Play Store only, ignored otherwise. The replacement mode to use when + * upgrading from another product. This field is ignored, unless [oldProductId] is non-null. + * + * @return A [Result] containing [SuccessfulPurchase] if successful, and + * [PurchasesTransactionException] in case of a failure. + */ +public suspend fun Purchases.awaitPurchaseResult( + packageToPurchase: Package, + isPersonalizedPrice: Boolean? = null, + oldProductId: String? = null, + replacementMode: GoogleReplacementMode = GoogleReplacementMode.WITHOUT_PRORATION, +): Result = suspendCoroutine { continuation -> + purchase( + packageToPurchase = packageToPurchase, + onError = { error, userCancelled -> + continuation.resume(Result.failure(PurchasesTransactionException(error, userCancelled))) + }, + onSuccess = { storeTransaction, customerInfo -> + continuation.resume(Result.success(SuccessfulPurchase(storeTransaction, customerInfo))) + }, + isPersonalizedPrice = isPersonalizedPrice, + oldProductId = oldProductId, + replacementMode = replacementMode, + ) +} + +/** + * Play Store only. Purchases [subscriptionOption]. + * + * @param subscriptionOption The [SubscriptionOption] you wish to purchase. + * @param isPersonalizedPrice Play Store only, ignored otherwise. Optional boolean indicates + * personalized pricing on products available for purchase in the EU. For compliance with EU + * regulations. User will see "This price has been customize for you" in the purchase dialog when + * true. See + * [developer.android.com](https://developer.android.com/google/play/billing/integrate#personalized-price) + * for more info. + * @param oldProductId Play Store only, ignored otherwise. If this purchase is an upgrade from + * another product, provide the previous product ID here. + * @param replacementMode Play Store only, ignored otherwise. The replacement mode to use when + * upgrading from another product. This field is ignored, unless [oldProductId] is non-null. + * + * @return A [Result] containing [SuccessfulPurchase] if successful, and + * [PurchasesTransactionException] in case of a failure. + */ +public suspend fun Purchases.awaitPurchaseResult( + subscriptionOption: SubscriptionOption, + isPersonalizedPrice: Boolean? = null, + oldProductId: String? = null, + replacementMode: GoogleReplacementMode = GoogleReplacementMode.WITHOUT_PRORATION, +): Result = suspendCoroutine { continuation -> + purchase( + subscriptionOption = subscriptionOption, + onError = { error, userCancelled -> + continuation.resume(Result.failure(PurchasesTransactionException(error, userCancelled))) + }, + onSuccess = { storeTransaction, customerInfo -> + continuation.resume(Result.success(SuccessfulPurchase(storeTransaction, customerInfo))) + }, + isPersonalizedPrice = isPersonalizedPrice, + oldProductId = oldProductId, + replacementMode = replacementMode, + ) +} + +/** + * App Store only. Use this function if you are not using the Offerings system to purchase a + * [StoreProduct] with an applied PromotionalOffer. If you are using the Offerings system, use the + * overload with a [Package] parameter instead. + * + * @param storeProduct The [StoreProduct] you wish to purchase. + * @param promotionalOffer The [PromotionalOffer] to apply to this purchase. + * [StoreTransaction] and updated [CustomerInfo]. + * + * @return A [Result] containing [SuccessfulPurchase] if successful, and + * [PurchasesTransactionException] in case of a failure. + * + * @see [awaitPromotionalOffer] + */ +public suspend fun Purchases.awaitPurchaseResult( + storeProduct: StoreProduct, + promotionalOffer: PromotionalOffer, +): Result = suspendCoroutine { continuation -> + purchase( + storeProduct = storeProduct, + promotionalOffer = promotionalOffer, + onError = { error, userCancelled -> + continuation.resume(Result.failure(PurchasesTransactionException(error, userCancelled))) + }, + onSuccess = { storeTransaction, customerInfo -> + continuation.resume(Result.success(SuccessfulPurchase(storeTransaction, customerInfo))) + }, + ) +} + +/** + * App Store only. Purchases [packageToPurchase]. Call this method when a user has decided to + * purchase a product with an applied discount. Only call this in direct response to user input. + * From here [Purchases] will handle the purchase with StoreKit. + * + * @param packageToPurchase The [Package] you wish to purchase. + * @param promotionalOffer The [PromotionalOffer] to apply to this purchase. + * [StoreTransaction] and updated [CustomerInfo]. + * + * @return A [Result] containing [SuccessfulPurchase] if successful, and + * [PurchasesTransactionException] in case of a failure. + * + * @see [awaitPromotionalOffer] + */ +public suspend fun Purchases.awaitPurchaseResult( + packageToPurchase: Package, + promotionalOffer: PromotionalOffer, +): Result = suspendCoroutine { continuation -> + purchase( + packageToPurchase = packageToPurchase, + promotionalOffer = promotionalOffer, + onError = { error, userCancelled -> + continuation.resume(Result.failure(PurchasesTransactionException(error, userCancelled))) + }, + onSuccess = { storeTransaction, customerInfo -> + continuation.resume(Result.success(SuccessfulPurchase(storeTransaction, customerInfo))) + }, + ) +} + +/** + * Restores purchases made with the current Store account for the current user. This method will + * post all purchases associated with the current Store account to RevenueCat and become associated + * with the current [appUserID]. If the receipt token is being used by an existing user, the current + * [appUserID] will be aliased together with the [appUserID] of the existing user. Going forward, + * either [appUserID] will be able to reference the same user. + * + * You shouldn't use this method if you have your own account system. In that case "restoration" is + * provided by your app passing the same [appUserID] used to purchase originally. + * + * @return A [Result] containing [CustomerInfo] if successful, and [PurchasesException] in case of + * a failure. + */ +public suspend fun Purchases.awaitRestoreResult(): Result = + suspendCoroutine { continuation -> + restorePurchases( + onError = { continuation.resume(Result.failure(PurchasesException(it))) }, + onSuccess = { continuation.resume(Result.success(it)) }, + ) + } + +/** + * This function will change the current [appUserID]. Typically this would be used after a log out + * to identify a new user without calling `configure()`. + * @param newAppUserID The new appUserID that should be linked to the currently user + * + * @return A [Result] containing [CustomerInfo] if successful, and [PurchasesException] in case of + * a failure. + */ +public suspend fun Purchases.awaitLogInResult( + newAppUserID: String, +): Result = suspendCoroutine { continuation -> + logIn( + newAppUserID = newAppUserID, + onError = { continuation.resume(Result.failure(PurchasesException(it))) }, + onSuccess = { customerInfo, created -> + continuation.resume(Result.success(SuccessfulLogin(customerInfo, created))) + }, + ) +} + +/** + * Resets the Purchases client clearing the save [appUserID]. This will generate a random user + * id and save it in the cache. + * + * @return A [Result] containing [CustomerInfo] if successful, and [PurchasesException] in case of + * a failure. + */ +public suspend fun Purchases.awaitLogOutResult(): Result = + suspendCoroutine { continuation -> + logOut( + onError = { continuation.resume(Result.failure(PurchasesException(it))) }, + onSuccess = { continuation.resume(Result.success(it)) }, + ) + } + +/** + * Get the latest available customer info. + * + * @param fetchPolicy Specifies cache behavior for customer info retrieval. + * + * @return A [Result] containing [CustomerInfo] if successful, and [PurchasesException] in case of + * a failure. + */ +public suspend fun Purchases.awaitCustomerInfoResult( + fetchPolicy: CacheFetchPolicy = CacheFetchPolicy.default(), +): Result = suspendCoroutine { continuation -> + getCustomerInfo( + fetchPolicy = fetchPolicy, + onError = { continuation.resume(Result.failure(PurchasesException(it))) }, + onSuccess = { continuation.resume(Result.success(it)) }, + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0df0e33f..b54c1c50 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { + includeBuild("build-logic") repositories { google() gradlePluginPortal() @@ -18,4 +19,5 @@ dependencyResolutionManagement { rootProject.name = "KobanKat" include(":core") +include(":result") include(":composeApp")