Skip to content

Conversation

joshdholtz
Copy link
Member

@joshdholtz joshdholtz commented Aug 21, 2025

Summary

This PR adds the ability to override the system locale for RevenueCat UI components (Paywalls and Customer Center), allowing developers to display the UI in a specific language regardless of the system locale setting. This implements the same functionality that was added to the iOS SDK in RevenueCat/purchases-ios#5292.

Changes

Configuration API

  • Add preferredUILocaleOverride property to PurchasesConfiguration
  • Add preferredUILocaleOverride(String?) builder method with comprehensive documentation

Runtime API

  • Add overridePreferredUILocale(String?): Boolean method to Purchases class
  • Add public getter preferredUILocaleOverride property
  • Automatic cache management: Cache clearing happens automatically when locale changes
  • Rate limiting: Built-in rate limiting (10 calls per 60 seconds) prevents excessive API calls
  • Both APIs available for default and custom entitlement computation flavors

UI Components Integration

  • Update PaywallViewModel and PaywallState to use preferred locale override
  • Add reflection-based approach to access preferred locale from UI components
  • Support both "es-ES" and "es_ES" locale format patterns
  • Proper locale matching logic that prioritizes preferred override
  • Graceful fallback to system default for invalid locales with proper logging

HTTP Request Integration

  • Update LocaleProvider to include preferred locale override in API requests
  • Preferred locale is sent first in Accept-Language header when set
  • Ensures both UI rendering and API responses respect the same locale preference

Paywall Tester Enhancements

  • Add comprehensive locale selection tab with 15+ predefined locales
  • Add search/filter functionality to offerings tab
  • Support custom locale input with validation
  • Optional preferred locale configuration via Constants.kt
  • Real-time feedback on cache clearing status with rate limit handling

API Usage

Configuration time:

val configuration = PurchasesConfiguration.Builder(context, apiKey)
    .preferredUILocaleOverride("es-ES")  // or "es_ES" - both formats supported
    .build()
Purchases.configure(configuration)

Runtime override:

// Set preferred locale (automatically clears cache if changed)
val cacheCleared = Purchases.sharedInstance.overridePreferredUILocale("de-DE")
if (cacheCleared) {
    // Cache was cleared and offerings will be refetched with new locale
}

// Revert to system default
Purchases.sharedInstance.overridePreferredUILocale(null)

Architecture

Rate Limiting

  • Cache clearing operations are rate limited to prevent excessive network requests
  • Uses the same RateLimiter utility as subscriber attributes (10 calls per 60 seconds)
  • Locale setting is immediate, cache clearing is rate limited separately

Locale Resolution Priority

  1. Preferred override (if set via configuration or runtime API)
  2. System default locales (from LocaleListCompat.getDefault())
  3. Paywall default locale (fallback for UI components)

Thread Safety

  • All operations are properly synchronized
  • Reflection-based access includes proper exception handling
  • State management through PurchasesOrchestrator ensures consistency

Test plan

  • All existing unit tests pass
  • Code compiles successfully for all modules
  • Lint/detekt checks pass with no new violations
  • API compatibility checks pass (metalava)
  • Graceful error handling for invalid locale strings
  • Thread-safe access to shared configuration state
  • Rate limiting functionality verified
  • Paywall tester UI enhancements tested

Testing Notes

  • Manual testing shows paywalls now correctly display in the preferred locale
  • HTTP requests include the preferred locale in Accept-Language headers
  • Cache clearing triggers background refetch of offerings with new locale
  • Rate limiting prevents excessive API calls when rapidly changing locales
  • Paywall tester provides comprehensive UI for testing different locales

🤖 Generated with Claude Code

@joshdholtz joshdholtz added the pr:feat A new feature label Aug 21, 2025
@joshdholtz joshdholtz force-pushed the feature/preferred-ui-locale-override branch from e8f9518 to e7dd8a6 Compare August 28, 2025 13:12
Copy link

codecov bot commented Aug 28, 2025

Codecov Report

❌ Patch coverage is 44.44444% with 35 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.10%. Comparing base (c6fd4b0) to head (402f0d1).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
.../com/revenuecat/purchases/PurchasesOrchestrator.kt 13.79% 25 Missing ⚠️
.../com/revenuecat/purchases/common/LocaleProvider.kt 33.33% 5 Missing and 1 partial ⚠️
...com/revenuecat/purchases/PurchasesConfiguration.kt 63.63% 1 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2620      +/-   ##
==========================================
- Coverage   78.30%   78.10%   -0.20%     
==========================================
  Files         306      306              
  Lines       11424    11482      +58     
  Branches     1581     1590       +9     
==========================================
+ Hits         8945     8968      +23     
- Misses       1786     1817      +31     
- Partials      693      697       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@joshdholtz joshdholtz requested review from a team August 28, 2025 15:24
Copy link

emerge-tools bot commented Aug 28, 2025

📸 Snapshot Test

660 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
0 0 0 0 404 0 N/A
TestPurchasesUIAndroidCompatibility Paparazzi
com.revenuecat.testpurchasesuiandroidcompatibility.paparazzi
0 0 0 0 256 0 N/A

🛸 Powered by Emerge Tools

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.

Left some comments but it's taking shape! Thanks for doing this!

modifier: Modifier = Modifier,
) {
var dropdownExpandedOffering by remember { mutableStateOf<Offering?>(null) }
var displayPaywallDialogOffering by remember { mutableStateOf<Offering?>(null) }

val showDialog = remember { mutableStateOf(false) }

// Filter offerings based on search query
val filteredOfferings = remember(offeringsState.offerings, offeringsState.searchQuery) {
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 extract this to a separate PR?

Copy link
Member Author

Choose a reason for hiding this comment

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

I originally thought so but I also needed it for my testing on this one 😛 I didn't like that I was struggling to scroll to find my paywall to test 🙃

@@ -68,6 +71,7 @@ fun OfferingsScreen(
tappedOnNavigateToOfferingCondensedFooter = tappedOnOfferingCondensedFooter,
tappedOnNavigateToOfferingByPlacement = tappedOnOfferingByPlacement,
tappedOnReloadOfferings = { viewModel.refreshOfferings() },
onSearchQueryChange = { query -> viewModel.updateSearchQuery(query) },
Copy link
Contributor

Choose a reason for hiding this comment

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

Amazing!! 🙌

One thing though, should we extract this to a separate PR? NABD though.

internal fun fetchOfferingsWithRateLimit(callback: (Offerings?, PurchasesError?) -> Unit): Boolean {
return if (preferredLocaleOverrideRateLimiter.shouldProceed()) {
log(LogIntent.DEBUG) { "Fetching fresh offerings" }
getOfferings(
Copy link
Contributor

Choose a reason for hiding this comment

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

A couple possible edge case of this... If our backend is down (very unlikely), or this takes a bit longer for any reason, the paywall might be displayed with the cached offerings, which could be in the wrong locale... Not sure how big of a deal this is it though...

We could clear the offerings cache before fetching it to at least make sure we don't use the wrong locale... though I guess in that case we might be delaying displaying the paywall, or might cause some errors if the request fails for any reason...

@joshdholtz joshdholtz marked this pull request as ready for review August 29, 2025 16:11
@joshdholtz joshdholtz requested a review from tonidero August 29, 2025 16:12
Copy link
Contributor

@JZDesign JZDesign left a comment

Choose a reason for hiding this comment

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

Potentially dumb question—new to the android sdk—But can't we allow the user to pass in a custom context on application initialization that overrides the locale and then we don't have to make these broad sweeping changes?

They could do something like:

    fun cloneContextAndSetLocale(context: Context, languageCode: String) : Context {
        val locale = Locale(languageCode)

        val config = Configuration(context.resources.configuration)

        val newContext = context.createConfigurationContext(config) // Create a new context
        
        val newConfig = Configuration(newContext.resources.configuration)
        newConfig.setLocale(locale) // Set for the configuration
        newContext.resources.updateConfiguration(newConfig, newContext.resources.displayMetrics)
        
        return newContext
    }

Then give us the context.

Or is that less feasible than what we're doing here?

@tonidero
Copy link
Contributor

Not dumb! However, I think the point here is to be able to change the locale at runtime at any point in time. If we wanted to do that by allowing to change the context, I think that would be trickier. And if we are ok with only setting it once at configure time, we should just add the locale as a parameter of the PurchasesConfiguration and it would indeed be much easier than this.

Whether it’s worth the extra complexity of being able to change it at runtime… I’m honestly a bit on the fence… I think 99% of the time, it would be ok to set it once when configuring but there may be some cases where it won’t and devs want to change it at runtime.

@joshdholtz
Copy link
Member Author

But can't we allow the user to pass in a custom context on application

@JZDesign Ah, configuring at application is part of this PR with 👇

val configuration = PurchasesConfiguration.Builder(context, apiKey)
    .preferredUILocaleOverride("es-ES")  // or "es_ES" - both formats supported
    .build()
Purchases.configure(configuration)

I think 99% of the time, it would be ok to set it once when configuring but there may be some cases where it won’t and devs want to change it at runtime.

Yes yes, odds are that most of the time this will get set at app launch but... only after a user has configured it at runtime (if/when the developer allows it)

So, changing at runtime is still super important! When developers allow users to change the language in their app, the user should see all UI (including paywalls) have the new language. This is also almost more important for the developer for testing that this actually works 😇

@joshdholtz joshdholtz force-pushed the feature/preferred-ui-locale-override branch from a64972c to a45653d Compare August 29, 2025 18:30
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.

Another point related to the context approach. To override the locale in a context, you usually need to provide an "activity" context, but we recommend developers to pass us their application context, which is trickier to override.

In any case, I have a few more comments but smaller, I think this is pretty close to ready!

@joshdholtz joshdholtz requested a review from tonidero September 3, 2025 11:37
joshdholtz and others added 13 commits September 3, 2025 06:39
This change adds the ability to override the system locale for RevenueCat UI
components (Paywalls and Customer Center), allowing developers to display the UI
in a specific language regardless of the system locale setting.

- Add `preferredUILocaleOverride` property to `PurchasesConfiguration`
- Add `preferredUILocaleOverride(String?)` builder method

- Add `overridePreferredUILocale(String?)` method to `Purchases` class
- Add public getter `preferredUILocaleOverride` property

- Update `PaywallViewModel` and `CustomerCenterViewModel` to use preferred locale
- Extend `PurchasesType` interface to expose the preferred locale override
- Add locale parsing logic that handles "language_COUNTRY" format (e.g., "en_US")
- Graceful fallback to system default for invalid locales with logging

```kotlin
val config = PurchasesConfiguration.Builder(context, apiKey)
    .preferredUILocaleOverride("de_DE")
    .build()
```

```kotlin
Purchases.sharedInstance.overridePreferredUILocale("es_ES")
```

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove exception parameter from Logger.w() calls to match API
- Suppress SwallowedException detekt warnings for intentional exception handling
- Generate API signature files for new public methods:
  - PurchasesConfiguration.preferredUILocaleOverride
  - PurchasesConfiguration.Builder.preferredUILocaleOverride()
  - Purchases.preferredUILocaleOverride
  - Purchases.overridePreferredUILocale()

These changes are required for CI checks to pass.
- Add test for default null value in configuration
- Add test for setting preferredUILocaleOverride with string value
- Add test for setting preferredUILocaleOverride with null value
- Ensures test coverage for the new configuration property
- Add preferredUILocaleOverride mock to PaywallViewModelTest setup
- Add preferredUILocaleOverride mock to CustomerCenterViewModelTests setup
- Fixes MockKException failures caused by unmocked property access
- Both PaywallViewModel and CustomerCenterViewModel tests now pass
- Add search/filter functionality to offerings tab in paywall tester
- Add new locale selection tab with comprehensive UI for preferred locale override
- Separate cache clearing from locale setting for more flexible API control
- Add clearOfferingsCacheIfNeeded() convenience method with change detection
- Update LocaleScreen to use new separated API methods
- Support optional preferred locale configuration via Constants.kt

This provides developers with three distinct API methods:
1. overridePreferredUILocale() - Sets locale immediately
2. clearOfferingsCache() - Clears cache with rate limiting separately
3. clearOfferingsCacheIfNeeded() - Convenience method combining both with change detection

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove clearOfferingsCacheIfNeeded() method
- Move cache clearing logic directly into overridePreferredUILocale()
- Update paywall tester to use simplified single-method API
- Cache clearing now happens automatically when locale changes

This results in a cleaner, more intuitive API where developers only need
to call overridePreferredUILocale() and cache management happens automatically.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove reflection calls from PaywallState to eliminate UnsatisfiedLinkError in tests
- Revert PaywallState.toLocaleId() to original simple implementation
- Rename clearOfferingsCache to clearOfferingsCacheIfPossible for clarity
- Remove defensive logging wrappers as they're no longer needed
- Preferred locale logic now handled properly at PaywallViewModel level

Fixed 40 test failures by removing problematic reflection code while maintaining
the same preferred locale functionality through proper dependency injection.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove reflection-based preferred locale access from DefaultLocaleProvider
- Create OrchestrationAwareLocaleProvider for proper dependency injection
- Update PurchasesFactory to inject orchestrator reference into locale provider
- Maintain runtime locale override functionality without reflection

This eliminates all reflection usage in the preferred locale feature while
preserving the same functionality through clean dependency injection.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fixes two critical issues that broke locale override after removing reflection:

1. **PaywallState locale resolution**: Updated toLocaleId() to prioritize
   preferred locale override before device locales, ensuring UI renders in
   the correct language.

2. **Fresh offerings fetch**: Replaced cache clearing approach with direct
   getOfferings(fetchCurrent=true) calls, providing cleaner and more targeted
   fresh data fetching when locale changes.

Key changes:
- PaywallState.toLocaleId() now checks Purchases.sharedInstance.preferredUILocaleOverride
- Added fetchOfferingsWithRateLimit() method using fetchCurrent=true parameter
- Improved OrchestrationAwareLocaleProvider with proper dependency injection
- Rate limiting prevents excessive API calls (10 per 60 seconds)

The preferred locale override now works end-to-end:
✅ HTTP requests include correct Accept-Language headers
✅ UI renders in preferred language
✅ Fresh API requests triggered on locale changes
✅ Proper fallback to device locales when no override set

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Fix Android log tag length (max 23 chars): shortened "OrchestrationAwareLocaleProvider" to "OrchestrationLocale"
- Update API signatures for new public preferredUILocaleOverride property

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
…allState

Replaces direct Purchases.sharedInstance access with PurchasesType dependency injection:

- Add MockPurchasesType for tests and previews with configurable preferredUILocaleOverride
- Update PaywallState.Loaded.Components constructor to accept PurchasesType parameter
- Modify toComponentsPaywallState() to pass PurchasesType dependency
- Update PaywallViewModel to inject purchases instance into PaywallState
- Fix all test files and preview helpers to use MockPurchasesType
- Remove unsafe Purchases.sharedInstance access that caused test failures

This follows proper architectural patterns and ensures PaywallState can access
preferred locale override without breaking in test environments where Purchases
might not be initialized.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Tightens rate limiting for locale override fresh offerings fetch from 10 to 2 calls
per minute to reduce API load while still allowing reasonable user interaction with
locale switching functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Since we're using getOfferings(fetchCurrent=true), we no longer need to
clear caches manually. This simplifies the implementation:

- Remove clearOfferingsCache() and clearOfferingsCacheWithRateLimit() methods
- Rename clearOfferingsCacheIfPossible() to fetchOfferingsIfPossible() for clarity
- Update comments and debug logs to reflect fresh fetch approach instead of cache clearing
- Update rate limit documentation from 10 to 2 calls per 60 seconds

The fetchCurrent=true approach is cleaner and more direct than cache clearing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
joshdholtz and others added 14 commits September 3, 2025 06:39
…rchasesType

- Create shared createLocaleFromString() function in LocaleHelpers.kt
  - Supports both "es-ES" and "es_ES" locale formats
  - Eliminates duplicate code across PaywallViewModel and CustomerCenterViewModel
- Move MockPurchasesType from main to test/debug source sets
  - Follows project patterns for mock organization
  - Available in test and debug builds, not production
- Update PaywallState to use shared locale parsing utility
- Fix import paths for proper dependency resolution

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Revert GOOGLE_API_KEY_A back to placeholder 'API_KEY_A'
- Revert GOOGLE_API_KEY_A_LABEL back to placeholder 'API_KEY_A_LABEL'
- Keep PREFERRED_UI_LOCALE_OVERRIDE field which is intentional

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove unused PurchasesAwareLocaleProvider (was never instantiated)
- Merge RuntimeLocaleProvider functionality into DefaultLocaleProvider
- DefaultLocaleProvider now supports optional runtime injection via setOrchestratorProvider()
- When no orchestrator set: behaves as simple system locale provider
- When orchestrator set: supports preferred locale override with fallback to system locales
- All existing usage (OfferingsCache, HTTPClient, tests) continues to work unchanged
- Eliminates duplicate code and simplifies architecture

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Replace complex orchestrator injection with simple String property
- DefaultLocaleProvider now holds preferred locale override directly
- PurchasesOrchestrator updates locale provider via setter when locale changes
- Eliminates deferred injection pattern and circular dependency complexity
- Architecture is now much simpler: direct property updates instead of lambda providers

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Replace complex orchestrator injection with simple String property
- DefaultLocaleProvider now holds preferred locale override directly
- PurchasesOrchestrator updates locale provider via setter when locale changes
- Eliminates deferred injection pattern and circular dependency complexity
- Architecture is now much simpler: direct property updates instead of lambda providers
- HTTPClient no longer needs default parameter fallback
- OfferingsCache now uses same unified locale provider

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add missing localeProvider parameter to PurchasesOrchestrator constructor calls in tests
- Restore HTTPClient default parameter for localeProvider to maintain backward compatibility
- Update OfferingsCache test to include required localeProvider parameter
- All tests now compile successfully with simplified LocaleProvider architecture

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove default LocaleProvider parameter from HTTPClient constructor
- Add proper DefaultLocaleProvider imports to test files
- Replace fully qualified names with imported references
- All HTTPClient instantiations now explicitly pass LocaleProvider parameter
- Maintains explicit dependency injection without implicit defaults

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
…urce set

- Remove duplicate return statements and non-existent fetchOfferingsIfPossible method
- Inline fresh offerings fetch logic directly in overridePreferredUILocale
- Add MockPurchasesType to main source set for preview/test data usage
- Ensure both defaults and customEntitlementComputation flavors compile successfully

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
…chestrator

- Move overridePreferredUILocale implementation from Purchases.kt to PurchasesOrchestrator
- Make preferredUILocaleOverride property readonly in orchestrator
- Consolidate locale update, locale provider update, and offerings fetch in one method
- Update both flavor variants to delegate to orchestrator method
- Remove duplicate MockPurchasesType from debug source set
- Fix detekt issues (trailing spaces and unused imports)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
…chestrator

- Move overridePreferredUILocale implementation from Purchases.kt to PurchasesOrchestrator
- Make preferredUILocaleOverride property readonly in orchestrator
- Consolidate locale update, locale provider update, and offerings fetch in one method
- Update both flavor variants to delegate to orchestrator method
- Remove duplicate MockPurchasesType from debug source set

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
…avor

The preferred UI locale override functionality is not needed in the
customEntitlementComputation flavor variant.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@joshdholtz joshdholtz force-pushed the feature/preferred-ui-locale-override branch from 513ed40 to 85f5912 Compare September 3, 2025 11:40
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! 🎉

joshdholtz and others added 2 commits September 3, 2025 07:16
- Fix PurchasesOrchestrator.overridePreferredUILocale to return Boolean
- Fix defaults Purchases.overridePreferredUILocale to return Boolean
- Update API signatures to reflect method removal from customEntitlementComputation
- Maintain boolean return type in defaults flavor for compatibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
* @return The preferred UI locale override, or null if using system default
*/
val preferredUILocaleOverride: String?
@Synchronized get() = purchasesOrchestrator.preferredUILocaleOverride
Copy link
Contributor

Choose a reason for hiding this comment

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

Is a synchronized getter necessary? Wouldn't we also need an atomic setter for this to matter?

Copy link
Member Author

Choose a reason for hiding this comment

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

Pretty sure that @Synchronized covers everything we need here! At least according to research that I just did 🤷‍♂️

But maybe @tonidero can answer better? 😅

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh hmm I missed this but I agree with @JZDesign actually... I don't think this is doing much right now, other than disallowing simultaneous getter operations on different threads... But you could totally override the locale on a different thread and there would be no guarantees...

Now, having said that, it does seem very unlikely for these APIs to be called multiple times in different threads I would say... So I do think that it's probably unnecessary? And the one in PurchasesOrchestrator is probably unnecessary too I would say.

@joshdholtz joshdholtz added this pull request to the merge queue Sep 3, 2025
Merged via the queue into main with commit 354e30d Sep 3, 2025
21 checks passed
@joshdholtz joshdholtz deleted the feature/preferred-ui-locale-override branch September 3, 2025 14:26
@joshdholtz joshdholtz restored the feature/preferred-ui-locale-override branch September 3, 2025 14:52
github-merge-queue bot pushed a commit that referenced this pull request Sep 3, 2025
**This is an automatic release.**

## RevenueCat SDK
### ✨ New Features
* Add preferred UI locale override for RevenueCat UI components (#2620)
via Josh Holtz (@joshdholtz)

### 🔄 Other Changes
* Improve thread safety of setting paywalls preferred locale (#2655) via
Josh Holtz (@joshdholtz)
* Remove validation for no packages on paywalls (#2653) via Josh Holtz
(@joshdholtz)
* Video Component Models (dark code) (#2646) via Jacob Rakidzich
(@JZDesign)
* [EXTERNAL] docs: fixed a typo on documentation about
`Purchases.awaitPurchase` by @matteinn in #2593 (#2651) via Toni Rico
(@tonidero)
* Add warning with 9.x issues to all versions since 9.0.0 in CHANGELOG
(#2650) via Toni Rico (@tonidero)
* [AUTOMATIC][Paywalls V2] Updates paywall-preview-resources submodule
(#2647) via RevenueCat Git Bot (@RCGitBot)
* Delete CLAUDE.md (#2648) via Cesar de la Vega (@vegaro)
* MON-1193 flatten Transition JSON structure after chatting more
thoroughly with team (#2641) via Jacob Rakidzich (@JZDesign)

---------

Co-authored-by: revenuecat-ops <ops@revenuecat.com>
Co-authored-by: Josh Holtz <me@joshholtz.com>
tonidero pushed a commit that referenced this pull request Sep 10, 2025
## Summary

This PR adds the ability to override the system locale for RevenueCat UI
components (Paywalls and Customer Center), allowing developers to
display the UI in a specific language regardless of the system locale
setting. This implements the same functionality that was added to the
iOS SDK in RevenueCat/purchases-ios#5292.

## Changes

### Configuration API
- Add `preferredUILocaleOverride` property to `PurchasesConfiguration`
- Add `preferredUILocaleOverride(String?)` builder method with
comprehensive documentation

### Runtime API  
- Add `overridePreferredUILocale(String?): Boolean` method to
`Purchases` class
- Add public getter `preferredUILocaleOverride` property
- **Automatic cache management**: Cache clearing happens automatically
when locale changes
- **Rate limiting**: Built-in rate limiting (10 calls per 60 seconds)
prevents excessive API calls
- Both APIs available for default and custom entitlement computation
flavors

### UI Components Integration
- Update `PaywallViewModel` and `PaywallState` to use preferred locale
override
- Add reflection-based approach to access preferred locale from UI
components
- Support both "es-ES" and "es_ES" locale format patterns
- Proper locale matching logic that prioritizes preferred override
- Graceful fallback to system default for invalid locales with proper
logging

### HTTP Request Integration
- Update `LocaleProvider` to include preferred locale override in API
requests
- Preferred locale is sent first in Accept-Language header when set
- Ensures both UI rendering and API responses respect the same locale
preference

### Paywall Tester Enhancements
- Add comprehensive locale selection tab with 15+ predefined locales
- Add search/filter functionality to offerings tab
- Support custom locale input with validation
- Optional preferred locale configuration via `Constants.kt`
- Real-time feedback on cache clearing status with rate limit handling

## API Usage

### Configuration time:
```kotlin
val configuration = PurchasesConfiguration.Builder(context, apiKey)
    .preferredUILocaleOverride("es-ES")  // or "es_ES" - both formats supported
    .build()
Purchases.configure(configuration)
```

### Runtime override:
```kotlin
// Set preferred locale (automatically clears cache if changed)
val cacheCleared = Purchases.sharedInstance.overridePreferredUILocale("de-DE")
if (cacheCleared) {
    // Cache was cleared and offerings will be refetched with new locale
}

// Revert to system default
Purchases.sharedInstance.overridePreferredUILocale(null)
```

## Architecture

### Rate Limiting
- Cache clearing operations are rate limited to prevent excessive
network requests
- Uses the same `RateLimiter` utility as subscriber attributes (10 calls
per 60 seconds)
- Locale setting is immediate, cache clearing is rate limited separately

### Locale Resolution Priority
1. **Preferred override** (if set via configuration or runtime API)
2. **System default locales** (from `LocaleListCompat.getDefault()`)
3. **Paywall default locale** (fallback for UI components)

### Thread Safety
- All operations are properly synchronized
- Reflection-based access includes proper exception handling
- State management through PurchasesOrchestrator ensures consistency

## Test plan

- [x] All existing unit tests pass
- [x] Code compiles successfully for all modules  
- [x] Lint/detekt checks pass with no new violations
- [x] API compatibility checks pass (metalava)
- [x] Graceful error handling for invalid locale strings
- [x] Thread-safe access to shared configuration state
- [x] Rate limiting functionality verified
- [x] Paywall tester UI enhancements tested

## Testing Notes

- Manual testing shows paywalls now correctly display in the preferred
locale
- HTTP requests include the preferred locale in Accept-Language headers
- Cache clearing triggers background refetch of offerings with new
locale
- Rate limiting prevents excessive API calls when rapidly changing
locales
- Paywall tester provides comprehensive UI for testing different locales

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
@tonidero tonidero mentioned this pull request Sep 10, 2025
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.

3 participants