Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion Dangerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
])
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/).
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,6 @@ private fun KonanTarget.isAppleTarget(): Boolean =
// watchOS
KonanTarget.WATCHOS_ARM64,
KonanTarget.WATCHOS_SIMULATOR_ARM64,
KonanTarget.WATCHOS_X64,
KonanTarget.WATCHOS_DEVICE_ARM64,
)

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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}")
}
Expand All @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion core/api/core.klib.api
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING
import com.revenuecat.purchases.kmp.buildlogic.watchosTargets

plugins {
id("revenuecat-library")
Expand All @@ -10,6 +11,8 @@ revenueCat {
}

kotlin {
watchosTargets()

sourceSets {
commonMain.dependencies {
api(projects.models)
Expand All @@ -19,7 +22,7 @@ kotlin {
implementation(libs.revenuecat.android)
implementation(projects.mappings)
}
iosMain.dependencies {
appleMain.dependencies {
implementation(projects.knCore)
implementation(projects.mappings)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,47 +16,52 @@ 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
}

iosAdTracker.trackAdDisplayed(data.toIos())
}

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
}

iosAdTracker.trackAdOpened(data.toIos())
}

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
}

iosAdTracker.trackAdRevenue(data.toIos())
}

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
}

iosAdTracker.trackAdLoaded(data.toIos())
}

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."
Loading