diff --git a/purchases/api-defaults-bc7.txt b/purchases/api-defaults-bc7.txt index 7087c18885..81115596a7 100644 --- a/purchases/api-defaults-bc7.txt +++ b/purchases/api-defaults-bc7.txt @@ -373,6 +373,7 @@ package com.revenuecat.purchases { } public final class Purchases { + method public void addUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener listener); method public static void canMakePayments(android.content.Context context, com.revenuecat.purchases.interfaces.Callback callback); method public static void canMakePayments(android.content.Context context, optional java.util.List features, com.revenuecat.purchases.interfaces.Callback callback); method public void close(); @@ -408,7 +409,7 @@ package com.revenuecat.purchases { method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public void getStorefrontLocale(com.revenuecat.purchases.interfaces.GetStorefrontLocaleCallback callback); method @Deprecated public void getSubscriptionSkus(java.util.List productIds, com.revenuecat.purchases.interfaces.GetStoreProductsCallback callback); method @kotlin.jvm.JvmSynthetic public com.revenuecat.purchases.TrackedEventListener? getTrackedEventListener(); - method @kotlin.jvm.Synchronized public com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? getUpdatedCustomerInfoListener(); + method @Deprecated @kotlin.jvm.Synchronized public com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? getUpdatedCustomerInfoListener(); method public void getVirtualCurrencies(com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback callback); method public void invalidateCustomerInfoCache(); method public void invalidateVirtualCurrenciesCache(); @@ -427,7 +428,8 @@ package com.revenuecat.purchases { method @Deprecated public void purchasePackage(android.app.Activity activity, com.revenuecat.purchases.Package packageToPurchase, com.revenuecat.purchases.interfaces.PurchaseCallback listener); method @Deprecated public void purchaseProduct(android.app.Activity activity, com.revenuecat.purchases.models.StoreProduct storeProduct, com.revenuecat.purchases.interfaces.PurchaseCallback callback); method public void redeemWebPurchase(com.revenuecat.purchases.WebPurchaseRedemption webPurchaseRedemption, com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener listener); - method public void removeUpdatedCustomerInfoListener(); + method @Deprecated public void removeUpdatedCustomerInfoListener(); + method public void removeUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener listener); method public void restorePurchases(com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback callback); method public void setAd(String? ad); method public void setAdGroup(String? adGroup); @@ -470,7 +472,7 @@ package com.revenuecat.purchases { method public void setSolarEngineVisitorId(String? solarEngineVisitorId); method public void setTenjinAnalyticsInstallationID(String? tenjinAnalyticsInstallationID); method @kotlin.jvm.JvmSynthetic public void setTrackedEventListener(com.revenuecat.purchases.TrackedEventListener?); - method @kotlin.jvm.Synchronized public void setUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener?); + method @Deprecated @kotlin.jvm.Synchronized public void setUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener?); method public void showInAppMessagesIfNeeded(android.app.Activity activity); method public void showInAppMessagesIfNeeded(android.app.Activity activity, optional java.util.List inAppMessageTypes); method @Deprecated public void syncAmazonPurchase(String productID, String receiptID, String amazonUserID, String? isoCurrencyCode, Double? price); @@ -503,7 +505,7 @@ package com.revenuecat.purchases { property @kotlin.jvm.Synchronized public final String? storefrontCountryCode; property @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final java.util.Locale? storefrontLocale; property @kotlin.jvm.JvmSynthetic public final com.revenuecat.purchases.TrackedEventListener? trackedEventListener; - property @kotlin.jvm.Synchronized public final com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? updatedCustomerInfoListener; + property @Deprecated @kotlin.jvm.Synchronized public final com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? updatedCustomerInfoListener; field public static final com.revenuecat.purchases.Purchases.Companion Companion; } diff --git a/purchases/api-defauts.txt b/purchases/api-defauts.txt index 7087c18885..81115596a7 100644 --- a/purchases/api-defauts.txt +++ b/purchases/api-defauts.txt @@ -373,6 +373,7 @@ package com.revenuecat.purchases { } public final class Purchases { + method public void addUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener listener); method public static void canMakePayments(android.content.Context context, com.revenuecat.purchases.interfaces.Callback callback); method public static void canMakePayments(android.content.Context context, optional java.util.List features, com.revenuecat.purchases.interfaces.Callback callback); method public void close(); @@ -408,7 +409,7 @@ package com.revenuecat.purchases { method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public void getStorefrontLocale(com.revenuecat.purchases.interfaces.GetStorefrontLocaleCallback callback); method @Deprecated public void getSubscriptionSkus(java.util.List productIds, com.revenuecat.purchases.interfaces.GetStoreProductsCallback callback); method @kotlin.jvm.JvmSynthetic public com.revenuecat.purchases.TrackedEventListener? getTrackedEventListener(); - method @kotlin.jvm.Synchronized public com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? getUpdatedCustomerInfoListener(); + method @Deprecated @kotlin.jvm.Synchronized public com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? getUpdatedCustomerInfoListener(); method public void getVirtualCurrencies(com.revenuecat.purchases.interfaces.GetVirtualCurrenciesCallback callback); method public void invalidateCustomerInfoCache(); method public void invalidateVirtualCurrenciesCache(); @@ -427,7 +428,8 @@ package com.revenuecat.purchases { method @Deprecated public void purchasePackage(android.app.Activity activity, com.revenuecat.purchases.Package packageToPurchase, com.revenuecat.purchases.interfaces.PurchaseCallback listener); method @Deprecated public void purchaseProduct(android.app.Activity activity, com.revenuecat.purchases.models.StoreProduct storeProduct, com.revenuecat.purchases.interfaces.PurchaseCallback callback); method public void redeemWebPurchase(com.revenuecat.purchases.WebPurchaseRedemption webPurchaseRedemption, com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener listener); - method public void removeUpdatedCustomerInfoListener(); + method @Deprecated public void removeUpdatedCustomerInfoListener(); + method public void removeUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener listener); method public void restorePurchases(com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback callback); method public void setAd(String? ad); method public void setAdGroup(String? adGroup); @@ -470,7 +472,7 @@ package com.revenuecat.purchases { method public void setSolarEngineVisitorId(String? solarEngineVisitorId); method public void setTenjinAnalyticsInstallationID(String? tenjinAnalyticsInstallationID); method @kotlin.jvm.JvmSynthetic public void setTrackedEventListener(com.revenuecat.purchases.TrackedEventListener?); - method @kotlin.jvm.Synchronized public void setUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener?); + method @Deprecated @kotlin.jvm.Synchronized public void setUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener?); method public void showInAppMessagesIfNeeded(android.app.Activity activity); method public void showInAppMessagesIfNeeded(android.app.Activity activity, optional java.util.List inAppMessageTypes); method @Deprecated public void syncAmazonPurchase(String productID, String receiptID, String amazonUserID, String? isoCurrencyCode, Double? price); @@ -503,7 +505,7 @@ package com.revenuecat.purchases { property @kotlin.jvm.Synchronized public final String? storefrontCountryCode; property @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final java.util.Locale? storefrontLocale; property @kotlin.jvm.JvmSynthetic public final com.revenuecat.purchases.TrackedEventListener? trackedEventListener; - property @kotlin.jvm.Synchronized public final com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? updatedCustomerInfoListener; + property @Deprecated @kotlin.jvm.Synchronized public final com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? updatedCustomerInfoListener; field public static final com.revenuecat.purchases.Purchases.Companion Companion; } diff --git a/purchases/api-entitlement.txt b/purchases/api-entitlement.txt index f3ce21b841..a66cc69f5c 100644 --- a/purchases/api-entitlement.txt +++ b/purchases/api-entitlement.txt @@ -341,6 +341,7 @@ package com.revenuecat.purchases { } public final class Purchases { + method public void addUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener listener); method public static void canMakePayments(android.content.Context context, com.revenuecat.purchases.interfaces.Callback callback); method public static void canMakePayments(android.content.Context context, optional java.util.List features, com.revenuecat.purchases.interfaces.Callback callback); method public void close(); @@ -358,16 +359,17 @@ package com.revenuecat.purchases { method public static com.revenuecat.purchases.Purchases getSharedInstance(); method @kotlin.jvm.Synchronized public String? getStorefrontCountryCode(); method public void getStorefrontCountryCode(com.revenuecat.purchases.interfaces.GetStorefrontCallback callback); - method @kotlin.jvm.Synchronized public com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? getUpdatedCustomerInfoListener(); + method @Deprecated @kotlin.jvm.Synchronized public com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? getUpdatedCustomerInfoListener(); method public static boolean isConfigured(); method public void purchase(com.revenuecat.purchases.PurchaseParams purchaseParams, com.revenuecat.purchases.interfaces.PurchaseCallback callback); - method public void removeUpdatedCustomerInfoListener(); + method @Deprecated public void removeUpdatedCustomerInfoListener(); + method public void removeUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener listener); method public void restorePurchases(com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback callback); method @kotlin.jvm.Synchronized public static void setLogHandler(com.revenuecat.purchases.LogHandler); method public static void setLogLevel(com.revenuecat.purchases.LogLevel); method public static void setPlatformInfo(com.revenuecat.purchases.common.PlatformInfo); method public static void setProxyURL(java.net.URL?); - method @kotlin.jvm.Synchronized public void setUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener?); + method @Deprecated @kotlin.jvm.Synchronized public void setUpdatedCustomerInfoListener(com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener?); method public void showInAppMessagesIfNeeded(android.app.Activity activity); method public void showInAppMessagesIfNeeded(android.app.Activity activity, optional java.util.List inAppMessageTypes); method public void switchUser(String newAppUserID); @@ -380,7 +382,7 @@ package com.revenuecat.purchases { property public static final java.net.URL? proxyURL; property public static final com.revenuecat.purchases.Purchases sharedInstance; property @kotlin.jvm.Synchronized public final String? storefrontCountryCode; - property @kotlin.jvm.Synchronized public final com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? updatedCustomerInfoListener; + property @Deprecated @kotlin.jvm.Synchronized public final com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener? updatedCustomerInfoListener; field public static final com.revenuecat.purchases.Purchases.Companion Companion; } diff --git a/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt b/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt index 935058b716..3fe50e5868 100644 --- a/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt +++ b/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt @@ -49,10 +49,19 @@ public class Purchases internal constructor( * The listener is responsible for handling changes to customer information. * Make sure [removeUpdatedCustomerInfoListener] is called when the listener needs to be destroyed. */ + @Deprecated( + "Use addUpdatedCustomerInfoListener/removeUpdatedCustomerInfoListener instead", + ReplaceWith("addUpdatedCustomerInfoListener(value!!)"), + ) public var updatedCustomerInfoListener: UpdatedCustomerInfoListener? - @Synchronized get() = purchasesOrchestrator.updatedCustomerInfoListener - - @Synchronized set(value) { + @Suppress("DEPRECATION") + @Synchronized + get() = + purchasesOrchestrator.updatedCustomerInfoListener + + @Suppress("DEPRECATION") + @Synchronized + set(value) { purchasesOrchestrator.updatedCustomerInfoListener = value } @@ -131,9 +140,33 @@ public class Purchases internal constructor( * Call this when you are finished using the [UpdatedCustomerInfoListener]. You should call this * to avoid memory leaks. */ + @Deprecated( + "Use removeUpdatedCustomerInfoListener(listener) instead", + ReplaceWith("removeUpdatedCustomerInfoListener(listener)"), + ) @Suppress("MemberVisibilityCanBePrivate") public fun removeUpdatedCustomerInfoListener() { - purchasesOrchestrator.removeUpdatedCustomerInfoListener() + purchasesOrchestrator.removeLegacyUpdatedCustomerInfoListener() + } + + /** + * Add a listener that will be called whenever customer info changes. + * Multiple listeners can be registered. The listener will immediately receive the latest + * cached customer info if available. + * + * Make sure to call [removeUpdatedCustomerInfoListener] when the listener is no longer needed + * to avoid memory leaks. + */ + public fun addUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + purchasesOrchestrator.addUpdatedCustomerInfoListener(listener) + } + + /** + * Remove a previously added [UpdatedCustomerInfoListener]. Call this when you are finished + * using the listener to avoid memory leaks. + */ + public fun removeUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + purchasesOrchestrator.removeUpdatedCustomerInfoListener(listener) } /** diff --git a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt index 4a7f723a39..d94e3005de 100644 --- a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt +++ b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt @@ -126,10 +126,19 @@ public class Purchases internal constructor( * The listener is responsible for handling changes to customer information. * Make sure [removeUpdatedCustomerInfoListener] is called when the listener needs to be destroyed. */ + @Deprecated( + "Use addUpdatedCustomerInfoListener/removeUpdatedCustomerInfoListener instead", + ReplaceWith("addUpdatedCustomerInfoListener(value!!)"), + ) public var updatedCustomerInfoListener: UpdatedCustomerInfoListener? - @Synchronized get() = purchasesOrchestrator.updatedCustomerInfoListener - - @Synchronized set(value) { + @Suppress("DEPRECATION") + @Synchronized + get() = + purchasesOrchestrator.updatedCustomerInfoListener + + @Suppress("DEPRECATION") + @Synchronized + set(value) { purchasesOrchestrator.updatedCustomerInfoListener = value } @@ -605,9 +614,33 @@ public class Purchases internal constructor( * Call this when you are finished using the [UpdatedCustomerInfoListener]. You should call this * to avoid memory leaks. */ + @Deprecated( + "Use removeUpdatedCustomerInfoListener(listener) instead", + ReplaceWith("removeUpdatedCustomerInfoListener(listener)"), + ) @Suppress("MemberVisibilityCanBePrivate") public fun removeUpdatedCustomerInfoListener() { - purchasesOrchestrator.removeUpdatedCustomerInfoListener() + purchasesOrchestrator.removeLegacyUpdatedCustomerInfoListener() + } + + /** + * Add a listener that will be called whenever customer info changes. + * Multiple listeners can be registered. The listener will immediately receive the latest + * cached customer info if available. + * + * Make sure to call [removeUpdatedCustomerInfoListener] when the listener is no longer needed + * to avoid memory leaks. + */ + public fun addUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + purchasesOrchestrator.addUpdatedCustomerInfoListener(listener) + } + + /** + * Remove a previously added [UpdatedCustomerInfoListener]. Call this when you are finished + * using the listener to avoid memory leaks. + */ + public fun removeUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + purchasesOrchestrator.removeUpdatedCustomerInfoListener(listener) } /** diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/CustomerInfoUpdateHandler.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/CustomerInfoUpdateHandler.kt index 619df59344..9bde685327 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/CustomerInfoUpdateHandler.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/CustomerInfoUpdateHandler.kt @@ -26,43 +26,74 @@ internal class CustomerInfoUpdateHandler constructor( private val handler: Handler = Handler(Looper.getMainLooper()), ) { + @Deprecated("Use addUpdatedCustomerInfoListener/removeUpdatedCustomerInfoListener instead") var updatedCustomerInfoListener: UpdatedCustomerInfoListener? = null @Synchronized get set(value) { synchronized(this@CustomerInfoUpdateHandler) { field = value } - afterSetListener(value) + afterSetLegacyListener(value) } + private val listeners = mutableListOf() + private var lastSentCustomerInfo: CustomerInfo? = null + fun addUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + log(LogIntent.DEBUG) { ConfigureStrings.LISTENER_SET } + if (!appConfig.customEntitlementComputation) { + getCachedCustomerInfo(identityManager.currentAppUserID)?.let { cachedInfo -> + sendToSingleListener(listener, cachedInfo) + } + } + synchronized(this@CustomerInfoUpdateHandler) { + listeners.add(listener) + } + } + + fun removeUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + synchronized(this@CustomerInfoUpdateHandler) { + listeners.remove(listener) + } + } + + @Suppress("DEPRECATION") + fun removeAllListeners() { + synchronized(this@CustomerInfoUpdateHandler) { + listeners.clear() + updatedCustomerInfoListener = null + } + } + fun cacheAndNotifyListeners(customerInfo: CustomerInfo) { deviceCache.cacheCustomerInfo(identityManager.currentAppUserID, customerInfo) notifyListeners(customerInfo) } + @Suppress("DEPRECATION") fun notifyListeners(customerInfo: CustomerInfo) { - synchronized(this@CustomerInfoUpdateHandler) { updatedCustomerInfoListener to lastSentCustomerInfo } - .let { (listener, lastSentCustomerInfo) -> - if (lastSentCustomerInfo != customerInfo) { - diagnosticsTracker?.trackCustomerInfoVerificationResultIfNeeded(customerInfo) - } - if (listener != null && lastSentCustomerInfo != customerInfo) { - if (lastSentCustomerInfo != null) { - log(LogIntent.DEBUG) { CustomerInfoStrings.CUSTOMERINFO_UPDATED_NOTIFYING_LISTENER } - } else { - log(LogIntent.DEBUG) { CustomerInfoStrings.SENDING_LATEST_CUSTOMERINFO_TO_LISTENER } - } - synchronized(this@CustomerInfoUpdateHandler) { - this.lastSentCustomerInfo = customerInfo - } - dispatch { listener.onReceived(customerInfo) } - } + val (legacyListener, addedListeners, lastSent) = synchronized(this@CustomerInfoUpdateHandler) { + Triple(updatedCustomerInfoListener, listeners.toList(), lastSentCustomerInfo) + } + if (lastSent != customerInfo) { + diagnosticsTracker?.trackCustomerInfoVerificationResultIfNeeded(customerInfo) + if (lastSent != null) { + log(LogIntent.DEBUG) { CustomerInfoStrings.CUSTOMERINFO_UPDATED_NOTIFYING_LISTENER } + } else { + log(LogIntent.DEBUG) { CustomerInfoStrings.SENDING_LATEST_CUSTOMERINFO_TO_LISTENER } + } + synchronized(this@CustomerInfoUpdateHandler) { + this.lastSentCustomerInfo = customerInfo + } + legacyListener?.let { dispatch { it.onReceived(customerInfo) } } + addedListeners.forEach { listener -> + dispatch { listener.onReceived(customerInfo) } } + } } - private fun afterSetListener(listener: UpdatedCustomerInfoListener?) { + private fun afterSetLegacyListener(listener: UpdatedCustomerInfoListener?) { if (listener != null) { log(LogIntent.DEBUG) { ConfigureStrings.LISTENER_SET } if (!appConfig.customEntitlementComputation) { @@ -73,6 +104,11 @@ internal class CustomerInfoUpdateHandler constructor( } } + private fun sendToSingleListener(listener: UpdatedCustomerInfoListener, customerInfo: CustomerInfo) { + log(LogIntent.DEBUG) { CustomerInfoStrings.SENDING_LATEST_CUSTOMERINFO_TO_LISTENER } + dispatch { listener.onReceived(customerInfo) } + } + private fun getCachedCustomerInfo(appUserID: String): CustomerInfo? { return offlineEntitlementsManager.offlineCustomerInfo ?: deviceCache.getCachedCustomerInfo(appUserID) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt index 33f4939fb9..625981a830 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt @@ -186,13 +186,27 @@ internal class PurchasesOrchestrator( val appUserID: String @Synchronized get() = identityManager.currentAppUserID + @Deprecated("Use addUpdatedCustomerInfoListener/removeUpdatedCustomerInfoListener instead") var updatedCustomerInfoListener: UpdatedCustomerInfoListener? - @Synchronized get() = customerInfoUpdateHandler.updatedCustomerInfoListener + @Suppress("DEPRECATION") + @Synchronized + get() = + customerInfoUpdateHandler.updatedCustomerInfoListener - @Synchronized set(value) { + @Suppress("DEPRECATION") + @Synchronized + set(value) { customerInfoUpdateHandler.updatedCustomerInfoListener = value } + fun addUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(listener) + } + + fun removeUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + customerInfoUpdateHandler.removeUpdatedCustomerInfoListener(listener) + } + @get:Synchronized @set:Synchronized var customerCenterListener: CustomerCenterListener? = null @@ -793,7 +807,7 @@ internal class PurchasesOrchestrator( this.backend.close() billing.close() - updatedCustomerInfoListener = null // Do not call on state since the setter does more stuff + customerInfoUpdateHandler.removeAllListeners() dispatch { processLifecycleOwnerProvider().lifecycle.removeObserver(lifecycleHandler) @@ -821,7 +835,8 @@ internal class PurchasesOrchestrator( ) } - fun removeUpdatedCustomerInfoListener() { + @Suppress("DEPRECATION") + fun removeLegacyUpdatedCustomerInfoListener() { // Don't set on state directly since setter does more things this.updatedCustomerInfoListener = null } diff --git a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt index c543919b38..afef342b8b 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt @@ -374,6 +374,15 @@ internal open class BasePurchasesTest { every { updatedCustomerInfoListener } returns null + every { + addUpdatedCustomerInfoListener(any()) + } just Runs + every { + removeUpdatedCustomerInfoListener(any()) + } just Runs + every { + removeAllListeners() + } just Runs } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/CustomerInfoUpdateHandlerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/CustomerInfoUpdateHandlerTest.kt index 8ad7c7d387..4e1d8d83a7 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/CustomerInfoUpdateHandlerTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/CustomerInfoUpdateHandlerTest.kt @@ -55,8 +55,9 @@ class CustomerInfoUpdateHandlerTest { } - // region updatedCustomerInfoListener + // region updatedCustomerInfoListener (legacy) + @Suppress("DEPRECATION") @Test fun `setting listener sends cached value if it exists`() { val listenerMock = mockk(relaxed = true) @@ -65,6 +66,7 @@ class CustomerInfoUpdateHandlerTest { verify(exactly = 1) { listenerMock.onReceived(mockInfo) } } + @Suppress("DEPRECATION") @Test fun `setting listener doesn't send cached value if custom entitlements computation enabled`() { every { appConfig.customEntitlementComputation } returns true @@ -74,6 +76,7 @@ class CustomerInfoUpdateHandlerTest { verify(exactly = 0) { listenerMock.onReceived(mockInfo) } } + @Suppress("DEPRECATION") @Test fun `setting listener sends offline customer info cached value if it exists over cached value`() { val mockCustomerInfo2 = mockk() @@ -84,6 +87,7 @@ class CustomerInfoUpdateHandlerTest { verify(exactly = 1) { listenerMock.onReceived(mockCustomerInfo2) } } + @Suppress("DEPRECATION") @Test fun `setting listener does not send cached value if it does not exist`() { val listenerMock = mockk(relaxed = true) @@ -93,6 +97,7 @@ class CustomerInfoUpdateHandlerTest { verify(exactly = 0) { listenerMock.onReceived(mockInfo) } } + @Suppress("DEPRECATION") @Test fun `setting listener tracks customer info verification result`() { val listenerMock = mockk(relaxed = true) @@ -112,6 +117,7 @@ class CustomerInfoUpdateHandlerTest { verify(exactly = 1) { deviceCache.cacheCustomerInfo(appUserId, mockInfo) } } + @Suppress("DEPRECATION") @Test fun `caching and notifying listeners does not notify listeners if same than previous one`() { val listenerMock = mockk(relaxed = true) @@ -122,6 +128,7 @@ class CustomerInfoUpdateHandlerTest { verify(exactly = 1) { listenerMock.onReceived(mockInfo) } // From setting the listener } + @Suppress("DEPRECATION") @Test fun `caching and notifying listeners notifies listeners if different than previous one`() { val listenerMock = mockk(relaxed = true) @@ -139,6 +146,7 @@ class CustomerInfoUpdateHandlerTest { // region notifyListeners + @Suppress("DEPRECATION") @Test fun `does not update listener if customer info same as previous one`() { val listenerMock = mockk(relaxed = true) @@ -149,6 +157,7 @@ class CustomerInfoUpdateHandlerTest { verify(exactly = 1) { listenerMock.onReceived(mockInfo) } // From setting the listener } + @Suppress("DEPRECATION") @Test fun `updates listener if customer info different than previous one`() { val listenerMock = mockk(relaxed = true) @@ -163,6 +172,7 @@ class CustomerInfoUpdateHandlerTest { verify(exactly = 1) { listenerMock.onReceived(newCustomerInfo) } } + @Suppress("DEPRECATION") @Test fun `does not update listener if customer info same one in several calls`() { val listenerMock = mockk(relaxed = true) @@ -180,6 +190,7 @@ class CustomerInfoUpdateHandlerTest { verify(exactly = 1) { diagnosticsTracker.trackCustomerInfoVerificationResultIfNeeded(mockInfo) } // From setting the listener } + @Suppress("DEPRECATION") @Test fun `tracks customer info verification result if customer info different than previous one`() { val listenerMock = mockk(relaxed = true) @@ -194,4 +205,148 @@ class CustomerInfoUpdateHandlerTest { } // endregion + + // region addUpdatedCustomerInfoListener + + @Test + fun `added listener receives cached customer info immediately`() { + val listenerMock = mockk(relaxed = true) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(listenerMock) + + verify(exactly = 1) { listenerMock.onReceived(mockInfo) } + } + + @Test + fun `added listener does not receive cached info if custom entitlements computation enabled`() { + every { appConfig.customEntitlementComputation } returns true + val listenerMock = mockk(relaxed = true) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(listenerMock) + + verify(exactly = 0) { listenerMock.onReceived(any()) } + } + + @Test + fun `multiple added listeners all get notified on change`() { + val listener1 = mockk(relaxed = true) + val listener2 = mockk(relaxed = true) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(listener1) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(listener2) + + val newCustomerInfo = mockk() + every { deviceCache.cacheCustomerInfo(appUserId, newCustomerInfo) } just Runs + customerInfoUpdateHandler.notifyListeners(newCustomerInfo) + + verify(exactly = 1) { listener1.onReceived(newCustomerInfo) } + verify(exactly = 1) { listener2.onReceived(newCustomerInfo) } + } + + @Test + fun `removing a specific listener stops its notifications`() { + val listener1 = mockk(relaxed = true) + val listener2 = mockk(relaxed = true) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(listener1) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(listener2) + + customerInfoUpdateHandler.removeUpdatedCustomerInfoListener(listener1) + + val newCustomerInfo = mockk() + every { deviceCache.cacheCustomerInfo(appUserId, newCustomerInfo) } just Runs + customerInfoUpdateHandler.notifyListeners(newCustomerInfo) + + verify(exactly = 0) { listener1.onReceived(newCustomerInfo) } + verify(exactly = 1) { listener2.onReceived(newCustomerInfo) } + } + + @Suppress("DEPRECATION") + @Test + fun `legacy property and added listeners coexist`() { + val legacyListener = mockk(relaxed = true) + val addedListener = mockk(relaxed = true) + + customerInfoUpdateHandler.updatedCustomerInfoListener = legacyListener + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(addedListener) + + val newCustomerInfo = mockk() + every { deviceCache.cacheCustomerInfo(appUserId, newCustomerInfo) } just Runs + customerInfoUpdateHandler.notifyListeners(newCustomerInfo) + + verify(exactly = 1) { legacyListener.onReceived(newCustomerInfo) } + verify(exactly = 1) { addedListener.onReceived(newCustomerInfo) } + } + + @Suppress("DEPRECATION") + @Test + fun `setting legacy to null does not affect added listeners`() { + val addedListener = mockk(relaxed = true) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(addedListener) + + customerInfoUpdateHandler.updatedCustomerInfoListener = null + + val newCustomerInfo = mockk() + every { deviceCache.cacheCustomerInfo(appUserId, newCustomerInfo) } just Runs + customerInfoUpdateHandler.notifyListeners(newCustomerInfo) + + verify(exactly = 1) { addedListener.onReceived(newCustomerInfo) } + } + + @Test + fun `new listener gets cached info immediately even after prior broadcasts`() { + val listener1 = mockk(relaxed = true) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(listener1) + + // listener1 already got mockInfo. Now add a second listener — it should also get mockInfo. + val listener2 = mockk(relaxed = true) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(listener2) + + verify(exactly = 1) { listener2.onReceived(mockInfo) } + } + + @Suppress("DEPRECATION") + @Test + fun `removeAllListeners clears everything`() { + val legacyListener = mockk(relaxed = true) + val addedListener = mockk(relaxed = true) + customerInfoUpdateHandler.updatedCustomerInfoListener = legacyListener + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(addedListener) + + customerInfoUpdateHandler.removeAllListeners() + + val newCustomerInfo = mockk() + every { deviceCache.cacheCustomerInfo(appUserId, newCustomerInfo) } just Runs + customerInfoUpdateHandler.notifyListeners(newCustomerInfo) + + // Only the initial cached info call, nothing after removeAllListeners + verify(exactly = 1) { legacyListener.onReceived(mockInfo) } + verify(exactly = 1) { addedListener.onReceived(mockInfo) } + verify(exactly = 0) { legacyListener.onReceived(newCustomerInfo) } + verify(exactly = 0) { addedListener.onReceived(newCustomerInfo) } + } + + @Test + fun `adding a new listener does not cause double delivery to existing listeners`() { + // Simulate: notifyListeners(newInfo) has already run, so lastSentCustomerInfo = newInfo. + // Then a second listener is added while the cache still holds an older value. + // The existing listener must NOT receive newInfo a second time. + val existingListener = mockk(relaxed = true) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(existingListener) + // existingListener received mockInfo (initial cached delivery) + + val newInfo = mockk() + every { deviceCache.cacheCustomerInfo(appUserId, newInfo) } just Runs + customerInfoUpdateHandler.notifyListeners(newInfo) + // existingListener received newInfo; lastSentCustomerInfo = newInfo + + // Cache still returns the older mockInfo (e.g. not yet updated) + every { deviceCache.getCachedCustomerInfo(appUserId) } returns mockInfo + + val newListener = mockk(relaxed = true) + customerInfoUpdateHandler.addUpdatedCustomerInfoListener(newListener) + // newListener should receive the stale mockInfo as its initial delivery + // but existingListener must NOT receive newInfo again + + verify(exactly = 1) { newListener.onReceived(mockInfo) } + verify(exactly = 1) { existingListener.onReceived(newInfo) } // exactly once, not twice + } + + // endregion } diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt index 46553686ce..336818bad2 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt @@ -119,13 +119,14 @@ internal class PurchasesCommonTest: BasePurchasesTest() { verifyClose() } + @Suppress("DEPRECATION") @Test fun `when setting listener for anonymous user, we set customer info helper listener`() { anonymousSetup(true) purchases.updatedCustomerInfoListener = updatedCustomerInfoListener verify(exactly = 1) { - mockCustomerInfoUpdateHandler.updatedCustomerInfoListener = null + mockCustomerInfoUpdateHandler.removeAllListeners() } verify(exactly = 1) { mockCustomerInfoUpdateHandler.updatedCustomerInfoListener = updatedCustomerInfoListener diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt index 4d1c8b1163..ee0f3449b8 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt @@ -10,6 +10,7 @@ import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.common.events.FeatureEvent import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.customercenter.CustomerCenterListener +import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies @@ -59,4 +60,10 @@ internal class MockPurchasesType( // No-op for mock - return success to simulate success return CreateSupportTicketResult(success = true) } + override fun addUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + // No-op for mock + } + override fun removeUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + // No-op for mock + } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt index 1a2b6a5b98..645b83db52 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt @@ -21,6 +21,7 @@ import com.revenuecat.purchases.awaitSyncPurchases import com.revenuecat.purchases.common.events.FeatureEvent import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.customercenter.CustomerCenterListener +import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.googleProduct import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies @@ -63,6 +64,10 @@ internal interface PurchasesType { @Throws(PurchasesException::class) suspend fun awaitCreateSupportTicket(email: String, description: String): CreateSupportTicketResult + + fun addUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) + + fun removeUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) } @Suppress("TooManyFunctions") @@ -131,4 +136,12 @@ internal class PurchasesImpl(private val purchases: Purchases = Purchases.shared override suspend fun awaitCreateSupportTicket(email: String, description: String): CreateSupportTicketResult { return purchases.awaitCreateSupportTicket(email, description) } + + override fun addUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + purchases.addUpdatedCustomerInfoListener(listener) + } + + override fun removeUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + purchases.removeUpdatedCustomerInfoListener(listener) + } } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt index a5ab16a710..e208110f5c 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt @@ -10,6 +10,7 @@ import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.common.events.FeatureEvent import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.customercenter.CustomerCenterListener +import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies @@ -56,4 +57,10 @@ internal class MockPurchasesType( // No-op for mock - return success to simulate success return CreateSupportTicketResult(success = true) } + override fun addUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + // No-op for mock + } + override fun removeUpdatedCustomerInfoListener(listener: UpdatedCustomerInfoListener) { + // No-op for mock + } }