Skip to content
Open
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 @@ -39,11 +39,13 @@ 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.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 @@ -196,6 +198,9 @@ internal class PurchasesFactory(
eventsDispatcher,
httpClient,
backendHelper,
workflowCdnFetcher = FileCachedWorkflowCdnFetcher(
fileRepository = DefaultFileRepository(contextForStorage),
),
)

val purchasesStateProvider = PurchasesStateCache(PurchasesState())
Expand Down
153 changes: 153 additions & 0 deletions purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ 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.FileCachedWorkflowCdnFetcher
import com.revenuecat.purchases.common.workflows.WorkflowCdnFetcher
import com.revenuecat.purchases.common.workflows.WorkflowDetailHttpProcessingResult
import com.revenuecat.purchases.common.workflows.WorkflowDetailHttpProcessor
import com.revenuecat.purchases.common.workflows.WorkflowFetchResult
import com.revenuecat.purchases.common.workflows.WorkflowJsonParser
import com.revenuecat.purchases.common.workflows.WorkflowsListResponse
import com.revenuecat.purchases.customercenter.CustomerCenterConfigData
import com.revenuecat.purchases.customercenter.CustomerCenterRoot
import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener
Expand Down Expand Up @@ -96,6 +103,12 @@ internal typealias VirtualCurrenciesCallback = Pair<(VirtualCurrencies) -> Unit,

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

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

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

internal enum class PostReceiptErrorHandlingBehavior {
SHOULD_BE_MARKED_SYNCED,
SHOULD_USE_OFFLINE_ENTITLEMENTS_AND_NOT_CONSUME,
Expand All @@ -115,7 +128,9 @@ internal class Backend(
private val eventsDispatcher: Dispatcher,
private val httpClient: HTTPClient,
private val backendHelper: BackendHelper,
workflowCdnFetcher: WorkflowCdnFetcher = FileCachedWorkflowCdnFetcher(fileRepository = null),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm why default here to a different one since we're creating the Backend instance in the PurchasesFactory with a value? If this is for tests, I wonder if we should just make this explicit there... Also, it would make fileRespository not nullable, which feels better?

) {
private val workflowDetailHttpProcessor = WorkflowDetailHttpProcessor(workflowCdnFetcher)
companion object {
private const val APP_USER_ID = "app_user_id"
private const val FETCH_TOKEN = "fetch_token"
Expand Down Expand Up @@ -177,6 +192,14 @@ internal class Backend(
@get:Synchronized @set:Synchronized
@Volatile var webBillingProductsCallbacks = mutableMapOf<String, MutableList<WebBillingProductsCallback>>()

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

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

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

fun getWorkflows(
appUserID: String,
appInBackground: Boolean,
onSuccess: (WorkflowsListResponse) -> Unit,
onError: (PurchasesError) -> Unit,
) {
val endpoint = Endpoint.GetWorkflows(appUserID)
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,
)
}

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

override fun onCompletion(result: HTTPResult) {
synchronized(this@Backend) {
workflowsListCallbacks.remove(cacheKey)
}?.forEach { (onSuccessHandler, onErrorHandler) ->
if (result.isSuccessful()) {
try {
onSuccessHandler(WorkflowJsonParser.parseWorkflowsListResponse(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
workflowsListCallbacks.addBackgroundAwareCallback(
call,
dispatcher,
cacheKey,
onSuccess to onError,
delay,
)
}
}

fun getWorkflow(
appUserID: String,
workflowId: String,
appInBackground: Boolean,
onSuccess: (WorkflowFetchResult) -> 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() {
private var workflowDetailProcessing: WorkflowDetailHttpProcessingResult? = null

override fun call(): HTTPResult {
val raw = httpClient.performRequest(
appConfig.baseURL,
endpoint,
body = null,
postFieldsToSign = null,
backendHelper.authenticationHeaders,
fallbackBaseURLs = appConfig.fallbackBaseURLs,
)
val processed = workflowDetailHttpProcessor.process(raw)
workflowDetailProcessing = processed
return processed.httpResult
}

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 {
val workflow = WorkflowJsonParser.parsePublishedWorkflow(result.payload)
onSuccessHandler(
WorkflowFetchResult(
workflow = workflow,
enrolledVariants = workflowDetailProcessing?.enrolledVariants,
),
)
} 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,21 @@ internal sealed class Endpoint(
}
}
}

data class GetWorkflows(val userId: String) : Endpoint(
"/v1/subscribers/%s/workflows",
"get_workflows",
) {
override fun getPath(useFallback: Boolean) = pathTemplate.format(Uri.encode(userId))
}

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 +117,7 @@ internal sealed class Endpoint(
LogIn,
PostReceipt,
is GetOfferings,
is GetWorkflows,
GetProductEntitlementMapping,
PostRedeemWebPurchase,
is GetVirtualCurrencies,
Expand All @@ -115,6 +131,7 @@ internal sealed class Endpoint(
PostCreateSupportTicket,
is WebBillingGetProducts,
is AliasUsers,
is GetWorkflow,
->
false
}
Expand All @@ -130,6 +147,8 @@ internal sealed class Endpoint(
true
is GetAmazonReceipt,
is GetOfferings,
is GetWorkflows,
is GetWorkflow,
is PostAttributes,
PostDiagnostics,
PostEvents,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@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 when non-null; otherwise reads the URL directly.
*/
internal class FileCachedWorkflowCdnFetcher(
private val fileRepository: FileRepository?,
) : WorkflowCdnFetcher {

@Throws(IOException::class)
override fun fetchCompiledWorkflowJson(cdnUrl: String): String {
val url = URL(cdnUrl)
return if (fileRepository != null) {
runBlocking {
val uri = fileRepository.generateOrGetCachedFileURL(url)
File(uri).readText()
}
} else {
url.openConnection().getInputStream().use { stream ->
stream.readBytes().decodeToString()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
@file:OptIn(InternalRevenueCatAPI::class)

package com.revenuecat.purchases.common.workflows

import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.common.isSuccessful
import com.revenuecat.purchases.common.networking.HTTPResult
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException

/**
* Normalizes a successful workflow-detail HTTP payload: `inline` (unwrap `data`) or `use_cdn` (fetch JSON).
* Non-success [HTTPResult] is returned unchanged with null [WorkflowDetailHttpProcessingResult.enrolledVariants].
*/
internal class WorkflowDetailHttpProcessor(
private val workflowCdnFetcher: WorkflowCdnFetcher,
) {

@Throws(JSONException::class, IOException::class)
fun process(result: HTTPResult): WorkflowDetailHttpProcessingResult {
if (!result.isSuccessful()) {
return WorkflowDetailHttpProcessingResult(httpResult = result, enrolledVariants = null)
}
val root = JSONObject(result.payload)
val enrolledVariants = root.optJSONObject("enrolled_variants")?.toEnrolledVariantsMap()
val rawAction = root.getString("action")
val action = WorkflowResponseAction.fromValue(rawAction)
?: throw JSONException("Unknown workflow response action: $rawAction")
val normalizedPayload = when (action) {
WorkflowResponseAction.INLINE -> root.getJSONObject("data").toString()
WorkflowResponseAction.USE_CDN -> workflowCdnFetcher.fetchCompiledWorkflowJson(root.getString("url"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should just stick to plain Json serialization here, and avoid the custom parsing we are doing here. Then, do the fetching of the compiled workflow as a higher level later on top of the network call? Feels the network call and the logic are a bit mixed right now...

}
return WorkflowDetailHttpProcessingResult(
httpResult = result.copy(payload = normalizedPayload),
enrolledVariants = enrolledVariants,
)
}

private fun JSONObject.toEnrolledVariantsMap(): Map<String, String>? {
val map = mutableMapOf<String, String>()
val keyIterator = keys()
while (keyIterator.hasNext()) {
val key = keyIterator.next()
map[key] = optString(key)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optString will convert anything to string? How does that work? Can we add tests for non-string keys?

}
return map.takeUnless { it.isEmpty() }
}
}

internal data class WorkflowDetailHttpProcessingResult(
val httpResult: HTTPResult,
val enrolledVariants: Map<String, String>?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@file:OptIn(InternalRevenueCatAPI::class)

package com.revenuecat.purchases.common.workflows

import com.revenuecat.purchases.InternalRevenueCatAPI
import kotlinx.serialization.json.Json

internal object WorkflowJsonParser {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like an unnecessary abstraction to me. Or what am I missing? Can't we reuse an existing parser?

private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}

fun parseWorkflowsListResponse(payload: String): WorkflowsListResponse {
return json.decodeFromString(payload)
}

fun parsePublishedWorkflow(payload: String): PublishedWorkflow {
return json.decodeFromString(payload)
}
}
Loading