Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ internal class CheckoutBottomSheet(
private var dismissing = false
private var dismissFinalized = false

/**
* Invoked once when this sheet reaches its terminal dismissal state, before the dialog window
* is torn down. Lets the presenter release per-presentation resources on every dismissal path.
*/
internal var onDismissFinalized: (() -> Unit)? = null

/**
* Inflates, configures, and shows the bottom sheet around shared checkout content.
*
Expand Down Expand Up @@ -202,6 +208,8 @@ internal class CheckoutBottomSheet(
if (dismissFinalized) return

dismissFinalized = true
onDismissFinalized?.invoke()
onDismissFinalized = null
destroyPresentedCheckoutView()
findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)?.onDismissRequested = null
if (!isShowing) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ public object ShopifyCheckoutKit {

internal val log = LogWrapper()

/**
* The presentation currently tracked as on screen, so repeat [present] calls can be refused.
*/
private class LivePresentation(
private val sheet: CheckoutBottomSheet,
private val activity: ComponentActivity,
val handle: CheckoutHandle,
) {
fun isShowingFor(context: ComponentActivity): Boolean = activity === context && sheet.isShowing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wondering here if the activity === context `check is needed?

Could we still stack if presenting from a separate activity? Is that problematic?

fun isShowing(): Boolean = sheet.isShowing

or directly without the function

val alreadyPresented = livePresentation?.takeIf { it.sheet.isShowing }

Maybe with docs to say only one checkout can be presented per SDK process


fun tracks(other: CheckoutBottomSheet): Boolean = sheet === other
}

private var livePresentation: LivePresentation? = null

/**
* Returns the current version of ShopifyCheckoutKit.

Expand Down Expand Up @@ -203,6 +218,31 @@ public object ShopifyCheckoutKit {
return null
}

val alreadyPresented = livePresentation?.takeIf { it.isShowingFor(context) }
if (alreadyPresented != null) {
log.w("ShopifyCheckoutKit", "A checkout is already presented, ignoring this presentation.")
}
return alreadyPresented?.handle ?: startPresentation(
checkoutUrl = checkoutUrl,
context = context,
checkoutListener = checkoutListener,
protocolClient = protocolClient,
webMessageTransport = webMessageTransport,
)
}

/**
* Builds, starts, and tracks a new bottom-sheet presentation.
*
* Called only once the activity is usable and no checkout is already on screen for it.
*/
private fun <T : DefaultCheckoutListener> startPresentation(
checkoutUrl: String,
context: ComponentActivity,
checkoutListener: T,
protocolClient: CheckoutProtocol.Client?,
webMessageTransport: WebMessageTransport,
): CheckoutHandle? {
log.d("ShopifyCheckoutKit", "Constructing bottom sheet")
val checkout = CheckoutBottomSheet(
checkoutUrl = checkoutUrl,
Expand All @@ -224,8 +264,18 @@ public object ShopifyCheckoutKit {
val checkoutStarted = checkout.start()
if (!checkoutStarted) {
context.lifecycle.removeObserver(lifecycleObserver)
return null
}

val handle = CheckoutHandle { checkout.dismiss() }
livePresentation = LivePresentation(sheet = checkout, activity = context, handle = handle)
checkout.onDismissFinalized = {
context.lifecycle.removeObserver(lifecycleObserver)
if (livePresentation?.tracks(checkout) == true) {
livePresentation = null
}
}
return if (checkoutStarted) CheckoutHandle { checkout.dismiss() } else null
return handle
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.shopify.checkoutkit
import android.widget.RelativeLayout
import androidx.activity.ComponentActivity
import androidx.core.view.children
import androidx.lifecycle.LifecycleRegistry
import org.assertj.core.api.Assertions.assertThat
import org.junit.After
import org.junit.Before
Expand All @@ -16,6 +17,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowDialog
import org.robolectric.shadows.ShadowLooper
import java.util.concurrent.TimeUnit

@RunWith(RobolectricTestRunner::class)
class ShopifyCheckoutKitTest {
Expand Down Expand Up @@ -84,6 +86,77 @@ class ShopifyCheckoutKitTest {
}
}

@Test
fun `present ignores a second call while a checkout is showing`() {
Robolectric.buildActivity(ComponentActivity::class.java).setup().use { activityController ->
val activity = activityController.get()

val first = presentCheckout(activity)
val second = presentCheckout(activity)

assertThat(first).isNotNull
assertThat(ShadowDialog.getShownDialogs()).hasSize(1)
assertThat(second).isSameAs(first)
}
}

@Test
fun `present shows a new checkout after the previous one is dismissed`() {
Robolectric.buildActivity(ComponentActivity::class.java).setup().use { activityController ->
val activity = activityController.get()
val first = presentCheckout(activity)
layoutLatestSheet()

first?.dismiss()
ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS)
val second = presentCheckout(activity)

assertThat(second).isNotNull
assertThat(second).isNotSameAs(first)
assertThat(ShadowDialog.getShownDialogs()).hasSize(2)
}
}

@Test
fun `present releases its lifecycle observer once checkout is dismissed`() {
Robolectric.buildActivity(ComponentActivity::class.java).setup().use { activityController ->
val activity = activityController.get()
val registry = activity.lifecycle as LifecycleRegistry
val observerCountBeforePresent = registry.observerCount

val checkout = presentCheckout(activity)
layoutLatestSheet()
assertThat(registry.observerCount).isEqualTo(observerCountBeforePresent + 1)

checkout?.dismiss()
ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS)

assertThat(registry.observerCount).isEqualTo(observerCountBeforePresent)
}
}

@Test
fun `releasing the lifecycle observer keeps the preloaded checkout view reusable`() {
Robolectric.buildActivity(ComponentActivity::class.java).setup().use { activityController ->
val activity = activityController.get()
val registry = activity.lifecycle as LifecycleRegistry
preload(PRELOAD_URL, activity)
ShadowLooper.shadowMainLooper().runToEndOfTasks()
val cachedView = CheckoutWebView.cachedPreloadViewForTesting()!!
val observerCountBeforePresent = registry.observerCount

val first = presentCheckout(activity, PRELOAD_URL)
layoutLatestSheet()
first?.dismiss()
ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS)
presentCheckout(activity, PRELOAD_URL)

assertThat(shadowOf(cachedView).wasDestroyCalled()).isFalse()
assertThat(latestSheetCheckoutWebView()).isSameAs(cachedView)
assertThat(registry.observerCount).isEqualTo(observerCountBeforePresent + 1)
}
}

@Test
fun `activity destroy dismisses checkout without waiting for sheet animation`() {
Robolectric.buildActivity(ComponentActivity::class.java).setup().use { activityController ->
Expand Down Expand Up @@ -202,7 +275,32 @@ class ShopifyCheckoutKitTest {
ShopifyCheckoutKit.preload(url, activity, webMessageTransport)
}

private fun presentCheckout(
activity: ComponentActivity,
url: String = "https://shopify.dev",
): CheckoutHandle? {
return ShopifyCheckoutKit.present(
url,
activity,
noopDefaultCheckoutListener(),
webMessageTransport = webMessageTransport,
)
}

private fun layoutLatestSheet() {
val sheet = ShadowDialog.getLatestDialog() as CheckoutBottomSheet
sheet.findViewById<CheckoutBottomSheetLayout>(R.id.checkoutKitSheet)!!
.layout(0, 0, TEST_SHEET_SIZE, TEST_SHEET_SIZE)
}

private fun latestSheetCheckoutWebView(): CheckoutWebView {
val sheet = ShadowDialog.getLatestDialog() as CheckoutBottomSheet
return sheet.findViewById<RelativeLayout>(R.id.checkoutKitContainer)!!
.children.first { it is CheckoutWebView } as CheckoutWebView
}

private companion object {
private const val TEST_SHEET_SIZE = 1000
private const val PRELOAD_URL = "https://shopify.dev/cart/123"
}
}
Loading