Skip to content

Commit 41f5a58

Browse files
authored
Ensure no existing parent is present before adding to container (#128)
1 parent 28c2f6d commit 41f5a58

File tree

8 files changed

+167
-23
lines changed

8 files changed

+167
-23
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 3.1.1 October 2, 2024
4+
5+
- Ensure that cached WebView instances don't have existing parents before trying to add them to their container.
6+
37
## 3.1.0 September 6, 2024
48

59
- Implement and expose `onShowFileChooser()`, to support clients with checkouts that need to show a native file chooser. Example in the MobileBuyIntegration demo app.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ your project:
2323
#### Gradle
2424

2525
```groovy
26-
implementation "com.shopify:checkout-sheet-kit:3.1.0"
26+
implementation "com.shopify:checkout-sheet-kit:3.1.1"
2727
```
2828

2929
#### Maven
@@ -33,7 +33,7 @@ implementation "com.shopify:checkout-sheet-kit:3.1.0"
3333
<dependency>
3434
<groupId>com.shopify</groupId>
3535
<artifactId>checkout-sheet-kit</artifactId>
36-
<version>3.1.0</version>
36+
<version>3.1.1</version>
3737
</dependency>
3838
```
3939

lib/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def resolveEnvVarValue(name, defaultValue) {
1414
return rawValue ? rawValue : defaultValue
1515
}
1616

17-
def versionName = resolveEnvVarValue("CHECKOUT_SHEET_KIT_VERSION", "3.1.0")
17+
def versionName = resolveEnvVarValue("CHECKOUT_SHEET_KIT_VERSION", "3.1.1")
1818

1919
ext {
2020
app_compat_version = '1.6.1'

lib/src/main/java/com/shopify/checkoutsheetkit/BaseWebView.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import android.os.Build
3030
import android.util.AttributeSet
3131
import android.view.KeyEvent
3232
import android.view.View
33+
import android.view.ViewGroup
3334
import android.view.ViewGroup.LayoutParams
3435
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
3536
import android.webkit.PermissionRequest
@@ -214,3 +215,15 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet
214215
private val CLIENT_ERROR = 400..499
215216
}
216217
}
218+
219+
/**
220+
* Removes the WebView from its parent if a parent exists
221+
*/
222+
internal fun BaseWebView.removeFromParent() {
223+
val parent = this.parent
224+
if (parent is ViewGroup) {
225+
// Ensure view is not destroyed when removing from parent
226+
CheckoutWebViewContainer.retainCacheEntry = RetainCacheEntry.YES
227+
parent.removeView(this)
228+
}
229+
}

lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutDialog.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ internal class CheckoutDialog(
8989

9090
addWebViewToContainer(colorScheme, checkoutWebView)
9191
setOnCancelListener {
92-
CheckoutWebViewContainer.retainCache = ShopifyCheckoutSheetKit.configuration.preloading.enabled
92+
CheckoutWebViewContainer.retainCacheEntry = RetainCacheEntry.IF_NOT_STALE
9393
checkoutEventProcessor.onCheckoutCanceled()
9494
}
9595

@@ -119,12 +119,13 @@ internal class CheckoutDialog(
119119

120120
private fun addWebViewToContainer(
121121
colorScheme: ColorScheme,
122-
checkoutWebView: WebView
122+
checkoutWebView: BaseWebView,
123123
) {
124124
findViewById<RelativeLayout>(R.id.checkoutSdkContainer).apply {
125125
setBackgroundColor(colorScheme.webViewBackgroundColor())
126126
val layoutParams = RelativeLayout.LayoutParams(WRAP_CONTENT, MATCH_PARENT)
127127
layoutParams.addRule(RelativeLayout.BELOW, R.id.progressBar)
128+
checkoutWebView.removeFromParent()
128129
addView(checkoutWebView, layoutParams)
129130
}
130131
}

lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutWebViewContainer.kt

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,31 @@ internal class CheckoutWebViewContainer @JvmOverloads constructor(
3838
// We should only clear the cache and destroy the WebView after it's been removed from it's parent
3939
override fun onViewRemoved(child: View?) {
4040
super.onViewRemoved(child)
41-
if (child is CheckoutWebView && (!retainCache || CheckoutWebView.cacheEntry?.isStale == true)) {
42-
CheckoutWebView.clearCache()
41+
if (child is CheckoutWebView) {
42+
if (retainCacheEntry == RetainCacheEntry.IF_NOT_STALE && CheckoutWebView.cacheEntry?.isStale == true) {
43+
CheckoutWebView.clearCache()
44+
}
4345
}
4446

4547
if (child is FallbackWebView) {
4648
child.destroy()
4749
}
4850

49-
retainCache = false
51+
retainCacheEntry = RetainCacheEntry.IF_NOT_STALE
5052
}
5153

5254
companion object {
53-
internal var retainCache = false
55+
internal var retainCacheEntry = RetainCacheEntry.IF_NOT_STALE
5456
}
5557
}
58+
59+
internal enum class RetainCacheEntry {
60+
/**
61+
* Retain a WebView in the cache if it is not stale
62+
*/
63+
IF_NOT_STALE,
64+
/**
65+
* Always retain the WebView in the cache, regardless of whether it is stale or not
66+
*/
67+
YES,
68+
}

lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewContainerTest.kt

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,39 +26,55 @@ import android.os.Looper
2626
import androidx.activity.ComponentActivity
2727
import org.assertj.core.api.Assertions.assertThat
2828
import org.junit.After
29+
import org.junit.Before
2930
import org.junit.Test
3031
import org.junit.runner.RunWith
32+
import org.mockito.kotlin.mock
33+
import org.mockito.kotlin.whenever
3134
import org.robolectric.Robolectric
3235
import org.robolectric.RobolectricTestRunner
3336
import org.robolectric.Shadows.shadowOf
37+
import kotlin.time.Duration.Companion.minutes
3438

3539
@RunWith(RobolectricTestRunner::class)
3640
class CheckoutWebViewContainerTest {
3741

42+
private val mockCacheClock = mock<CheckoutWebView.CheckoutWebViewCacheClock>()
43+
44+
@Before
45+
fun setUp() {
46+
CheckoutWebView.cacheClock = mockCacheClock
47+
}
48+
3849
@After
3950
fun tearDown() {
4051
CheckoutWebView.clearCache()
52+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
4153
}
4254

4355
@Test
44-
fun `should destroy FallbackWebView when it is removed`() {
56+
fun `should destroy FallbackWebView when it is removed in all cases`() {
4557
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->
46-
val activity = activityController.get()
58+
withPreloadingEnabled {
59+
val activity = activityController.get()
4760

48-
val container = CheckoutWebViewContainer(activity)
49-
val fallbackView = FallbackWebView(activity)
50-
val shadow = shadowOf(fallbackView)
61+
val container = CheckoutWebViewContainer(activity)
62+
val fallbackView = FallbackWebView(activity)
63+
val shadow = shadowOf(fallbackView)
5164

52-
container.addView(fallbackView)
53-
assertThat(shadow.wasDestroyCalled()).isFalse()
65+
container.addView(fallbackView)
66+
assertThat(shadow.wasDestroyCalled()).isFalse()
5467

55-
container.removeView(fallbackView)
56-
assertThat(shadow.wasDestroyCalled()).isTrue()
68+
container.removeView(fallbackView)
69+
70+
assertThat(shadow.wasDestroyCalled()).isTrue()
71+
}
5772
}
5873
}
5974

75+
// cache entries are essentially immediately stale if preloading is disabled
6076
@Test
61-
fun `should destroy CheckoutWebView when it is removed if preloading disabled`() {
77+
fun `should destroy CheckoutWebView when retainCacheEntry is IF_NOT_STALE and preloading is disabled`() {
6278
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->
6379
val activity = activityController.get()
6480

@@ -69,6 +85,7 @@ class CheckoutWebViewContainerTest {
6985
container.addView(checkoutWebView)
7086
assertThat(shadow.wasDestroyCalled()).isFalse()
7187

88+
CheckoutWebViewContainer.retainCacheEntry = RetainCacheEntry.IF_NOT_STALE
7289
container.removeView(checkoutWebView)
7390
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
7491

@@ -77,9 +94,11 @@ class CheckoutWebViewContainerTest {
7794
}
7895

7996
@Test
80-
fun `should destroy CheckoutWebView when it is removed if preloading and retain is false`() {
97+
fun `should destroy CheckoutWebView when retainCacheEntry is IF_NOT_STALE and entry is stale`() {
8198
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->
8299
withPreloadingEnabled {
100+
whenever(mockCacheClock.currentTimeMillis()).thenReturn(System.currentTimeMillis())
101+
83102
val activity = activityController.get()
84103

85104
val container = CheckoutWebViewContainer(activity)
@@ -89,6 +108,9 @@ class CheckoutWebViewContainerTest {
89108
container.addView(checkoutWebView)
90109
assertThat(shadow.wasDestroyCalled()).isFalse()
91110

111+
CheckoutWebViewContainer.retainCacheEntry = RetainCacheEntry.IF_NOT_STALE
112+
makeCacheEntryStale()
113+
92114
container.removeView(checkoutWebView)
93115
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
94116

@@ -98,9 +120,33 @@ class CheckoutWebViewContainerTest {
98120
}
99121

100122
@Test
101-
fun `should not destroy CheckoutWebView when it is removed if preloading and retain is true`() {
123+
fun `should not destroy non-stale CheckoutWebView when retainCacheEntry == IF_NOT_STALE and entry is not stale`() {
124+
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->
125+
withPreloadingEnabled {
126+
val activity = activityController.get()
127+
128+
val container = CheckoutWebViewContainer(activity)
129+
val checkoutWebView = CheckoutWebView.cacheableCheckoutView("https://shopify.dev", activity, true)
130+
val shadow = shadowOf(checkoutWebView)
131+
132+
container.addView(checkoutWebView)
133+
assertThat(shadow.wasDestroyCalled()).isFalse()
134+
135+
CheckoutWebViewContainer.retainCacheEntry = RetainCacheEntry.IF_NOT_STALE
136+
container.removeView(checkoutWebView)
137+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
138+
139+
assertThat(shadow.wasDestroyCalled()).isFalse()
140+
}
141+
}
142+
}
143+
144+
@Test
145+
fun `should not destroy CheckoutWebView when retainCacheEntry == YES and entry is stale`() {
102146
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->
103147
withPreloadingEnabled {
148+
whenever(mockCacheClock.currentTimeMillis()).thenReturn(System.currentTimeMillis())
149+
104150
val activity = activityController.get()
105151

106152
val container = CheckoutWebViewContainer(activity)
@@ -110,13 +156,44 @@ class CheckoutWebViewContainerTest {
110156
container.addView(checkoutWebView)
111157
assertThat(shadow.wasDestroyCalled()).isFalse()
112158

113-
CheckoutWebViewContainer.retainCache = true
159+
CheckoutWebViewContainer.retainCacheEntry = RetainCacheEntry.YES
160+
makeCacheEntryStale()
161+
114162
container.removeView(checkoutWebView)
115163
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
164+
assertThat(shadow.wasDestroyCalled()).isFalse()
165+
assertThat(CheckoutWebViewContainer.retainCacheEntry).isEqualTo(RetainCacheEntry.IF_NOT_STALE)
166+
}
167+
}
168+
}
169+
170+
@Test
171+
fun `should not destroy CheckoutWebView when retainCacheEntry == YES and entry is not stale`() {
172+
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->
173+
withPreloadingEnabled {
174+
whenever(mockCacheClock.currentTimeMillis()).thenReturn(System.currentTimeMillis())
175+
176+
val activity = activityController.get()
177+
178+
val container = CheckoutWebViewContainer(activity)
179+
val checkoutWebView = CheckoutWebView.cacheableCheckoutView("https://shopify.dev", activity, true)
180+
val shadow = shadowOf(checkoutWebView)
116181

182+
container.addView(checkoutWebView)
117183
assertThat(shadow.wasDestroyCalled()).isFalse()
118-
assertThat(CheckoutWebViewContainer.retainCache).isFalse()
184+
185+
CheckoutWebViewContainer.retainCacheEntry = RetainCacheEntry.YES
186+
187+
container.removeView(checkoutWebView)
188+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
189+
assertThat(shadow.wasDestroyCalled()).isFalse()
190+
assertThat(CheckoutWebViewContainer.retainCacheEntry).isEqualTo(RetainCacheEntry.IF_NOT_STALE)
119191
}
120192
}
121193
}
194+
195+
private fun makeCacheEntryStale() {
196+
val initialTime = mockCacheClock.currentTimeMillis()
197+
whenever(mockCacheClock.currentTimeMillis()).thenReturn(initialTime.plus(60 * 10 * 1000))
198+
}
122199
}

lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewTest.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,42 @@ class CheckoutWebViewTest {
280280
}
281281
}
282282

283+
@Test
284+
fun `removeFromParent() should remove parent if a parent exists but not destroy WebView`() {
285+
withPreloadingEnabled {
286+
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->
287+
val ctx = activityController.get()
288+
val webView = CheckoutWebView.cacheableCheckoutView("https://shopify.dev", ctx, true)
289+
val container = CheckoutWebViewContainer(ctx)
290+
container.addView(webView)
291+
assertThat(webView.parent).isNotNull()
292+
293+
webView.removeFromParent()
294+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
295+
296+
val shadow = shadowOf(webView)
297+
assertThat(shadow.wasDestroyCalled()).isFalse()
298+
assertThat(webView.parent).isNull()
299+
}
300+
}
301+
}
302+
303+
@Test
304+
fun `removeFromParent() should do nothing if no parent exists`() {
305+
withPreloadingEnabled {
306+
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->
307+
val ctx = activityController.get()
308+
val webView = CheckoutWebView.cacheableCheckoutView("https://shopify.dev", ctx, true)
309+
webView.removeFromParent()
310+
shadowOf(Looper.getMainLooper()).runToEndOfTasks()
311+
312+
val shadow = shadowOf(webView)
313+
assertThat(shadow.wasDestroyCalled()).isFalse()
314+
assertThat(webView.parent).isNull()
315+
}
316+
}
317+
}
318+
283319
companion object {
284320
private const val URL = "https://a.checkout.testurl"
285321
}

0 commit comments

Comments
 (0)