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
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -10,5 +11,6 @@ class HushTimerApplication : Application() {
initKoin {
androidContext(this@HushTimerApplication)
}
initializeBilling()
}
}
4 changes: 4 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.rickyhu.hushtimer.billing.data

internal expect fun initializeBilling()
Original file line number Diff line number Diff line change
@@ -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>(BillingState.Loading)

override fun observeBillingState(): Flow<BillingState> = _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<Unit> =
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<Unit> =
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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.rickyhu.hushtimer.billing.domain

import kotlinx.coroutines.flow.Flow

interface BillingService {
fun observeBillingState(): Flow<BillingState>

suspend fun refreshCustomerInfo()

suspend fun purchase(packageId: String): Result<Unit>

suspend fun restorePurchases(): Result<Unit>
}
Original file line number Diff line number Diff line change
@@ -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<EntitlementInfo>,
) : BillingState

data object Unavailable : BillingState

data class Error(val message: String) : BillingState
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.rickyhu.hushtimer.billing.domain

data class EntitlementInfo(
val id: String,
val isActive: Boolean,
val expiresAtEpochMillis: Long?,
)
Original file line number Diff line number Diff line change
@@ -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<BillingState> =
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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,6 +58,9 @@ val sharedModule =
singleOf(::SettingsRepositoryImpl).bind<SettingsRepository>()
singleOf(::ConfigRepositoryImpl).bind<ConfigRepository>()

// Billing
singleOf(::RevenueCatBillingService).bind<BillingService>()

// Timer engine
single { TimerEngine(CoroutineScope(SupervisorJob() + Dispatchers.Default)) }

Expand All @@ -63,4 +69,5 @@ val sharedModule =
viewModelOf(::SessionViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::VersionCheckViewModel)
viewModelOf(::BillingViewModel)
}
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions composeApp/src/iosMain/kotlin/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -10,5 +11,6 @@ fun MainViewController(): UIViewController =
ComposeUIViewController(
configure = {
initKoin()
initializeBilling()
},
) { App() }
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.rickyhu.hushtimer.billing.data

internal actual fun initializeBilling() {
// No-op: RevenueCat doesn't support Desktop
}
Original file line number Diff line number Diff line change
@@ -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<BillingState> = flowOf(BillingState.Unavailable)

override suspend fun refreshCustomerInfo() {
// No-op
}

override suspend fun purchase(packageId: String): Result<Unit> =
Result.failure(UnsupportedOperationException("Billing not available on Desktop"))

override suspend fun restorePurchases(): Result<Unit> =
Result.failure(UnsupportedOperationException("Billing not available on Desktop"))
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -34,4 +36,7 @@ actual val platformModule: Module

// Scramble repository - JVM implementation
single<ScrambleRepository> { ScrambleRepositoryImpl() }

// Billing - Desktop no-op
single<BillingService> { NoOpBillingService() }
}
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down