Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6b12219
Add `GetWorkflows` and `GetWorkflow`
vegaro Apr 6, 2026
939f21f
Add typed WorkflowTrigger model to match backend response shape
vegaro Apr 7, 2026
2b01ff3
Fix unknown action crash and use consistent import style
vegaro Apr 7, 2026
2e253b5
PR comments
vegaro Apr 10, 2026
c0e8e35
Remove unused uiConfig from WorkflowsListResponse
vegaro Apr 14, 2026
e7efbfe
Change PublishedWorkflow.metadata to Map<String, Any>
vegaro Apr 14, 2026
9310914
Remove unused metadata and contentMaxWidth from PublishedWorkflow
vegaro Apr 14, 2026
1427405
add JsonObjectToMapSerializer
vegaro Apr 14, 2026
f64f413
Add WorkflowDetailResponse envelope model and hash field
vegaro Apr 14, 2026
ca0226c
Simplify Backend.getWorkflow() to return raw envelope
vegaro Apr 14, 2026
8690d2d
Enable signature verification for GetWorkflow endpoint
vegaro Apr 14, 2026
7bacd77
Add WorkflowDetailResolver with CDN hash verification
vegaro Apr 14, 2026
cb71b78
Add WorkflowManager to coordinate Backend + WorkflowDetailResolver
vegaro Apr 14, 2026
b9a3180
add type paywall
vegaro Apr 15, 2026
8e0e582
fix import
vegaro Apr 15, 2026
980e650
catch IOException
vegaro Apr 15, 2026
724aa86
more imports
vegaro Apr 15, 2026
36ffc5e
feat: add workflow public API and network layer
vegaro Apr 16, 2026
925f940
chore: revert public workflow API from network layer
vegaro Apr 16, 2026
9f3e90f
updates hash to match backend
vegaro Apr 16, 2026
cddfcaf
rename to workflowListCallbacks
vegaro Apr 16, 2026
a76d1f7
remove getWorkflows from network layer
vegaro Apr 17, 2026
9e5c450
fix import
vegaro Apr 17, 2026
1a6e1d2
throw SignatureVerificationException
vegaro Apr 17, 2026
5795e3f
Merge branch 'main' into feat/multipage-paywalls-milestone-1-go8
vegaro Apr 17, 2026
e6e0a7b
remove supression
vegaro Apr 17, 2026
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ Variant names combine both dimensions, e.g. `defaultsBc8Debug`, `customEntitleme
- **`@ExperimentalPreviewRevenueCatPurchasesAPI`** - Public APIs for developers that may change before being made stable
- **`@ExperimentalPreviewRevenueCatUIPurchasesAPI`** - Same as above but for the `:ui:revenuecatui` module

## Code Style

- **Imports over inline fully-qualified references**: Always add an `import` statement at the top of the file rather than using a fully-qualified name inline (e.g., write `import foo.Bar` and use `Bar`, not `foo.Bar` inline in the code).

## Testing Framework

### Technologies Used
Expand Down
1 change: 1 addition & 0 deletions config/detekt/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
<ID>TooManyFunctions:HTTPClient.kt$HTTPClient</ID>
<ID>TooManyFunctions:Purchases.kt$Purchases : LifecycleDelegate</ID>
<ID>TooManyFunctions:SubscriberAttributesCache.kt$SubscriberAttributesCache</ID>
<ID>TooManyFunctions:CoroutinesExtensionsCommon.kt$com.revenuecat.purchases.CoroutinesExtensionsCommon.kt</ID>
<ID>TooManyFunctions:coroutinesExtensions.kt$com.revenuecat.purchases.coroutinesExtensions.kt</ID>
<ID>TooManyFunctions:listenerConversions.kt$com.revenuecat.purchases.listenerConversions.kt</ID>
<ID>UnusedParameter:SampleWeatherData.kt$SampleWeatherData.Companion$environment: Environment</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.revenuecat.purchases.common.errorLog
import com.revenuecat.purchases.common.events.FeatureEvent
import com.revenuecat.purchases.common.infoLog
import com.revenuecat.purchases.common.log
import com.revenuecat.purchases.common.workflows.WorkflowFetchResult
import com.revenuecat.purchases.customercenter.CustomerCenterListener
import com.revenuecat.purchases.deeplinks.DeepLinkParser
import com.revenuecat.purchases.interfaces.Callback
Expand Down Expand Up @@ -394,6 +395,16 @@ public class Purchases internal constructor(
purchasesOrchestrator.getOfferings(listener)
}

@InternalRevenueCatAPI
@JvmSynthetic
public fun getWorkflowWith(
workflowId: String,
onError: (PurchasesError) -> Unit,
onSuccess: (WorkflowFetchResult) -> Unit,
) {
purchasesOrchestrator.getWorkflow(workflowId, onSuccess, onError)
}

/**
* Gets the StoreProduct(s) for the given list of product ids for all product types.
* @param [productIds] List of productIds
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.revenuecat.purchases

import android.content.Context
import com.revenuecat.purchases.common.workflows.WorkflowFetchResult
import com.revenuecat.purchases.models.BillingFeature
import com.revenuecat.purchases.models.StoreProduct
import com.revenuecat.purchases.models.StoreTransaction
Expand Down Expand Up @@ -281,3 +282,28 @@ public suspend fun Purchases.Companion.awaitCanMakePayments(
)
}
}

/**
* Fetches a published workflow by identifier.
*
* Coroutine friendly version of [Purchases.getWorkflowWith].
*
* @param workflowId The identifier of the workflow to fetch.
* @throws [PurchasesException] with a [PurchasesError] if there's an error fetching the workflow.
* @return The [WorkflowFetchResult] for the given identifier.
*/
@OptIn(InternalRevenueCatAPI::class)
@InternalRevenueCatAPI
@JvmSynthetic
@Throws(PurchasesException::class)
public suspend fun Purchases.awaitGetWorkflow(
workflowId: String,
): WorkflowFetchResult {
return suspendCoroutine { continuation ->
getWorkflowWith(
workflowId = workflowId,
onSuccess = continuation::resume,
onError = { continuation.resumeWithException(PurchasesException(it)) },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ import com.revenuecat.purchases.common.offlineentitlements.PurchasedProductsFetc
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.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.DefaultFileRepository
import com.revenuecat.purchases.strings.ConfigureStrings
import com.revenuecat.purchases.strings.Emojis
import com.revenuecat.purchases.subscriberattributes.SubscriberAttributesManager
Expand Down Expand Up @@ -198,6 +202,16 @@ internal class PurchasesFactory(
backendHelper,
)

val workflowManager = WorkflowManager(
backend = backend,
workflowDetailResolver = WorkflowDetailResolver(
workflowCdnFetcher = FileCachedWorkflowCdnFetcher(
fileRepository = DefaultFileRepository(contextForStorage),
),
signatureVerificationMode = signatureVerificationMode,
),
)

val purchasesStateProvider = PurchasesStateCache(PurchasesState())

// Override used for integration tests.
Expand Down Expand Up @@ -415,6 +429,7 @@ internal class PurchasesFactory(
localeProvider = localeProvider,
virtualCurrencyManager = virtualCurrencyManager,
purchaseParamsValidator = purchaseParamsValidator,
workflowManager = workflowManager,
)

return Purchases(purchasesOrchestrator)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import com.revenuecat.purchases.common.sha1
import com.revenuecat.purchases.common.subscriberattributes.SubscriberAttributeKey
import com.revenuecat.purchases.common.verboseLog
import com.revenuecat.purchases.common.warnLog
import com.revenuecat.purchases.common.workflows.WorkflowFetchResult
import com.revenuecat.purchases.common.workflows.WorkflowManager
import com.revenuecat.purchases.customercenter.CustomerCenterListener
import com.revenuecat.purchases.deeplinks.WebPurchaseRedemptionHelper
import com.revenuecat.purchases.google.isSuccessful
Expand Down Expand Up @@ -152,6 +154,8 @@ internal class PurchasesOrchestrator(
),
private val virtualCurrencyManager: VirtualCurrencyManager,
private val purchaseParamsValidator: PurchaseParamsValidator,

private val workflowManager: WorkflowManager,
Comment thread
cursor[bot] marked this conversation as resolved.
val processLifecycleOwnerProvider: () -> LifecycleOwner = { ProcessLifecycleOwner.get() },
private val blockstoreHelper: BlockstoreHelper = BlockstoreHelper(application, identityManager),
private val backupManager: BackupManager = BackupManager(application),
Expand Down Expand Up @@ -559,6 +563,21 @@ internal class PurchasesOrchestrator(
)
}

@InternalRevenueCatAPI
fun getWorkflow(
workflowId: String,
onSuccess: (WorkflowFetchResult) -> Unit,
onError: (PurchasesError) -> Unit,
) {
workflowManager.getWorkflow(
appUserID = identityManager.currentAppUserID,
workflowId = workflowId,
appInBackground = state.appInBackground,
onSuccess = onSuccess,
onError = onError,
)
}

fun getProducts(
productIds: List<String>,
type: ProductType? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import com.revenuecat.purchases.common.networking.WebBillingProductsResponse
import com.revenuecat.purchases.common.networking.buildPostReceiptResponse
import com.revenuecat.purchases.common.offlineentitlements.ProductEntitlementMapping
import com.revenuecat.purchases.common.verification.SignatureVerificationMode
import com.revenuecat.purchases.common.workflows.WorkflowDetailResponse
import com.revenuecat.purchases.common.workflows.WorkflowJsonParser
import com.revenuecat.purchases.customercenter.CustomerCenterConfigData
import com.revenuecat.purchases.customercenter.CustomerCenterRoot
import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener
Expand Down Expand Up @@ -96,6 +98,9 @@ internal typealias VirtualCurrenciesCallback = Pair<(VirtualCurrencies) -> Unit,

internal typealias WebBillingProductsCallback = Pair<(WebBillingProductsResponse) -> Unit, (PurchasesError) -> Unit>

@OptIn(InternalRevenueCatAPI::class)
internal typealias WorkflowDetailCallback = Pair<(WorkflowDetailResponse) -> Unit, (PurchasesError) -> Unit>

internal enum class PostReceiptErrorHandlingBehavior {
SHOULD_BE_MARKED_SYNCED,
SHOULD_USE_OFFLINE_ENTITLEMENTS_AND_NOT_CONSUME,
Expand Down Expand Up @@ -177,6 +182,10 @@ internal class Backend(
@get:Synchronized @set:Synchronized
@Volatile var webBillingProductsCallbacks = mutableMapOf<String, MutableList<WebBillingProductsCallback>>()

@get:Synchronized @set:Synchronized
@Volatile var workflowDetailCallbacks =
mutableMapOf<BackgroundAwareCallbackCacheKey, MutableList<WorkflowDetailCallback>>()

fun close() {
this.dispatcher.close()
}
Expand Down Expand Up @@ -975,6 +984,68 @@ internal class Backend(
}
}

fun getWorkflow(
appUserID: String,
workflowId: String,
appInBackground: Boolean,
onSuccess: (WorkflowDetailResponse) -> Unit,
onError: (PurchasesError) -> Unit,
) {
val endpoint = Endpoint.GetWorkflow(appUserID, workflowId)
val path = endpoint.getPath()
val cacheKey = BackgroundAwareCallbackCacheKey(listOf(path), appInBackground)
val call = object : Dispatcher.AsyncCall() {
override fun call(): HTTPResult {
return httpClient.performRequest(
appConfig.baseURL,
endpoint,
body = null,
postFieldsToSign = null,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
}
Comment thread
cursor[bot] marked this conversation as resolved.

override fun onError(error: PurchasesError) {
synchronized(this@Backend) {
workflowDetailCallbacks.remove(cacheKey)
}?.forEach { (_, onErrorHandler) ->
onErrorHandler(error)
}
}

override fun onCompletion(result: HTTPResult) {
synchronized(this@Backend) {
workflowDetailCallbacks.remove(cacheKey)
}?.forEach { (onSuccessHandler, onErrorHandler) ->
if (result.isSuccessful()) {
try {
onSuccessHandler(
WorkflowJsonParser.parseWorkflowDetailResponse(result.payload),
)
} catch (e: SerializationException) {
onErrorHandler(e.toPurchasesError().also { errorLog(it) })
} catch (e: IllegalArgumentException) {
onErrorHandler(e.toPurchasesError().also { errorLog(it) })
}
} else {
onErrorHandler(result.toPurchasesError().also { errorLog(it) })
}
}
}
}
synchronized(this@Backend) {
val delay = if (appInBackground) Delay.DEFAULT else Delay.NONE
workflowDetailCallbacks.addBackgroundAwareCallback(
call,
dispatcher,
cacheKey,
onSuccess to onError,
delay,
)
}
}

fun getWebBillingProducts(
appUserID: String,
productIds: Set<String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ internal sealed class Endpoint(
}
}
}

data class GetWorkflow(val userId: String, val workflowId: String) : Endpoint(
"/v1/subscribers/%s/workflows/%s",
"get_workflow",
) {
override fun getPath(useFallback: Boolean) =
pathTemplate.format(Uri.encode(userId), Uri.encode(workflowId))
}
object LogIn : Endpoint("/v1/subscribers/identify", "log_in") {
override fun getPath(useFallback: Boolean) = pathTemplate
}
Expand Down Expand Up @@ -102,6 +110,7 @@ internal sealed class Endpoint(
LogIn,
PostReceipt,
is GetOfferings,
is GetWorkflow,
Comment thread
cursor[bot] marked this conversation as resolved.
GetProductEntitlementMapping,
PostRedeemWebPurchase,
is GetVirtualCurrencies,
Expand Down Expand Up @@ -130,6 +139,7 @@ internal sealed class Endpoint(
true
is GetAmazonReceipt,
is GetOfferings,
is GetWorkflow,
is PostAttributes,
PostDiagnostics,
PostEvents,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@file:OptIn(InternalRevenueCatAPI::class)

package com.revenuecat.purchases.common.workflows

import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.storage.FileRepository
import kotlinx.coroutines.runBlocking
import java.io.File
import java.io.IOException
import java.net.URL

/**
* Fetches compiled workflow JSON from a CDN URL (inject for tests; use [FileCachedWorkflowCdnFetcher] in production).
*/
internal fun interface WorkflowCdnFetcher {
@Throws(IOException::class)
fun fetchCompiledWorkflowJson(cdnUrl: String): String
}

/**
* Uses [FileRepository] for disk caching.
*/
internal class FileCachedWorkflowCdnFetcher(
private val fileRepository: FileRepository,
) : WorkflowCdnFetcher {

@Throws(IOException::class)
override fun fetchCompiledWorkflowJson(cdnUrl: String): String {
val url = URL(cdnUrl)
return runBlocking {
val uri = fileRepository.generateOrGetCachedFileURL(url)
File(uri).readText()
}
}
}
Loading