diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index ebe2b57eb..bad276e8a 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index 302e4d57d..5205c3d53 100644
--- a/.github/badges/jacoco.svg
+++ b/.github/badges/jacoco.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d77864c1..7d919d668 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.
+## 2.8.5
+
+## Enhancements
+- Adds the Customer Center, a self-service screen where users can view and restore their purchases, cancel or change a Google Play subscription, request a refund, manage a web subscription and contact support. Present it with `Superwall.instance.presentCustomerCenter()`, dismiss it with `Superwall.instance.dismissCustomerCenter()`, and configure it with `SuperwallOptions.customerCenter`.
+- Adds `CustomerCenterDelegate` and the `customerCenter_open`, `customerCenter_close`, `customerCenter_action`, `customerCenter_surveyResponse` and `customerCenter_refundRequest` events.
+
+## Fixes
+- Fix subscribers with an unexpired subscription being reported as inactive when Google Play fails to answer a purchase query, for example when the billing client isn't ready at launch. A query that succeeds and reports no purchases still deactivates straight away.
+- Fix subscribers being reported as inactive when Google Play returns an active purchase whose product no longer maps to an entitlement in config. A lost mapping is no longer treated as the subscription ending.
+- Fix purchases being refunded by Google Play for not being acknowledged when they did not complete through the billing flow callback, for example a pending purchase that settled while the app was closed, the app being killed mid-purchase, or a Play Store promo code. The `AutomaticPurchaseController` now acknowledges any unacknowledged purchase whenever it syncs the subscription status, including on launch, and retries acknowledgements that fail.
+- Fix `register()` never calling its handler, and every later placement stalling behind it, when a Google Play product query at launch was dropped. A request that found the billing client disconnected between being queued and running was discarded without a callback, leaving its products loading forever. Such requests are now re-queued until the client reconnects, a product query that Google Play never answers times out after 15 seconds, a disconnect with queued requests reconnects, and a second `startConnection` is no longer issued while one is in flight.
+- A paywall whose webview can't load, because every URL and retry has been used up or `paywalls.timeoutAfter` passed, is now dismissed as declined with the `webViewFailedToLoad` close reason, matching iOS. The register handler gets `onDismiss` and then the feature block for a non-gated placement, or `onError` for a gated one. Previously the paywall stayed on screen, and with `timeoutAfter` set the handler received `onPresent` followed by a bare `onError` while the paywall was still showing. A paywall that fails to load off-screen, for example during preload, reloads the next time it is presented.
+
## 2.8.4
## Fixes
diff --git a/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt b/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt
index 0b4e54117..03ad3ecf2 100644
--- a/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt
+++ b/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
@@ -50,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.sp
+import com.superwall.sdk.Superwall
import com.superwall.sdk.analytics.superwall.SuperwallEvent
import com.superwall.superapp.ui.theme.MyApplicationTheme
import org.json.JSONArray
@@ -122,6 +124,17 @@ private fun TimelineListScreen(
modifier = Modifier.padding(16.dp),
)
+ Button(
+ onClick = { Superwall.instance.presentCustomerCenter() },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 8.dp),
+ ) {
+ Text("Open Customer Center")
+ }
+
if (timelines.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/billing/GoogleBillingWrapperTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/billing/GoogleBillingWrapperTest.kt
index 03d7bd8b6..df25908a4 100644
--- a/superwall/src/androidTest/java/com/superwall/sdk/billing/GoogleBillingWrapperTest.kt
+++ b/superwall/src/androidTest/java/com/superwall/sdk/billing/GoogleBillingWrapperTest.kt
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import org.junit.After
@@ -229,8 +230,9 @@ class GoogleBillingWrapperTest {
runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
}
- // Advance so the async block runs and adds its request to the queue
- advanceUntilIdle()
+ // runCurrent, not advanceUntilIdle: idling would run the product query timeout
+ // before the setup result below is delivered.
+ runCurrent()
capturedStateListener?.onBillingSetupFinished(
billingResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE),
@@ -257,8 +259,9 @@ class GoogleBillingWrapperTest {
runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
}
- // Advance so the async block runs and adds its request to the queue
- advanceUntilIdle()
+ // runCurrent, not advanceUntilIdle: idling would run the product query timeout
+ // before the setup result below is delivered.
+ runCurrent()
capturedStateListener?.onBillingSetupFinished(
billingResult(BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED),
@@ -387,11 +390,11 @@ class GoogleBillingWrapperTest {
}
// ========================================================================
- // Region: Products cache — transient errors are not cached
+ // Region: Billing unavailable — later requests fail fast
// ========================================================================
@Test
- fun test_billing_not_available_is_cached() =
+ fun test_billing_not_available_fails_later_requests_fast() =
runTest {
Given("a wrapper where billing is unavailable") {
val wrapper = createWrapper(clientReady = false)
@@ -402,8 +405,9 @@ class GoogleBillingWrapperTest {
runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
}
- // Advance so the async block runs and adds its request to the queue
- advanceUntilIdle()
+ // runCurrent, not advanceUntilIdle: idling would run the product query timeout
+ // before the setup result below is delivered.
+ runCurrent()
capturedStateListener?.onBillingSetupFinished(
billingResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE),
@@ -416,11 +420,11 @@ class GoogleBillingWrapperTest {
outcome1.exceptionOrNull() is BillingError.BillingNotAvailable,
)
- Then("a second call should fail immediately from cache without hitting billing") {
+ Then("a second call should fail immediately without reconnecting") {
val outcome2 = runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
assertTrue("Second call should also fail", outcome2.isFailure)
assertTrue(
- "Should be BillingNotAvailable from cache",
+ "Should be BillingNotAvailable",
outcome2.exceptionOrNull() is BillingError.BillingNotAvailable,
)
}
@@ -429,7 +433,7 @@ class GoogleBillingWrapperTest {
}
@Test
- fun test_multiple_products_cached_on_billing_not_available() =
+ fun test_billing_not_available_fails_later_requests_for_any_product() =
runTest {
Given("multiple products that fail due to billing unavailable") {
val wrapper = createWrapper(clientReady = false)
@@ -442,8 +446,9 @@ class GoogleBillingWrapperTest {
runCatching { wrapper.awaitGetProducts(ids) }
}
- // Advance so the async block runs and adds its request to the queue
- advanceUntilIdle()
+ // runCurrent, not advanceUntilIdle: idling would run the product query timeout
+ // before the setup result below is delivered.
+ runCurrent()
capturedStateListener?.onBillingSetupFinished(
billingResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE),
@@ -451,11 +456,11 @@ class GoogleBillingWrapperTest {
assertTrue(result1.await().isFailure)
- Then("retrying any single product should fail from cache immediately") {
+ Then("retrying any single product should fail immediately") {
val outcome = runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
assertTrue(outcome.isFailure)
assertTrue(
- "Should be a cached BillingNotAvailable error",
+ "Should be BillingNotAvailable",
outcome.exceptionOrNull() is BillingError.BillingNotAvailable,
)
}
@@ -475,8 +480,9 @@ class GoogleBillingWrapperTest {
runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
}
- // Advance so the async block runs and adds its request to the queue
- advanceUntilIdle()
+ // runCurrent, not advanceUntilIdle: idling would run the product query timeout
+ // before the setup result below is delivered.
+ runCurrent()
// SERVICE_UNAVAILABLE retries connection but does NOT drain requests
capturedStateListener?.onBillingSetupFinished(
@@ -495,11 +501,11 @@ class GoogleBillingWrapperTest {
outcome1.exceptionOrNull() is BillingError.BillingNotAvailable,
)
- Then("product is cached as BillingNotAvailable, second call fails from cache") {
+ Then("billing is known to be unavailable, so a second call fails immediately") {
val outcome2 = runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
assertTrue("Second call should also fail", outcome2.isFailure)
assertTrue(
- "Should be BillingNotAvailable from cache",
+ "Should be BillingNotAvailable",
outcome2.exceptionOrNull() is BillingError.BillingNotAvailable,
)
}
@@ -777,7 +783,7 @@ class GoogleBillingWrapperTest {
runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
}
- advanceUntilIdle()
+ runCurrent()
capturedStateListener?.onBillingSetupFinished(
billingResult(
@@ -811,7 +817,7 @@ class GoogleBillingWrapperTest {
runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
}
- advanceUntilIdle()
+ runCurrent()
When("SERVICE_UNAVAILABLE occurs (requests stay in queue)") {
capturedStateListener?.onBillingSetupFinished(
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt
index ecedcb0f2..ff5667ff6 100644
--- a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt
+++ b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt
@@ -1,6 +1,5 @@
package com.superwall.sdk.config
-import And
import Given
import Then
import When
@@ -10,7 +9,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.superwall.sdk.Superwall
import com.superwall.sdk.analytics.Tier
-import com.superwall.sdk.config.models.ConfigState
import com.superwall.sdk.config.options.SuperwallOptions
import com.superwall.sdk.dependencies.DependencyContainer
import com.superwall.sdk.misc.Either
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/OffMainViewConstructionTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/OffMainViewConstructionTest.kt
new file mode 100644
index 000000000..657ffe9a4
--- /dev/null
+++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/OffMainViewConstructionTest.kt
@@ -0,0 +1,102 @@
+package com.superwall.sdk.paywall.view
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.os.Looper
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.superwall.sdk.misc.ActivityProvider
+import com.superwall.sdk.misc.primitives.SequentialActor
+import com.superwall.sdk.network.device.DeviceHelper
+import com.superwall.sdk.paywall.manager.PaywallCacheState
+import com.superwall.sdk.paywall.manager.PaywallViewCache
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * PaywallViewCache builds the shared loading and shimmer views on its actor's
+ * IO thread, which has no Looper. These tests run on a real device to prove
+ * that construction is safe there and that the views still attach, draw and
+ * animate once handed to the main thread.
+ */
+@RunWith(AndroidJUnit4::class)
+class OffMainViewConstructionTest {
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val ctx = instrumentation.targetContext
+
+ private fun exerciseOnMain(vararg views: View) {
+ instrumentation.runOnMainSync {
+ views.forEach { view ->
+ view.measure(
+ View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY),
+ )
+ view.layout(0, 0, 400, 800)
+ (view as? PaywallShimmerView)?.showShimmer()
+ (view as? PaywallPurchaseLoadingView)?.showLoading()
+ view.draw(Canvas(Bitmap.createBitmap(400, 800, Bitmap.Config.ARGB_8888)))
+ (view as? PaywallShimmerView)?.hideShimmer()
+ }
+ }
+ }
+
+ @Test
+ fun loadingAndShimmerCanBeBuiltOnAThreadWithoutALooper() {
+ var error: Throwable? = null
+ var hadLooper = true
+ var loading: LoadingView? = null
+ var shimmer: ShimmerView? = null
+ val thread =
+ Thread {
+ hadLooper = Looper.myLooper() != null
+ try {
+ loading = LoadingView(ctx, loadingColor = android.R.color.black)
+ shimmer = ShimmerView(ctx)
+ } catch (t: Throwable) {
+ error = t
+ }
+ }
+ thread.start()
+ thread.join()
+
+ assertTrue("background thread must not have a Looper", !hadLooper)
+ assertNull("construction off main threw: $error", error)
+ exerciseOnMain(loading!!, shimmer!!)
+ }
+
+ @Test
+ fun cacheAcquireFromMainReturnsUsableViewsWithoutDeadlocking() {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ try {
+ val cache =
+ PaywallViewCache(
+ ctx,
+ object : ViewStorage {
+ override val views = ConcurrentHashMap()
+ },
+ mockk { every { getCurrentActivity() } returns null },
+ mockk { every { locale } returns "en_US" },
+ actor = SequentialActor(PaywallCacheState(), scope),
+ )
+ var loading: PaywallPurchaseLoadingView? = null
+ var shimmer: PaywallShimmerView? = null
+ instrumentation.runOnMainSync {
+ loading = cache.acquireLoadingView()
+ shimmer = cache.acquireShimmerView()
+ }
+ exerciseOnMain(loading as View, shimmer as View)
+ } finally {
+ scope.cancel()
+ }
+ }
+}
diff --git a/superwall/src/main/AndroidManifest.xml b/superwall/src/main/AndroidManifest.xml
index a3e594afd..d2d397688 100644
--- a/superwall/src/main/AndroidManifest.xml
+++ b/superwall/src/main/AndroidManifest.xml
@@ -3,4 +3,12 @@
-
\ No newline at end of file
+
+
+
+
+
diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt
index cdcc333c4..2a21ab93d 100644
--- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt
+++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt
@@ -15,10 +15,12 @@ import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent.*
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
import com.superwall.sdk.billing.toInternalResult
-import com.superwall.sdk.config.models.ConfigState
+import com.superwall.sdk.config.ConfigState
import com.superwall.sdk.config.models.ConfigurationStatus
import com.superwall.sdk.config.options.EventTrackingBehavior
import com.superwall.sdk.config.options.SuperwallOptions
+import com.superwall.sdk.customercenter.CustomerCenterConfiguration
+import com.superwall.sdk.customercenter.CustomerCenterDelegate
import com.superwall.sdk.deeplinks.DeepLinkRouter
import com.superwall.sdk.delegate.InternalPurchaseResult
import com.superwall.sdk.delegate.PurchaseResult
@@ -98,7 +100,6 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
@@ -776,12 +777,15 @@ class Superwall(
else -> old::class == new::class
}
}
- .drop(1) // Drops the cached/initial emission
- .collect { newValue ->
+ // Pair each status with the one before it. Entitlements persists the
+ // new status before this collector runs, so storage can't supply `from`.
+ .scan?>(null) { previous, newStatus ->
+ Pair(previous?.second, newStatus)
+ }.filterNotNull()
+ .filter { it.first != null } // Drops the cached/initial emission
+ .collect { (previous, newValue) ->
// Save and handle the new value
- val oldValue =
- dependencyContainer.storage.read(StoredSubscriptionStatus)
- ?: SubscriptionStatus.Unknown
+ val oldValue = previous ?: SubscriptionStatus.Unknown
dependencyContainer.storage.write(StoredSubscriptionStatus, newValue)
dependencyContainer.delegateAdapter.subscriptionStatusDidChange(
oldValue,
@@ -1381,6 +1385,39 @@ class Superwall(
observe(PurchasingObserverState.PurchaseResult(billingResult, purchases))
}
+ /**
+ * Presents the Customer Center, a self-service screen where users can view and manage their
+ * subscriptions, request refunds, restore purchases, and contact support.
+ *
+ * Only one Customer Center can be presented at a time; calling this while one is already
+ * presented does nothing. Call it from the main thread.
+ *
+ * @param configuration Overrides [SuperwallOptions.customerCenter] for this presentation.
+ * `null` uses the value configured via [SuperwallOptions].
+ * @param delegate Receives Customer Center events. Retained while the Customer Center is
+ * presented.
+ * @param onDismiss Called after the Customer Center is dismissed.
+ */
+ @JvmOverloads
+ fun presentCustomerCenter(
+ configuration: CustomerCenterConfiguration? = null,
+ delegate: CustomerCenterDelegate? = null,
+ onDismiss: (() -> Unit)? = null,
+ ) {
+ dependencyContainer.customerCenterManager.present(configuration, delegate, onDismiss)
+ }
+
+ /**
+ * Dismisses a Customer Center presented via [presentCustomerCenter]. Does nothing if none is
+ * presented. Call it from the main thread.
+ *
+ * @param completion Called once the Customer Center has been dismissed.
+ */
+ @JvmOverloads
+ fun dismissCustomerCenter(completion: (() -> Unit)? = null) {
+ dependencyContainer.customerCenterManager.dismiss(completion)
+ }
+
/**
* Restores purchases
*
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt
index 706b81c49..e896b1725 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt
@@ -4,7 +4,7 @@ import com.superwall.sdk.Superwall
import com.superwall.sdk.analytics.internal.trackable.Trackable
import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
-import com.superwall.sdk.config.models.ConfigState
+import com.superwall.sdk.config.ConfigState
import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt
index be9ea5479..be1a63ab5 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt
@@ -7,6 +7,9 @@ import com.superwall.sdk.config.models.Survey
import com.superwall.sdk.config.models.SurveyOption
import com.superwall.sdk.config.options.SuperwallOptions
import com.superwall.sdk.config.options.toMap
+import com.superwall.sdk.customercenter.CustomerCenterRefundStatus
+import com.superwall.sdk.customercenter.CustomerCenterScreenType
+import com.superwall.sdk.customercenter.CustomerCenterUrls
import com.superwall.sdk.dependencies.ComputedPropertyRequestsFactory
import com.superwall.sdk.dependencies.FeatureFlagsFactory
import com.superwall.sdk.dependencies.RuleAttributesFactory
@@ -1320,6 +1323,82 @@ sealed class InternalSuperwallEvent(
)
}
+ data class CustomerCenterOpen(
+ val screen: CustomerCenterScreenType,
+ ) : InternalSuperwallEvent(SuperwallEvent.CustomerCenterOpen(screen)) {
+ override val audienceFilterParams: Map = emptyMap()
+
+ override suspend fun getSuperwallParameters(): Map =
+ mapOf(
+ "screen" to screen.analyticsName,
+ // The Customer Center always presents as its own screen on Android. Kept for
+ // parity with iOS, where it can also be embedded.
+ "presentation" to "sheet",
+ )
+ }
+
+ class CustomerCenterClose : InternalSuperwallEvent(SuperwallEvent.CustomerCenterClose()) {
+ override val audienceFilterParams: Map = emptyMap()
+
+ override suspend fun getSuperwallParameters(): Map = emptyMap()
+ }
+
+ data class CustomerCenterAction(
+ val action: com.superwall.sdk.customercenter.CustomerCenterAction,
+ val pathId: String,
+ val productId: String?,
+ ) : InternalSuperwallEvent(SuperwallEvent.CustomerCenterAction(action, pathId, productId)) {
+ override val audienceFilterParams: Map = emptyMap()
+
+ override suspend fun getSuperwallParameters(): Map =
+ buildMap {
+ put("action", action.analyticsName)
+ put("path_id", pathId)
+ productId?.let { put("product_id", it) }
+ when (action) {
+ is com.superwall.sdk.customercenter.CustomerCenterAction.Url ->
+ put("url", CustomerCenterUrls.withoutQueryOrFragment(action.url))
+ is com.superwall.sdk.customercenter.CustomerCenterAction.Custom ->
+ put("custom_identifier", action.identifier)
+ else -> Unit
+ }
+ }
+ }
+
+ data class CustomerCenterSurveyResponse(
+ val surveyId: String,
+ val optionId: String,
+ val action: com.superwall.sdk.customercenter.CustomerCenterAction,
+ val pathId: String,
+ val productId: String?,
+ ) : InternalSuperwallEvent(
+ SuperwallEvent.CustomerCenterSurveyResponse(surveyId, optionId, action, pathId, productId),
+ ) {
+ override val audienceFilterParams: Map = emptyMap()
+
+ override suspend fun getSuperwallParameters(): Map =
+ buildMap {
+ put("survey_id", surveyId)
+ put("option_id", optionId)
+ put("action", action.analyticsName)
+ put("path_id", pathId)
+ productId?.let { put("product_id", it) }
+ }
+ }
+
+ data class CustomerCenterRefundRequest(
+ val productId: String,
+ val status: CustomerCenterRefundStatus,
+ ) : InternalSuperwallEvent(SuperwallEvent.CustomerCenterRefundRequest(productId, status)) {
+ override val audienceFilterParams: Map = emptyMap()
+
+ override suspend fun getSuperwallParameters(): Map =
+ mapOf(
+ "product_id" to productId,
+ "status" to status.analyticsName,
+ )
+ }
+
data class TestModeModal(
val state: State,
) : InternalSuperwallEvent(
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt b/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt
index 417786d0c..feb063433 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt
@@ -6,7 +6,7 @@ import com.superwall.sdk.Superwall
import com.superwall.sdk.analytics.internal.track
import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
import com.superwall.sdk.config.ConfigManager
-import com.superwall.sdk.config.models.getConfig
+import com.superwall.sdk.config.getConfig
import com.superwall.sdk.dependencies.DeviceHelperFactory
import com.superwall.sdk.dependencies.UserAttributesEventFactory
import com.superwall.sdk.misc.IOScope
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt
index 61dca8428..b2b768822 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt
@@ -2,6 +2,8 @@ package com.superwall.sdk.analytics.superwall
import com.superwall.sdk.config.models.Survey
import com.superwall.sdk.config.models.SurveyOption
+import com.superwall.sdk.customercenter.CustomerCenterRefundStatus
+import com.superwall.sdk.customercenter.CustomerCenterScreenType
import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.triggers.TriggerResult
import com.superwall.sdk.paywall.presentation.PaywallInfo
@@ -479,6 +481,51 @@ sealed class SuperwallEvent {
get() = SuperwallEvents.TestModeModalClose.rawName
}
+ // / When the Customer Center is presented.
+ data class CustomerCenterOpen(
+ val screen: CustomerCenterScreenType,
+ ) : SuperwallEvent() {
+ override val rawName: String
+ get() = SuperwallEvents.CustomerCenterOpen.rawName
+ }
+
+ // / When the Customer Center is dismissed.
+ class CustomerCenterClose : SuperwallEvent() {
+ override val rawName: String
+ get() = SuperwallEvents.CustomerCenterClose.rawName
+ }
+
+ // / When the user taps a path in the Customer Center.
+ data class CustomerCenterAction(
+ val action: com.superwall.sdk.customercenter.CustomerCenterAction,
+ val pathId: String,
+ val productId: String?,
+ ) : SuperwallEvent() {
+ override val rawName: String
+ get() = SuperwallEvents.CustomerCenterAction.rawName
+ }
+
+ // / When the user answers a Customer Center survey.
+ data class CustomerCenterSurveyResponse(
+ val surveyId: String,
+ val optionId: String,
+ val action: com.superwall.sdk.customercenter.CustomerCenterAction,
+ val pathId: String,
+ val productId: String?,
+ ) : SuperwallEvent() {
+ override val rawName: String
+ get() = SuperwallEvents.CustomerCenterSurveyResponse.rawName
+ }
+
+ // / When a refund request started from the Customer Center completes.
+ data class CustomerCenterRefundRequest(
+ val productId: String,
+ val status: CustomerCenterRefundStatus,
+ ) : SuperwallEvent() {
+ override val rawName: String
+ get() = SuperwallEvents.CustomerCenterRefundRequest.rawName
+ }
+
object RedemptionComplete : SuperwallPlacement() {
override val rawName: String
get() = SuperwallEvents.RedemptionComplete.rawName
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt
index b4e640578..c77d6236c 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt
@@ -68,4 +68,9 @@ enum class SuperwallEvents(
PaywallPageView("paywall_page_view"),
TestModeModalOpen("testModeModal_open"),
TestModeModalClose("testModeModal_close"),
+ CustomerCenterOpen("customerCenter_open"),
+ CustomerCenterClose("customerCenter_close"),
+ CustomerCenterAction("customerCenter_action"),
+ CustomerCenterSurveyResponse("customerCenter_surveyResponse"),
+ CustomerCenterRefundRequest("customerCenter_refundRequest"),
}
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt b/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt
new file mode 100644
index 000000000..05ff1c7e0
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt
@@ -0,0 +1,20 @@
+package com.superwall.sdk.billing
+
+/**
+ * Whether Google Play Billing can be used on this device, as learned from the billing
+ * client's connection attempts.
+ */
+internal sealed interface BillingAvailability {
+ /** No connection attempt has resolved yet. */
+ object Unknown : BillingAvailability
+
+ object Available : BillingAvailability
+
+ /**
+ * The device can't use Play Billing (no Play Store, no signed in account, ...).
+ * Requests fail straight away with [error] instead of reconnecting.
+ */
+ data class Unavailable(
+ val error: BillingError.BillingNotAvailable,
+ ) : BillingAvailability
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/BillingClientUseCase.kt b/superwall/src/main/java/com/superwall/sdk/billing/BillingClientUseCase.kt
index d63c3f734..8de9e913d 100644
--- a/superwall/src/main/java/com/superwall/sdk/billing/BillingClientUseCase.kt
+++ b/superwall/src/main/java/com/superwall/sdk/billing/BillingClientUseCase.kt
@@ -17,6 +17,10 @@ internal const val RETRY_TIMER_MAX_TIME_MILLISECONDS = 1000L * 60L * 15L // 15 m
private const val FOREGROUND_BACKOFF_START_MILLISECONDS = 250L
private const val FOREGROUND_BACKOFF_MAX_ATTEMPTS = 2
+// How many times a request is put back on the queue after finding the billing client
+// disconnected when it came to run, before it fails instead.
+internal const val MAX_CLIENT_NOT_READY_RETRIES = 3
+
internal interface UseCaseParams {
val appInBackground: Boolean
}
@@ -36,6 +40,7 @@ internal abstract class BillingClientUseCase(
private var retryAttempt: Int = 0
private var retryBackoffMilliseconds = RETRY_TIMER_START_MILLISECONDS
private var foregroundBackoffAttempt: Int = 0
+ private var clientNotReadyRetries: Int = 0
fun run(delayMilliseconds: Long = 0) {
executeRequestOnUIThread(delayMilliseconds) { connectionError ->
@@ -51,6 +56,38 @@ internal abstract class BillingClientUseCase(
abstract fun onOk(received: T)
+ /**
+ * The billing client lost its connection between this request being taken off the
+ * queue and it running. Put it back on the queue, which reconnects and runs it once
+ * the client is ready again. A request that keeps finding the client disconnected
+ * fails, so its caller never waits on a callback that isn't coming.
+ */
+ protected fun retryWhenClientReconnects() {
+ if (clientNotReadyRetries < MAX_CLIENT_NOT_READY_RETRIES) {
+ clientNotReadyRetries++
+ Logger.debug(
+ logLevel = LogLevel.warn,
+ scope = LogScope.productsManager,
+ message =
+ "Billing client not ready, re-queueing request " +
+ "($clientNotReadyRetries/$MAX_CLIENT_NOT_READY_RETRIES).",
+ )
+ run()
+ } else {
+ Logger.debug(
+ logLevel = LogLevel.error,
+ scope = LogScope.productsManager,
+ message = "Billing client not ready after $clientNotReadyRetries retries, failing request.",
+ )
+ onError(
+ BillingError.WithCode(
+ code = BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
+ description = "Billing client was not ready after $clientNotReadyRetries retries.",
+ ),
+ )
+ }
+ }
+
fun processResult(
billingResult: BillingResult,
response: T,
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/BillingError.kt b/superwall/src/main/java/com/superwall/sdk/billing/BillingError.kt
index 851296161..11a71c7ee 100644
--- a/superwall/src/main/java/com/superwall/sdk/billing/BillingError.kt
+++ b/superwall/src/main/java/com/superwall/sdk/billing/BillingError.kt
@@ -17,4 +17,9 @@ sealed class BillingError(
code: Int,
description: String,
) : BillingError(code, "Google Billing error: $code - $description")
+
+ /** Google Play did not answer a request in time. Not cached, so a later request retries. */
+ class Timeout(
+ description: String,
+ ) : BillingError(3, description)
}
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt
index fc547d566..a9d0dc61c 100644
--- a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt
+++ b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt
@@ -28,15 +28,19 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
@@ -50,6 +54,16 @@ import kotlin.math.min
internal const val RECONNECT_TIMER_START_MILLISECONDS = 1L * 1000L
internal const val RECONNECT_TIMER_MAX_TIME_MILLISECONDS = 16L * 1000L
+// Consecutive transient setup failures after which billing is treated as unavailable, so
+// requests stop waiting on a connection that may never come (e.g. a broken Play Store).
+// Reconnecting carries on in the background and a later successful setup makes it available.
+internal const val MAX_TRANSIENT_SETUP_FAILURES = 3
+
+// How long a product query may take, connection time included, before it fails. Play
+// normally answers within a couple of seconds; this is a backstop so a callback that
+// never arrives can't leave paywalls waiting on their products for the life of the process.
+internal const val PRODUCTS_QUERY_TIMEOUT_MS = 15_000L
+
class GoogleBillingWrapper(
val context: Context,
val ioScope: IOScope,
@@ -101,6 +115,17 @@ class GoogleBillingWrapper(
@set:Synchronized
private var reconnectionAlreadyScheduled = false
+ private val transientSetupFailures = AtomicInteger(0)
+
+ private val _availability = MutableStateFlow(BillingAvailability.Unknown)
+
+ /**
+ * Whether Play Billing can be used on this device. Once [BillingAvailability.Unavailable],
+ * requests fail straight away instead of reconnecting, until the app next returns to
+ * the foreground and billing is probed again.
+ */
+ internal val availability = _availability.asStateFlow()
+
// Setup mutable state flow for purchase results
override val purchaseResults = MutableStateFlow(null)
@@ -109,6 +134,20 @@ class GoogleBillingWrapper(
init {
startConnectionOnMainThread()
+ // Unavailable isn't always permanent - the user may sign in to the Play Store
+ // while the app is in the background - so probe again on every foregrounding.
+ ioScope.launch {
+ appLifecycleObserver.isInBackground
+ .drop(1)
+ .filter { inBackground -> !inBackground }
+ .collect {
+ if (availability.value is BillingAvailability.Unavailable) {
+ _availability.value = BillingAvailability.Unknown
+ transientSetupFailures.set(0)
+ startConnection()
+ }
+ }
+ }
}
internal class Handler(
@@ -148,14 +187,16 @@ class GoogleBillingWrapper(
}
}
- override suspend fun queryAllPurchases(): List =
- coroutineScope {
+ override suspend fun queryAllPurchases(): List {
+ if (availability.value is BillingAvailability.Unavailable) return emptyList()
+ return coroutineScope {
val apps =
async { retryOrNull(QUERY_PURCHASES_MAX_RETRIES) { queryType(ProductType.INAPP).getOrThrow() } }
val subs =
async { retryOrNull(QUERY_PURCHASES_MAX_RETRIES) { queryType(ProductType.SUBS).getOrThrow() } }
(apps.await() ?: emptyList()) + (subs.await() ?: emptyList())
}
+ }
override suspend fun consume(purchaseToken: String): Result =
suspendCoroutine { cont ->
@@ -187,12 +228,28 @@ class GoogleBillingWrapper(
fun startConnection() {
synchronized(this@GoogleBillingWrapper) {
if (billingClient == null) {
- billingClient = createBillingClient(this)
+ billingClient =
+ try {
+ createBillingClient(this)
+ } catch (e: Throwable) {
+ markUnavailable(
+ BillingError.BillingNotAvailable(
+ "Billing is not available in this device. " +
+ "The billing client could not be created: ${e.message}",
+ ),
+ )
+ return
+ }
}
reconnectionAlreadyScheduled = false
billingClient?.let {
+ // A second startConnection while one is in flight is answered with
+ // DEVELOPER_ERROR and does nothing; the in-flight one still calls back.
+ if (it.connectionState == BillingClient.ConnectionState.CONNECTING) {
+ return
+ }
if (!it.isReady) {
Logger.debug(
LogLevel.debug,
@@ -249,45 +306,51 @@ class GoogleBillingWrapper(
val missingFullProductIds =
fullProductIds - cachedProducts.map { it.fullIdentifier }.toSet()
- return suspendCoroutine { continuation ->
- getProducts(
- missingFullProductIds,
- object : GetStoreProductsCallback {
- override fun onReceived(storeProducts: Set) {
- // Update cache with fetched products and collect their identifiers
- val foundProductIds =
- storeProducts.map { product ->
- productsCache[product.fullIdentifier] = Either.Success(product)
- product.fullIdentifier
- }
-
- // Identify and handle missing products
- missingFullProductIds
- .filterNot { it in foundProductIds }
- .forEach { fullProductId ->
- productsCache[fullProductId] =
- Either.Failure(Exception("Failed to query product details for $fullProductId"))
+ return try {
+ withTimeout(PRODUCTS_QUERY_TIMEOUT_MS) {
+ suspendCancellableCoroutine { continuation ->
+ getProducts(
+ missingFullProductIds,
+ object : GetStoreProductsCallback {
+ override fun onReceived(storeProducts: Set) {
+ // Update cache with fetched products and collect their identifiers
+ val foundProductIds =
+ storeProducts.map { product ->
+ productsCache[product.fullIdentifier] = Either.Success(product)
+ product.fullIdentifier
+ }
+
+ // Identify and handle missing products
+ missingFullProductIds
+ .filterNot { it in foundProductIds }
+ .forEach { fullProductId ->
+ productsCache[fullProductId] =
+ Either.Failure(Exception("Failed to query product details for $fullProductId"))
+ }
+
+ // Combine cached products (now including the newly fetched ones) with the fetched products
+ val allProducts = cachedProducts + storeProducts
+ // A late answer to a request that already timed out is ignored here,
+ // but the products it carried are now cached for the next request.
+ if (continuation.isActive) continuation.resume(allProducts)
}
- // Combine cached products (now including the newly fetched ones) with the fetched products
- val allProducts = cachedProducts + storeProducts
- continuation.resume(allProducts)
- }
-
- override fun onError(error: BillingError) {
- // Cache BillingNotAvailable — it's a permanent device state
- // that won't resolve, so retrying is wasteful.
- // Other billing errors (service unavailable, disconnected, network)
- // are transient and should NOT be cached to allow retry.
- if (error is BillingError.BillingNotAvailable) {
- missingFullProductIds.forEach { fullProductId ->
- productsCache[fullProductId] = Either.Failure(error)
+ override fun onError(error: BillingError) {
+ // Billing errors aren't cached so a later request can retry. Setup-time
+ // unavailability is remembered in [availability]; a BillingNotAvailable
+ // returned by a query isn't, so the next load queries billing again.
+ if (continuation.isActive) continuation.resumeWithException(error)
}
- }
- continuation.resumeWithException(error)
- }
- },
- )
+ },
+ )
+ }
+ }
+ } catch (e: TimeoutCancellationException) {
+ val message =
+ "Timed out after ${PRODUCTS_QUERY_TIMEOUT_MS}ms waiting for Google Play to return " +
+ "products: ${missingFullProductIds.joinToString()}"
+ Logger.debug(LogLevel.error, LogScope.productsManager, message)
+ throw BillingError.Timeout(message)
}
}
@@ -411,6 +474,10 @@ class GoogleBillingWrapper(
delayMilliseconds: Long? = null,
request: (BillingError?) -> Unit,
) {
+ (availability.value as? BillingAvailability.Unavailable)?.let { unavailable ->
+ threadHandler.post { request(unavailable.error) }
+ return
+ }
serviceRequests.add(request to delayMilliseconds)
if (billingClient?.isReady == false) {
startConnectionOnMainThread()
@@ -454,6 +521,11 @@ class GoogleBillingWrapper(
LogScope.productsManager,
"Billing client disconnected",
)
+ // Requests already on the queue only run from a setup callback, and a request that
+ // finds the client disconnected re-queues itself, so reconnect for either to finish.
+ if (serviceRequests.isNotEmpty()) {
+ retryBillingServiceConnectionWithExponentialBackoff()
+ }
}
/**
@@ -508,8 +580,10 @@ class GoogleBillingWrapper(
LogScope.productsManager,
"Billing client connected",
)
+ _availability.value = BillingAvailability.Available
executePendingRequests()
reconnectMilliseconds = RECONNECT_TIMER_START_MILLISECONDS
+ transientSetupFailures.set(0)
trackProductDetailsNotSupportedIfNeeded()
}
@@ -554,7 +628,7 @@ class GoogleBillingWrapper(
)
// The calls will fail with an error that will be surfaced. We want to surface these errors
// Can't call executePendingRequests because it will not do anything since it checks for isReady()
- sendErrorsToAllPendingRequests(error)
+ markUnavailable(error)
}
BillingClient.BillingResponseCode.ERROR,
@@ -568,6 +642,18 @@ class GoogleBillingWrapper(
LogScope.productsManager,
"Billing client error, retrying: ${billingResult.responseCode}",
)
+ val failures = transientSetupFailures.incrementAndGet()
+ if (failures >= MAX_TRANSIENT_SETUP_FAILURES &&
+ availability.value !is BillingAvailability.Unavailable
+ ) {
+ markUnavailable(
+ BillingError.BillingNotAvailable(
+ "Billing is not available in this device. Setup failed $failures " +
+ "times in a row. Last error: ${billingResult.debugMessage} " +
+ "ErrorCode: ${billingResult.responseCode}.",
+ ),
+ )
+ }
retryBillingServiceConnectionWithExponentialBackoff()
}
@@ -583,12 +669,22 @@ class GoogleBillingWrapper(
}
BillingClient.BillingResponseCode.DEVELOPER_ERROR -> {
- // Billing service is already trying to connect. Don't do anything.
+ // Reported when startConnection is called while a connection is already in
+ // flight. That connection delivers its own result, so there's nothing to do
+ // while it's still connecting (or already connected). If it has since
+ // dropped, no callback is coming for the queued requests, so reconnect.
Logger.debug(
LogLevel.error,
LogScope.productsManager,
"Billing client error, developer error: ${billingResult.responseCode}",
)
+ val connectionState = billingClient?.connectionState
+ if (connectionState != BillingClient.ConnectionState.CONNECTING &&
+ connectionState != BillingClient.ConnectionState.CONNECTED &&
+ serviceRequests.isNotEmpty()
+ ) {
+ retryBillingServiceConnectionWithExponentialBackoff()
+ }
}
}
}
@@ -682,6 +778,11 @@ class GoogleBillingWrapper(
}
}
+ private fun markUnavailable(error: BillingError.BillingNotAvailable) {
+ _availability.value = BillingAvailability.Unavailable(error)
+ sendErrorsToAllPendingRequests(error)
+ }
+
@Synchronized
private fun sendErrorsToAllPendingRequests(error: BillingError) {
while (true) {
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt b/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt
index a2672edd0..c073776f6 100644
--- a/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt
+++ b/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt
@@ -26,7 +26,7 @@ internal class QueryProductDetailsUseCase(
private val useCaseParams: QueryProductDetailsUseCaseParams,
val onReceive: (List) -> Unit,
val onError: (BillingError) -> Unit,
- val withConnectedClient: (BillingClient.() -> Unit) -> Unit,
+ val withConnectedClient: (BillingClient.() -> Unit) -> Unit?,
executeRequestOnUIThread: ExecuteRequestOnUIThreadFunction,
) : BillingClientUseCase(useCaseParams, onError, executeRequestOnUIThread) {
private fun log(msg: String) =
@@ -44,16 +44,20 @@ internal class QueryProductDetailsUseCase(
onReceive(emptyList())
return
}
- withConnectedClient {
- val googleType = useCaseParams.productType
- val params = googleType.buildQueryProductDetailsParams(nonEmptyProductIds)
+ val dispatched =
+ withConnectedClient {
+ val googleType = useCaseParams.productType
+ val params = googleType.buildQueryProductDetailsParams(nonEmptyProductIds)
- queryProductDetailsAsyncEnsuringOneResponse(
- this,
- params,
- ::processResult,
- )
- }
+ queryProductDetailsAsyncEnsuringOneResponse(
+ this,
+ params,
+ ::processResult,
+ )
+ }
+ // The client disconnected after this request was dequeued. Dropping it here would
+ // leave every caller waiting for a product that is never delivered.
+ if (dispatched == null) retryWhenClientReconnects()
}
/**
diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt
index 368d8a100..7327c766c 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt
@@ -1,12 +1,9 @@
package com.superwall.sdk.config
import android.content.Context
-import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
-import com.superwall.sdk.config.models.ConfigState
import com.superwall.sdk.config.options.SuperwallOptions
import com.superwall.sdk.identity.IdentityManager
import com.superwall.sdk.misc.primitives.BaseContext
-import com.superwall.sdk.models.config.Config
import com.superwall.sdk.models.entitlements.SubscriptionStatus
import com.superwall.sdk.models.triggers.Trigger
import com.superwall.sdk.network.SuperwallAPI
@@ -33,7 +30,6 @@ interface ConfigContext : BaseContext {
val identityManager: (() -> IdentityManager)?
val setSubscriptionStatus: ((SubscriptionStatus) -> Unit)?
val awaitUtilNetwork: suspend () -> Unit
- val activateTestMode: suspend (config: Config, justActivated: Boolean) -> Unit
fun setTriggers(triggers: Map)
}
diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt
index bc377ad6f..e571d58fe 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt
@@ -1,10 +1,7 @@
package com.superwall.sdk.config
import android.content.Context
-import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
-import com.superwall.sdk.config.models.ConfigState
-import com.superwall.sdk.config.models.getConfig
import com.superwall.sdk.config.options.SuperwallOptions
import com.superwall.sdk.dependencies.DeviceHelperFactory
import com.superwall.sdk.dependencies.DeviceInfoFactory
@@ -36,7 +33,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.launch
open class ConfigManager(
override val context: Context,
@@ -59,7 +55,6 @@ open class ConfigManager(
override val awaitUtilNetwork: suspend () -> Unit = {
context.awaitUntilNetworkExists()
},
- override val activateTestMode: suspend (Config, Boolean) -> Unit = { _, _ -> },
override val actor: StateActor,
) : ConfigContext {
interface Factory :
diff --git a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigState.kt
similarity index 98%
rename from superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt
rename to superwall/src/main/java/com/superwall/sdk/config/ConfigState.kt
index 0d5b7ad21..989720472 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigState.kt
@@ -1,15 +1,11 @@
-package com.superwall.sdk.config.models
+package com.superwall.sdk.config
import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
-import com.superwall.sdk.config.ConfigContext
-import com.superwall.sdk.config.ConfigLogic
-import com.superwall.sdk.config.PaywallPreload
import com.superwall.sdk.config.options.computedShouldPreload
import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
import com.superwall.sdk.misc.Either
-import com.superwall.sdk.misc.awaitFirstValidConfig
import com.superwall.sdk.misc.fold
import com.superwall.sdk.misc.into
import com.superwall.sdk.misc.onError
@@ -326,7 +322,7 @@ sealed class ConfigState {
manager.setOverriddenSubscriptionStatus(defaultStatus)
entitlements.setSubscriptionStatus(defaultStatus)
}
- scope.launch { activateTestMode(config, testModeJustActivated) }
+ scope.launch { manager.activate(config, testModeJustActivated) }
} else {
if (wasTestMode) {
manager?.clearTestModeState()
@@ -357,7 +353,7 @@ sealed class ConfigState {
manager.clearTestModeState()
setSubscriptionStatus?.invoke(SubscriptionStatus.Inactive)
} else if (!wasTestMode && isNowTestMode) {
- scope.launch { activateTestMode(config, true) }
+ scope.launch { manager.activate(config, justActivated = true) }
}
})
diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt
index 3ceca48ab..130c910cf 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt
@@ -152,8 +152,13 @@ class PaywallOptions() {
var optimisticLoading: Boolean = false
/**
- * How long until a paywall timeout is invoked.
- * If not using fallback loading, setting this will trigger a paywall timeout instead of retrying.
+ * How long a presented paywall may take to finish loading before it's given up on.
+ *
+ * When the timeout passes the paywall is dismissed as declined with the
+ * `webViewFailedToLoad` close reason, the same as a webview that can't load at all. Your
+ * `PaywallPresentationHandler` gets `onDismiss`, followed by the feature block for a
+ * non-gated placement or `onError` for a gated one, so you can show your own fallback.
+ * A `paywallWebviewLoad_timeout` event is tracked. `null` (the default) means no timeout.
*/
var timeoutAfter: Duration? = null
diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt
index abb2abda7..f00cfa12e 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt
@@ -1,5 +1,6 @@
package com.superwall.sdk.config.options
+import com.superwall.sdk.customercenter.CustomerCenterConfiguration
import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.store.testmode.TestModeBehavior
@@ -17,6 +18,9 @@ class SuperwallOptions() {
// Configures the appearance and behavior of paywalls.
var paywalls: PaywallOptions = PaywallOptions()
+ // Configures the Customer Center presented via `Superwall.instance.presentCustomerCenter()`.
+ var customerCenter: CustomerCenterConfiguration = CustomerCenterConfiguration.default
+
var shouldObservePurchases = false
// **WARNING**: The different network environments that the SDK should use.
diff --git a/superwall/src/main/java/com/superwall/sdk/customercenter/CustomerCenterAction.kt b/superwall/src/main/java/com/superwall/sdk/customercenter/CustomerCenterAction.kt
new file mode 100644
index 000000000..bd13b8a43
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/customercenter/CustomerCenterAction.kt
@@ -0,0 +1,127 @@
+package com.superwall.sdk.customercenter
+
+import com.superwall.sdk.models.customer.NonSubscriptionTransaction
+import com.superwall.sdk.models.customer.SubscriptionTransaction
+import com.superwall.sdk.models.entitlements.Entitlement
+import com.superwall.sdk.models.product.Store
+
+/** An action the user selected in the Customer Center. */
+sealed class CustomerCenterAction {
+ object Restore : CustomerCenterAction() {
+ override fun toString() = "Restore"
+ }
+
+ object ManageSubscription : CustomerCenterAction() {
+ override fun toString() = "ManageSubscription"
+ }
+
+ object Refund : CustomerCenterAction() {
+ override fun toString() = "Refund"
+ }
+
+ object ChangePlan : CustomerCenterAction() {
+ override fun toString() = "ChangePlan"
+ }
+
+ object ContactSupport : CustomerCenterAction() {
+ override fun toString() = "ContactSupport"
+ }
+
+ data class Url(
+ val url: String,
+ ) : CustomerCenterAction()
+
+ data class Custom(
+ val identifier: String,
+ ) : CustomerCenterAction()
+
+ /** Snake-case name used in events. */
+ internal val analyticsName: String
+ get() =
+ when (this) {
+ Restore -> "restore"
+ ManageSubscription -> "manage_subscription"
+ Refund -> "refund"
+ ChangePlan -> "change_plan"
+ ContactSupport -> "contact_support"
+ is Url -> "url"
+ is Custom -> "custom"
+ }
+
+ internal companion object {
+ fun from(pathType: CustomerCenterConfiguration.PathType): CustomerCenterAction =
+ when (pathType) {
+ CustomerCenterConfiguration.PathType.Restore -> Restore
+ CustomerCenterConfiguration.PathType.ManageSubscription -> ManageSubscription
+ is CustomerCenterConfiguration.PathType.Refund -> Refund
+ is CustomerCenterConfiguration.PathType.ChangePlan -> ChangePlan
+ CustomerCenterConfiguration.PathType.ContactSupport -> ContactSupport
+ is CustomerCenterConfiguration.PathType.Url -> Url(pathType.url)
+ is CustomerCenterConfiguration.PathType.Custom -> Custom(pathType.identifier)
+ }
+ }
+}
+
+/**
+ * Outcome of a refund request made from the Customer Center.
+ *
+ * Google Play takes refund requests on its own pages, so the SDK can't see how one ends:
+ * [SUCCESS] means the request was handed to Google Play, [ERROR] that it couldn't be.
+ */
+enum class CustomerCenterRefundStatus {
+ SUCCESS,
+
+ /**
+ * Not reported on Android, where the SDK can't see the customer back out of Google Play's
+ * page. Kept so a `when` over this enum, and the events it names, match the iOS SDK's.
+ */
+ USER_CANCELLED,
+ ERROR,
+ ;
+
+ internal val analyticsName: String
+ get() =
+ when (this) {
+ SUCCESS -> "success"
+ USER_CANCELLED -> "user_cancelled"
+ ERROR -> "error"
+ }
+}
+
+/** Which Customer Center screen was shown, reported on [com.superwall.sdk.analytics.superwall.SuperwallEvent.CustomerCenterOpen]. */
+enum class CustomerCenterScreenType {
+ /** The user has, or had, at least one purchase. */
+ MANAGEMENT,
+
+ /** The user has no purchases. */
+ NO_PURCHASES,
+ ;
+
+ internal val analyticsName: String
+ get() =
+ when (this) {
+ MANAGEMENT -> "management"
+ NO_PURCHASES -> "no_purchases"
+ }
+}
+
+/** The purchase a Customer Center action applies to. */
+data class CustomerCenterPurchase(
+ /**
+ * The product purchased. `null` for an entitlement with no product behind it, such as a
+ * manually granted one.
+ */
+ val productId: String?,
+ /** Where the purchase was made. */
+ val store: Store,
+ /**
+ * The entitlements the purchase unlocks, including any it no longer grants: for a purchase
+ * with a transaction behind it these are every entitlement the product has ever unlocked, so
+ * check [Entitlement.isActive] before treating one as current.
+ */
+ val entitlements: Set,
+ /** The subscription, when the purchase is one. */
+ val subscription: SubscriptionTransaction? = null,
+ /** The one-time purchase, when the purchase is one. */
+ val nonSubscription: NonSubscriptionTransaction? = null,
+)
diff --git a/superwall/src/main/java/com/superwall/sdk/customercenter/CustomerCenterActivity.kt b/superwall/src/main/java/com/superwall/sdk/customercenter/CustomerCenterActivity.kt
new file mode 100644
index 000000000..a98982703
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/customercenter/CustomerCenterActivity.kt
@@ -0,0 +1,862 @@
+package com.superwall.sdk.customercenter
+
+import android.animation.ObjectAnimator
+import android.animation.ValueAnimator
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.res.ColorStateList
+import android.content.res.Configuration
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.card.MaterialCardView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.superwall.sdk.R
+import com.superwall.sdk.Superwall
+import kotlinx.coroutines.launch
+import java.text.DateFormat
+
+/**
+ * The Customer Center's screen. Presented by [com.superwall.sdk.Superwall.presentCustomerCenter];
+ * not meant to be started directly.
+ */
+class CustomerCenterActivity : AppCompatActivity() {
+ private var session: CustomerCenterManager.Session? = null
+ private val viewModel: CustomerCenterViewModel get() = session!!.viewModel
+ private val strings: CustomerCenterStrings get() = viewModel.strings
+
+ private lateinit var toolbar: MaterialToolbar
+ private lateinit var content: FrameLayout
+ private lateinit var scrollView: ScrollView
+ private lateinit var list: LinearLayout
+ private lateinit var loadingCover: View
+ private lateinit var restoreOverlay: View
+
+ /** The purchase whose detail screen is showing, if one is. */
+ private var detailPurchaseId: String? = null
+ private var shownSheet: CustomerCenterSheet? = null
+ private var sheetDialog: android.app.Dialog? = null
+ private var restoreDialog: AlertDialog? = null
+ private var copiedUserId = false
+ private var lastState: CustomerCenterUiState? = null
+
+ /** What rows, buttons and spinners are tinted with. See [CustomerCenterTint]. */
+ private var tint: Int = CustomerCenterTint.FALLBACK_LIGHT
+
+ private val backCallback =
+ object : OnBackPressedCallback(false) {
+ override fun handleOnBackPressed() {
+ closeDetail()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val current = sessionHost()?.session
+ if (current == null) {
+ // Restored after the process died, or started directly: there's no presentation to
+ // show, and no delegate waiting on one.
+ finish()
+ return
+ }
+ session = current
+ current.activity.set(this)
+ sessionHost()?.sessionAttached(current)
+ detailPurchaseId = savedInstanceState?.getString(STATE_DETAIL_PURCHASE_ID)
+ tint =
+ CustomerCenterTint.resolve(
+ configured = configuredAccent(),
+ host = CustomerCenterTint.hostColor(this, current.hostThemeResId),
+ isDark = isDark(),
+ )
+
+ // Drawn edge to edge on every API level, so the insets below are always the ones to honour.
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ setContentView(buildLayout())
+ WindowCompat.getInsetsController(window, window.decorView).apply {
+ isAppearanceLightStatusBars = !isDark()
+ isAppearanceLightNavigationBars = !isDark()
+ }
+ onBackPressedDispatcher.addCallback(this, backCallback)
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.state.collect(::render)
+ }
+ }
+ viewModel.start()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ session?.let {
+ it.activity.set(this)
+ it.viewModel.onResume()
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putString(STATE_DETAIL_PURCHASE_ID, detailPurchaseId)
+ }
+
+ override fun onDestroy() {
+ sheetDialog?.setOnDismissListener(null)
+ sheetDialog?.dismiss()
+ restoreDialog?.setOnDismissListener(null)
+ restoreDialog?.dismiss()
+ val current = session
+ super.onDestroy()
+ if (current != null && isFinishing) {
+ sessionHost()?.sessionEnded(current)
+ }
+ }
+
+ // region Layout
+
+ private fun buildLayout(): View {
+ val root =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setBackgroundColor(ContextCompat.getColor(context, R.color.superwall_customer_center_background))
+ }
+ toolbar =
+ MaterialToolbar(this).apply {
+ setBackgroundColor(ContextCompat.getColor(context, R.color.superwall_customer_center_background))
+ }
+ root.addView(toolbar, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
+
+ content = FrameLayout(this)
+ root.addView(content, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
+
+ list =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(16), dp(8), dp(16), dp(24))
+ }
+ scrollView =
+ ScrollView(this).apply {
+ isFillViewport = true
+ clipToPadding = false
+ addView(list)
+ }
+ content.addView(scrollView)
+
+ loadingCover =
+ FrameLayout(this).apply {
+ setBackgroundColor(ContextCompat.getColor(context, R.color.superwall_customer_center_background))
+ isClickable = true
+ addView(
+ tintedProgressBar(),
+ FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER),
+ )
+ }
+ content.addView(loadingCover)
+
+ restoreOverlay = buildRestoreOverlay()
+ content.addView(restoreOverlay)
+
+ ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
+ val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
+ toolbar.updatePadding(top = bars.top)
+ view.updatePadding(left = bars.left, right = bars.right)
+ list.updatePadding(bottom = dp(24) + bars.bottom)
+ insets
+ }
+ return root
+ }
+
+ private fun buildRestoreOverlay(): View {
+ val card =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER_HORIZONTAL
+ setPadding(dp(24), dp(24), dp(24), dp(24))
+ background = roundedBackground(ContextCompat.getColor(context, R.color.superwall_customer_center_card), dp(16).toFloat())
+ addView(tintedProgressBar())
+ addView(
+ TextView(context).apply {
+ text = strings.string("customer_center_restoring")
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
+ setPadding(0, dp(12), 0, 0)
+ },
+ )
+ }
+ return FrameLayout(this).apply {
+ setBackgroundColor(Color.argb(64, 0, 0, 0))
+ isClickable = true
+ visibility = View.GONE
+ addView(
+ card,
+ FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER),
+ )
+ }
+ }
+
+ // endregion
+
+ // region Rendering
+
+ private fun render(state: CustomerCenterUiState) {
+ lastState = state
+ loadingCover.visibility = if (state.screen == CustomerCenterScreenState.LOADING) View.VISIBLE else View.GONE
+ restoreOverlay.visibility = if (state.restoreState == CustomerCenterRestoreState.RESTORING) View.VISIBLE else View.GONE
+
+ val detail = detailPurchaseId?.let { id -> state.purchases.firstOrNull { it.id == id } }
+ if (detailPurchaseId != null && detail == null && state.screen != CustomerCenterScreenState.LOADING) {
+ // The purchase has gone, say after a refresh. Go back to the list rather than showing
+ // a detail screen for nothing.
+ detailPurchaseId = null
+ }
+ // Before the sheet renders, so a sheet that left with its detail screen isn't shown.
+ viewModel.screenShown(detailPurchaseId)
+ backCallback.isEnabled = detail != null
+ renderToolbar(state, detail)
+
+ list.removeAllViews()
+ when {
+ state.screen == CustomerCenterScreenState.LOADING -> Unit
+ detail != null -> renderDetail(state, detail)
+ state.screen == CustomerCenterScreenState.MANAGEMENT -> renderManagement(state)
+ else -> renderNoPurchases(state)
+ }
+
+ renderSheet(viewModel.state.value.sheet)
+ renderRestoreResult(state.restoreState)
+ }
+
+ private fun renderToolbar(
+ state: CustomerCenterUiState,
+ detail: PurchasePresentation?,
+ ) {
+ toolbar.title =
+ when {
+ detail != null -> detail.title.orEmpty()
+ state.screen == CustomerCenterScreenState.MANAGEMENT ->
+ viewModel.configuration.managementScreen.title ?: strings.string("customer_center_management_title")
+ else -> ""
+ }
+ if (detail != null) {
+ toolbar.setNavigationIcon(R.drawable.superwall_customer_center_back)
+ toolbar.navigationContentDescription = null
+ toolbar.setNavigationOnClickListener { closeDetail() }
+ } else {
+ toolbar.setNavigationIcon(R.drawable.superwall_customer_center_close)
+ toolbar.navigationContentDescription = strings.string("customer_center_close")
+ toolbar.setNavigationOnClickListener { finish() }
+ }
+ }
+
+ private fun renderManagement(state: CustomerCenterUiState) {
+ if (state.showsUpdateBanner) list.addView(updateBanner())
+ if (state.showsDuplicateBanner) list.addView(duplicateBanner())
+
+ val subscriptions = state.purchases.filter { it.opensDetail }
+ val others = state.purchases.filterNot { it.opensDetail }
+ if (subscriptions.isNotEmpty()) {
+ // Every subscription — and every entitlement-only purchase — is a row that opens its
+ // own detail screen. This screen keeps the actions that apply to the account; anything
+ // that only makes sense against one purchase lives where the row leads.
+ addSection(
+ strings.string("customer_center_section_subscriptions"),
+ subscriptions.map { purchase ->
+ purchaseCard(purchase, state.refundResult, showsChevron = true).apply {
+ setOnClickListener { openDetail(purchase) }
+ background = selectableBackground()
+ }
+ },
+ )
+ }
+ if (others.isNotEmpty()) {
+ addSection(strings.string("customer_center_section_purchases"), others.map { purchaseCard(it, null) })
+ }
+ addSection(strings.string("customer_center_section_actions"), pathRows(state, purchase = null, isScreenLevel = true))
+ if (viewModel.configuration.showsAccountDetails) addAccountDetails()
+ }
+
+ private fun renderNoPurchases(state: CustomerCenterUiState) {
+ val screen = viewModel.configuration.noPurchasesScreen
+ val header =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(16), dp(14), dp(16), dp(14))
+ addView(text(screen.title ?: strings.string("customer_center_no_purchases_title"), 17f, bold = true))
+ addView(
+ text(screen.subtitle ?: strings.string("customer_center_no_purchases_subtitle"), 15f, secondary = true).apply {
+ setPadding(0, dp(4), 0, 0)
+ },
+ )
+ }
+ addSection(null, listOf(header))
+ addSection(null, pathRows(state, purchase = null, isScreenLevel = true))
+ if (viewModel.configuration.showsAccountDetails) addAccountDetails()
+ }
+
+ private fun renderDetail(
+ state: CustomerCenterUiState,
+ purchase: PurchasePresentation,
+ ) {
+ addSection(null, listOf(purchaseCard(purchase, state.refundResult)))
+ val empty = viewModel.detailEmptyState(purchase)
+ if (empty != null) {
+ // The row opened this screen regardless, so say what there is to say rather than head
+ // an empty list with "Actions".
+ val sentence =
+ when (empty) {
+ DetailEmptyState.NothingToDo -> strings.string("customer_center_detail_nothing_to_manage")
+ is DetailEmptyState.ManagedElsewhere ->
+ empty.storeLabelKey?.let {
+ strings.string("customer_center_detail_managed_through", strings.string(it))
+ } ?: strings.string("customer_center_detail_managed_where_bought")
+ }
+ addSection(null, listOf(text(sentence, 15f, secondary = true).apply { setPadding(dp(16), dp(14), dp(16), dp(14)) }))
+ } else {
+ addSection(strings.string("customer_center_section_actions"), pathRows(state, purchase, isScreenLevel = false))
+ }
+ }
+
+ private fun pathRows(
+ state: CustomerCenterUiState,
+ purchase: PurchasePresentation?,
+ isScreenLevel: Boolean,
+ ): List =
+ viewModel.paths(purchase, isScreenLevel).map { resolved ->
+ val row =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ minimumHeight = dp(52)
+ setPadding(dp(16), dp(12), dp(16), dp(12))
+ contentDescription = CustomerCenterPathTitles.title(resolved, strings)
+ tag = "customer_center.path.${resolved.id}"
+ }
+ val title =
+ text(CustomerCenterPathTitles.title(resolved, strings), 16f).apply {
+ setTextColor(tint)
+ }
+ row.addView(title, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ if (state.busyPathId == resolved.id) {
+ row.addView(tintedProgressBar(), LinearLayout.LayoutParams(dp(20), dp(20)))
+ }
+ val enabled = state.busyPathId == null
+ row.isEnabled = enabled
+ row.alpha = if (enabled || state.busyPathId == resolved.id) 1f else 0.5f
+ row.background = selectableBackground()
+ row.setOnClickListener { viewModel.onPathTapped(resolved, purchase) }
+ row
+ }
+
+ private fun purchaseCard(
+ purchase: PurchasePresentation,
+ refundResult: Pair?,
+ showsChevron: Boolean = false,
+ ): View {
+ val column =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ }
+ if (purchase.isAwaitingCatalogue) {
+ // Placeholders for what the catalogue supplies: the name, the price, and a status line
+ // that quotes the price.
+ column.addView(placeholder(dp(160), dp(18)))
+ column.addView(placeholder(dp(110), dp(14)))
+ if (purchase.badge == PurchaseBadge.ACTIVE) {
+ column.addView(placeholder(dp(220), dp(14)))
+ } else {
+ column.addView(text(purchase.statusLine, 14f, secondary = true))
+ }
+ } else {
+ purchase.title?.let { column.addView(text(it, 17f, bold = true)) }
+ purchase.priceLine?.let { column.addView(text(it, 14f)) }
+ if (purchase.statusLine.isNotEmpty()) column.addView(text(purchase.statusLine, 14f, secondary = true))
+ }
+ purchase.storeLabelKey?.let { column.addView(text(strings.string(it), 12f, secondary = true)) }
+ if (refundResult != null && refundResult.first == purchase.productId && refundResult.second == CustomerCenterRefundStatus.ERROR) {
+ column.addView(
+ text(strings.string("customer_center_refund_error"), 12f).apply {
+ setTextColor(ContextCompat.getColor(context, R.color.superwall_customer_center_badge_red))
+ },
+ )
+ }
+ for (i in 0 until column.childCount) {
+ (column.getChildAt(i).layoutParams as? LinearLayout.LayoutParams)?.topMargin = if (i == 0) 0 else dp(4)
+ }
+
+ return LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPaddingRelative(dp(16), dp(14), dp(12), dp(14))
+ tag = "customer_center.purchase.${purchase.productId ?: purchase.id}"
+ addView(column, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ addView(
+ badge(purchase.badge),
+ LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
+ marginStart = dp(8)
+ },
+ )
+ if (showsChevron) {
+ addView(
+ text("›", 22f, secondary = true).apply { setPaddingRelative(dp(8), 0, 0, 0) },
+ )
+ }
+ }
+ }
+
+ private fun badge(badge: PurchaseBadge): View {
+ val (key, colorRes) =
+ when (badge) {
+ PurchaseBadge.ACTIVE -> "customer_center_badge_active" to R.color.superwall_customer_center_badge_green
+ PurchaseBadge.LIFETIME -> "customer_center_badge_lifetime" to R.color.superwall_customer_center_badge_green
+ PurchaseBadge.FREE_TRIAL -> "customer_center_badge_free_trial" to R.color.superwall_customer_center_badge_orange
+ PurchaseBadge.CANCELLED -> "customer_center_badge_cancelled" to R.color.superwall_customer_center_badge_red
+ PurchaseBadge.BILLING_ISSUE -> "customer_center_badge_billing_issue" to R.color.superwall_customer_center_badge_red
+ PurchaseBadge.REVOKED -> "customer_center_badge_revoked" to R.color.superwall_customer_center_badge_red
+ PurchaseBadge.EXPIRED -> "customer_center_badge_expired" to R.color.superwall_customer_center_badge_gray
+ }
+ val color = ContextCompat.getColor(this, colorRes)
+ return TextView(this).apply {
+ text = strings.string(key)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
+ setTypeface(typeface, Typeface.BOLD)
+ setTextColor(color)
+ setPadding(dp(8), dp(3), dp(8), dp(3))
+ background = roundedBackground(Color.argb(38, Color.red(color), Color.green(color), Color.blue(color)), dp(12).toFloat())
+ }
+ }
+
+ private fun updateBanner(): View {
+ val column =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(16), dp(14), dp(16), dp(14))
+ addView(text(strings.string("customer_center_update_title"), 17f, bold = true))
+ addView(text(strings.string("customer_center_update_message"), 14f, secondary = true).apply { setPadding(0, dp(4), 0, dp(8)) })
+ }
+ val buttons = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
+ buttons.addView(
+ MaterialButton(this).apply {
+ text = strings.string("customer_center_update_action")
+ backgroundTintList = ColorStateList.valueOf(tint)
+ setOnClickListener { viewModel.openAppListing() }
+ },
+ )
+ buttons.addView(
+ MaterialButton(this, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
+ text = strings.string("customer_center_update_continue")
+ setTextColor(tint)
+ setOnClickListener { viewModel.continueAfterUpdateWarning() }
+ },
+ LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
+ marginStart = dp(8)
+ },
+ )
+ column.addView(buttons)
+ return card(listOf(column))
+ }
+
+ private fun duplicateBanner(): View {
+ val column =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(16), dp(14), dp(16), dp(14))
+ tag = "customer_center.duplicate_warning"
+ addView(text("⚠ " + strings.string("customer_center_duplicate_title"), 17f, bold = true))
+ addView(text(strings.string("customer_center_duplicate_message"), 14f, secondary = true).apply { setPadding(0, dp(4), 0, 0) })
+ }
+ return card(listOf(column))
+ }
+
+ private fun addAccountDetails() {
+ val userId = viewModel.userId
+ val userRow =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPaddingRelative(dp(16), dp(10), dp(8), dp(10))
+ }
+ val labels =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(text(strings.string("customer_center_user_id"), 12f, secondary = true))
+ addView(
+ text(userId, 13f).apply {
+ maxLines = 1
+ ellipsize = TextUtils.TruncateAt.MIDDLE
+ },
+ )
+ }
+ userRow.addView(labels, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ userRow.addView(
+ MaterialButton(this, null, com.google.android.material.R.attr.borderlessButtonStyle).apply {
+ text = strings.string(if (copiedUserId) "customer_center_copied" else "customer_center_copy")
+ setTextColor(tint)
+ setOnClickListener {
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText(strings.string("customer_center_user_id"), userId))
+ copiedUserId = true
+ text = strings.string("customer_center_copied")
+ }
+ },
+ )
+ val rows = mutableListOf(userRow)
+ viewModel.originalDownloadDate?.let { date ->
+ rows.add(
+ LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ setPadding(dp(16), dp(12), dp(16), dp(12))
+ addView(
+ text(strings.string("customer_center_original_download_date"), 13f),
+ LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f),
+ )
+ addView(text(DateFormat.getDateInstance(DateFormat.MEDIUM, viewModel.locale).format(date), 13f, secondary = true))
+ },
+ )
+ }
+ addSection(strings.string("customer_center_account_details"), rows)
+ }
+
+ // endregion
+
+ // region Sheets and alerts
+
+ private fun renderSheet(sheet: CustomerCenterSheet?) {
+ if (sheet == shownSheet) return
+ sheetDialog?.setOnDismissListener(null)
+ sheetDialog?.dismiss()
+ sheetDialog = null
+ shownSheet = sheet
+ sheetDialog =
+ when (sheet) {
+ null -> null
+ is CustomerCenterSheet.Survey -> surveyDialog()
+ is CustomerCenterSheet.NoMailApp -> messageDialog(strings.string("customer_center_no_mail_app", sheet.email))
+ CustomerCenterSheet.WebManageUnavailable -> messageDialog(strings.string("customer_center_web_manage_unavailable"))
+ }
+ sheetDialog?.setOnDismissListener {
+ shownSheet = null
+ sheetDialog = null
+ viewModel.sheetDismissed()
+ }
+ sheetDialog?.show()
+ (sheetDialog as? AlertDialog)?.tintButtons()
+ }
+
+ private fun surveyDialog(): android.app.Dialog? {
+ val (path, survey) = viewModel.pendingSurvey ?: return null
+ val dialog = BottomSheetDialog(this)
+ val column =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(0, dp(8), 0, dp(24))
+ }
+ val header =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPaddingRelative(dp(20), dp(8), dp(8), dp(8))
+ }
+ header.addView(
+ text(CustomerCenterPathTitles.surveyTitle(survey, path, strings), 18f, bold = true),
+ LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f),
+ )
+ header.addView(
+ MaterialButton(this, null, com.google.android.material.R.attr.borderlessButtonStyle).apply {
+ text = strings.string("customer_center_cancel")
+ setTextColor(tint)
+ setOnClickListener { dialog.dismiss() }
+ },
+ )
+ column.addView(header)
+ var answered = false
+ for (option in survey.options) {
+ column.addView(
+ text(CustomerCenterPathTitles.optionTitle(option, strings), 16f).apply {
+ setPadding(dp(20), dp(14), dp(20), dp(14))
+ background = selectableBackground()
+ tag = "customer_center.survey.option.${option.id}"
+ setOnClickListener {
+ if (answered) return@setOnClickListener
+ answered = true
+ dialog.setOnDismissListener(null)
+ dialog.dismiss()
+ shownSheet = null
+ sheetDialog = null
+ viewModel.onSurveyAnswered(option.id)
+ }
+ },
+ )
+ }
+ dialog.setContentView(column)
+ return dialog
+ }
+
+ private fun messageDialog(message: String): android.app.Dialog =
+ MaterialAlertDialogBuilder(this)
+ .setMessage(message)
+ .setPositiveButton(strings.string("customer_center_done"), null)
+ .create()
+
+ private fun renderRestoreResult(restoreState: CustomerCenterRestoreState) {
+ val isResult = restoreState == CustomerCenterRestoreState.RESTORED || restoreState == CustomerCenterRestoreState.NOT_FOUND
+ if (!isResult) {
+ restoreDialog?.setOnDismissListener(null)
+ restoreDialog?.dismiss()
+ restoreDialog = null
+ return
+ }
+ if (restoreDialog != null) return
+ val restored = restoreState == CustomerCenterRestoreState.RESTORED
+ val builder =
+ MaterialAlertDialogBuilder(this)
+ .setTitle(strings.string(if (restored) "customer_center_restore_success_title" else "customer_center_restore_none_title"))
+ .setMessage(strings.string(if (restored) "customer_center_restore_success_message" else "customer_center_restore_none_message"))
+ .setPositiveButton(strings.string("customer_center_done"), null)
+ if (!restored) {
+ if (lastState?.showsUpdateBanner == true) {
+ builder.setNeutralButton(strings.string("customer_center_update_action")) { _, _ -> viewModel.openAppListing() }
+ }
+ if (viewModel.supportMailtoUrl != null) {
+ builder.setNegativeButton(strings.string("customer_center_path_contact_support")) { _, _ -> viewModel.contactSupport() }
+ }
+ }
+ restoreDialog =
+ builder.create().apply {
+ setOnDismissListener {
+ restoreDialog = null
+ viewModel.restoreAlertDismissed()
+ }
+ show()
+ tintButtons()
+ }
+ }
+
+ // endregion
+
+ // region Navigation
+
+ private fun openDetail(purchase: PurchasePresentation) {
+ detailPurchaseId = purchase.id
+ scrollView.scrollTo(0, 0)
+ lastState?.let(::render)
+ }
+
+ private fun closeDetail() {
+ detailPurchaseId = null
+ lastState?.let(::render)
+ }
+
+ // endregion
+
+ // region View helpers
+
+ private fun addSection(
+ title: String?,
+ rows: List,
+ ) {
+ if (rows.isEmpty()) return
+ if (title != null) {
+ list.addView(
+ text(title.uppercase(viewModel.locale), 12f, secondary = true).apply {
+ setPadding(dp(16), dp(16), dp(16), dp(6))
+ },
+ )
+ }
+ list.addView(card(rows, topMargin = if (title == null) dp(12) else 0))
+ }
+
+ private fun card(
+ rows: List,
+ topMargin: Int = dp(12),
+ ): View {
+ val column = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
+ rows.forEachIndexed { index, row ->
+ if (index > 0) {
+ column.addView(
+ View(this).apply { setBackgroundColor(themeColor(com.google.android.material.R.attr.colorOutlineVariant)) },
+ LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1).apply { marginStart = dp(16) },
+ )
+ }
+ column.addView(row)
+ }
+ return MaterialCardView(this).apply {
+ radius = dp(12).toFloat()
+ cardElevation = 0f
+ strokeWidth = 0
+ setCardBackgroundColor(ContextCompat.getColor(context, R.color.superwall_customer_center_card))
+ addView(column)
+ layoutParams =
+ LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
+ this.topMargin = topMargin
+ }
+ }
+ }
+
+ private fun text(
+ value: String,
+ sizeSp: Float,
+ bold: Boolean = false,
+ secondary: Boolean = false,
+ ): TextView =
+ TextView(this).apply {
+ text = value
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, sizeSp)
+ if (bold) setTypeface(typeface, Typeface.BOLD)
+ setTextColor(themeColor(if (secondary) android.R.attr.textColorSecondary else android.R.attr.textColorPrimary))
+ }
+
+ private fun placeholder(
+ width: Int,
+ height: Int,
+ ): View =
+ View(this).apply {
+ background = roundedBackground(themeColor(com.google.android.material.R.attr.colorOutlineVariant), dp(4).toFloat())
+ layoutParams = LinearLayout.LayoutParams(width, height)
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ val pulse =
+ ObjectAnimator.ofFloat(this, View.ALPHA, 1f, 0.4f).apply {
+ duration = 800
+ repeatMode = ValueAnimator.REVERSE
+ repeatCount = ValueAnimator.INFINITE
+ }
+ // Every render replaces the rows, so the pulse must stop with the view it animates.
+ addOnAttachStateChangeListener(
+ object : View.OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(view: View) = pulse.start()
+
+ override fun onViewDetachedFromWindow(view: View) = pulse.cancel()
+ },
+ )
+ }
+
+ private fun roundedBackground(
+ color: Int,
+ radius: Float,
+ ) = GradientDrawable().apply {
+ setColor(color)
+ cornerRadius = radius
+ }
+
+ private fun selectableBackground() =
+ TypedValue().let {
+ theme.resolveAttribute(android.R.attr.selectableItemBackground, it, true)
+ ContextCompat.getDrawable(this, it.resourceId)
+ }
+
+ private fun themeColor(attr: Int): Int {
+ val value = TypedValue()
+ theme.resolveAttribute(attr, value, true)
+ return if (value.resourceId != 0) ContextCompat.getColor(this, value.resourceId) else value.data
+ }
+
+ /** The configured accent for the current light/dark mode, or `null` when none is configured. */
+ private fun configuredAccent(): Int? {
+ val pair = viewModel.configuration.appearance.accent ?: return null
+ return CustomerCenterColors.parseHex(if (isDark()) pair.dark else pair.light)
+ }
+
+ private fun isDark(): Boolean =
+ (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
+
+ private fun tintedProgressBar(): ProgressBar =
+ ProgressBar(this).apply { indeterminateTintList = ColorStateList.valueOf(tint) }
+
+ /** Dialog buttons take the theme's colour otherwise, which isn't the app's. Call after `show()`. */
+ private fun AlertDialog.tintButtons() {
+ listOf(AlertDialog.BUTTON_POSITIVE, AlertDialog.BUTTON_NEGATIVE, AlertDialog.BUTTON_NEUTRAL)
+ .forEach { getButton(it)?.setTextColor(tint) }
+ }
+
+ private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
+
+ // endregion
+
+ internal companion object {
+ private const val STATE_DETAIL_PURCHASE_ID = "superwall_customer_center_detail_purchase_id"
+
+ /** Where the activity finds its presentation. Replaceable so tests can host one. */
+ internal var sessionHost: () -> CustomerCenterSessionHost? = {
+ if (Superwall.initialized) Superwall.instance.dependencyContainer.customerCenterManager else null
+ }
+ }
+}
+
+/** What rows and surveys say. Separate from the activity so the rules can be tested directly. */
+internal object CustomerCenterPathTitles {
+ fun title(
+ resolved: ResolvedPath,
+ strings: CustomerCenterStrings,
+ ): String {
+ val path = resolved.path
+ path.title?.let { return it }
+ return when (val type = path.type) {
+ CustomerCenterConfiguration.PathType.Restore -> strings.string("customer_center_path_restore")
+ // "Cancel subscription" is right for Google Play, where the row carries the
+ // cancellation survey and opens Play's subscription page. A web management page does
+ // more than cancel, so naming it that way there undersells it.
+ CustomerCenterConfiguration.PathType.ManageSubscription ->
+ if (resolved.destination.isWebManagement) {
+ strings.string("customer_center_path_manage_subscription_web")
+ } else {
+ strings.string("customer_center_path_manage_subscription")
+ }
+ is CustomerCenterConfiguration.PathType.Refund -> strings.string("customer_center_path_refund")
+ is CustomerCenterConfiguration.PathType.ChangePlan -> strings.string("customer_center_path_change_plan")
+ CustomerCenterConfiguration.PathType.ContactSupport -> strings.string("customer_center_path_contact_support")
+ is CustomerCenterConfiguration.PathType.Url -> CustomerCenterUrls.host(type.url) ?: type.url
+ is CustomerCenterConfiguration.PathType.Custom -> type.identifier
+ }
+ }
+
+ /**
+ * The survey's own title, else the cancellation question on the path that cancels. Any other
+ * path gets no title rather than asking the customer why they're cancelling.
+ */
+ fun surveyTitle(
+ survey: CustomerCenterConfiguration.FeedbackSurvey,
+ path: CustomerCenterConfiguration.Path,
+ strings: CustomerCenterStrings,
+ ): String =
+ survey.title
+ ?: if (path.type == CustomerCenterConfiguration.PathType.ManageSubscription) {
+ strings.string("customer_center_survey_cancel_title")
+ } else {
+ ""
+ }
+
+ fun optionTitle(
+ option: CustomerCenterConfiguration.FeedbackSurvey.Option,
+ strings: CustomerCenterStrings,
+ ): String =
+ option.title ?: when (option.id) {
+ "too_expensive" -> strings.string("customer_center_survey_too_expensive")
+ "dont_use" -> strings.string("customer_center_survey_dont_use")
+ "bought_by_mistake" -> strings.string("customer_center_survey_bought_by_mistake")
+ else -> option.id
+ }
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/customercenter/CustomerCenterConfiguration.kt b/superwall/src/main/java/com/superwall/sdk/customercenter/CustomerCenterConfiguration.kt
new file mode 100644
index 000000000..3d74d8214
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/customercenter/CustomerCenterConfiguration.kt
@@ -0,0 +1,449 @@
+package com.superwall.sdk.customercenter
+
+import com.superwall.sdk.logger.LogLevel
+import com.superwall.sdk.logger.LogScope
+import com.superwall.sdk.logger.Logger
+import java.net.URI
+
+/**
+ * Configures the screens, actions, support options and appearance of the Customer Center.
+ *
+ * Set the default via [com.superwall.sdk.config.options.SuperwallOptions.customerCenter] before
+ * calling `configure`, or pass one to
+ * [com.superwall.sdk.Superwall.presentCustomerCenter].
+ */
+data class CustomerCenterConfiguration
+ @JvmOverloads
+ constructor(
+ /** The screen shown when the user has at least one subscription (active or expired) or purchase. */
+ val managementScreen: Screen,
+ /** The screen shown when the user has no purchases at all. */
+ val noPurchasesScreen: Screen,
+ /** Support-related settings (email, app update warning, web management URL). */
+ val support: Support = Support(),
+ /** Optional color overrides. `null` values use the default theme colors. */
+ val appearance: Appearance = Appearance(),
+ /** Shows the account details section (user ID, original download date). Defaults to `true`. */
+ val showsAccountDetails: Boolean = true,
+ /** Warns when both a Google Play and a web subscription are active. Defaults to `true`. */
+ val warnsAboutDuplicateSubscriptions: Boolean = true,
+ ) {
+ /** A Customer Center screen: a title, optional subtitle and an ordered list of paths. */
+ data class Screen
+ @JvmOverloads
+ constructor(
+ /** Title. `null` uses the localized default for the screen. */
+ val title: String? = null,
+ /** Subtitle. `null` uses the localized default (no-purchases screen) or none (management screen). */
+ val subtitle: String? = null,
+ /** Ordered paths (actions) shown on the screen. */
+ val paths: List,
+ )
+
+ /** An action row in the Customer Center. */
+ data class Path
+ @JvmOverloads
+ constructor(
+ /** What the path does. */
+ val type: PathType,
+ /** Row title. `null` uses the localized default for [type]. */
+ val title: String? = null,
+ /** Optional survey shown before the action runs. */
+ val survey: FeedbackSurvey? = null,
+ /**
+ * Stable identifier, reported as `path_id` on Customer Center events. Defaults to
+ * [PathType.defaultId]. Only needed to tell apart two paths that would otherwise
+ * share one, such as two paths of the same built-in type.
+ */
+ val id: String = type.defaultId,
+ ) {
+ /**
+ * Lets a screen list its paths as `Path.restore()`, `Path.refund(window = 86_400_000)`,
+ * `Path.url("https://…", title = "FAQ")` and so on, rather than spelling out
+ * `Path(type = …)` each time.
+ */
+ companion object {
+ @JvmStatic
+ @JvmOverloads
+ fun restore(
+ id: String? = null,
+ title: String? = null,
+ survey: FeedbackSurvey? = null,
+ ): Path = make(PathType.Restore, id, title, survey)
+
+ @JvmStatic
+ @JvmOverloads
+ fun manageSubscription(
+ id: String? = null,
+ title: String? = null,
+ survey: FeedbackSurvey? = null,
+ ): Path = make(PathType.ManageSubscription, id, title, survey)
+
+ /** @param windowMillis Milliseconds since purchase during which a refund may be requested. */
+ @JvmStatic
+ @JvmOverloads
+ fun refund(
+ windowMillis: Long? = null,
+ id: String? = null,
+ title: String? = null,
+ survey: FeedbackSurvey? = null,
+ ): Path = make(PathType.Refund(windowMillis), id, title, survey)
+
+ /** @param productIds The subset of plans to offer. `null` offers every plan. */
+ @JvmStatic
+ @JvmOverloads
+ fun changePlan(
+ productIds: List? = null,
+ id: String? = null,
+ title: String? = null,
+ survey: FeedbackSurvey? = null,
+ ): Path = make(PathType.ChangePlan(productIds), id, title, survey)
+
+ @JvmStatic
+ @JvmOverloads
+ fun contactSupport(
+ id: String? = null,
+ title: String? = null,
+ survey: FeedbackSurvey? = null,
+ ): Path = make(PathType.ContactSupport, id, title, survey)
+
+ /**
+ * @param title What the row says. Required: a URL has no name the SDK could give it.
+ * @param openMethod Opens in an in-app browser tab by default.
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun url(
+ url: String,
+ title: String,
+ openMethod: OpenMethod = OpenMethod.IN_APP,
+ id: String? = null,
+ survey: FeedbackSurvey? = null,
+ ): Path = make(PathType.Url(url, openMethod), id, title, survey)
+
+ /** @param identifier Passed back in [CustomerCenterAction.Custom] when tapped. */
+ @JvmStatic
+ @JvmOverloads
+ fun custom(
+ identifier: String,
+ title: String? = null,
+ id: String? = null,
+ survey: FeedbackSurvey? = null,
+ ): Path = make(PathType.Custom(identifier), id, title, survey)
+
+ private fun make(
+ type: PathType,
+ id: String?,
+ title: String?,
+ survey: FeedbackSurvey?,
+ ): Path = Path(type = type, title = title, survey = survey, id = id ?: type.defaultId)
+ }
+ }
+
+ /** The kinds of path the Customer Center supports. */
+ sealed class PathType {
+ object Restore : PathType() {
+ override fun toString() = "Restore"
+ }
+
+ object ManageSubscription : PathType() {
+ override fun toString() = "ManageSubscription"
+ }
+
+ /** @property windowMillis Optional milliseconds since purchase during which a refund may be requested. */
+ data class Refund(
+ val windowMillis: Long? = null,
+ ) : PathType()
+
+ /** @property productIds Optional subset of plans to offer. `null` offers every plan. */
+ data class ChangePlan(
+ val productIds: List? = null,
+ ) : PathType()
+
+ object ContactSupport : PathType() {
+ override fun toString() = "ContactSupport"
+ }
+
+ /**
+ * Opens a URL. Unlike the other types, a URL has no name the SDK can give it, so set the
+ * row's title on [Path.title] — the [Path.url] shorthand requires one. Without a title
+ * the row shows the URL's host, which is the same for every link to one site, so FAQ,
+ * terms and privacy would read identically.
+ */
+ data class Url(
+ val url: String,
+ val openMethod: OpenMethod = OpenMethod.IN_APP,
+ ) : PathType()
+
+ data class Custom(
+ val identifier: String,
+ ) : PathType()
+
+ /**
+ * The ID a path gets when none is given: the type's name for the built-in types, the
+ * URL's host and path for a URL path, and the identifier for a custom path.
+ *
+ * A URL path leaves out the query and fragment because the ID is reported in analytics,
+ * and a query can carry a token.
+ */
+ val defaultId: String
+ get() =
+ when (this) {
+ Restore -> "restore"
+ ManageSubscription -> "manage_subscription"
+ is Refund -> "refund"
+ is ChangePlan -> "change_plan"
+ ContactSupport -> "contact_support"
+ is Url -> CustomerCenterUrls.hostAndPath(url)
+ is Custom -> identifier
+ }
+ }
+
+ /** How a URL path opens. */
+ enum class OpenMethod {
+ /** In an in-app browser tab (Custom Tabs), when the URL is `http` or `https`. */
+ IN_APP,
+
+ /** Handed to whichever app handles the URL. */
+ EXTERNAL,
+ }
+
+ /** A single-choice survey shown before a path's action runs. */
+ data class FeedbackSurvey
+ @JvmOverloads
+ constructor(
+ val id: String,
+ /**
+ * Question text. `null` uses the localized "Why are you cancelling?" on a
+ * manage-subscription path, and no title on any other path.
+ */
+ val title: String? = null,
+ val options: List