diff --git a/androidApp/src/main/kotlin/com/rickyhu/hushtimer/HushTimerApplication.kt b/androidApp/src/main/kotlin/com/rickyhu/hushtimer/HushTimerApplication.kt index 2065a022..2df2b7ea 100644 --- a/androidApp/src/main/kotlin/com/rickyhu/hushtimer/HushTimerApplication.kt +++ b/androidApp/src/main/kotlin/com/rickyhu/hushtimer/HushTimerApplication.kt @@ -1,6 +1,7 @@ package com.rickyhu.hushtimer import android.app.Application +import com.rickyhu.hushtimer.billing.data.initializeBilling import com.rickyhu.hushtimer.di.initKoin import org.koin.android.ext.koin.androidContext @@ -10,5 +11,6 @@ class HushTimerApplication : Application() { initKoin { androidContext(this@HushTimerApplication) } + initializeBilling() } } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index f0101fdd..2d0ef08b 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -94,6 +94,8 @@ kotlin { implementation(libs.multiplatform.settings) implementation(libs.multiplatform.settings.coroutines) implementation(libs.multiplatform.settings.observable) + implementation(libs.purchases.kmp.core) + implementation(libs.purchases.kmp.result) implementation(libs.room.runtime) implementation(libs.semantic.versioning) implementation(libs.sqlite.bundled) @@ -182,6 +184,8 @@ buildkonfig { // Uses CONFIGCAT_KEY_TEST by default, which must be available defaultConfigs { buildConfigField(STRING, "CONFIGCAT_SDK_KEY", getSecret("CONFIGCAT_KEY_TEST")) + buildConfigField(STRING, "REVENUECAT_APPLE_API_KEY", getSecret("REVENUECAT_APPLE_API_KEY", "")) + buildConfigField(STRING, "REVENUECAT_GOOGLE_API_KEY", getSecret("REVENUECAT_GOOGLE_API_KEY", "")) } // B. ANDROID VARIANTS (Auto-detected by the plugin) diff --git a/composeApp/src/androidMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.android.kt b/composeApp/src/androidMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.android.kt new file mode 100644 index 00000000..bb774423 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.android.kt @@ -0,0 +1,8 @@ +package com.rickyhu.hushtimer.billing.data + +import com.revenuecat.purchases.kmp.Purchases +import com.rickyhu.hushtimer.BuildKonfig + +internal actual fun initializeBilling() { + Purchases.configure(apiKey = BuildKonfig.REVENUECAT_GOOGLE_API_KEY) +} diff --git a/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.kt b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.kt new file mode 100644 index 00000000..dfa9b346 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.kt @@ -0,0 +1,3 @@ +package com.rickyhu.hushtimer.billing.data + +internal expect fun initializeBilling() diff --git a/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/data/RevenueCatBillingService.kt b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/data/RevenueCatBillingService.kt new file mode 100644 index 00000000..63f42de4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/data/RevenueCatBillingService.kt @@ -0,0 +1,67 @@ +package com.rickyhu.hushtimer.billing.data + +import com.revenuecat.purchases.kmp.Purchases +import com.rickyhu.hushtimer.billing.domain.BillingService +import com.rickyhu.hushtimer.billing.domain.BillingState +import com.rickyhu.hushtimer.billing.domain.EntitlementInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal class RevenueCatBillingService : BillingService { + private val _billingState = MutableStateFlow(BillingState.Loading) + + override fun observeBillingState(): Flow = _billingState.asStateFlow() + + override suspend fun refreshCustomerInfo() { + try { + val purchases = Purchases.sharedInstance + val customerInfo = purchases.awaitCustomerInfo() + + val activeEntitlements = + customerInfo.entitlements.active.map { (id, entitlement) -> + EntitlementInfo( + id = id, + isActive = entitlement.isActive, + expiresAtEpochMillis = entitlement.expirationDate?.toEpochMilliseconds(), + ) + } + + val isPro = customerInfo.entitlements.active.containsKey(PRO_ENTITLEMENT_ID) + + _billingState.value = BillingState.Ready(isPro = isPro, activeEntitlements = activeEntitlements) + } catch (e: Exception) { + _billingState.value = BillingState.Error(e.message ?: "Failed to refresh customer info") + } + } + + override suspend fun purchase(packageId: String): Result = + try { + val purchases = Purchases.sharedInstance + val offerings = purchases.awaitOfferings() + val currentOffering = offerings.current ?: return Result.failure(Exception("No current offering")) + val packageToPurchase = + currentOffering.availablePackages.find { it.identifier == packageId } + ?: return Result.failure(Exception("Package not found: $packageId")) + + purchases.awaitPurchase(packageToPurchase) + refreshCustomerInfo() + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + + override suspend fun restorePurchases(): Result = + try { + val purchases = Purchases.sharedInstance + purchases.awaitRestorePurchases() + refreshCustomerInfo() + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + + companion object { + private const val PRO_ENTITLEMENT_ID = "pro" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/domain/BillingService.kt b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/domain/BillingService.kt new file mode 100644 index 00000000..eabe8cc6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/domain/BillingService.kt @@ -0,0 +1,13 @@ +package com.rickyhu.hushtimer.billing.domain + +import kotlinx.coroutines.flow.Flow + +interface BillingService { + fun observeBillingState(): Flow + + suspend fun refreshCustomerInfo() + + suspend fun purchase(packageId: String): Result + + suspend fun restorePurchases(): Result +} diff --git a/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/domain/BillingState.kt b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/domain/BillingState.kt new file mode 100644 index 00000000..13897710 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/domain/BillingState.kt @@ -0,0 +1,14 @@ +package com.rickyhu.hushtimer.billing.domain + +sealed interface BillingState { + data object Loading : BillingState + + data class Ready( + val isPro: Boolean, + val activeEntitlements: List, + ) : BillingState + + data object Unavailable : BillingState + + data class Error(val message: String) : BillingState +} diff --git a/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/domain/EntitlementInfo.kt b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/domain/EntitlementInfo.kt new file mode 100644 index 00000000..d4ffd4b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/domain/EntitlementInfo.kt @@ -0,0 +1,7 @@ +package com.rickyhu.hushtimer.billing.domain + +data class EntitlementInfo( + val id: String, + val isActive: Boolean, + val expiresAtEpochMillis: Long?, +) diff --git a/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/presentation/BillingViewModel.kt b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/presentation/BillingViewModel.kt new file mode 100644 index 00000000..568c5841 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/billing/presentation/BillingViewModel.kt @@ -0,0 +1,37 @@ +package com.rickyhu.hushtimer.billing.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rickyhu.hushtimer.billing.domain.BillingService +import com.rickyhu.hushtimer.billing.domain.BillingState +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class BillingViewModel( + private val billingService: BillingService, +) : ViewModel() { + val billingState: StateFlow = + billingService + .observeBillingState() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), BillingState.Loading) + + init { + viewModelScope.launch { + billingService.refreshCustomerInfo() + } + } + + fun purchase(packageId: String) { + viewModelScope.launch { + billingService.purchase(packageId) + } + } + + fun restorePurchases() { + viewModelScope.launch { + billingService.restorePurchases() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/di/Modules.kt b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/di/Modules.kt index 03dcee1f..4876ac52 100644 --- a/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/com/rickyhu/hushtimer/di/Modules.kt @@ -4,6 +4,9 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.configcat.ConfigCatClient import com.configcat.log.LogLevel import com.rickyhu.hushtimer.BuildKonfig +import com.rickyhu.hushtimer.billing.data.RevenueCatBillingService +import com.rickyhu.hushtimer.billing.domain.BillingService +import com.rickyhu.hushtimer.billing.presentation.BillingViewModel import com.rickyhu.hushtimer.config.data.ConfigRepositoryImpl import com.rickyhu.hushtimer.config.domain.ConfigRepository import com.rickyhu.hushtimer.core.data.DatabaseFactory @@ -55,6 +58,9 @@ val sharedModule = singleOf(::SettingsRepositoryImpl).bind() singleOf(::ConfigRepositoryImpl).bind() + // Billing + singleOf(::RevenueCatBillingService).bind() + // Timer engine single { TimerEngine(CoroutineScope(SupervisorJob() + Dispatchers.Default)) } @@ -63,4 +69,5 @@ val sharedModule = viewModelOf(::SessionViewModel) viewModelOf(::SettingsViewModel) viewModelOf(::VersionCheckViewModel) + viewModelOf(::BillingViewModel) } diff --git a/composeApp/src/iosMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.ios.kt b/composeApp/src/iosMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.ios.kt new file mode 100644 index 00000000..f2676688 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.ios.kt @@ -0,0 +1,8 @@ +package com.rickyhu.hushtimer.billing.data + +import com.revenuecat.purchases.kmp.Purchases +import com.rickyhu.hushtimer.BuildKonfig + +internal actual fun initializeBilling() { + Purchases.configure(apiKey = BuildKonfig.REVENUECAT_APPLE_API_KEY) +} diff --git a/composeApp/src/iosMain/kotlin/main.kt b/composeApp/src/iosMain/kotlin/main.kt index 42775acb..068ae874 100644 --- a/composeApp/src/iosMain/kotlin/main.kt +++ b/composeApp/src/iosMain/kotlin/main.kt @@ -2,6 +2,7 @@ import androidx.compose.ui.window.ComposeUIViewController import com.rickyhu.hushtimer.App +import com.rickyhu.hushtimer.billing.data.initializeBilling import com.rickyhu.hushtimer.di.initKoin import platform.UIKit.UIViewController @@ -10,5 +11,6 @@ fun MainViewController(): UIViewController = ComposeUIViewController( configure = { initKoin() + initializeBilling() }, ) { App() } diff --git a/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.jvm.kt b/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.jvm.kt new file mode 100644 index 00000000..fd02d278 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/billing/data/BillingInitializer.jvm.kt @@ -0,0 +1,5 @@ +package com.rickyhu.hushtimer.billing.data + +internal actual fun initializeBilling() { + // No-op: RevenueCat doesn't support Desktop +} diff --git a/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/billing/data/NoOpBillingService.kt b/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/billing/data/NoOpBillingService.kt new file mode 100644 index 00000000..17f07835 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/billing/data/NoOpBillingService.kt @@ -0,0 +1,20 @@ +package com.rickyhu.hushtimer.billing.data + +import com.rickyhu.hushtimer.billing.domain.BillingService +import com.rickyhu.hushtimer.billing.domain.BillingState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +internal class NoOpBillingService : BillingService { + override fun observeBillingState(): Flow = flowOf(BillingState.Unavailable) + + override suspend fun refreshCustomerInfo() { + // No-op + } + + override suspend fun purchase(packageId: String): Result = + Result.failure(UnsupportedOperationException("Billing not available on Desktop")) + + override suspend fun restorePurchases(): Result = + Result.failure(UnsupportedOperationException("Billing not available on Desktop")) +} diff --git a/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/di/Modules.jvm.kt b/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/di/Modules.jvm.kt index 3298b47a..84c2b68e 100644 --- a/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/di/Modules.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/rickyhu/hushtimer/di/Modules.jvm.kt @@ -1,5 +1,7 @@ package com.rickyhu.hushtimer.di +import com.rickyhu.hushtimer.billing.data.NoOpBillingService +import com.rickyhu.hushtimer.billing.domain.BillingService import com.rickyhu.hushtimer.core.data.DatabaseFactory import com.rickyhu.hushtimer.core.domain.AppVersion import com.rickyhu.hushtimer.core.domain.AppVersionImpl @@ -34,4 +36,7 @@ actual val platformModule: Module // Scramble repository - JVM implementation single { ScrambleRepositoryImpl() } + + // Billing - Desktop no-op + single { NoOpBillingService() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ba5a3224..599f0a25 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ ksp = "2.3.4" material-kolor = "4.1.1" multiplatform-settings = "1.3.0" navigation-compose = "2.9.2" +purchases-kmp = "2.4.0+17.30.0" room = "2.8.4" semver = "3.0.0" sqlite = "2.6.2" @@ -66,6 +67,8 @@ multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", vers multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatform-settings" } multiplatform-settings-observable = { module = "com.russhwolf:multiplatform-settings-make-observable", version.ref = "multiplatform-settings" } jetbrains-compose-navigation = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref= "navigation-compose"} +purchases-kmp-core = { module = "com.revenuecat.purchases:purchases-kmp-core", version.ref = "purchases-kmp" } +purchases-kmp-result = { module = "com.revenuecat.purchases:purchases-kmp-result", version.ref = "purchases-kmp" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }