Skip to content
Closed
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
Expand Up @@ -426,7 +426,18 @@ public class Purchases internal constructor(
}

@InternalRevenueCatAPI
public fun workflowIdForOfferingId(offeringId: String): String? =
@JvmSynthetic
@Throws(PurchasesException::class)
public suspend fun awaitGetUiConfig(): UiConfig = suspendCoroutine { continuation ->
purchasesOrchestrator.getUiConfig(
onSuccess = continuation::resume,
onError = { continuation.resumeWithException(PurchasesException(it)) },
)
}

@InternalRevenueCatAPI
@JvmSynthetic
public suspend fun workflowIdForOfferingId(offeringId: String): String? =
purchasesOrchestrator.workflowIdForOfferingId(offeringId)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,18 @@ import com.revenuecat.purchases.common.offlineentitlements.PurchasedProductsFetc
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobStore
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigDiskCache
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider
import com.revenuecat.purchases.common.verification.SignatureVerificationMode
import com.revenuecat.purchases.common.verification.SigningManager
import com.revenuecat.purchases.common.warnLog
import com.revenuecat.purchases.common.workflows.FileCachedWorkflowCdnFetcher
import com.revenuecat.purchases.common.workflows.WorkflowDetailResolver
import com.revenuecat.purchases.common.workflows.WorkflowManager
import com.revenuecat.purchases.common.workflows.WorkflowsCache
import com.revenuecat.purchases.common.workflows.WorkflowsConfigProvider
import com.revenuecat.purchases.identity.IdentityManager
import com.revenuecat.purchases.paywalls.FontLoader
import com.revenuecat.purchases.paywalls.OfferingFontPreDownloader
import com.revenuecat.purchases.paywalls.PaywallPresentedCache
import com.revenuecat.purchases.paywalls.events.PaywallStoredEvent
import com.revenuecat.purchases.storage.DefaultFileCache
import com.revenuecat.purchases.storage.DefaultFileRepository
import com.revenuecat.purchases.strings.ConfigureStrings
import com.revenuecat.purchases.strings.Emojis
Expand All @@ -65,13 +64,8 @@ import com.revenuecat.purchases.utils.IsDebugBuildProvider
import com.revenuecat.purchases.utils.OfferingImagePreDownloader
import com.revenuecat.purchases.utils.PaywallComponentsImagePreDownloader
import com.revenuecat.purchases.utils.PurchaseParamsValidator
import com.revenuecat.purchases.utils.WorkflowAssetPreDownloader
import com.revenuecat.purchases.utils.isAndroidNOrNewer
import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencyManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import java.net.URL
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
Expand All @@ -85,7 +79,6 @@ internal class PurchasesFactory(
@OptIn(
ExperimentalPreviewRevenueCatPurchasesAPI::class,
InternalRevenueCatAPI::class,
ExperimentalCoroutinesApi::class,
)
@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod")
fun createPurchases(
Expand Down Expand Up @@ -274,7 +267,9 @@ internal class PurchasesFactory(

val workflowsCache = if (appConfig.useWorkflows) WorkflowsCache(deviceCache = cache) else null

val remoteConfigManager = if (BuildConfig.ENABLE_REMOTE_CONFIG) {
// useWorkflows implies the config layer: workflows are served from `/v1/config`, so the manager
// must exist whenever workflows are on, not only when the standalone flag is set.
val remoteConfigManager = if (BuildConfig.ENABLE_REMOTE_CONFIG || appConfig.useWorkflows) {
RemoteConfigManager(
backend = backend,
diskCache = RemoteConfigDiskCache(contextForStorage),
Expand All @@ -285,6 +280,7 @@ internal class PurchasesFactory(
} else {
null
}
val uiConfigProvider = remoteConfigManager?.let { UiConfigProvider(it) }

val identityManager = IdentityManager(
cache,
Expand Down Expand Up @@ -383,32 +379,14 @@ internal class PurchasesFactory(
fontLoader = fontLoader,
)

val workflowManager = workflowsCache?.let {
WorkflowManager(
backend = backend,
workflowDetailResolver = WorkflowDetailResolver(
workflowCdnFetcher = FileCachedWorkflowCdnFetcher(
// Dedicated FileRepository instance with a concurrency-limited scope, so workflow
// CDN downloads are capped without affecting the instances used for images/video.
fileRepository = DefaultFileRepository(
fileCacheManager = DefaultFileCache(contextForStorage, "rc_compiled_workflows"),
ioScope = CoroutineScope(
Dispatchers.IO.limitedParallelism(MAX_CONCURRENT_WORKFLOW_CDN_FETCHES) +
NonCancellable,
),
),
),
),
workflowAssetPreDownloader = WorkflowAssetPreDownloader(
paywallComponentsImagePreDownloader = paywallComponentsImagePreDownloader,
offeringFontPreDownloader = offeringFontPreDownloader,
),
workflowsCache = it,
prefetchDispatcher = Dispatcher(
createConcurrentExecutor(),
runningIntegrationTests = runningIntegrationTests,
),
)
// Workflows are served from the `/v1/config` layer: WorkflowManager stays the consumer-facing seam,
// but behind it sit the RemoteConfig stack (sync + blob store + on-demand fetch) and the
// WorkflowsConfigProvider. Lifecycle (foreground refresh, identity clearCache, teardown) is driven
// through remoteConfigManager, which the orchestrator and IdentityManager already own.
val workflowManager = if (appConfig.useWorkflows && remoteConfigManager != null) {
WorkflowManager(WorkflowsConfigProvider(remoteConfigManager))
} else {
null
}

val offeringsManager = OfferingsManager(
Expand All @@ -422,7 +400,6 @@ internal class PurchasesFactory(
diagnosticsTracker,
offeringFontPreDownloader = offeringFontPreDownloader,
uiPreviewMode = appConfig.uiPreviewMode,
workflowManager = workflowManager,
)

log(LogIntent.DEBUG) { ConfigureStrings.DEBUG_ENABLED }
Expand Down Expand Up @@ -492,6 +469,7 @@ internal class PurchasesFactory(
workflowManager = workflowManager,
fileRepository = fileRepository,
remoteConfigManager = remoteConfigManager,
uiConfigProvider = uiConfigProvider,
)

return Purchases(purchasesOrchestrator)
Expand Down Expand Up @@ -574,12 +552,6 @@ internal class PurchasesFactory(
return Executors.newSingleThreadScheduledExecutor(LowPriorityThreadFactory("revenuecat-events-thread"))
}

// Bounded pool for backend calls that are issued many-at-a-time (workflow detail prefetch), so
// they run concurrently instead of serializing on the single-threaded default executor.
private fun createConcurrentExecutor(): ExecutorService {
return Executors.newScheduledThreadPool(CONCURRENT_BACKEND_CALLS)
}

private class LowPriorityThreadFactory(private val threadName: String) : ThreadFactory {
override fun newThread(r: Runnable?): Thread {
val wrapperRunnable = Runnable {
Expand All @@ -593,12 +565,6 @@ internal class PurchasesFactory(
}

companion object {
private const val CONCURRENT_BACKEND_CALLS = 4

// Caps concurrent workflow CDN downloads on the dedicated workflows FileRepository scope, the
// same way CONCURRENT_BACKEND_CALLS caps concurrent workflow detail fetches on prefetchDispatcher.
private const val MAX_CONCURRENT_WORKFLOW_CDN_FETCHES = 4

@VisibleForTesting
internal fun shouldInitializeDiagnostics(
diagnosticsEnabled: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import com.revenuecat.purchases.common.log
import com.revenuecat.purchases.common.offerings.OfferingsManager
import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager
import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager
import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider
import com.revenuecat.purchases.common.sha1
import com.revenuecat.purchases.common.subscriberattributes.SubscriberAttributeKey
import com.revenuecat.purchases.common.verboseLog
Expand Down Expand Up @@ -106,6 +107,11 @@ import com.revenuecat.purchases.utils.Result
import com.revenuecat.purchases.utils.isAndroidNOrNewer
import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies
import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencyManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.net.URL
import java.util.Collections
import java.util.Date
Expand Down Expand Up @@ -162,10 +168,13 @@ internal class PurchasesOrchestrator(
private val backupManager: BackupManager = BackupManager(application),
val fileRepository: FileRepository = DefaultFileRepository(application),
private val remoteConfigManager: RemoteConfigManager? = null,
private val uiConfigProvider: UiConfigProvider? = null,
@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
val adTracker: AdTracker = AdTracker(adEventsManager),
) : LifecycleDelegate, CustomActivityLifecycleHandler {

private val uiConfigScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

internal var state: PurchasesState
get() = purchasesStateCache.purchasesState
set(value) {
Expand Down Expand Up @@ -581,10 +590,9 @@ internal class PurchasesOrchestrator(
) {
// Deliver every outcome through dispatch so the callback always lands on the main thread,
// matching the rest of the SDK's callback APIs (e.g. getOfferings, getCustomerInfo).
// WorkflowManager.getWorkflow intentionally has no fixed delivery thread — a cache hit calls
// back synchronously on the caller's thread while a miss resolves on its IO scope, and the
// prefetch path routes detail callbacks onto a dedicated dispatcher — so normalizing here, at
// the consumer boundary, is what gives callers (including awaitGetWorkflow) a stable thread.
// WorkflowManager.getWorkflow intentionally has no fixed delivery thread — it calls back on its
// IO scope — so normalizing here, at the consumer boundary, is what gives callers (including
// awaitGetWorkflow) a stable thread.
if (appConfig.uiPreviewMode) {
dispatch {
onError(
Expand All @@ -608,17 +616,53 @@ internal class PurchasesOrchestrator(
return
}
workflowManager.getWorkflow(
appUserID = identityManager.currentAppUserID,
workflowOrOfferingId = workflowId,
appInBackground = state.appInBackground,
onSuccess = { dispatch { onSuccess(it) } },
onError = { dispatch { onError(it) } },
)
}

fun workflowIdForOfferingId(offeringId: String): String? =
suspend fun workflowIdForOfferingId(offeringId: String): String? =
workflowManager?.workflowIdForOfferingId(offeringId)

fun getUiConfig(
onSuccess: (UiConfig) -> Unit,
onError: (PurchasesError) -> Unit,
) {
if (appConfig.uiPreviewMode) {
dispatch {
onError(
PurchasesError(
PurchasesErrorCode.ConfigurationError,
"UI config cannot be fetched in UI preview mode.",
),
)
}
return
}
if (uiConfigProvider == null) {
dispatch {
onError(
PurchasesError(
PurchasesErrorCode.ConfigurationError,
"UI config is not enabled.",
),
)
}
return
}
uiConfigScope.launch {
try {
val uiConfig = uiConfigProvider.getUiConfig()
dispatch { onSuccess(uiConfig) }
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
dispatch {
onError(PurchasesError(PurchasesErrorCode.UnknownError, "Failed to fetch UI config: ${e.message}"))
}
}
}
}

fun getProducts(
productIds: List<String>,
type: ProductType? = null,
Expand Down Expand Up @@ -853,6 +897,7 @@ internal class PurchasesOrchestrator(
this.backend.close()
this.remoteConfigManager?.close()
this.workflowManager?.close()
this.uiConfigScope.cancel()

billing.close()
updatedCustomerInfoListener = null // Do not call on state since the setter does more stuff
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker
import com.revenuecat.purchases.common.errorLog
import com.revenuecat.purchases.common.log
import com.revenuecat.purchases.common.warnLog
import com.revenuecat.purchases.common.workflows.WorkflowManager
import com.revenuecat.purchases.paywalls.OfferingFontPreDownloader
import com.revenuecat.purchases.strings.OfferingStrings
import com.revenuecat.purchases.utils.OfferingImagePreDownloader
Expand All @@ -39,7 +38,6 @@ internal class OfferingsManager(
private val dateProvider: DateProvider = DefaultDateProvider(),
// This is nullable due to: https://github.com/RevenueCat/purchases-flutter/issues/408
private val mainHandler: Handler? = Handler(Looper.getMainLooper()),
private val workflowManager: WorkflowManager? = null,
) {

private val emptyOfferings: Offerings = Offerings(current = null, all = emptyMap())
Expand Down Expand Up @@ -157,9 +155,7 @@ internal class OfferingsManager(
null,
null,
)
val dispatchSuccess = { dispatch { onSuccess?.invoke(cachedOfferings) } }
workflowManager?.getWorkflowsList(appUserID, appInBackground, onComplete = dispatchSuccess)
?: dispatchSuccess()
dispatch { onSuccess?.invoke(cachedOfferings) }
if (isCacheStale) {
log(LogIntent.DEBUG) {
if (appInBackground) {
Expand Down Expand Up @@ -200,8 +196,6 @@ internal class OfferingsManager(
appInBackground,
{ body, originalDataSource ->
createAndCacheOfferings(
appUserID = appUserID,
appInBackground = appInBackground,
offeringsJSON = body,
originalDataSource = originalDataSource,
loadedFromDiskCache = false,
Expand All @@ -228,8 +222,6 @@ internal class OfferingsManager(
}
} ?: HTTPResponseOriginalSource.MAIN
createAndCacheOfferings(
appUserID = appUserID,
appInBackground = appInBackground,
offeringsJSON = cachedOfferingsResponse,
originalDataSource = originalDataSource,
loadedFromDiskCache = true,
Expand All @@ -247,8 +239,6 @@ internal class OfferingsManager(
}

private fun createAndCacheOfferings(
appUserID: String,
appInBackground: Boolean,
offeringsJSON: JSONObject,
originalDataSource: HTTPResponseOriginalSource,
loadedFromDiskCache: Boolean,
Expand All @@ -268,14 +258,7 @@ internal class OfferingsManager(
}
offeringFontPreDownloader.preDownloadOfferingFontsIfNeeded(offeringsResultData.offerings)
offeringsCache.cacheOfferings(offeringsResultData.offerings, offeringsJSON)
val dispatchSuccess = { dispatch { onSuccess?.invoke(offeringsResultData) } }
workflowManager?.getWorkflowsList(
appUserID,
appInBackground,
// Refetch workflows only when these offerings are fresh from the network.
forceRefresh = !loadedFromDiskCache,
onComplete = dispatchSuccess,
) ?: dispatchSuccess()
dispatch { onSuccess?.invoke(offeringsResultData) }
},
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.revenuecat.purchases.common.remoteconfig

import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.JsonTools
import com.revenuecat.purchases.UiConfig
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement

/**
* The topic-specific front door for `ui_config`: three independently-updated parts — `app`, `localizations`,
* `variable_config` — that together deserialize as one [UiConfig], the same shape the legacy offerings response
* sends pre-assembled in a single JSON object. Each part is inline item metadata (no blob), so it's read straight
* off the topic's item index. A part with no body (topic absent or item absent) is simply omitted, so [UiConfig]'s
* own field defaults fill the gap.
*/
@OptIn(InternalRevenueCatAPI::class)
internal class UiConfigProvider(
private val manager: RemoteConfigManager,
) {

suspend fun getUiConfig(): UiConfig {
val topic = manager.topic(RemoteConfigTopic.UiConfig)
val parts = PART_KEYS.mapNotNull { key -> topic?.get(key)?.let { key to it.metadata } }.toMap()
return JsonTools.json.decodeFromJsonElement(JsonObject(parts))
}

private companion object {
private val PART_KEYS = listOf("app", "localizations", "variable_config")
}
}
Loading