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 @@ -branches38.8% \ No newline at end of file +branches43.6% \ 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 @@ -coverage47.7% \ No newline at end of file +coverage53.6% \ 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