Skip to content

Conversation

fire-at-will
Copy link
Contributor

@fire-at-will fire-at-will commented Jul 8, 2025

Motivation

This PR is a follow-up PR to #2466 that supports fetching virtual currencies from the backend in the VirtualCurrencyManager.getVirtualCurrencies() function.

Description

This PR:

  • Fetches Virtual Currencies from the network in VirtualCurrencyManager.getVirtualCurrencies() when the VC cache is empty or stale
  • Adds log messages for the virtual currencies features. The log messages match those on iOS and are logged at the same time.
  • Makes the errors thrown by VirtualCurrenciesFactory more explicit
  • Removes the unused VirtualCurrencyFactory class and its tests
  • Adds buttons to the Purchase Tester app to support manually testing:
    • The Purchases.getVirtualCurrencies function
    • Fetching the cached virtual currencies from Purchases.cachedVirtualCurrencies
    • Invalidating the VC cache using Purchases.invalidateVirtualCurrenciesCache()

Here's a screenshot of the updated Purchase Tester screen:
Screenshot 2025-07-10 at 2 56 56 PM

Testing

This PR also adds unit tests for:

  • Ensuring the DeviceCache appropriately handles the new exceptions declared in VirtualCurrenciesFactory
  • Ensuring that the backend VC request is executed properly
  • Updates a few VirtualCurrencyManager tests to reflect the new behavior

I've also manually tested the following scenarios through the purchase tester app:

  • Purchases.shared.getVirtualCurrencies() fetches VCs from the network when the cache is empty.
  • Purchases.shared.getVirtualCurrencies() returns cached VCs when non-stale virtual currencies are cached
  • Purchases.shared.getVirtualCurrencies() fetches VCs from the backend when the cached VCs are present on the device but are stale
  • Purchases.shared.cachedVirtualCurrencies returns null when the cache is empty.
  • Purchases.shared.cachedVirtualCurrencies returns virtual currencies immediately after fetching virtual currencies from the network
    -Purchases.shared.cachedVirtualCurrencies returns cached VCs even if they are stale (useful if the app is offline)

Next Steps

In a future PR, we'll add backend integration tests.

To keep the virtual-currency-dev branch clean, this PR will be merged into a fetch-virtual-currencies-new-endpoint branch. We'll make future PRs go into the fetch-virtual-currencies-new-endpoint branch, and when the functionality of the new APIs is complete, we'll merge fetch-virtual-currencies-new-endpoint into virtual-currency-dev.

@fire-at-will fire-at-will changed the title [WIP]: Fetch VC from Network Fetch VCs from Network Jul 9, 2025
}

override fun onError(error: PurchasesError) {
synchronized(this@Backend) {

Choose a reason for hiding this comment

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

Nit: consider logging the PurchasesError in onError() here for consistency with the explicit logging in the ViewModel and to ensure errors during VC fetch are visible in logs

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey @harrytmthy, thanks for the review! I've added logging in 8cdde1d. Network errors are now logged in the VirtualCurrencyManager class 👍

Copy link
Contributor

@tonidero tonidero left a comment

Choose a reason for hiding this comment

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

Looking good! Just some comments

@@ -103,9 +106,41 @@ class OverviewViewModel(private val interactionHandler: OverviewInteractionHandl
interactionHandler.syncAttributes()
}

fun onFetchVCsClicked() {
viewModelScope.launch {
val virtualCurrencies: VirtualCurrencies = Purchases.sharedInstance.awaitGetVirtualCurrencies()
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we planning on using this for something other than logs? Could be good to add a section to visualize the virtual currency to the purchase tester app I think.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@tonidero I've updated the purchase tester app to show the VCs on the overview screen here: b028b29

Here's what it looks like:
Screenshot 2025-07-10 at 2 56 56 PM

every {
mockBackend.getVirtualCurrencies(any(), any(), any(), any())
} answers {
val onSuccess = arg<(VirtualCurrencies) -> Unit>(2)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we add some tests to also verify the behavior on an error?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great idea - I've added a test to ensure that the VCManager forwards the PurchasesError from the backend to the callback here: b1b0117 👍

originalCallback.onReceived(virtualCurrencies)
}
override fun onError(error: PurchasesError) {
log(LogIntent.RC_SUCCESS) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm is this logIntent expected on an error?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Definitely not - I should remove the copy/paste functionality from my computer 🤦‍♂️

Fixed in 1f1b8b2

Copy link
Member

@ajpallares ajpallares left a comment

Choose a reason for hiding this comment

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

Looks good to me in general, but I think there's a small bug in the onError callbacks of the get virtual currencies request. I'm requesting changes to make sure we don't miss that before merging.
Great job btw! 🙌

log(LogIntent.RC_SUCCESS) {
VirtualCurrencyStrings.VIRTUAL_CURRENCIES_UPDATED_FROM_NETWORK_ERROR.format(error)
}
originalCallback.onError(error)
Copy link
Member

Choose a reason for hiding this comment

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

Not saying we should, but I wonder: should we clear the cache at this point?
FWIW, following the code, this point would only be reached if the cache is stale

Copy link
Contributor

Choose a reason for hiding this comment

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

No, I think we still want to allow devs to access stale data by using the cachedVirtualCurrencies synchronous accessor.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense. Thank you for the clarification!

@fire-at-will
Copy link
Contributor Author

fire-at-will commented Jul 10, 2025

@tonidero @ajpallares I've applied the fix that @ajpallares suggested - great find! We already had some tests (here) that ensured that the callbacks were triggered when the JSON deserialization fails, but they were passing despite the bug because the function's callback was still being called 😅

I've tried to find a way to ensure that the Backend.getVirtualCurrencies() function calls the Backend.virtualCurrenciesCallbacks callbacks, but I've honestly haven't been able to figure out how to do that since it's private and there's no way to control what callbacks get added there from outside the function. I'm open to ideas if y'all have any!

Copy link
Contributor

@tonidero tonidero left a comment

Choose a reason for hiding this comment

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

This looks great to me! :shipit:

Copy link
Member

@ajpallares ajpallares left a comment

Choose a reason for hiding this comment

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

:shipit:

@fire-at-will fire-at-will merged commit 1b54f74 into fetch-virtual-currencies-new-endpoint Jul 11, 2025
12 checks passed
@fire-at-will fire-at-will deleted the vc-network-call branch July 11, 2025 13:49
fire-at-will added a commit that referenced this pull request Jul 11, 2025
This PR includes the following PRs with no other changes:
- #2466
- #2496
- #2505

It is the collection of all of the work required to update the VC APIs
and fetch the virtual currencies from the new v1 endpoint. To keep the
PR sizes manageable, the work was broken up into smaller PRs, which have
been individually approved and aggregated into this branch.

Forgoing a review on this PR since all changes have already been
reviewed, and we'll do a final review when we merge
`virtual-currency-dev` into `main`
github-merge-queue bot pushed a commit that referenced this pull request Jul 16, 2025
### Description
Android counterpart to
RevenueCat/purchases-ios#5108.

This PR introduces virtual currencies to the Android SDK. It's a large
PR, but all parts of it have been reviewed individually before here:
- #2466
- #2496
- #2505

It includes the following:

### New APIs for Fetching Virtual Currency Balances + Metadata
#### New Top-Level Functions
These functions allow you to fetch the virtual currencies for the
current subscriber. The `invalidateVirtualCurrenciesCache` function
allows developers to clear the cached virtual currencies, which will
then force the refreshing of the virtual currencies from the backend the
next time `getVirtualCurrencies()` is called.

```kotlin
class Purchases {
   fun getVirtualCurrencies(
        callback: GetVirtualCurrenciesCallback,
    )

   fun invalidateVirtualCurrenciesCache()

   val cachedVirtualCurrencies: VirtualCurrencies?
}
```



#### New Objects
```kotlin
class VirtualCurrencies internal constructor(
    val all: Map<String, VirtualCurrency>,
) : Parcelable {
    operator fun get(code: String): VirtualCurrency? = all[code]
}

class VirtualCurrency internal constructor(
    val balance: Int,
    val name: String,
    val code: String,
    val serverDescription: String? = null,
)
```

#### Example Usage
Kotlin:
```swift
Purchases.sharedInstance.invalidateVirtualCurrenciesCache()

Purchases.sharedInstance.getVirtualCurrencies(
    object: GetVirtualCurrenciesCallback {
        override fun onReceived(virtualCurrencies: VirtualCurrencies) {}
        override fun onError(error: PurchasesError) {}
    }
)

Purchases.sharedInstance.getVirtualCurrenciesWith(
        onError = { _: PurchasesError -> },
        onSuccess = { _: VirtualCurrencies -> },
)

val getVirtualCurrenciesResult: VirtualCurrencies = Purchases.sharedInstance.awaitGetVirtualCurrencies()

val cachedVirtualCurrencies = Purchases.sharedInstance.cachedVirtualCurrencies
```
tonidero pushed a commit that referenced this pull request Aug 25, 2025
Android counterpart to
RevenueCat/purchases-ios#5108.

This PR introduces virtual currencies to the Android SDK. It's a large
PR, but all parts of it have been reviewed individually before here:
- #2466
- #2496
- #2505

It includes the following:

These functions allow you to fetch the virtual currencies for the
current subscriber. The `invalidateVirtualCurrenciesCache` function
allows developers to clear the cached virtual currencies, which will
then force the refreshing of the virtual currencies from the backend the
next time `getVirtualCurrencies()` is called.

```kotlin
class Purchases {
   fun getVirtualCurrencies(
        callback: GetVirtualCurrenciesCallback,
    )

   fun invalidateVirtualCurrenciesCache()

   val cachedVirtualCurrencies: VirtualCurrencies?
}
```

```kotlin
class VirtualCurrencies internal constructor(
    val all: Map<String, VirtualCurrency>,
) : Parcelable {
    operator fun get(code: String): VirtualCurrency? = all[code]
}

class VirtualCurrency internal constructor(
    val balance: Int,
    val name: String,
    val code: String,
    val serverDescription: String? = null,
)
```

Kotlin:
```swift
Purchases.sharedInstance.invalidateVirtualCurrenciesCache()

Purchases.sharedInstance.getVirtualCurrencies(
    object: GetVirtualCurrenciesCallback {
        override fun onReceived(virtualCurrencies: VirtualCurrencies) {}
        override fun onError(error: PurchasesError) {}
    }
)

Purchases.sharedInstance.getVirtualCurrenciesWith(
        onError = { _: PurchasesError -> },
        onSuccess = { _: VirtualCurrencies -> },
)

val getVirtualCurrenciesResult: VirtualCurrencies = Purchases.sharedInstance.awaitGetVirtualCurrencies()

val cachedVirtualCurrencies = Purchases.sharedInstance.cachedVirtualCurrencies
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants