Skip to content

Fetch Products from Galaxy Store#2908

Merged
fire-at-will merged 37 commits into
samsung-devfrom
fetch-galaxy-products
Dec 11, 2025
Merged

Fetch Products from Galaxy Store#2908
fire-at-will merged 37 commits into
samsung-devfrom
fetch-galaxy-products

Conversation

@fire-at-will

@fire-at-will fire-at-will commented Dec 9, 2025

Copy link
Copy Markdown
Contributor

Description

Supports fetching products from the Galaxy Store.

Notable Files

  • GalaxyBillingWrapper: Main abstraction class for the Galaxy Store.
  • SerialRequestExecutor: Responsible for ensuring that we only make one call to the Galaxy Store at a time, since calling multiple functions that make networks requests to the store is unsupported and causes the Samsung SDK to throw an exception.
  • ProductDataHandler: Handles the fetching of products from the Samsung SDK.
  • StoreProductConversions: Handles the conversions from Samsung ProductVo instances to our GalaxyStoreProduct class

Samsung IAP SDK Inclusion

We're now pulling in the Samsung IAP SDK from the local machine while we determine how the SDK should be distributed.

  • You can include the Samsung SDK on your machine by including the following in your local.properties file: samsungIapSdkPath=/.../SamsungInAppPurchaseSDK_v6.5.0/Libs/samsung-iap-6.5.0.aar
  • This means that CI will fail until we determine how the SDK should be properly distributed.

@fire-at-will fire-at-will added the pr:feat A new feature label Dec 9, 2025

// TODO: Remove this when we figure out how to properly integrate the Samsung SDK
// Avoid merging Android manifests for JVM unit tests to prevent minSdk conflicts from optional AARs.
unitTests.isIncludeAndroidResources = false

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a temporary hack that I introduced so that I can run the unit tests locally despite the fact that the Samsung SDK's minSDK != our minSDK requirement.

We should remove this when we properly add in the Samsung SDK as a dependency to our SDK.

package com.revenuecat.purchases.galaxy.constants

@SuppressWarnings("MagicNumber")
internal enum class GalaxyErrorCode(val code: Int, val description: String) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There are more error codes used by the Galaxy Store, we'll add them in as needed

@fire-at-will fire-at-will marked this pull request as ready for review December 9, 2025 22:37
@fire-at-will fire-at-will requested a review from a team as a code owner December 9, 2025 22:37

@tonidero tonidero left a comment

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.

Still need to review tests but looking great! Mostly a couple concerns over race conditions.

val cachedProducts = productIds.mapNotNull(productDataCache::get)

synchronized(this) {
this.inFlightRequest = request

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 guess there is a small chance of a race condition between this being set, and when we check at the beginning of this method. I wonder if we should maybe only check whether there is an inflight request here?

Again, this probably doesn't apply right now since we're executing requests serially though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Honestly, I think that all of the synchronization in this class is probably unnecessary since we're executing the requests serially. That said, would it just be safer to make this entire getProductDetails() function synchronized by annotating it with @Synchronized?

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 well, I guess it's dangerous to synchronize on a possibly long block of code. Specially considering that may be doing network requests (the actual fetching of products). So I would be against that. I do agree it's probably unnecessary considering we're executing all of this serially, but just concerning that it's not part of the contract, and this could potentially be executed from somewhere else (though it shouldn't)... Still, I guess I wouldn't consider it a blocker

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.

Might be worth removing all the synchronous code, and instead create a single thread to execute all these Galaxy operations. We've created single thread executors for backend requests, and we could follow a similar approach. See:

return Executors.newSingleThreadScheduledExecutor()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Would this have any advantages over the SerialRequestExecutor/synchronized model we're using now? I'm not super familiar with the executors, so I asked Codex about it, and it gave me this:

A single-thread ScheduledExecutorService only serializes the submitted runnables. Our requests are async: we enqueue a block, it kicks off work, returns
immediately, and completes later via a callback. That means the executor thread would finish the runnable while the request is still in flight, and the next runnable
would start right away—breaking the one-at-a-time guarantee. SerialRequestExecutor keeps the head request “active” until its finish is called, so even though the work
hops off to other threads, we don’t start the next until the callback signals completion. To drop it, we’d need every request to fully execute and finish within the
executor’s runnable, or refactor to chain scheduling on callback completion, which is what SerialRequestExecutor already does.

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.

Well, this would be mostly a replacement for the synchronized model we have, not for the SerialRequestExecutor, since I assume the galaxy SDK operations are done in another thread, so that wouldn't protect us against doing multiple requests to the galaxy SDK.

And well, as I mentioned, it could potentially simplify the synchronization. But well, considering we have the SerialRequestExecutor, we can probably get away with removing all that, and documenting well that requests are expected to be performed in serial... Not ideal in case we miss that constraint, since we don't have any compile time guarantees... but might be the simplest for now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sounds good! Removed all synchronized blocks in 43a226f

I also introduced a new @GalaxySerialOperation annotation that I applied to the getProductDetails() function. This annotation acts similarly to how the @ExperimentalPreviewRevenueCatPurchasesAPI works, where it forces any consumers of the annotated functions to explicitly opt-in to it. This will at least ensure that in the future, any other developer that tries to call these functions must acknowledge that the function must be called serially with other annotated functions. Let me know what you think!

)

synchronized(this) {
this.inFlightRequest = request

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.

Same here about possible race conditions.

)

@get:Synchronized
internal val productDataCache = mutableMapOf<String, ProductVo>()

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.

Just wondering, would it be worth caching the StoreProduct itself instead of ProductVo? So we don't need to transform it on every call to get product details. Might be missing a reason why we need to cache the original ProductVo though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oooh, that's a good idea! Honestly, the only reason is so that we can re-use the same handleSuccessfulProductsResponse() function, which takes in ProductVos as a param.

I'll move the code around a little so that we can cache the StoreProduct instead :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Made this optimization in ef18774 🙌

Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/utils/SerialRequestExecutor.kt Outdated
Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/utils/SerialRequestExecutor.kt Outdated

@tonidero tonidero left a comment

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 think this looks great. Thanks for addressing everything!! This will be great 💪

// - An empty string: queries all products
// - A string with one product ID in it: queries for that one product
// - A string with multiple product IDs in it, delimited by a comma
val productIdRequestString = productIds.joinToString(separator = ",")

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 guess if we have some of the products in the cache, we would still be querying for all of them. Probably a small optimization though, so not a blocker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will add this to a list of future optimizations to make!

}
}

override fun onGetProducts(error: ErrorVo, products: ArrayList<ProductVo>) {

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.

Just to check, the API would return either a non-success ErrorVo or a list of products, but not both correct? For example, if part of the productIds requested do not exist, or we're passing an incorrect productId format for some of them, would it return the valid products, without an error? Or would it return the valid ones + an error?

In case it's the second case, we probably should log the error but still return a sucess result I think.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I just tested this out! If you request 2 valid product IDs and 1 invalid product IDs, it returns:

  • A non-error (success) ErrorVo
  • An ArrayList with the 2 valid products 😅

I'll see if we can create an error log for this in a follow-up PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Created Linear ticket CAT-2148 to track this

import com.samsung.android.sdk.iap.lib.listener.OnGetProductsDetailsListener

internal interface IAPHelperProvider {
fun getProductsDetails(

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'm wondering if we should also annotate this method with the @GalaxySerialRequest, so if/when we add other operation helpers, it's easier to remember we need to add that annotation there as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a great idea! Added in a8a0d84

?: localProperties.getProperty("samsungIapSdkPath")
}.map { path ->
val aar = file(path)
check(aar.exists()) {

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 guess this will fail to work if the aar is not set somehow... we shouldn't get this merged to main, but it's ok to keep it on the integration branch. Hopefully we can figure out a way to integrate it 🙏

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this is 1000% a temporary thing and should not be merged into main. We'll work with @MarkVillacampa at some point to find a proper way to integrate the Samsung SDK into our SDK 😄

@fire-at-will fire-at-will merged commit 408e3d4 into samsung-dev Dec 11, 2025
4 of 19 checks passed
@fire-at-will fire-at-will deleted the fetch-galaxy-products branch December 11, 2025 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants