diff --git a/.circleci/config.yml b/.circleci/config.yml index 0286bf348..dbef11d6f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -233,6 +233,35 @@ jobs: - save-kotlin-native-compiler-to-cache - save-incremental-gradle-build-to-workspace + # Self-contained on purpose: no workspace persistence or attachment, so it can run in + # parallel to the iOS chain without workspace conflicts. + build-and-test-watchos: + executor: xcode16 + steps: + - checkout + - checkout-submodule + - restore-gradle-user-home-directory-from-cache + - restore-kotlin-native-compiler-from-cache + - run: + name: Build libraries and run tests for watchOS + command: | + tasks=() + for module in core result; do + for arch in Arm64 DeviceArm64 SimulatorArm64; do + tasks+=(":$module:compileKotlinWatchos$arch") + done + done + # either skips DeviceArm64: arrow-core doesn't publish it. + for arch in Arm64 SimulatorArm64; do + tasks+=(":either:compileKotlinWatchos$arch") + done + # The commonized (metadata) compilation only runs at publish time otherwise. + tasks+=("compileAppleMainKotlinMetadata") + tasks+=("watchosSimulatorArm64Test") + ./gradlew "${tasks[@]}" + - save-gradle-user-home-directory-to-cache + - save-kotlin-native-compiler-to-cache + build-sample-android: executor: android202409 steps: @@ -435,6 +464,7 @@ workflows: - unit-tests-build-logic - build-libraries-android - build-libraries-ios + - build-and-test-watchos - build-sample-android: requires: [build-libraries-android] - build-sample-ios: @@ -478,8 +508,8 @@ workflows: outputs: | PurchasesErrorCode.kt:models/src/commonMain/kotlin/generated/com/revenuecat/purchases/kmp/models/PurchasesErrorCode.kt errors.android.kt:mappings/src/androidMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.android.kt - errors.ios.kt:mappings/src/iosMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.ios.kt - commit_paths: "models/src/commonMain/kotlin/generated/com/revenuecat/purchases/kmp/models/PurchasesErrorCode.kt,mappings/src/androidMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.android.kt,mappings/src/iosMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.ios.kt,models/api/models.api,models/api/models.klib.api" + errors.ios.kt:mappings/src/appleMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.apple.kt + commit_paths: "models/src/commonMain/kotlin/generated/com/revenuecat/purchases/kmp/models/PurchasesErrorCode.kt,mappings/src/androidMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.android.kt,mappings/src/appleMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.apple.kt,models/api/models.api,models/api/models.klib.api" context: - git-user-ops - github-bot-public @@ -530,6 +560,7 @@ workflows: - unit-tests-build-logic - build-libraries-android - build-libraries-ios + - build-and-test-watchos - build-sample-android: requires: [build-libraries-android] - build-sample-ios: @@ -551,6 +582,7 @@ workflows: requires: - detekt - unit-tests-build-logic + - build-and-test-watchos - build-sample-android - build-sample-ios - public-api-tests-android diff --git a/AGENTS.md b/AGENTS.md index c8d24e9f4..2f2f068e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ purchases-kmp/ │ └── src/ │ ├── commonMain/ # Shared Kotlin code │ ├── androidMain/ # Android-specific implementations -│ └── iosMain/ # iOS-specific implementations (Swift interop) +│ └── appleMain/ # Apple-specific implementations (Swift interop), split further into iosMain/watchosMain where needed ├── models/ # Shared data models and domain objects ├── mappings/ # Platform-specific mappings ├── revenuecatui/ # Jetpack Compose UI components for paywalls @@ -121,8 +121,8 @@ module/src/ ├── commonTest/ # Shared tests ├── androidMain/ # Android-specific implementations ├── androidUnitTest/ # Android unit tests -├── iosMain/ # iOS-specific implementations -└── iosTest/ # iOS tests +├── appleMain/ # Apple-specific implementations (iOS + watchOS) +└── appleTest/ # Apple platform tests ``` ## Constraints / Support Policy @@ -184,7 +184,7 @@ When creating a pull request, **always add one of these labels** to categorize t - **Don't make large refactors** unless explicitly requested - **Keep diffs minimal** — only touch what's necessary, preserve existing formatting - **Don't break the public API** — binary compatibility validator will catch issues -- **Follow KMP conventions** — put shared code in `commonMain`, platform-specific in `androidMain`/`iosMain` +- **Follow KMP conventions** — put shared code in `commonMain`, platform-specific in `androidMain`/`appleMain` - **Run Detekt** before committing (`./gradlew detektAll`) - **Check both platforms** — changes in `commonMain` affect both iOS and Android - **Never commit API keys or secrets** — do not stage or commit credentials or sensitive data diff --git a/Dangerfile b/Dangerfile index 445de0e4f..82f1bf38c 100644 --- a/Dangerfile +++ b/Dangerfile @@ -3,5 +3,5 @@ danger.import_dangerfile(github: 'RevenueCat/Dangerfile') fail_on_generated_edits([ "models/src/commonMain/kotlin/generated/", "mappings/src/androidMain/kotlin/generated/", - "mappings/src/iosMain/kotlin/generated/", + "mappings/src/appleMain/kotlin/generated/", ]) diff --git a/README.md b/README.md index e4ffb4bd1..130aa4af9 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ our [migration guide](./migrations/KobanKat-MIGRATION.md) | --- | --- | ✅ | Server-side receipt validation ➡️ | [Webhooks](https://docs.revenuecat.com/docs/webhooks) - enhanced server-to-server communication with events for purchases, renewals, cancellations, and more -📱 | Android and iOS support +📱 | Android, iOS and watchOS support 🎯 | Subscription status tracking - know whether a user is subscribed whether they're on iOS, Android or web 📊 | Analytics - automatic calculation of metrics like conversion, mrr, and churn 📝 | [Online documentation](https://docs.revenuecat.com/docs) and [SDK Reference](https://revenuecat.github.io/purchases-kmp/) up to date @@ -66,7 +66,8 @@ This codelab is a step-by-step tutorial designed to help you learn and master th - Java 8+ - Kotlin 2.3.20+ - Android 6.0+ (API level 23+) -- iOS 13.0+ +- iOS 13.0+ +- watchOS 7.0+ (9.0+ on `watchosDeviceArm64`). Notes: Paywalls and Customer Center (the `-ui` artifact) are iOS only, and the optional `-either` artifact has no `watchosDeviceArm64` variant. ## SDK Reference Our full SDK reference [can be found here](https://revenuecat.github.io/purchases-kmp/). diff --git a/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/AppleTargets.kt b/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/AppleTargets.kt new file mode 100644 index 000000000..9e87a023b --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/AppleTargets.kt @@ -0,0 +1,14 @@ +package com.revenuecat.purchases.kmp.buildlogic + +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +/** + * Declares all watchOS compilation targets. The `revenuecat-library` convention plugin declares + * Android and iOS targets for every module; modules that also support watchOS call this from + * their `kotlin {}` block. + */ +fun KotlinMultiplatformExtension.watchosTargets() { + watchosArm64() + watchosDeviceArm64() + watchosSimulatorArm64() +} diff --git a/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/convention/configureKotlin.kt b/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/convention/configureKotlin.kt index 9a2061831..2f447ac17 100644 --- a/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/convention/configureKotlin.kt +++ b/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/convention/configureKotlin.kt @@ -35,8 +35,12 @@ internal fun Project.configureKotlin() { } sourceSets.all { languageSettings.apply { - if (name.lowercase().startsWith("ios")) { + val appleSourceSetPrefixes = listOf("apple", "ios", "watchos", "tvos", "macos") + if (appleSourceSetPrefixes.any { name.lowercase().startsWith(it) }) { optIn("kotlinx.cinterop.ExperimentalForeignApi") + // NSInteger and friends commonize to variable-width types (32 bits on + // watchosArm64), which require this opt-in in shared Apple code. + optIn("kotlinx.cinterop.UnsafeNumber") } } } diff --git a/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/swift/ConfigureSwiftDependencies.kt b/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/swift/ConfigureSwiftDependencies.kt index a84b33188..d2fdeb016 100644 --- a/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/swift/ConfigureSwiftDependencies.kt +++ b/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/swift/ConfigureSwiftDependencies.kt @@ -219,7 +219,6 @@ private fun KonanTarget.isAppleTarget(): Boolean = // watchOS KonanTarget.WATCHOS_ARM64, KonanTarget.WATCHOS_SIMULATOR_ARM64, - KonanTarget.WATCHOS_X64, KonanTarget.WATCHOS_DEVICE_ARM64, ) @@ -310,7 +309,6 @@ private fun getTaskSuffix(konanTarget: KonanTarget): String = when (konanTarget) // watchOS KonanTarget.WATCHOS_ARM64 -> "WatchosArm64" KonanTarget.WATCHOS_SIMULATOR_ARM64 -> "WatchosSimulatorArm64" - KonanTarget.WATCHOS_X64 -> "WatchosX64" KonanTarget.WATCHOS_DEVICE_ARM64 -> "WatchosDeviceArm64" else -> error("Unexpected target: $konanTarget") } @@ -376,7 +374,7 @@ private fun KonanTarget.getSdkName(): String = when (this) { KonanTarget.TVOS_X64, KonanTarget.TVOS_SIMULATOR_ARM64 -> "appletvsimulator" KonanTarget.TVOS_ARM64 -> "appletvos" // watchOS - KonanTarget.WATCHOS_X64, KonanTarget.WATCHOS_SIMULATOR_ARM64 -> "watchsimulator" + KonanTarget.WATCHOS_SIMULATOR_ARM64 -> "watchsimulator" KonanTarget.WATCHOS_ARM64, KonanTarget.WATCHOS_DEVICE_ARM64 -> "watchos" else -> error("Unsupported target: ${this}") } @@ -394,8 +392,10 @@ private fun KonanTarget.getTriple(): String = when (this) { KonanTarget.TVOS_SIMULATOR_ARM64 -> "arm64-apple-tvos-simulator" KonanTarget.TVOS_ARM64 -> "arm64-apple-tvos" // watchOS - KonanTarget.WATCHOS_X64 -> "x86_64-apple-watchos-simulator" KonanTarget.WATCHOS_SIMULATOR_ARM64 -> "arm64-apple-watchos-simulator" - KonanTarget.WATCHOS_ARM64, KonanTarget.WATCHOS_DEVICE_ARM64 -> "arm64-apple-watchos" + // Note: Kotlin/Native's watchosArm64 is arm64_32 (see targetTriple.watchos_arm64 in + // konan.properties), while watchosDeviceArm64 is arm64. + KonanTarget.WATCHOS_ARM64 -> "arm64_32-apple-watchos" + KonanTarget.WATCHOS_DEVICE_ARM64 -> "arm64-apple-watchos" else -> error("Unsupported target: $this") } diff --git a/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/swift/task/GenerateDefFileTask.kt b/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/swift/task/GenerateDefFileTask.kt index ecc614460..079290e95 100644 --- a/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/swift/task/GenerateDefFileTask.kt +++ b/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/swift/task/GenerateDefFileTask.kt @@ -78,6 +78,9 @@ abstract class GenerateDefFileTask : DefaultTask() { linkerOpts.ios_x64 = -L$toolchain/lib/swift/iphonesimulator/ linkerOpts.ios_arm64 = -L$toolchain/lib/swift/iphoneos/ linkerOpts.ios_simulator_arm64 = -L$toolchain/lib/swift/iphonesimulator/ + linkerOpts.watchos_arm64 = -L$toolchain/lib/swift/watchos/ + linkerOpts.watchos_device_arm64 = -L$toolchain/lib/swift/watchos/ + linkerOpts.watchos_simulator_arm64 = -L$toolchain/lib/swift/watchsimulator/ """.trimIndent() val content = if (customDeclarations.isPresent) { diff --git a/core/api/core.klib.api b/core/api/core.klib.api index 3d5785260..a5699e954 100644 --- a/core/api/core.klib.api +++ b/core/api/core.klib.api @@ -1,5 +1,5 @@ // Klib ABI Dump -// Targets: [iosArm64, iosSimulatorArm64, iosX64] +// Targets: [iosArm64, iosSimulatorArm64, iosX64, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64] // Rendering settings: // - Signature version: 2 // - Show manifest properties: true diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 38a5ffa2f..4e9c5dfb5 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,4 +1,5 @@ import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING +import com.revenuecat.purchases.kmp.buildlogic.watchosTargets plugins { id("revenuecat-library") @@ -10,6 +11,8 @@ revenueCat { } kotlin { + watchosTargets() + sourceSets { commonMain.dependencies { api(projects.models) @@ -19,7 +22,7 @@ kotlin { implementation(libs.revenuecat.android) implementation(projects.mappings) } - iosMain.dependencies { + appleMain.dependencies { implementation(projects.knCore) implementation(projects.mappings) } diff --git a/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/AdTracker.ios.kt b/core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/AdTracker.apple.kt similarity index 61% rename from core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/AdTracker.ios.kt rename to core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/AdTracker.apple.kt index 11d27e914..b342d931a 100644 --- a/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/AdTracker.ios.kt +++ b/core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/AdTracker.apple.kt @@ -16,8 +16,8 @@ public actual class AdTracker internal constructor( private val iosAdTracker: RCAdTracker ) { public actual fun trackAdDisplayed(data: AdDisplayedData) { - if (!AppleApiAvailability().isAdTrackingAPIAvailable()) { - Purchases.logHandler.w("Purchases", "Ad tracking requires iOS 15.0+. Current API is unavailable.") + if (!appleApiAvailability.isAdTrackingAPIAvailable()) { + Purchases.logHandler.w("Purchases", AD_TRACKING_UNAVAILABLE_MESSAGE) return } @@ -25,8 +25,8 @@ public actual class AdTracker internal constructor( } public actual fun trackAdOpened(data: AdOpenedData) { - if (!AppleApiAvailability().isAdTrackingAPIAvailable()) { - Purchases.logHandler.w("Purchases", "Ad tracking requires iOS 15.0+. Current API is unavailable.") + if (!appleApiAvailability.isAdTrackingAPIAvailable()) { + Purchases.logHandler.w("Purchases", AD_TRACKING_UNAVAILABLE_MESSAGE) return } @@ -34,8 +34,8 @@ public actual class AdTracker internal constructor( } public actual fun trackAdRevenue(data: AdRevenueData) { - if (!AppleApiAvailability().isAdTrackingAPIAvailable()) { - Purchases.logHandler.w("Purchases", "Ad tracking requires iOS 15.0+. Current API is unavailable.") + if (!appleApiAvailability.isAdTrackingAPIAvailable()) { + Purchases.logHandler.w("Purchases", AD_TRACKING_UNAVAILABLE_MESSAGE) return } @@ -43,8 +43,8 @@ public actual class AdTracker internal constructor( } public actual fun trackAdLoaded(data: AdLoadedData) { - if (!AppleApiAvailability().isAdTrackingAPIAvailable()) { - Purchases.logHandler.w("Purchases", "Ad tracking requires iOS 15.0+. Current API is unavailable.") + if (!appleApiAvailability.isAdTrackingAPIAvailable()) { + Purchases.logHandler.w("Purchases", AD_TRACKING_UNAVAILABLE_MESSAGE) return } @@ -52,11 +52,16 @@ public actual class AdTracker internal constructor( } public actual fun trackAdFailedToLoad(data: AdFailedToLoadData) { - if (!AppleApiAvailability().isAdTrackingAPIAvailable()) { - Purchases.logHandler.w("Purchases", "Ad tracking requires iOS 15.0+. Current API is unavailable.") + if (!appleApiAvailability.isAdTrackingAPIAvailable()) { + Purchases.logHandler.w("Purchases", AD_TRACKING_UNAVAILABLE_MESSAGE) return } iosAdTracker.trackAdFailedToLoad(data.toIos()) } } + +private val appleApiAvailability = AppleApiAvailability() + +private const val AD_TRACKING_UNAVAILABLE_MESSAGE = + "Ad tracking requires iOS 15.0+ or watchOS 8.0+. Current API is unavailable." diff --git a/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/LogHandler.ios.kt b/core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/LogHandler.apple.kt similarity index 100% rename from core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/LogHandler.ios.kt rename to core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/LogHandler.apple.kt diff --git a/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/LogLevel.ios.kt b/core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/LogLevel.apple.kt similarity index 100% rename from core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/LogLevel.ios.kt rename to core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/LogLevel.apple.kt diff --git a/core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/Purchases.apple.kt b/core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/Purchases.apple.kt new file mode 100644 index 000000000..cb9096431 --- /dev/null +++ b/core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/Purchases.apple.kt @@ -0,0 +1,841 @@ +package com.revenuecat.purchases.kmp + + +import com.revenuecat.purchases.kmp.ktx.mapEntriesNotNull +import com.revenuecat.purchases.kmp.mappings.toCustomerInfo +import com.revenuecat.purchases.kmp.mappings.toIntroEligibilityStatus +import com.revenuecat.purchases.kmp.mappings.toIosCacheFetchPolicy +import com.revenuecat.purchases.kmp.mappings.toIosEntitlementVerificationMode +import com.revenuecat.purchases.kmp.mappings.toIosOffering +import com.revenuecat.purchases.kmp.mappings.toIosPackage +import com.revenuecat.purchases.kmp.mappings.toIosPromotionalOffer +import com.revenuecat.purchases.kmp.mappings.toIosPurchasesAreCompletedBy +import com.revenuecat.purchases.kmp.mappings.toIosStoreKitVersion +import com.revenuecat.purchases.kmp.mappings.toIosStoreProduct +import com.revenuecat.purchases.kmp.mappings.toIosStoreProductDiscount +import com.revenuecat.purchases.kmp.mappings.toIosWinBackOffer +import com.revenuecat.purchases.kmp.mappings.toOfferings +import com.revenuecat.purchases.kmp.mappings.toPromotionalOffer +import com.revenuecat.purchases.kmp.mappings.toPurchasesErrorOrThrow +import com.revenuecat.purchases.kmp.mappings.toStoreProduct +import com.revenuecat.purchases.kmp.mappings.toStoreTransaction +import com.revenuecat.purchases.kmp.mappings.toStorefront +import com.revenuecat.purchases.kmp.mappings.toVirtualCurrencies +import com.revenuecat.purchases.kmp.mappings.toWinBackOffer +import com.revenuecat.purchases.kmp.models.BillingFeature +import com.revenuecat.purchases.kmp.models.CacheFetchPolicy +import com.revenuecat.purchases.kmp.models.CustomPaywallImpressionParams +import com.revenuecat.purchases.kmp.models.CustomerInfo +import com.revenuecat.purchases.kmp.models.DangerousSettings +import com.revenuecat.purchases.kmp.models.IntroEligibilityStatus +import com.revenuecat.purchases.kmp.models.Offerings +import com.revenuecat.purchases.kmp.models.Package +import com.revenuecat.purchases.kmp.models.PromotionalOffer +import com.revenuecat.purchases.kmp.models.PurchasesError +import com.revenuecat.purchases.kmp.models.PurchasesErrorCode +import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener +import com.revenuecat.purchases.kmp.models.ReplacementMode +import com.revenuecat.purchases.kmp.models.Store +import com.revenuecat.purchases.kmp.models.StoreMessageType +import com.revenuecat.purchases.kmp.models.StoreProduct +import com.revenuecat.purchases.kmp.models.StoreProductDiscount +import com.revenuecat.purchases.kmp.models.StoreTransaction +import com.revenuecat.purchases.kmp.models.Storefront +import com.revenuecat.purchases.kmp.models.SubscriptionOption +import com.revenuecat.purchases.kmp.models.VirtualCurrencies +import com.revenuecat.purchases.kmp.models.WebPurchaseRedemption +import com.revenuecat.purchases.kmp.models.WinBackOffer +import com.revenuecat.purchases.kmp.strings.ConfigureStrings +import platform.Foundation.NSError +import platform.Foundation.NSURL +import platform.Foundation.NSUserDefaults +import com.revenuecat.purchases.kn.core.RCConfiguration +import com.revenuecat.purchases.kn.core.RCCustomPaywallImpressionParams +import com.revenuecat.purchases.kn.core.RCCustomerInfo +import com.revenuecat.purchases.kn.core.RCIntroEligibility +import com.revenuecat.purchases.kn.core.RCPlatformInfo +import com.revenuecat.purchases.kn.core.RCPurchaseParamsBuilder +import com.revenuecat.purchases.kn.core.RCPurchasesDelegateProtocol +import com.revenuecat.purchases.kn.core.RCStoreProduct +import com.revenuecat.purchases.kn.core.overridePreferredUILocale +import com.revenuecat.purchases.kn.core.RCStoreTransaction +import com.revenuecat.purchases.kn.core.RCVirtualCurrencies +import com.revenuecat.purchases.kn.core.additional.AppleApiAvailability +import com.revenuecat.purchases.kn.core.configureWithConfiguration +import com.revenuecat.purchases.kn.core.enableAdServicesAttributionTokenCollection +import com.revenuecat.purchases.kn.core.parseAsWebPurchaseRedemption +import com.revenuecat.purchases.kn.core.recordPurchaseForProductID +import com.revenuecat.purchases.kn.core.setAirbridgeDeviceID +import com.revenuecat.purchases.kn.core.setAirshipChannelID +import com.revenuecat.purchases.kn.core.setOnesignalUserID +import com.revenuecat.purchases.kn.core.setPostHogUserID +import com.revenuecat.purchases.kn.core.trackCustomPaywallImpression +import com.revenuecat.purchases.kn.core.RCDangerousSettings as IosDangerousSettings +import com.revenuecat.purchases.kn.core.RCPurchases as IosPurchases +import com.revenuecat.purchases.kn.core.RCWinBackOffer as NativeIosWinBackOffer + +public actual class Purchases private constructor(private val iosPurchases: IosPurchases) { + public actual companion object { + private var _sharedInstance: Purchases? = null + public actual val sharedInstance: Purchases + get() = _sharedInstance ?: error(ConfigureStrings.NO_SINGLETON_INSTANCE) + + public actual var logLevel: LogLevel + get() = IosPurchases.logLevel().toLogLevel() + set(value) = IosPurchases.setLogLevel(value.toRcLogLevel()) + + public actual var logHandler: LogHandler + get() = IosPurchases.logHandler().toLogHandler() + set(value) { + IosPurchases.setLogHandler(value.toIosLogHandler()) + } + + public actual var proxyURL: String? + get() = IosPurchases.proxyURL()?.absoluteString() + set(value) = IosPurchases.setProxyURL(value?.let { NSURL(string = it) }) + + public actual val isConfigured: Boolean + get() = IosPurchases.isConfigured() + + public actual var simulatesAskToBuyInSandbox: Boolean + get() = IosPurchases.simulatesAskToBuyInSandbox() + set(value) = IosPurchases.setSimulatesAskToBuyInSandbox(value) + + public actual var forceUniversalAppStore: Boolean + get() = IosPurchases.forceUniversalAppStore() + set(value) = IosPurchases.setForceUniversalAppStore(value) + + public actual fun configure( + configuration: PurchasesConfiguration + ): Purchases = with(configuration) { + IosPurchases.configureWithConfiguration( + configuration = RCConfiguration.builderWithAPIKey(apiKey) + .withAppUserID(appUserId) + .apply { + purchasesAreCompletedBy.toIosPurchasesAreCompletedBy().also { + withPurchasesAreCompletedBy( + purchasesAreCompletedBy = it.purchasesAreCompletedBy, + storeKitVersion = it.storeKitVersion + ) + } + } + .withUserDefaults(NSUserDefaults(suiteName = userDefaultsSuiteName)) + .withPlatformInfo( + RCPlatformInfo( + flavor = BuildKonfig.platformFlavor, + version = frameworkVersion + ) + ) + .withStoreKitVersion(storeKitVersion.toIosStoreKitVersion()) + .withDangerousSettings(dangerousSettings.toIosDangerousSettings()) + .withShowStoreMessagesAutomatically(showInAppMessagesAutomatically) + .withEntitlementVerificationMode(verificationMode.toIosEntitlementVerificationMode()) + .withPreferredUILocaleOverride(preferredUILocaleOverride) + .build() + ) + }.let { Purchases(it) } + .also { _sharedInstance = it } + + public actual fun canMakePayments( + features: List, + callback: (Boolean) -> Unit + ) { + callback(IosPurchases.canMakePayments()) + } + + private fun DangerousSettings.toIosDangerousSettings(): IosDangerousSettings = + IosDangerousSettings(autoSyncPurchases) + + public actual fun parseAsWebPurchaseRedemption(url: String): WebPurchaseRedemption? { + return if (IosPurchases.parseAsWebPurchaseRedemption(NSURL(string = url)) != null) { + WebPurchaseRedemption(url) + } else { + null + } + } + } + + public actual val appUserID: String + get() = iosPurchases.appUserID() + + /** + * Making sure we keep a strong reference to our delegate wrapper, as iosPurchases only keeps + * a weak one. This avoids the wrapper from being deallocated the first chance it gets. This + * behavior matches the Android platform behavior. + */ + private var _delegateWrapper: RCPurchasesDelegateProtocol? = null + public actual var delegate: PurchasesDelegate? + get() = iosPurchases.delegate()?.toPurchasesDelegate() + set(value) { + _delegateWrapper = value.toRcPurchasesDelegate() + iosPurchases.setDelegate(_delegateWrapper) + } + + public actual val isAnonymous: Boolean + get() = iosPurchases.isAnonymous() + + public actual val store: Store + get() = Store.APP_STORE + + public actual fun getStorefront(callback: ((Storefront?) -> Unit)) { + iosPurchases.getStorefrontWithCompletionHandler { storefront -> + if (storefront == null) { + callback(null) + } else { + callback(storefront.toStorefront()) + } + } + } + + public actual fun syncPurchases( + onError: (error: PurchasesError) -> Unit, + onSuccess: (CustomerInfo) -> Unit, + ): Unit = iosPurchases.syncPurchasesWithCompletionHandler { customerInfo, error -> + if (error != null) onError(error.toPurchasesErrorOrThrow()) + else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo")) + } + + public actual fun syncAmazonPurchase( + productID: String, + receiptID: String, + amazonUserID: String, + isoCurrencyCode: String?, + price: Double?, + ) { + // No-op on iOS + } + + public actual fun syncAttributesAndOfferingsIfNeeded( + onError: (error: PurchasesError) -> Unit, + onSuccess: (offerings: Offerings) -> Unit, + ): Unit = iosPurchases.syncAttributesAndOfferingsIfNeededWithCompletion { offerings, error -> + if (error != null) onError(error.toPurchasesErrorOrThrow()) + else onSuccess(offerings?.toOfferings() ?: error("Expected a non-null RCOfferings")) + } + + public actual fun getOfferings( + onError: (error: PurchasesError) -> Unit, + onSuccess: (offerings: Offerings) -> Unit, + ): Unit = iosPurchases.getOfferingsWithCompletion { offerings, error -> + if (error != null) onError(error.toPurchasesErrorOrThrow()) + else onSuccess(offerings?.toOfferings() ?: error("Expected a non-null RCOfferings")) + } + + public actual fun getProducts( + productIds: List, + onError: (error: PurchasesError) -> Unit, + onSuccess: (storeProducts: List) -> Unit, + ): Unit = iosPurchases.getProductsWithIdentifiers( + productIdentifiers = productIds, + completion = { + onSuccess(it.orEmpty().map { product -> (product as RCStoreProduct).toStoreProduct() }) + }, + ) + + public actual fun getPromotionalOffer( + discount: StoreProductDiscount, + storeProduct: StoreProduct, + onError: (error: PurchasesError) -> Unit, + onSuccess: (offer: PromotionalOffer) -> Unit, + ): Unit = iosPurchases.getPromotionalOfferForProductDiscount( + discount = discount.toIosStoreProductDiscount(), + withProduct = storeProduct.toIosStoreProduct(), + ) { offer, error -> + if (error != null) onError(error.toPurchasesErrorOrThrow()) + else onSuccess( + offer?.toPromotionalOffer() + ?: error("Expected a non-null RCPromotionalOffer")) + } + + public actual fun purchase( + storeProduct: StoreProduct, + onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, + onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, + isPersonalizedPrice: Boolean?, + oldProductId: String?, + replacementMode: ReplacementMode?, + ): Unit = iosPurchases.purchaseProduct( + storeProduct.toIosStoreProduct() + ) { transaction, customerInfo, error, userCancelled -> + if (error != null) onError(error.toPurchasesErrorOrThrow(), userCancelled) + else onSuccess( + transaction?.toStoreTransaction() + ?: error("Expected a non-null RCStoreTransaction"), + customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") + ) + } + + public actual fun purchase( + packageToPurchase: Package, + onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, + onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, + isPersonalizedPrice: Boolean?, + oldProductId: String?, + replacementMode: ReplacementMode?, + ): Unit = iosPurchases.purchasePackage( + packageToPurchase.toIosPackage(), + ) { transaction, customerInfo, error, userCancelled -> + if (error != null) onError(error.toPurchasesErrorOrThrow(), userCancelled) + else onSuccess( + transaction?.toStoreTransaction() + ?: error("Expected a non-null RCStoreTransaction"), + customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") + ) + } + + public actual fun purchase( + subscriptionOption: SubscriptionOption, + onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, + onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, + isPersonalizedPrice: Boolean?, + oldProductId: String?, + replacementMode: ReplacementMode?, + ): Unit = error( + "Purchasing a SubscriptionOption is not possible on iOS. " + + "Did you mean purchase(StoreProduct, PromotionalOffer) or " + + "Purchases.purchase(Package, PromotionalOffer)?" + ) + + public actual fun purchase( + storeProduct: StoreProduct, + promotionalOffer: PromotionalOffer, + onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, + onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, + ): Unit = iosPurchases.purchaseProduct( + storeProduct.toIosStoreProduct(), + promotionalOffer.toIosPromotionalOffer() + ) { transaction, customerInfo, error, userCancelled -> + if (error != null) onError(error.toPurchasesErrorOrThrow(), userCancelled) + else onSuccess( + transaction?.toStoreTransaction() + ?: error("Expected a non-null RCStoreTransaction"), + customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") + ) + } + + public actual fun purchase( + packageToPurchase: Package, + promotionalOffer: PromotionalOffer, + onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, + onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, + ): Unit = iosPurchases.purchasePackage( + packageToPurchase.toIosPackage(), + promotionalOffer.toIosPromotionalOffer() + ) { transaction, customerInfo, error, userCancelled -> + if (error != null) onError(error.toPurchasesErrorOrThrow(), userCancelled) + else onSuccess( + transaction?.toStoreTransaction() + ?: error("Expected a non-null RCStoreTransaction"), + customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") + ) + } + + public actual fun restorePurchases( + onError: (error: PurchasesError) -> Unit, + onSuccess: (customerInfo: CustomerInfo) -> Unit, + ): Unit = iosPurchases.restorePurchasesWithCompletion { customerInfo, error -> + if (error != null) onError(error.toPurchasesErrorOrThrow()) + else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo")) + } + + public actual fun recordPurchase( + productID: String, + onError: (error: PurchasesError) -> Unit, + onSuccess: (storeTransaction: StoreTransaction) -> Unit, + ) { + iosPurchases.recordPurchaseForProductID(productID) { storeTransaction, error -> + if (error != null) { + onError(error.toPurchasesErrorOrThrow()) + return@recordPurchaseForProductID + } + if (storeTransaction == null) { + onError( + PurchasesError( + code = PurchasesErrorCode.UnknownError, + underlyingErrorMessage = + "Expected a non-null RCStoreTransaction when error is non-null." + ) + ) + } else { + onSuccess(storeTransaction.toStoreTransaction()) + } + } + } + + public actual fun checkTrialOrIntroPriceEligibility( + products: List, + callback: (Map) -> Unit, + ) { + val productsById = products.associateBy { it.id } + iosPurchases.checkTrialOrIntroDiscountEligibility(productsById.keys.toList()) { map -> + val eligibilityByProduct = + map.orEmpty().mapEntriesNotNull { (productId, iosEligibility) -> + productsById[productId as String]?.let { product -> + product to (iosEligibility as RCIntroEligibility).toIntroEligibilityStatus() + } + } + + callback(eligibilityByProduct) + } + } + + public actual fun getEligibleWinBackOffersForProduct( + storeProduct: StoreProduct, + onError: (error: PurchasesError) -> Unit, + onSuccess: (List) -> Unit, + ) { + // API availability checks must be performed here at the KMP level, since the KMP/ObjC/Swift + // interoperability drops the @available(osVersion) requirements, and you can technically + // call functions with an @available from any OS version in KMP + if (!appleApiAvailability.isWinBackOfferAPIAvailable()) { + onError( + PurchasesError( + PurchasesErrorCode.UnsupportedError, + underlyingErrorMessage = "getEligibleWinBackOffersForProduct $WIN_BACK_UNAVAILABLE_SUFFIX" + ) + ) + return + } + + iosPurchases.eligibleWinBackOffersForProduct( + product = storeProduct.toIosStoreProduct(), + completion = { eligibleWinBackOffers, error -> + + if (error != null) { + try { + onError(error.toPurchasesErrorOrThrow()) + } catch(e: IllegalStateException) { + onError( + PurchasesError( + code = PurchasesErrorCode.UnknownError, + underlyingErrorMessage = + "An unknown error occurred. Could not convert error to a PurchasesError." + ) + ) + } + return@eligibleWinBackOffersForProduct + } + + val typedEligibleWinBackOffers = eligibleWinBackOffers?.mapNotNull { + (it as? NativeIosWinBackOffer)?.toWinBackOffer() + } ?: emptyList() + + onSuccess(typedEligibleWinBackOffers) + } + ) + } + + public actual fun getEligibleWinBackOffersForPackage( + packageToCheck: Package, + onError: (error: PurchasesError) -> Unit, + onSuccess: (List) -> Unit, + ) { + // API availability checks must be performed here at the KMP level, since the KMP/ObjC/Swift + // interoperability drops the @available(osVersion) requirements, and you can technically + // call functions with an @available from any OS version in KMP + if (!appleApiAvailability.isWinBackOfferAPIAvailable()) { + onError( + PurchasesError( + PurchasesErrorCode.UnsupportedError, + underlyingErrorMessage = "getEligibleWinBackOffersForPackage $WIN_BACK_UNAVAILABLE_SUFFIX" + ) + ) + return + } + + iosPurchases.eligibleWinBackOffersForPackage( + packageToCheck.toIosPackage(), + completion = { eligibleWinBackOffers, error -> + + if (error != null) { + try { + onError(error.toPurchasesErrorOrThrow()) + } catch(e: IllegalStateException) { + onError( + PurchasesError( + code = PurchasesErrorCode.UnknownError, + underlyingErrorMessage = + "An unknown error occurred. Could not convert error to a PurchasesError." + ) + ) + } + return@eligibleWinBackOffersForPackage + } + + val typedEligibleWinBackOffers = eligibleWinBackOffers?.mapNotNull { + (it as? NativeIosWinBackOffer)?.toWinBackOffer() + } ?: emptyList() + + onSuccess(typedEligibleWinBackOffers) + } + ) + } + + public actual fun purchase( + storeProduct: StoreProduct, + winBackOffer: WinBackOffer, + onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, + onSuccess: (transaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, + ) { + // API availability checks must be performed here at the KMP level, since the KMP/ObjC/Swift + // interoperability drops the @available(osVersion) requirements, and you can technically + // call functions with an @available from any OS version in KMP + if (!appleApiAvailability.isWinBackOfferAPIAvailable()) { + onError( + PurchasesError( + PurchasesErrorCode.UnsupportedError, + underlyingErrorMessage = "purchase(product:winBackOffer:onError:onSuccess:) $WIN_BACK_UNAVAILABLE_SUFFIX" + ), + false + ) + return + } + + val purchaseParams = RCPurchaseParamsBuilder(product = storeProduct.toIosStoreProduct()) + .withWinBackOffer(winBackOffer.toIosWinBackOffer()) + .build() + + iosPurchases.purchaseWithParams( + params = purchaseParams, + completion = { + transaction: RCStoreTransaction?, + customerInfo: RCCustomerInfo?, + error: NSError?, + userCancelled: Boolean -> + + if (error != null) { + try { + onError(error.toPurchasesErrorOrThrow(), userCancelled) + } catch(e: IllegalStateException) { + onError( + PurchasesError( + code = PurchasesErrorCode.UnknownError, + underlyingErrorMessage = + "An unknown error occurred. Could not convert error to a PurchasesError." + ), + false + ) + } + return@purchaseWithParams + } + + onSuccess( + transaction?.toStoreTransaction() + ?: error("Expected a non-null RCStoreTransaction"), + customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") + ) + } + ) + } + + public actual fun purchase( + packageToPurchase: Package, + winBackOffer: WinBackOffer, + onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, + onSuccess: (transaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, + ) { + // API availability checks must be performed here at the KMP level, since the KMP/ObjC/Swift + // interoperability drops the @available(osVersion) requirements, and you can technically + // call functions with an @available from any OS version in KMP + if (!appleApiAvailability.isWinBackOfferAPIAvailable()) { + onError( + PurchasesError( + PurchasesErrorCode.UnsupportedError, + underlyingErrorMessage = "purchase(packageToPurchase:winBackOffer:onError:onSuccess:) $WIN_BACK_UNAVAILABLE_SUFFIX" + ), + false + ) + return + } + + val purchaseParams = RCPurchaseParamsBuilder(`package` = packageToPurchase.toIosPackage()) + .withWinBackOffer(winBackOffer.toIosWinBackOffer()) + .build() + + iosPurchases.purchaseWithParams( + params = purchaseParams, + completion = { + transaction: RCStoreTransaction?, + customerInfo: RCCustomerInfo?, + error: NSError?, + userCancelled: Boolean -> + + if (error != null) { + try { + onError(error.toPurchasesErrorOrThrow(), userCancelled) + } catch(e: IllegalStateException) { + onError( + PurchasesError( + code = PurchasesErrorCode.UnknownError, + underlyingErrorMessage = + "An unknown error occurred. Could not convert error to a PurchasesError." + ), + false + ) + } + return@purchaseWithParams + } + + onSuccess( + transaction?.toStoreTransaction() + ?: error("Expected a non-null RCStoreTransaction"), + customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") + ) + } + ) + } + + public actual fun logIn( + newAppUserID: String, + onError: (error: PurchasesError) -> Unit, + onSuccess: (customerInfo: CustomerInfo, created: Boolean) -> Unit + ): Unit = iosPurchases.logIn( + appUserID = newAppUserID, + completion = { customerInfo, created, error -> + if (error != null) onError(error.toPurchasesErrorOrThrow()) + else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo"), created) + } + ) + + public actual fun logOut( + onError: (error: PurchasesError) -> Unit, + onSuccess: (customerInfo: CustomerInfo) -> Unit, + ): Unit = iosPurchases.logOutWithCompletion { customerInfo, error -> + if (error != null) onError(error.toPurchasesErrorOrThrow()) + else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo")) + } + + public actual fun close() { + delegate = null + } + + public actual fun getCustomerInfo( + fetchPolicy: CacheFetchPolicy, + onError: (error: PurchasesError) -> Unit, + onSuccess: (customerInfo: CustomerInfo) -> Unit, + ): Unit = iosPurchases.getCustomerInfoWithFetchPolicy( + fetchPolicy.toIosCacheFetchPolicy() + ) { customerInfo, error -> + if (error != null) onError(error.toPurchasesErrorOrThrow()) + else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo")) + } + + public actual fun showInAppMessagesIfNeeded( + messageTypes: List, + ) { + iosPurchases.showStoreMessagesIfAvailable(messageTypes) + } + + public actual fun invalidateCustomerInfoCache(): Unit = + iosPurchases.invalidateCustomerInfoCache() + + public actual fun overridePreferredUILocale(locale: String?): Unit = + iosPurchases.overridePreferredUILocale(locale) + + public actual fun setAttributes(attributes: Map): Unit = + iosPurchases.setAttributes(attributes.mapKeys { (key, _) -> key }) + + public actual fun setEmail(email: String?): Unit = + iosPurchases.setEmail(email) + + public actual fun setPhoneNumber(phoneNumber: String?): Unit = + iosPurchases.setPhoneNumber(phoneNumber) + + public actual fun setDisplayName(displayName: String?): Unit = + iosPurchases.setDisplayName(displayName) + + public actual fun setPushToken(fcmToken: String?): Unit = + iosPurchases.setPushTokenString(fcmToken) + + public actual fun setMixpanelDistinctID(mixpanelDistinctID: String?): Unit = + iosPurchases.setMixpanelDistinctID(mixpanelDistinctID) + + public actual fun setOnesignalID(onesignalID: String?): Unit = + iosPurchases.setOnesignalID(onesignalID) + + public actual fun setOnesignalUserID(onesignalUserID: String?): Unit = + iosPurchases.attribution().setOnesignalUserID(onesignalUserID) + + public actual fun setPostHogUserID(postHogUserID: String?): Unit = + iosPurchases.attribution().setPostHogUserID(postHogUserID) + + public actual fun setAirshipChannelID(airshipChannelID: String?): Unit = + iosPurchases.attribution().setAirshipChannelID(airshipChannelID) + + public actual fun setFirebaseAppInstanceID(firebaseAppInstanceID: String?): Unit = + iosPurchases.setFirebaseAppInstanceID(firebaseAppInstanceID) + + public actual fun collectDeviceIdentifiers(): Unit = + iosPurchases.collectDeviceIdentifiers() + + public actual fun setAdjustID(adjustID: String?): Unit = + iosPurchases.setAdjustID(adjustID) + + public actual fun setAppsflyerID(appsflyerID: String?): Unit = + iosPurchases.setAppsflyerID(appsflyerID) + + public actual fun setAirbridgeDeviceID(airbridgeDeviceID: String?): Unit = + iosPurchases.attribution().setAirbridgeDeviceID(airbridgeDeviceID) + + public actual fun setFBAnonymousID(fbAnonymousID: String?): Unit = + iosPurchases.setFBAnonymousID(fbAnonymousID) + + public actual fun setMparticleID(mparticleID: String?): Unit = + iosPurchases.setMparticleID(mparticleID) + + public actual fun setCleverTapID(cleverTapID: String?): Unit = + iosPurchases.setCleverTapID(cleverTapID) + + public actual fun setMediaSource(mediaSource: String?): Unit = + iosPurchases.setMediaSource(mediaSource) + + public actual fun setCampaign(campaign: String?): Unit = + iosPurchases.setCampaign(campaign) + + public actual fun setAdGroup(adGroup: String?): Unit = + iosPurchases.setAdGroup(adGroup) + + public actual fun setAd(ad: String?): Unit = + iosPurchases.setAd(ad) + + public actual fun setKeyword(keyword: String?): Unit = + iosPurchases.setKeyword(keyword) + + public actual fun setCreative(creative: String?): Unit = + iosPurchases.setCreative(creative) + + public actual fun presentCodeRedemptionSheet() { + iosPurchases.presentCodeRedemptionSheetIfAvailable() + } + + public actual fun enableAdServicesAttributionTokenCollection() { + if (appleApiAvailability.isEnableAdServicesAttributionTokenCollectionAPIAvailable()) + iosPurchases.attribution().enableAdServicesAttributionTokenCollection() + else logHandler.d( + tag = "Purchases", + msg = "`enableAdServicesAttributionTokenCollection()` is only available on iOS 14.3 " + + "and up." + ) + } + + public actual fun redeemWebPurchase( + webPurchaseRedemption: WebPurchaseRedemption, + listener: RedeemWebPurchaseListener, + ) { + val nativeWebPurchaseRedemption = IosPurchases.parseAsWebPurchaseRedemption( + url = NSURL(string = webPurchaseRedemption.redemptionUrl) + ) + if (nativeWebPurchaseRedemption == null) { + listener.handleResult(RedeemWebPurchaseListener.Result.Error( + PurchasesError( + code = PurchasesErrorCode.ConfigurationError, + underlyingErrorMessage = "Invalid web purchase redemption URL." + ) + )) + return + } + iosPurchases.redeemWebPurchaseWithWebPurchaseRedemption( + nativeWebPurchaseRedemption, + ) { rcCustomerInfo, nsError -> + if (nsError != null) { + val errorCode = nsError.code.toInt() + val result = when (errorCode) { + PurchasesErrorCode.InvalidWebPurchaseToken.code -> + RedeemWebPurchaseListener.Result.InvalidToken + PurchasesErrorCode.PurchaseBelongsToOtherUser.code -> + RedeemWebPurchaseListener.Result.PurchaseBelongsToOtherUser + PurchasesErrorCode.ExpiredWebPurchaseToken.code -> + RedeemWebPurchaseListener.Result.Expired( + nsError.userInfo["rc_obfuscated_email"] as String? ?: "" + ) + else -> + RedeemWebPurchaseListener.Result.Error(nsError.toPurchasesErrorOrThrow()) + } + listener.handleResult(result) + return@redeemWebPurchaseWithWebPurchaseRedemption + } + if (rcCustomerInfo == null) { + listener.handleResult( + RedeemWebPurchaseListener.Result.Error( + PurchasesError( + code = PurchasesErrorCode.UnknownError, + underlyingErrorMessage = "Expected a non-null RCCustomerInfo when error is null." + ) + ) + ) + return@redeemWebPurchaseWithWebPurchaseRedemption + } + listener.handleResult( + RedeemWebPurchaseListener.Result.Success( + rcCustomerInfo.toCustomerInfo() + ) + ) + } + } + + public actual fun getVirtualCurrencies( + onError: (error: PurchasesError) -> Unit, + onSuccess: (virtualCurrencies: VirtualCurrencies) -> Unit, + ): Unit = iosPurchases.getVirtualCurrenciesWithCompletion { virtualCurrencies, error -> + if (error != null) { onError(error.toPurchasesErrorOrThrow()) } + else onSuccess(virtualCurrencies?.toVirtualCurrencies() ?: error("Expected a non-null RCVirtualCurrencies")) + } + + public actual fun invalidateVirtualCurrenciesCache(): Unit = iosPurchases.invalidateVirtualCurrenciesCache() + + public actual fun getCachedVirtualCurrencies(): VirtualCurrencies? { + val cachedVirtualCurrencies: RCVirtualCurrencies? = iosPurchases.cachedVirtualCurrencies() + return cachedVirtualCurrencies?.toVirtualCurrencies() + } + + public actual fun trackCustomPaywallImpression( + params: CustomPaywallImpressionParams, + ) { + if (!appleApiAvailability.isCustomPaywallTrackingAPIAvailable()) { + logHandler.w( + "Purchases", + "Custom paywall tracking requires iOS 15.0+ or watchOS 8.0+. Current API is unavailable." + ) + return + } + val paywallId = params.paywallId + @Suppress("DEPRECATION") + val offeringId = params.offeringId + val iosParams = params.offering?.let { offering -> + RCCustomPaywallImpressionParams( + paywallId = paywallId, + offering = offering.toIosOffering(), + ) + } ?: run { + @Suppress("DEPRECATION") + RCCustomPaywallImpressionParams( + paywallId = paywallId, + offeringId = offeringId + ) + } + + iosPurchases.trackCustomPaywallImpression(iosParams) + } + + @ExperimentalRevenueCatApi + public actual val adTracker: AdTracker by lazy { AdTracker(iosPurchases.adTracker()) } +} + +/** + * Presents the code redemption sheet where available (iOS 14.0+), or logs otherwise. The native + * API does not exist on watchOS, so the iOS call must live in an iOS-only source set: cinterop + * bindings shared across Apple targets only contain APIs available on all of them. + */ +internal expect fun IosPurchases.presentCodeRedemptionSheetIfAvailable() + +/** + * iOS: shows store messages of the given types (native API, iOS 16.0+). watchOS: logs and does + * nothing, the native API does not exist there, which is why the iOS call must live in an + * iOS-only source set: cinterop bindings shared across Apple targets only contain APIs + * available on all of them. + */ +internal expect fun IosPurchases.showStoreMessagesIfAvailable(messageTypes: List) + +private val appleApiAvailability = AppleApiAvailability() + +private const val WIN_BACK_UNAVAILABLE_SUFFIX = "is only available on iOS 18.0+ or watchOS 11.0+" diff --git a/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/PurchasesDelegate.ios.kt b/core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/PurchasesDelegate.apple.kt similarity index 100% rename from core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/PurchasesDelegate.ios.kt rename to core/src/appleMain/kotlin/com/revenuecat/purchases/kmp/PurchasesDelegate.apple.kt diff --git a/core/src/iosTest/kotlin/com/revenuecat/purchases/kmp/PrintLnLogHandler.kt b/core/src/appleTest/kotlin/com/revenuecat/purchases/kmp/PrintLnLogHandler.kt similarity index 100% rename from core/src/iosTest/kotlin/com/revenuecat/purchases/kmp/PrintLnLogHandler.kt rename to core/src/appleTest/kotlin/com/revenuecat/purchases/kmp/PrintLnLogHandler.kt diff --git a/core/src/iosTest/kotlin/com/revenuecat/purchases/kmp/PurchasesConfigurationTests.kt b/core/src/appleTest/kotlin/com/revenuecat/purchases/kmp/PurchasesConfigurationTests.kt similarity index 100% rename from core/src/iosTest/kotlin/com/revenuecat/purchases/kmp/PurchasesConfigurationTests.kt rename to core/src/appleTest/kotlin/com/revenuecat/purchases/kmp/PurchasesConfigurationTests.kt diff --git a/core/src/commonMain/kotlin/com/revenuecat/purchases/kmp/Purchases.kt b/core/src/commonMain/kotlin/com/revenuecat/purchases/kmp/Purchases.kt index 111722e84..854256dd5 100644 --- a/core/src/commonMain/kotlin/com/revenuecat/purchases/kmp/Purchases.kt +++ b/core/src/commonMain/kotlin/com/revenuecat/purchases/kmp/Purchases.kt @@ -460,7 +460,7 @@ public expect class Purchases { /** * Fetches the win-back offers that a subscriber is eligible for on a given product. * - * iOS only. Requires iOS 18.0+ and StoreKit 2 and emits an error if these requirements aren't + * Apple platforms only. Requires iOS 18.0+ or watchOS 11.0+ and StoreKit 2 and emits an error if these requirements aren't * met. * * @param storeProduct: The product to check for eligible win-back offers on. @@ -478,7 +478,7 @@ public expect class Purchases { /** * Fetches the win-back offers that a subscriber is eligible for on a given package. * - * iOS only. Requires iOS 18.0+ and StoreKit 2 and emits an error if these requirements aren't + * Apple platforms only. Requires iOS 18.0+ or watchOS 11.0+ and StoreKit 2 and emits an error if these requirements aren't * met. * * @param packageToCheck: The package to check for eligible win-back offers on. @@ -497,7 +497,7 @@ public expect class Purchases { * Purchases a product with a given win-back offer. If you are using the Offerings system, use the * overload with a [Package] parameter instead. * - * iOS only. Requires iOS 18.0+ and StoreKit 2 and emits an error if these requirements aren't + * Apple platforms only. Requires iOS 18.0+ or watchOS 11.0+ and StoreKit 2 and emits an error if these requirements aren't * met. * * @param storeProduct: The product to purchase @@ -519,7 +519,7 @@ public expect class Purchases { /** * Purchases a package with a given win-back offer. * - * iOS only. Requires iOS 18.0+ and StoreKit 2 and emits an error if these requirements aren't + * Apple platforms only. Requires iOS 18.0+ or watchOS 11.0+ and StoreKit 2 and emits an error if these requirements aren't * met. * * @param packageToPurchase: The package to purchase @@ -575,7 +575,7 @@ public expect class Purchases { ) /** - * Google Play and App Store only, no-op for Amazon. + * Google Play and App Store only, no-op for Amazon and watchOS. * Displays the specified in-app message types to the user as a snackbar if there are any * available to be shown. * If [PurchasesConfiguration.showInAppMessagesAutomatically] is enabled, this will be done @@ -806,7 +806,7 @@ public expect class Purchases { * Refer to https://docs.revenuecat.com/docs/ios-subscription-offers#offer-codes * for more information on how to configure and use offer codes. * - * On Android, this is a no-op. + * On Android and watchOS, this is a no-op. */ public fun presentCodeRedemptionSheet() diff --git a/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.ios.kt b/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.ios.kt index b4b990c68..c3a5708c7 100644 --- a/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.ios.kt +++ b/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.ios.kt @@ -1,829 +1,22 @@ package com.revenuecat.purchases.kmp - -import com.revenuecat.purchases.kmp.ktx.mapEntriesNotNull -import com.revenuecat.purchases.kmp.mappings.toCustomerInfo -import com.revenuecat.purchases.kmp.mappings.toIntroEligibilityStatus -import com.revenuecat.purchases.kmp.mappings.toIosCacheFetchPolicy -import com.revenuecat.purchases.kmp.mappings.toIosEntitlementVerificationMode -import com.revenuecat.purchases.kmp.mappings.toIosOffering -import com.revenuecat.purchases.kmp.mappings.toIosPackage -import com.revenuecat.purchases.kmp.mappings.toIosPromotionalOffer -import com.revenuecat.purchases.kmp.mappings.toIosPurchasesAreCompletedBy -import com.revenuecat.purchases.kmp.mappings.toIosStoreKitVersion import com.revenuecat.purchases.kmp.mappings.toIosStoreMessageTypes -import com.revenuecat.purchases.kmp.mappings.toIosStoreProduct -import com.revenuecat.purchases.kmp.mappings.toIosStoreProductDiscount -import com.revenuecat.purchases.kmp.mappings.toIosWinBackOffer -import com.revenuecat.purchases.kmp.mappings.toOfferings -import com.revenuecat.purchases.kmp.mappings.toPromotionalOffer -import com.revenuecat.purchases.kmp.mappings.toPurchasesErrorOrThrow -import com.revenuecat.purchases.kmp.mappings.toStoreProduct -import com.revenuecat.purchases.kmp.mappings.toStoreTransaction -import com.revenuecat.purchases.kmp.mappings.toStorefront -import com.revenuecat.purchases.kmp.mappings.toVirtualCurrencies -import com.revenuecat.purchases.kmp.mappings.toWinBackOffer -import com.revenuecat.purchases.kmp.models.BillingFeature -import com.revenuecat.purchases.kmp.models.CacheFetchPolicy -import com.revenuecat.purchases.kmp.models.CustomPaywallImpressionParams -import com.revenuecat.purchases.kmp.models.CustomerInfo -import com.revenuecat.purchases.kmp.models.DangerousSettings -import com.revenuecat.purchases.kmp.models.IntroEligibilityStatus -import com.revenuecat.purchases.kmp.models.Offerings -import com.revenuecat.purchases.kmp.models.Package -import com.revenuecat.purchases.kmp.models.PromotionalOffer -import com.revenuecat.purchases.kmp.models.PurchasesError -import com.revenuecat.purchases.kmp.models.PurchasesErrorCode -import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener -import com.revenuecat.purchases.kmp.models.ReplacementMode -import com.revenuecat.purchases.kmp.models.Store import com.revenuecat.purchases.kmp.models.StoreMessageType -import com.revenuecat.purchases.kmp.models.StoreProduct -import com.revenuecat.purchases.kmp.models.StoreProductDiscount -import com.revenuecat.purchases.kmp.models.StoreTransaction -import com.revenuecat.purchases.kmp.models.Storefront -import com.revenuecat.purchases.kmp.models.SubscriptionOption -import com.revenuecat.purchases.kmp.models.VirtualCurrencies -import com.revenuecat.purchases.kmp.models.WebPurchaseRedemption -import com.revenuecat.purchases.kmp.models.WinBackOffer -import com.revenuecat.purchases.kmp.strings.ConfigureStrings -import platform.Foundation.NSError -import platform.Foundation.NSURL -import platform.Foundation.NSUserDefaults -import com.revenuecat.purchases.kn.core.RCConfiguration -import com.revenuecat.purchases.kn.core.RCCustomPaywallImpressionParams -import com.revenuecat.purchases.kn.core.RCCustomerInfo -import com.revenuecat.purchases.kn.core.RCIntroEligibility -import com.revenuecat.purchases.kn.core.RCPlatformInfo -import com.revenuecat.purchases.kn.core.RCPurchaseParamsBuilder -import com.revenuecat.purchases.kn.core.RCPurchasesDelegateProtocol -import com.revenuecat.purchases.kn.core.RCStoreProduct -import com.revenuecat.purchases.kn.core.overridePreferredUILocale -import com.revenuecat.purchases.kn.core.RCStoreTransaction -import com.revenuecat.purchases.kn.core.RCVirtualCurrencies import com.revenuecat.purchases.kn.core.additional.AppleApiAvailability -import com.revenuecat.purchases.kn.core.configureWithConfiguration -import com.revenuecat.purchases.kn.core.enableAdServicesAttributionTokenCollection -import com.revenuecat.purchases.kn.core.parseAsWebPurchaseRedemption -import com.revenuecat.purchases.kn.core.recordPurchaseForProductID -import com.revenuecat.purchases.kn.core.setAirbridgeDeviceID -import com.revenuecat.purchases.kn.core.setAirshipChannelID -import com.revenuecat.purchases.kn.core.setOnesignalUserID -import com.revenuecat.purchases.kn.core.setPostHogUserID import com.revenuecat.purchases.kn.core.showStoreMessagesForTypes -import com.revenuecat.purchases.kn.core.trackCustomPaywallImpression -import com.revenuecat.purchases.kn.core.RCDangerousSettings as IosDangerousSettings import com.revenuecat.purchases.kn.core.RCPurchases as IosPurchases -import com.revenuecat.purchases.kn.core.RCWinBackOffer as NativeIosWinBackOffer - -public actual class Purchases private constructor(private val iosPurchases: IosPurchases) { - public actual companion object { - private var _sharedInstance: Purchases? = null - public actual val sharedInstance: Purchases - get() = _sharedInstance ?: error(ConfigureStrings.NO_SINGLETON_INSTANCE) - - public actual var logLevel: LogLevel - get() = IosPurchases.logLevel().toLogLevel() - set(value) = IosPurchases.setLogLevel(value.toRcLogLevel()) - - public actual var logHandler: LogHandler - get() = IosPurchases.logHandler().toLogHandler() - set(value) { - IosPurchases.setLogHandler(value.toIosLogHandler()) - } - - public actual var proxyURL: String? - get() = IosPurchases.proxyURL()?.absoluteString() - set(value) = IosPurchases.setProxyURL(value?.let { NSURL(string = it) }) - - public actual val isConfigured: Boolean - get() = IosPurchases.isConfigured() - - public actual var simulatesAskToBuyInSandbox: Boolean - get() = IosPurchases.simulatesAskToBuyInSandbox() - set(value) = IosPurchases.setSimulatesAskToBuyInSandbox(value) - - public actual var forceUniversalAppStore: Boolean - get() = IosPurchases.forceUniversalAppStore() - set(value) = IosPurchases.setForceUniversalAppStore(value) - - public actual fun configure( - configuration: PurchasesConfiguration - ): Purchases = with(configuration) { - IosPurchases.configureWithConfiguration( - configuration = RCConfiguration.builderWithAPIKey(apiKey) - .withAppUserID(appUserId) - .apply { - purchasesAreCompletedBy.toIosPurchasesAreCompletedBy().also { - withPurchasesAreCompletedBy( - purchasesAreCompletedBy = it.purchasesAreCompletedBy, - storeKitVersion = it.storeKitVersion - ) - } - } - .withUserDefaults(NSUserDefaults(suiteName = userDefaultsSuiteName)) - .withPlatformInfo( - RCPlatformInfo( - flavor = BuildKonfig.platformFlavor, - version = frameworkVersion - ) - ) - .withStoreKitVersion(storeKitVersion.toIosStoreKitVersion()) - .withDangerousSettings(dangerousSettings.toIosDangerousSettings()) - .withShowStoreMessagesAutomatically(showInAppMessagesAutomatically) - .withEntitlementVerificationMode(verificationMode.toIosEntitlementVerificationMode()) - .withPreferredUILocaleOverride(preferredUILocaleOverride) - .build() - ) - }.let { Purchases(it) } - .also { _sharedInstance = it } - - public actual fun canMakePayments( - features: List, - callback: (Boolean) -> Unit - ) { - callback(IosPurchases.canMakePayments()) - } - - private fun DangerousSettings.toIosDangerousSettings(): IosDangerousSettings = - IosDangerousSettings(autoSyncPurchases) - - public actual fun parseAsWebPurchaseRedemption(url: String): WebPurchaseRedemption? { - return if (IosPurchases.parseAsWebPurchaseRedemption(NSURL(string = url)) != null) { - WebPurchaseRedemption(url) - } else { - null - } - } - } - public actual val appUserID: String - get() = iosPurchases.appUserID() - - /** - * Making sure we keep a strong reference to our delegate wrapper, as iosPurchases only keeps - * a weak one. This avoids the wrapper from being deallocated the first chance it gets. This - * behavior matches the Android platform behavior. - */ - private var _delegateWrapper: RCPurchasesDelegateProtocol? = null - public actual var delegate: PurchasesDelegate? - get() = iosPurchases.delegate()?.toPurchasesDelegate() - set(value) { - _delegateWrapper = value.toRcPurchasesDelegate() - iosPurchases.setDelegate(_delegateWrapper) - } - - public actual val isAnonymous: Boolean - get() = iosPurchases.isAnonymous() - - public actual val store: Store - get() = Store.APP_STORE - - public actual fun getStorefront(callback: ((Storefront?) -> Unit)) { - iosPurchases.getStorefrontWithCompletionHandler { storefront -> - if (storefront == null) { - callback(null) - } else { - callback(storefront.toStorefront()) - } - } - } - - public actual fun syncPurchases( - onError: (error: PurchasesError) -> Unit, - onSuccess: (CustomerInfo) -> Unit, - ): Unit = iosPurchases.syncPurchasesWithCompletionHandler { customerInfo, error -> - if (error != null) onError(error.toPurchasesErrorOrThrow()) - else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo")) - } - - public actual fun syncAmazonPurchase( - productID: String, - receiptID: String, - amazonUserID: String, - isoCurrencyCode: String?, - price: Double?, - ) { - // No-op on iOS - } - - public actual fun syncAttributesAndOfferingsIfNeeded( - onError: (error: PurchasesError) -> Unit, - onSuccess: (offerings: Offerings) -> Unit, - ): Unit = iosPurchases.syncAttributesAndOfferingsIfNeededWithCompletion { offerings, error -> - if (error != null) onError(error.toPurchasesErrorOrThrow()) - else onSuccess(offerings?.toOfferings() ?: error("Expected a non-null RCOfferings")) - } - - public actual fun getOfferings( - onError: (error: PurchasesError) -> Unit, - onSuccess: (offerings: Offerings) -> Unit, - ): Unit = iosPurchases.getOfferingsWithCompletion { offerings, error -> - if (error != null) onError(error.toPurchasesErrorOrThrow()) - else onSuccess(offerings?.toOfferings() ?: error("Expected a non-null RCOfferings")) - } - - public actual fun getProducts( - productIds: List, - onError: (error: PurchasesError) -> Unit, - onSuccess: (storeProducts: List) -> Unit, - ): Unit = iosPurchases.getProductsWithIdentifiers( - productIdentifiers = productIds, - completion = { - onSuccess(it.orEmpty().map { product -> (product as RCStoreProduct).toStoreProduct() }) - }, +internal actual fun IosPurchases.presentCodeRedemptionSheetIfAvailable() { + if (AppleApiAvailability().isCodeRedemptionSheetAPIAvailable()) + presentCodeRedemptionSheet() + else Purchases.logHandler.d( + tag = "Purchases", + msg = "`presentCodeRedemptionSheet()` is only available on iOS 14.0 and up." ) +} - public actual fun getPromotionalOffer( - discount: StoreProductDiscount, - storeProduct: StoreProduct, - onError: (error: PurchasesError) -> Unit, - onSuccess: (offer: PromotionalOffer) -> Unit, - ): Unit = iosPurchases.getPromotionalOfferForProductDiscount( - discount = discount.toIosStoreProductDiscount(), - withProduct = storeProduct.toIosStoreProduct(), - ) { offer, error -> - if (error != null) onError(error.toPurchasesErrorOrThrow()) - else onSuccess( - offer?.toPromotionalOffer() - ?: error("Expected a non-null RCPromotionalOffer")) - } - - public actual fun purchase( - storeProduct: StoreProduct, - onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, - onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, - isPersonalizedPrice: Boolean?, - oldProductId: String?, - replacementMode: ReplacementMode?, - ): Unit = iosPurchases.purchaseProduct( - storeProduct.toIosStoreProduct() - ) { transaction, customerInfo, error, userCancelled -> - if (error != null) onError(error.toPurchasesErrorOrThrow(), userCancelled) - else onSuccess( - transaction?.toStoreTransaction() - ?: error("Expected a non-null RCStoreTransaction"), - customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") - ) - } - - public actual fun purchase( - packageToPurchase: Package, - onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, - onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, - isPersonalizedPrice: Boolean?, - oldProductId: String?, - replacementMode: ReplacementMode?, - ): Unit = iosPurchases.purchasePackage( - packageToPurchase.toIosPackage(), - ) { transaction, customerInfo, error, userCancelled -> - if (error != null) onError(error.toPurchasesErrorOrThrow(), userCancelled) - else onSuccess( - transaction?.toStoreTransaction() - ?: error("Expected a non-null RCStoreTransaction"), - customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") - ) - } - - public actual fun purchase( - subscriptionOption: SubscriptionOption, - onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, - onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, - isPersonalizedPrice: Boolean?, - oldProductId: String?, - replacementMode: ReplacementMode?, - ): Unit = error( - "Purchasing a SubscriptionOption is not possible on iOS. " + - "Did you mean purchase(StoreProduct, PromotionalOffer) or " + - "Purchases.purchase(Package, PromotionalOffer)?" - ) - - public actual fun purchase( - storeProduct: StoreProduct, - promotionalOffer: PromotionalOffer, - onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, - onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, - ): Unit = iosPurchases.purchaseProduct( - storeProduct.toIosStoreProduct(), - promotionalOffer.toIosPromotionalOffer() - ) { transaction, customerInfo, error, userCancelled -> - if (error != null) onError(error.toPurchasesErrorOrThrow(), userCancelled) - else onSuccess( - transaction?.toStoreTransaction() - ?: error("Expected a non-null RCStoreTransaction"), - customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") - ) - } - - public actual fun purchase( - packageToPurchase: Package, - promotionalOffer: PromotionalOffer, - onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, - onSuccess: (storeTransaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, - ): Unit = iosPurchases.purchasePackage( - packageToPurchase.toIosPackage(), - promotionalOffer.toIosPromotionalOffer() - ) { transaction, customerInfo, error, userCancelled -> - if (error != null) onError(error.toPurchasesErrorOrThrow(), userCancelled) - else onSuccess( - transaction?.toStoreTransaction() - ?: error("Expected a non-null RCStoreTransaction"), - customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") - ) - } - - public actual fun restorePurchases( - onError: (error: PurchasesError) -> Unit, - onSuccess: (customerInfo: CustomerInfo) -> Unit, - ): Unit = iosPurchases.restorePurchasesWithCompletion { customerInfo, error -> - if (error != null) onError(error.toPurchasesErrorOrThrow()) - else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo")) - } - - public actual fun recordPurchase( - productID: String, - onError: (error: PurchasesError) -> Unit, - onSuccess: (storeTransaction: StoreTransaction) -> Unit, - ) { - iosPurchases.recordPurchaseForProductID(productID) { storeTransaction, error -> - if (error != null) { - onError(error.toPurchasesErrorOrThrow()) - return@recordPurchaseForProductID - } - if (storeTransaction == null) { - onError( - PurchasesError( - code = PurchasesErrorCode.UnknownError, - underlyingErrorMessage = - "Expected a non-null RCStoreTransaction when error is non-null." - ) - ) - } else { - onSuccess(storeTransaction.toStoreTransaction()) - } - } - } - - public actual fun checkTrialOrIntroPriceEligibility( - products: List, - callback: (Map) -> Unit, - ) { - val productsById = products.associateBy { it.id } - iosPurchases.checkTrialOrIntroDiscountEligibility(productsById.keys.toList()) { map -> - val eligibilityByProduct = - map.orEmpty().mapEntriesNotNull { (productId, iosEligibility) -> - productsById[productId as String]?.let { product -> - product to (iosEligibility as RCIntroEligibility).toIntroEligibilityStatus() - } - } - - callback(eligibilityByProduct) - } - } - - public actual fun getEligibleWinBackOffersForProduct( - storeProduct: StoreProduct, - onError: (error: PurchasesError) -> Unit, - onSuccess: (List) -> Unit, - ) { - // API availability checks must be performed here at the KMP level, since the KMP/ObjC/Swift - // interoperability drops the @available(osVersion) requirements, and you can technically - // call functions with an @available from any OS version in KMP - if (!AppleApiAvailability().isWinBackOfferAPIAvailable()) { - onError( - PurchasesError( - PurchasesErrorCode.UnsupportedError, - underlyingErrorMessage = "getEligibleWinBackOffersForProduct is only available on iOS 18.0+" - ) - ) - return - } - - iosPurchases.eligibleWinBackOffersForProduct( - product = storeProduct.toIosStoreProduct(), - completion = { eligibleWinBackOffers, error -> - - if (error != null) { - try { - onError(error.toPurchasesErrorOrThrow()) - } catch(e: IllegalStateException) { - onError( - PurchasesError( - code = PurchasesErrorCode.UnknownError, - underlyingErrorMessage = - "An unknown error occurred. Could not convert error to a PurchasesError." - ) - ) - } - return@eligibleWinBackOffersForProduct - } - - val typedEligibleWinBackOffers = eligibleWinBackOffers?.mapNotNull { - (it as? NativeIosWinBackOffer)?.toWinBackOffer() - } ?: emptyList() - - onSuccess(typedEligibleWinBackOffers) - } - ) - } - - public actual fun getEligibleWinBackOffersForPackage( - packageToCheck: Package, - onError: (error: PurchasesError) -> Unit, - onSuccess: (List) -> Unit, - ) { - // API availability checks must be performed here at the KMP level, since the KMP/ObjC/Swift - // interoperability drops the @available(osVersion) requirements, and you can technically - // call functions with an @available from any OS version in KMP - if (!AppleApiAvailability().isWinBackOfferAPIAvailable()) { - onError( - PurchasesError( - PurchasesErrorCode.UnsupportedError, - underlyingErrorMessage = "getEligibleWinBackOffersForPackage is only available on iOS 18.0+" - ) - ) - return - } - - iosPurchases.eligibleWinBackOffersForPackage( - packageToCheck.toIosPackage(), - completion = { eligibleWinBackOffers, error -> - - if (error != null) { - try { - onError(error.toPurchasesErrorOrThrow()) - } catch(e: IllegalStateException) { - onError( - PurchasesError( - code = PurchasesErrorCode.UnknownError, - underlyingErrorMessage = - "An unknown error occurred. Could not convert error to a PurchasesError." - ) - ) - } - return@eligibleWinBackOffersForPackage - } - - val typedEligibleWinBackOffers = eligibleWinBackOffers?.mapNotNull { - (it as? NativeIosWinBackOffer)?.toWinBackOffer() - } ?: emptyList() - - onSuccess(typedEligibleWinBackOffers) - } - ) - } - - public actual fun purchase( - storeProduct: StoreProduct, - winBackOffer: WinBackOffer, - onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, - onSuccess: (transaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, - ) { - // API availability checks must be performed here at the KMP level, since the KMP/ObjC/Swift - // interoperability drops the @available(osVersion) requirements, and you can technically - // call functions with an @available from any OS version in KMP - if (!AppleApiAvailability().isWinBackOfferAPIAvailable()) { - onError( - PurchasesError( - PurchasesErrorCode.UnsupportedError, - underlyingErrorMessage = "purchase(product:winBackOffer:onError:onSuccess:) is only available on iOS 18.0+" - ), - false - ) - return - } - - val purchaseParams = RCPurchaseParamsBuilder(product = storeProduct.toIosStoreProduct()) - .withWinBackOffer(winBackOffer.toIosWinBackOffer()) - .build() - - iosPurchases.purchaseWithParams( - params = purchaseParams, - completion = { - transaction: RCStoreTransaction?, - customerInfo: RCCustomerInfo?, - error: NSError?, - userCancelled: Boolean -> - - if (error != null) { - try { - onError(error.toPurchasesErrorOrThrow(), userCancelled) - } catch(e: IllegalStateException) { - onError( - PurchasesError( - code = PurchasesErrorCode.UnknownError, - underlyingErrorMessage = - "An unknown error occurred. Could not convert error to a PurchasesError." - ), - false - ) - } - return@purchaseWithParams - } - - onSuccess( - transaction?.toStoreTransaction() - ?: error("Expected a non-null RCStoreTransaction"), - customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") - ) - } - ) - } - - public actual fun purchase( - packageToPurchase: Package, - winBackOffer: WinBackOffer, - onError: (error: PurchasesError, userCancelled: Boolean) -> Unit, - onSuccess: (transaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit, - ) { - // API availability checks must be performed here at the KMP level, since the KMP/ObjC/Swift - // interoperability drops the @available(osVersion) requirements, and you can technically - // call functions with an @available from any OS version in KMP - if (!AppleApiAvailability().isWinBackOfferAPIAvailable()) { - onError( - PurchasesError( - PurchasesErrorCode.UnsupportedError, - underlyingErrorMessage = "purchase(packageToPurchase:winBackOffer:onError:onSuccess:) is only available on iOS 18.0+" - ), - false - ) - return - } - - val purchaseParams = RCPurchaseParamsBuilder(`package` = packageToPurchase.toIosPackage()) - .withWinBackOffer(winBackOffer.toIosWinBackOffer()) - .build() - - iosPurchases.purchaseWithParams( - params = purchaseParams, - completion = { - transaction: RCStoreTransaction?, - customerInfo: RCCustomerInfo?, - error: NSError?, - userCancelled: Boolean -> - - if (error != null) { - try { - onError(error.toPurchasesErrorOrThrow(), userCancelled) - } catch(e: IllegalStateException) { - onError( - PurchasesError( - code = PurchasesErrorCode.UnknownError, - underlyingErrorMessage = - "An unknown error occurred. Could not convert error to a PurchasesError." - ), - false - ) - } - return@purchaseWithParams - } - - onSuccess( - transaction?.toStoreTransaction() - ?: error("Expected a non-null RCStoreTransaction"), - customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo") - ) - } - ) - } - - public actual fun logIn( - newAppUserID: String, - onError: (error: PurchasesError) -> Unit, - onSuccess: (customerInfo: CustomerInfo, created: Boolean) -> Unit - ): Unit = iosPurchases.logIn( - appUserID = newAppUserID, - completion = { customerInfo, created, error -> - if (error != null) onError(error.toPurchasesErrorOrThrow()) - else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo"), created) - } - ) - - public actual fun logOut( - onError: (error: PurchasesError) -> Unit, - onSuccess: (customerInfo: CustomerInfo) -> Unit, - ): Unit = iosPurchases.logOutWithCompletion { customerInfo, error -> - if (error != null) onError(error.toPurchasesErrorOrThrow()) - else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo")) - } - - public actual fun close() { - delegate = null - } - - public actual fun getCustomerInfo( - fetchPolicy: CacheFetchPolicy, - onError: (error: PurchasesError) -> Unit, - onSuccess: (customerInfo: CustomerInfo) -> Unit, - ): Unit = iosPurchases.getCustomerInfoWithFetchPolicy( - fetchPolicy.toIosCacheFetchPolicy() - ) { customerInfo, error -> - if (error != null) onError(error.toPurchasesErrorOrThrow()) - else onSuccess(customerInfo?.toCustomerInfo() ?: error("Expected a non-null RCCustomerInfo")) - } - - public actual fun showInAppMessagesIfNeeded( - messageTypes: List, - ) { - iosPurchases.showStoreMessagesForTypes(messageTypes.toIosStoreMessageTypes()) {} - } - - public actual fun invalidateCustomerInfoCache(): Unit = - iosPurchases.invalidateCustomerInfoCache() - - public actual fun overridePreferredUILocale(locale: String?): Unit = - iosPurchases.overridePreferredUILocale(locale) - - public actual fun setAttributes(attributes: Map): Unit = - iosPurchases.setAttributes(attributes.mapKeys { (key, _) -> key }) - - public actual fun setEmail(email: String?): Unit = - iosPurchases.setEmail(email) - - public actual fun setPhoneNumber(phoneNumber: String?): Unit = - iosPurchases.setPhoneNumber(phoneNumber) - - public actual fun setDisplayName(displayName: String?): Unit = - iosPurchases.setDisplayName(displayName) - - public actual fun setPushToken(fcmToken: String?): Unit = - iosPurchases.setPushTokenString(fcmToken) - - public actual fun setMixpanelDistinctID(mixpanelDistinctID: String?): Unit = - iosPurchases.setMixpanelDistinctID(mixpanelDistinctID) - - public actual fun setOnesignalID(onesignalID: String?): Unit = - iosPurchases.setOnesignalID(onesignalID) - - public actual fun setOnesignalUserID(onesignalUserID: String?): Unit = - iosPurchases.attribution().setOnesignalUserID(onesignalUserID) - - public actual fun setPostHogUserID(postHogUserID: String?): Unit = - iosPurchases.attribution().setPostHogUserID(postHogUserID) - - public actual fun setAirshipChannelID(airshipChannelID: String?): Unit = - iosPurchases.attribution().setAirshipChannelID(airshipChannelID) - - public actual fun setFirebaseAppInstanceID(firebaseAppInstanceID: String?): Unit = - iosPurchases.setFirebaseAppInstanceID(firebaseAppInstanceID) - - public actual fun collectDeviceIdentifiers(): Unit = - iosPurchases.collectDeviceIdentifiers() - - public actual fun setAdjustID(adjustID: String?): Unit = - iosPurchases.setAdjustID(adjustID) - - public actual fun setAppsflyerID(appsflyerID: String?): Unit = - iosPurchases.setAppsflyerID(appsflyerID) - - public actual fun setAirbridgeDeviceID(airbridgeDeviceID: String?): Unit = - iosPurchases.attribution().setAirbridgeDeviceID(airbridgeDeviceID) - - public actual fun setFBAnonymousID(fbAnonymousID: String?): Unit = - iosPurchases.setFBAnonymousID(fbAnonymousID) - - public actual fun setMparticleID(mparticleID: String?): Unit = - iosPurchases.setMparticleID(mparticleID) - - public actual fun setCleverTapID(cleverTapID: String?): Unit = - iosPurchases.setCleverTapID(cleverTapID) - - public actual fun setMediaSource(mediaSource: String?): Unit = - iosPurchases.setMediaSource(mediaSource) - - public actual fun setCampaign(campaign: String?): Unit = - iosPurchases.setCampaign(campaign) - - public actual fun setAdGroup(adGroup: String?): Unit = - iosPurchases.setAdGroup(adGroup) - - public actual fun setAd(ad: String?): Unit = - iosPurchases.setAd(ad) - - public actual fun setKeyword(keyword: String?): Unit = - iosPurchases.setKeyword(keyword) - - public actual fun setCreative(creative: String?): Unit = - iosPurchases.setCreative(creative) - - public actual fun presentCodeRedemptionSheet() { - if (AppleApiAvailability().isCodeRedemptionSheetAPIAvailable()) - iosPurchases.presentCodeRedemptionSheet() - else logHandler.d( - tag = "Purchases", - msg = "`presentCodeRedemptionSheet()` is only available on iOS 14.0 and up." - ) - } - - public actual fun enableAdServicesAttributionTokenCollection() { - if (AppleApiAvailability().isEnableAdServicesAttributionTokenCollectionAPIAvailable()) - iosPurchases.attribution().enableAdServicesAttributionTokenCollection() - else logHandler.d( - tag = "Purchases", - msg = "`enableAdServicesAttributionTokenCollection()` is only available on iOS 14.3 " + - "and up." - ) - } - - public actual fun redeemWebPurchase( - webPurchaseRedemption: WebPurchaseRedemption, - listener: RedeemWebPurchaseListener, - ) { - val nativeWebPurchaseRedemption = IosPurchases.parseAsWebPurchaseRedemption( - url = NSURL(string = webPurchaseRedemption.redemptionUrl) - ) - if (nativeWebPurchaseRedemption == null) { - listener.handleResult(RedeemWebPurchaseListener.Result.Error( - PurchasesError( - code = PurchasesErrorCode.ConfigurationError, - underlyingErrorMessage = "Invalid web purchase redemption URL." - ) - )) - return - } - iosPurchases.redeemWebPurchaseWithWebPurchaseRedemption( - nativeWebPurchaseRedemption, - ) { rcCustomerInfo, nsError -> - if (nsError != null) { - val errorCode = nsError.code.toInt() - val result = when (errorCode) { - PurchasesErrorCode.InvalidWebPurchaseToken.code -> - RedeemWebPurchaseListener.Result.InvalidToken - PurchasesErrorCode.PurchaseBelongsToOtherUser.code -> - RedeemWebPurchaseListener.Result.PurchaseBelongsToOtherUser - PurchasesErrorCode.ExpiredWebPurchaseToken.code -> - RedeemWebPurchaseListener.Result.Expired( - nsError.userInfo["rc_obfuscated_email"] as String? ?: "" - ) - else -> - RedeemWebPurchaseListener.Result.Error(nsError.toPurchasesErrorOrThrow()) - } - listener.handleResult(result) - return@redeemWebPurchaseWithWebPurchaseRedemption - } - if (rcCustomerInfo == null) { - listener.handleResult( - RedeemWebPurchaseListener.Result.Error( - PurchasesError( - code = PurchasesErrorCode.UnknownError, - underlyingErrorMessage = "Expected a non-null RCCustomerInfo when error is null." - ) - ) - ) - return@redeemWebPurchaseWithWebPurchaseRedemption - } - listener.handleResult( - RedeemWebPurchaseListener.Result.Success( - rcCustomerInfo.toCustomerInfo() - ) - ) - } - } - - public actual fun getVirtualCurrencies( - onError: (error: PurchasesError) -> Unit, - onSuccess: (virtualCurrencies: VirtualCurrencies) -> Unit, - ): Unit = iosPurchases.getVirtualCurrenciesWithCompletion { virtualCurrencies, error -> - if (error != null) { onError(error.toPurchasesErrorOrThrow()) } - else onSuccess(virtualCurrencies?.toVirtualCurrencies() ?: error("Expected a non-null RCVirtualCurrencies")) - } - - public actual fun invalidateVirtualCurrenciesCache(): Unit = iosPurchases.invalidateVirtualCurrenciesCache() - - public actual fun getCachedVirtualCurrencies(): VirtualCurrencies? { - val cachedVirtualCurrencies: RCVirtualCurrencies? = iosPurchases.cachedVirtualCurrencies() - return cachedVirtualCurrencies?.toVirtualCurrencies() - } - - public actual fun trackCustomPaywallImpression( - params: CustomPaywallImpressionParams, - ) { - if (!AppleApiAvailability().isCustomPaywallTrackingAPIAvailable()) { - logHandler.w( - "Purchases", - "Custom paywall tracking requires iOS 15.0+. Current API is unavailable." - ) - return - } - val paywallId = params.paywallId - @Suppress("DEPRECATION") - val offeringId = params.offeringId - val iosParams = params.offering?.let { offering -> - RCCustomPaywallImpressionParams( - paywallId = paywallId, - offering = offering.toIosOffering(), - ) - } ?: run { - @Suppress("DEPRECATION") - RCCustomPaywallImpressionParams( - paywallId = paywallId, - offeringId = offeringId - ) - } - - iosPurchases.trackCustomPaywallImpression(iosParams) - } - - @ExperimentalRevenueCatApi - public actual val adTracker: AdTracker by lazy { AdTracker(iosPurchases.adTracker()) } +internal actual fun IosPurchases.showStoreMessagesIfAvailable( + messageTypes: List, +) { + showStoreMessagesForTypes(messageTypes.toIosStoreMessageTypes()) {} } diff --git a/core/src/watchosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.watchos.kt b/core/src/watchosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.watchos.kt new file mode 100644 index 000000000..fee25be0a --- /dev/null +++ b/core/src/watchosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.watchos.kt @@ -0,0 +1,20 @@ +package com.revenuecat.purchases.kmp + +import com.revenuecat.purchases.kmp.models.StoreMessageType +import com.revenuecat.purchases.kn.core.RCPurchases as IosPurchases + +internal actual fun IosPurchases.presentCodeRedemptionSheetIfAvailable() { + Purchases.logHandler.d( + tag = "Purchases", + msg = "`presentCodeRedemptionSheet()` is not available on watchOS." + ) +} + +internal actual fun IosPurchases.showStoreMessagesIfAvailable( + messageTypes: List, +) { + Purchases.logHandler.d( + tag = "Purchases", + msg = "`showInAppMessagesIfNeeded()` is not available on watchOS." + ) +} diff --git a/either/api/either.klib.api b/either/api/either.klib.api index a8442a504..9ba37c4a5 100644 --- a/either/api/either.klib.api +++ b/either/api/either.klib.api @@ -1,5 +1,5 @@ // Klib ABI Dump -// Targets: [iosArm64, iosSimulatorArm64, iosX64] +// Targets: [iosArm64, iosSimulatorArm64, iosX64, watchosArm64, watchosSimulatorArm64] // Rendering settings: // - Signature version: 2 // - Show manifest properties: true diff --git a/either/build.gradle.kts b/either/build.gradle.kts index 00ddb26c6..4b555e2a0 100644 --- a/either/build.gradle.kts +++ b/either/build.gradle.kts @@ -7,6 +7,10 @@ revenueCat { } kotlin { + // Not using watchosTargets() because arrow-core does not publish watchosDeviceArm64. + watchosArm64() + watchosSimulatorArm64() + sourceSets { commonMain.dependencies { api(libs.arrow.core) diff --git a/kn-core/api/kn-core.klib.api b/kn-core/api/kn-core.klib.api index 1ec12c359..b250038c4 100644 --- a/kn-core/api/kn-core.klib.api +++ b/kn-core/api/kn-core.klib.api @@ -1,5 +1,5 @@ // Klib ABI Dump -// Targets: [iosArm64, iosSimulatorArm64, iosX64] +// Targets: [iosArm64, iosSimulatorArm64, iosX64, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64] // Rendering settings: // - Signature version: 2 // - Show manifest properties: true diff --git a/kn-core/build.gradle.kts b/kn-core/build.gradle.kts index 80db57a9e..695aae83c 100644 --- a/kn-core/build.gradle.kts +++ b/kn-core/build.gradle.kts @@ -1,13 +1,16 @@ import com.revenuecat.purchases.kmp.buildlogic.swift.model.SwiftSettings import com.revenuecat.purchases.kmp.buildlogic.swift.swiftPackage +import com.revenuecat.purchases.kmp.buildlogic.watchosTargets plugins { id("revenuecat-library") } kotlin { + watchosTargets() + sourceSets { - iosMain.dependencies { + appleMain.dependencies { swiftPackage( path = rootProject.file("upstream/purchases-ios"), target = "RevenueCat", diff --git a/kn-core/src/iosMain/kotlin/com/revenuecat/purchases/kn/core/Dummy.kt b/kn-core/src/appleMain/kotlin/com/revenuecat/purchases/kn/core/Dummy.kt similarity index 100% rename from kn-core/src/iosMain/kotlin/com/revenuecat/purchases/kn/core/Dummy.kt rename to kn-core/src/appleMain/kotlin/com/revenuecat/purchases/kn/core/Dummy.kt diff --git a/mappings/build.gradle.kts b/mappings/build.gradle.kts index 49e78612d..3ee51ad00 100644 --- a/mappings/build.gradle.kts +++ b/mappings/build.gradle.kts @@ -1,8 +1,12 @@ +import com.revenuecat.purchases.kmp.buildlogic.watchosTargets + plugins { id("revenuecat-library") } kotlin { + watchosTargets() + sourceSets { commonMain.dependencies { api(projects.models) @@ -10,7 +14,7 @@ kotlin { androidMain.dependencies { api(libs.revenuecat.android) } - iosMain.dependencies { + appleMain.dependencies { implementation(projects.knCore) } androidUnitTest.dependencies { diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/AdEventTypes.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/AdEventTypes.apple.kt similarity index 85% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/AdEventTypes.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/AdEventTypes.apple.kt index 96f5e7805..a9808c4de 100644 --- a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/AdEventTypes.ios.kt +++ b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/AdEventTypes.apple.kt @@ -9,6 +9,7 @@ import com.revenuecat.purchases.kmp.models.AdMediatorName import com.revenuecat.purchases.kmp.models.AdOpenedData import com.revenuecat.purchases.kmp.models.AdRevenueData import com.revenuecat.purchases.kmp.models.AdRevenuePrecision +import kotlinx.cinterop.convert import platform.Foundation.NSNumber import com.revenuecat.purchases.kn.core.RCAdDisplayed import com.revenuecat.purchases.kn.core.RCAdFailedToLoad @@ -64,7 +65,12 @@ public fun AdRevenueData.toIos(): RCAdRevenue { placement = placement, adUnitId = adUnitId, impressionId = impressionId, - revenueMicros = revenueMicros, + // The native type is NSInteger, which is 32 bits on watchosArm64 (arm64_32). Clamping + // before convert() turns a silent sign-wrap into a bounded value there; it is a no-op + // on 64-bit targets for realistic per-impression revenues. + revenueMicros = revenueMicros + .coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()) + .convert(), currency = currency, precision = precision.toIos(), ) @@ -89,6 +95,6 @@ public fun AdFailedToLoadData.toIos(): RCAdFailedToLoad { adFormat = adFormat.toIos(), placement = placement, adUnitId = adUnitId, - mediatorErrorCode = mediatorErrorCode?.let { NSNumber(it) }, + mediatorErrorCode = mediatorErrorCode?.let { NSNumber(int = it) }, ) } diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/CacheFetchPolicy.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/CacheFetchPolicy.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/CacheFetchPolicy.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/CacheFetchPolicy.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/CustomerInfo.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/CustomerInfo.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/CustomerInfo.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/CustomerInfo.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/DiscountPaymentMode.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/DiscountPaymentMode.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/DiscountPaymentMode.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/DiscountPaymentMode.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/DiscountType.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/DiscountType.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/DiscountType.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/DiscountType.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementInfo.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementInfo.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementInfo.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementInfo.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementInfos.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementInfos.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementInfos.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementInfos.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementVerificationMode.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementVerificationMode.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementVerificationMode.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/EntitlementVerificationMode.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/IntroEligibilityStatus.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/IntroEligibilityStatus.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/IntroEligibilityStatus.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/IntroEligibilityStatus.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Offering.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Offering.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Offering.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Offering.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Offerings.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Offerings.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Offerings.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Offerings.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Package.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Package.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Package.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Package.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Period.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Period.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Period.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Period.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/PresentedOfferingContext.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/PresentedOfferingContext.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/PresentedOfferingContext.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/PresentedOfferingContext.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Price.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Price.apple.kt similarity index 96% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Price.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Price.apple.kt index 8c3cc5fc0..e5a4ecdd2 100644 --- a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Price.ios.kt +++ b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Price.apple.kt @@ -9,7 +9,7 @@ import com.revenuecat.purchases.kn.core.price internal fun RCStoreProduct.toPrice(): Price = Price( formatted = localizedPriceString(), - amountMicros = price().decimalNumberByMultiplyingByPowerOf10(6).longValue, + amountMicros = price().decimalNumberByMultiplyingByPowerOf10(6).longLongValue, currencyCode = currencyCodeOrUsd(), ) @@ -19,7 +19,7 @@ internal fun RCStoreProduct.currencyCodeOrUsd(): String = internal fun RCStoreProductDiscount.toPrice(): Price = Price( formatted = localizedPriceString(), - amountMicros = price().decimalNumberByMultiplyingByPowerOf10(6).longValue, + amountMicros = price().decimalNumberByMultiplyingByPowerOf10(6).longLongValue, currencyCode = currencyCodeOrUsd(), ) @@ -37,7 +37,7 @@ internal fun priceOrNull( ): Price? = priceOrNull( currencyCode = currencyCode, formatted = formatted, - amountMicros = amountDecimal?.decimalNumberByMultiplyingByPowerOf10(6)?.longValue + amountMicros = amountDecimal?.decimalNumberByMultiplyingByPowerOf10(6)?.longLongValue ) /** diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/ProductCategory.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/ProductCategory.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/ProductCategory.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/ProductCategory.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/ProductType.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/ProductType.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/ProductType.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/ProductType.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/PromotionalOffer.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/PromotionalOffer.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/PromotionalOffer.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/PromotionalOffer.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/PurchasesAreCompletedBy.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/PurchasesAreCompletedBy.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/PurchasesAreCompletedBy.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/PurchasesAreCompletedBy.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/PurchasingData.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/PurchasingData.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/PurchasingData.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/PurchasingData.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreKitVersion.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreKitVersion.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreKitVersion.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreKitVersion.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreMessageType.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreMessageType.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreMessageType.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreMessageType.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProduct.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProduct.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProduct.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProduct.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProductDiscount.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProductDiscount.apple.kt similarity index 91% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProductDiscount.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProductDiscount.apple.kt index efddac1c4..6812f0c35 100644 --- a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProductDiscount.ios.kt +++ b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreProductDiscount.apple.kt @@ -16,7 +16,8 @@ private class IosStoreProductDiscount( val wrapped: NativeIosStoreProductDiscount, ): StoreProductDiscount { override val price: Price = wrapped.toPrice() - override val numberOfPeriods: Long = wrapped.numberOfPeriods() + // toLong() because the native type is NSInteger, which is 32 bits on watchosArm64 (arm64_32). + override val numberOfPeriods: Long = wrapped.numberOfPeriods().toLong() override val offerIdentifier: String? = wrapped.offerIdentifier() override val paymentMode: DiscountPaymentMode = wrapped.paymentMode().toDiscountPaymentMode() override val subscriptionPeriod: Period = wrapped.subscriptionPeriod().toPeriod() diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreTransaction.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreTransaction.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreTransaction.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/StoreTransaction.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Storefront.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Storefront.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Storefront.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Storefront.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/SubscriptionInfo.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/SubscriptionInfo.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/SubscriptionInfo.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/SubscriptionInfo.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Transaction.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Transaction.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/Transaction.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/Transaction.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/VerificationResult.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/VerificationResult.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/VerificationResult.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/VerificationResult.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/VirtualCurrencies.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/VirtualCurrencies.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/VirtualCurrencies.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/VirtualCurrencies.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/VirtualCurrency.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/VirtualCurrency.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/VirtualCurrency.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/VirtualCurrency.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/WinBackOffer.ios.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/WinBackOffer.apple.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/WinBackOffer.ios.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/WinBackOffer.apple.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/ktx/Map.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/ktx/Map.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/ktx/Map.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/ktx/Map.kt diff --git a/mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/ktx/NSDate.kt b/mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/ktx/NSDate.kt similarity index 100% rename from mappings/src/iosMain/kotlin/com/revenuecat/purchases/kmp/mappings/ktx/NSDate.kt rename to mappings/src/appleMain/kotlin/com/revenuecat/purchases/kmp/mappings/ktx/NSDate.kt diff --git a/mappings/src/iosMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.ios.kt b/mappings/src/appleMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.apple.kt similarity index 98% rename from mappings/src/iosMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.ios.kt rename to mappings/src/appleMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.apple.kt index 9af0be6ca..960280341 100644 --- a/mappings/src/iosMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.ios.kt +++ b/mappings/src/appleMain/kotlin/generated/com/revenuecat/purchases/kmp/mappings/errors.apple.kt @@ -9,7 +9,7 @@ import platform.Foundation.NSError public fun NSError.toPurchasesErrorOrThrow(): PurchasesError = PurchasesError( - code = code().toPurchasesErrorCode(), + code = code().toLong().toPurchasesErrorCode(), underlyingErrorMessage = localizedDescription(), ) diff --git a/models/api/models.klib.api b/models/api/models.klib.api index 86d349d0f..85370ec88 100644 --- a/models/api/models.klib.api +++ b/models/api/models.klib.api @@ -1,5 +1,5 @@ // Klib ABI Dump -// Targets: [iosArm64, iosSimulatorArm64, iosX64] +// Targets: [iosArm64, iosSimulatorArm64, iosX64, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64] // Rendering settings: // - Signature version: 2 // - Show manifest properties: true diff --git a/models/build.gradle.kts b/models/build.gradle.kts index 1a5e92e34..0e5e17564 100644 --- a/models/build.gradle.kts +++ b/models/build.gradle.kts @@ -1,3 +1,5 @@ +import com.revenuecat.purchases.kmp.buildlogic.watchosTargets + plugins { id("revenuecat-library") } @@ -7,6 +9,8 @@ revenueCat { } kotlin { + watchosTargets() + sourceSets { androidMain.dependencies { implementation(libs.revenuecat.android) diff --git a/result/api/result.klib.api b/result/api/result.klib.api index b2da56147..bc8651f13 100644 --- a/result/api/result.klib.api +++ b/result/api/result.klib.api @@ -1,5 +1,5 @@ // Klib ABI Dump -// Targets: [iosArm64, iosSimulatorArm64, iosX64] +// Targets: [iosArm64, iosSimulatorArm64, iosX64, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64] // Rendering settings: // - Signature version: 2 // - Show manifest properties: true diff --git a/result/build.gradle.kts b/result/build.gradle.kts index 950c27fdb..5b88215db 100644 --- a/result/build.gradle.kts +++ b/result/build.gradle.kts @@ -1,3 +1,5 @@ +import com.revenuecat.purchases.kmp.buildlogic.watchosTargets + plugins { id("revenuecat-library") } @@ -7,6 +9,8 @@ revenueCat { } kotlin { + watchosTargets() + sourceSets { commonMain.dependencies { implementation(projects.core)