Skip to content

Commit

Permalink
Attempt to fix ANRs by moving some tasks during configure to backgrou…
Browse files Browse the repository at this point in the history
…nd (#1772)

## Description
This PR addresses a significant number of ANRs (Application Not
Responding errors) reported, such as in issue #1629.

## Problem
The method `IdentityManager.configure` is currently running on the main
thread and accessing `SharedPreferences`. Accessing `SharedPreferences`
can be costly, especially if the XML file storing the preferences is
large. This cost is particularly high during the initial accesses, as
the XML file needs to be read and parsed, which can block the main
thread and lead to ANRs.

## Solution
**Custom Dispatcher**: This PR introduces a custom dispatcher to the
`IdentityManager`, allowing configure to run on a background thread. By
moving this operation off the main thread, we avoid blocking it with the
potentially expensive SharedPreferences access.
**Main Thread Optimization**: The remaining setup in
`PurchasesOrchestrator` has been deferred to execute after configure
completes, ensuring the main thread remains unblocked and improving
overall app responsiveness.

## Notes
While this fix should mitigate the reported ANRs, it's possible there
are other underlying causes contributing to these issues. This PR serves
as a good initial step towards improving performance.
  • Loading branch information
vegaro authored Jul 8, 2024
1 parent ac3dcfe commit c6b365f
Show file tree
Hide file tree
Showing 10 changed files with 142 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ internal class PurchasesFactory(
offeringsCache,
backend,
offlineEntitlementsManager,
dispatcher,
)

val customerInfoUpdateHandler = CustomerInfoUpdateHandler(
Expand Down Expand Up @@ -285,6 +286,7 @@ internal class PurchasesFactory(
createPaywallEventsManager(application, identityManager, eventsDispatcher, backend),
paywallPresentedCache,
purchasesStateProvider,
dispatcher = dispatcher,
)

return Purchases(purchasesOrchestrator)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import com.revenuecat.purchases.common.Backend
import com.revenuecat.purchases.common.BillingAbstract
import com.revenuecat.purchases.common.Config
import com.revenuecat.purchases.common.Constants
import com.revenuecat.purchases.common.Delay
import com.revenuecat.purchases.common.Dispatcher
import com.revenuecat.purchases.common.LogIntent
import com.revenuecat.purchases.common.PlatformInfo
import com.revenuecat.purchases.common.ReceiptInfo
Expand Down Expand Up @@ -73,7 +75,7 @@ import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.seconds

@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
internal class PurchasesOrchestrator constructor(
internal class PurchasesOrchestrator(
private val application: Application,
backingFieldAppUserID: String?,
private val backend: Backend,
Expand All @@ -97,6 +99,7 @@ internal class PurchasesOrchestrator constructor(
private val purchasesStateCache: PurchasesStateCache,
// This is nullable due to: https://github.com/RevenueCat/purchases-flutter/issues/408
private val mainHandler: Handler? = Handler(Looper.getMainLooper()),
private val dispatcher: Dispatcher,
) : LifecycleDelegate, CustomActivityLifecycleHandler {

internal var state: PurchasesState
Expand Down Expand Up @@ -194,22 +197,24 @@ internal class PurchasesOrchestrator constructor(
state = state.copy(appInBackground = false, firstTimeInForeground = false)
}
log(LogIntent.DEBUG, ConfigureStrings.APP_FOREGROUNDED)
if (shouldRefreshCustomerInfo(firstTimeInForeground)) {
log(LogIntent.DEBUG, CustomerInfoStrings.CUSTOMERINFO_STALE_UPDATING_FOREGROUND)
customerInfoHelper.retrieveCustomerInfo(
identityManager.currentAppUserID,
fetchPolicy = CacheFetchPolicy.FETCH_CURRENT,
appInBackground = false,
allowSharingPlayStoreAccount = allowSharingPlayStoreAccount,
)
}
offeringsManager.onAppForeground(identityManager.currentAppUserID)
postPendingTransactionsHelper.syncPendingPurchaseQueue(allowSharingPlayStoreAccount)
synchronizeSubscriberAttributesIfNeeded()
offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale()
flushPaywallEvents()
if (firstTimeInForeground && isAndroidNOrNewer()) {
diagnosticsSynchronizer?.syncDiagnosticsFileIfNeeded()
enqueue {
if (shouldRefreshCustomerInfo(firstTimeInForeground)) {
log(LogIntent.DEBUG, CustomerInfoStrings.CUSTOMERINFO_STALE_UPDATING_FOREGROUND)
customerInfoHelper.retrieveCustomerInfo(
identityManager.currentAppUserID,
fetchPolicy = CacheFetchPolicy.FETCH_CURRENT,
appInBackground = false,
allowSharingPlayStoreAccount = allowSharingPlayStoreAccount,
)
}
offeringsManager.onAppForeground(identityManager.currentAppUserID)
postPendingTransactionsHelper.syncPendingPurchaseQueue(allowSharingPlayStoreAccount)
synchronizeSubscriberAttributesIfNeeded()
offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale()
flushPaywallEvents()
if (firstTimeInForeground && isAndroidNOrNewer()) {
diagnosticsSynchronizer?.syncDiagnosticsFileIfNeeded()
}
}
}

Expand Down Expand Up @@ -775,6 +780,9 @@ internal class PurchasesOrchestrator constructor(
//endregion

// region Private Methods
private fun enqueue(command: () -> Unit) {
dispatcher.enqueue({ command() }, Delay.NONE)
}

private fun shouldRefreshCustomerInfo(firstTimeInForeground: Boolean): Boolean {
return !appConfig.customEntitlementComputation &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ internal open class DeviceCache(

private val offeringsResponseCacheKey: String by lazy { "$apiKeyPrefix.offeringsResponse" }

fun startEditing(): SharedPreferences.Editor {
return preferences.edit()
}

// region app user id

@Synchronized
Expand All @@ -74,7 +78,15 @@ internal open class DeviceCache(

@Synchronized
fun cacheAppUserID(appUserID: String) {
preferences.edit().putString(appUserIDCacheKey, appUserID).apply()
cacheAppUserID(appUserID, preferences.edit()).apply()
}

@Synchronized
fun cacheAppUserID(
appUserID: String,
cacheEditor: SharedPreferences.Editor,
): SharedPreferences.Editor {
return cacheEditor.putString(appUserIDCacheKey, appUserID)
}

@Synchronized
Expand Down Expand Up @@ -168,9 +180,17 @@ internal open class DeviceCache(
@Synchronized
fun clearCustomerInfoCache(appUserID: String) {
val editor = preferences.edit()
clearCustomerInfoCache(appUserID, editor)
editor.apply()
}

@Synchronized
fun clearCustomerInfoCache(
appUserID: String,
editor: SharedPreferences.Editor,
) {
editor.clearCustomerInfoCacheTimestamp(appUserID)
editor.remove(customerInfoCacheKey(appUserID))
editor.apply()
}

@Synchronized
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.revenuecat.purchases.identity

import android.content.SharedPreferences
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.VerificationResult
import com.revenuecat.purchases.common.Backend
import com.revenuecat.purchases.common.Delay
import com.revenuecat.purchases.common.Dispatcher
import com.revenuecat.purchases.common.LogIntent
import com.revenuecat.purchases.common.caching.DeviceCache
import com.revenuecat.purchases.common.debugLog
Expand All @@ -20,14 +23,15 @@ import com.revenuecat.purchases.subscriberattributes.caching.SubscriberAttribute
import java.util.Locale
import java.util.UUID

@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LongParameterList")
internal class IdentityManager(
private val deviceCache: DeviceCache,
private val subscriberAttributesCache: SubscriberAttributesCache,
private val subscriberAttributesManager: SubscriberAttributesManager,
private val offeringsCache: OfferingsCache,
private val backend: Backend,
private val offlineEntitlementsManager: OfflineEntitlementsManager,
private val dispatcher: Dispatcher,
) {

val currentAppUserID: String
Expand All @@ -38,7 +42,9 @@ internal class IdentityManager(
// region Public functions

@Synchronized
fun configure(appUserID: String?) {
fun configure(
appUserID: String?,
) {
if (appUserID?.isBlank() == true) {
log(LogIntent.WARNING, IdentityStrings.EMPTY_APP_USER_ID_WILL_BECOME_ANONYMOUS)
}
Expand All @@ -49,10 +55,16 @@ internal class IdentityManager(
?: deviceCache.getLegacyCachedAppUserID()
?: generateRandomID()
log(LogIntent.USER, IdentityStrings.IDENTIFYING_APP_USER_ID.format(appUserIDToUse))
deviceCache.cacheAppUserID(appUserIDToUse)
subscriberAttributesCache.cleanUpSubscriberAttributeCache(appUserIDToUse)
deviceCache.cleanupOldAttributionData()
invalidateCustomerInfoAndETagCacheIfNeeded(appUserIDToUse)

val cacheEditor = deviceCache.startEditing()
deviceCache.cacheAppUserID(appUserIDToUse, cacheEditor)
subscriberAttributesCache.cleanUpSubscriberAttributeCache(appUserIDToUse, cacheEditor)
invalidateCustomerInfoAndETagCacheIfNeeded(appUserIDToUse, cacheEditor)
cacheEditor.apply()

enqueue {
deviceCache.cleanupOldAttributionData()
}
}

fun logIn(
Expand Down Expand Up @@ -135,11 +147,17 @@ internal class IdentityManager(
}
}

private fun invalidateCustomerInfoAndETagCacheIfNeeded(appUserID: String) {
private fun invalidateCustomerInfoAndETagCacheIfNeeded(
appUserID: String,
cacheEditor: SharedPreferences.Editor,
) {
if (backend.verificationMode == SignatureVerificationMode.Disabled) {
return
}
val cachedCustomerInfo = deviceCache.getCachedCustomerInfo(appUserID)
if (shouldInvalidateCustomerInfoAndETagCache(cachedCustomerInfo)) {
infoLog(IdentityStrings.INVALIDATING_CACHED_CUSTOMER_INFO)
deviceCache.clearCustomerInfoCache(appUserID)
deviceCache.clearCustomerInfoCache(appUserID, cacheEditor)
backend.clearCaches()
}
}
Expand Down Expand Up @@ -172,5 +190,10 @@ internal class IdentityManager(
backend.clearCaches()
}

@Synchronized
private fun enqueue(command: () -> Unit) {
dispatcher.enqueue({ command() }, Delay.NONE)
}

// endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

package com.revenuecat.purchases.subscriberattributes.caching

import android.content.SharedPreferences
import com.revenuecat.purchases.common.LogIntent
import com.revenuecat.purchases.common.caching.DeviceCache
import com.revenuecat.purchases.common.log
Expand Down Expand Up @@ -76,12 +77,15 @@ internal class SubscriberAttributesCache(
}

@Synchronized
fun cleanUpSubscriberAttributeCache(currentAppUserID: String) {
migrateSubscriberAttributesIfNeeded()
deleteSyncedSubscriberAttributesForOtherUsers(currentAppUserID)
fun cleanUpSubscriberAttributeCache(
currentAppUserID: String,
cacheEditor: SharedPreferences.Editor,
) {
migrateSubscriberAttributesIfNeeded(cacheEditor)
deleteSyncedSubscriberAttributesForOtherUsers(currentAppUserID, cacheEditor)
}

internal fun DeviceCache.putAttributes(
private fun DeviceCache.putAttributes(
updatedSubscriberAttributesForAll: SubscriberAttributesPerAppUserIDMap,
) {
return deviceCache.putString(
Expand All @@ -91,7 +95,10 @@ internal class SubscriberAttributesCache(
}

@Synchronized
private fun deleteSyncedSubscriberAttributesForOtherUsers(currentAppUserID: String) {
private fun deleteSyncedSubscriberAttributesForOtherUsers(
currentAppUserID: String,
cacheEditor: SharedPreferences.Editor,
) {
log(LogIntent.DEBUG, AttributionStrings.DELETING_ATTRIBUTES_OTHER_USERS.format(currentAppUserID))

val allStoredSubscriberAttributes = getAllStoredSubscriberAttributes()
Expand All @@ -105,7 +112,10 @@ internal class SubscriberAttributesCache(
}
}.toMap().filterValues { it.isNotEmpty() }

deviceCache.putAttributes(filteredMap)
cacheEditor.putString(
subscriberAttributesCacheKey,
filteredMap.toJSONObject().toString(),
)
}

private fun SubscriberAttributeMap.filterUnsynced(appUserID: AppUserID): SubscriberAttributeMap =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
package com.revenuecat.purchases.subscriberattributes.caching

import android.content.SharedPreferences
import com.revenuecat.purchases.subscriberattributes.buildLegacySubscriberAttributes

@Synchronized
internal fun SubscriberAttributesCache.migrateSubscriberAttributesIfNeeded() {
internal fun SubscriberAttributesCache.migrateSubscriberAttributesIfNeeded(cacheEditor: SharedPreferences.Editor) {
getAllLegacyStoredSubscriberAttributes()
.takeIf { it.isNotEmpty() }
?.let { legacySubscriberAttributes ->
migrateSubscriberAttributes(legacySubscriberAttributes)
migrateSubscriberAttributes(legacySubscriberAttributes, cacheEditor)
}
}

@Synchronized
internal fun SubscriberAttributesCache.migrateSubscriberAttributes(
legacySubscriberAttributesForAppUserID: SubscriberAttributesPerAppUserIDMap,
cacheEditor: SharedPreferences.Editor,
) {
val storedSubscriberAttributesForAll: SubscriberAttributesPerAppUserIDMap =
getAllStoredSubscriberAttributes()
Expand All @@ -25,10 +27,13 @@ internal fun SubscriberAttributesCache.migrateSubscriberAttributes(
val current: SubscriberAttributeMap = storedSubscriberAttributesForAll[appUserID] ?: emptyMap()
val updated: SubscriberAttributeMap = legacy + current
updatedStoredSubscriberAttributesForAll[appUserID] = updated
deviceCache.remove(legacySubscriberAttributesCacheKey(appUserID))
cacheEditor.remove(legacySubscriberAttributesCacheKey(appUserID))
}

deviceCache.putAttributes(updatedStoredSubscriberAttributesForAll)
cacheEditor.putString(
subscriberAttributesCacheKey,
updatedStoredSubscriberAttributesForAll.toJSONObject().toString(),
)
}

internal fun SubscriberAttributesCache.legacySubscriberAttributesCacheKey(appUserID: String) =
Expand All @@ -39,12 +44,12 @@ internal fun SubscriberAttributesCache.getAllLegacyStoredSubscriberAttributes():
val legacySubscriberAttributesCacheKeyPrefix = legacySubscriberAttributesCacheKey("")
val allSubscriberAttributesKeys = deviceCache.findKeysThatStartWith(legacySubscriberAttributesCacheKeyPrefix)

return allSubscriberAttributesKeys.map { preferencesKey ->
return allSubscriberAttributesKeys.associate { preferencesKey ->
val appUserIDFromKey: AppUserID =
preferencesKey.split(legacySubscriberAttributesCacheKeyPrefix)[1]
val subscriberAttributeMap: SubscriberAttributeMap =
deviceCache.getJSONObjectOrNull(preferencesKey)
?.buildLegacySubscriberAttributes() ?: emptyMap()
appUserIDFromKey to subscriberAttributeMap
}.toMap()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.revenuecat.purchases.paywalls.PaywallPresentedCache
import com.revenuecat.purchases.paywalls.events.PaywallEventsManager
import com.revenuecat.purchases.subscriberattributes.SubscriberAttributesManager
import com.revenuecat.purchases.utils.STUB_PRODUCT_IDENTIFIER
import com.revenuecat.purchases.utils.SyncDispatcher
import com.revenuecat.purchases.utils.createMockOneTimeProductDetails
import com.revenuecat.purchases.utils.stubGooglePurchase
import com.revenuecat.purchases.utils.stubPurchaseHistoryRecord
Expand Down Expand Up @@ -406,6 +407,7 @@ internal open class BasePurchasesTest {
paywallEventsManager = mockPaywallEventsManager,
paywallPresentedCache = paywallPresentedCache,
purchasesStateCache = purchasesStateProvider,
dispatcher = SyncDispatcher(),
)
purchases = Purchases(purchasesOrchestrator)
Purchases.sharedInstance = purchases
Expand Down
Loading

0 comments on commit c6b365f

Please sign in to comment.