From b3590f08189e9772a003690f81a86c82f7408e7a Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 17 Aug 2026 10:04:34 +0100 Subject: [PATCH] Migrate remaining Java sources to Kotlin --- ...kage-info.java => PackageDocumentation.kt} | 2 +- .../com/shopify/checkoutkit/InteropTest.java | 396 ------- .../com/shopify/checkoutkit/InteropTest.kt | 266 +++++ .../checkoutkit/CustomCheckoutListener.java | 149 --- .../checkoutkit/CustomCheckoutListener.kt | 107 ++ .../checkoutkit/DispatchEventTypes.java | 25 - .../checkoutkit/DispatchEventTypes.kt | 18 + .../checkoutkit/DispatchHandle.java | 34 - .../reactnative/checkoutkit/DispatchHandle.kt | 29 + .../checkoutkit/ShopifyCheckoutKitModule.java | 519 ---------- .../checkoutkit/ShopifyCheckoutKitModule.kt | 352 +++++++ .../ShopifyCheckoutKitPackage.java | 52 - .../checkoutkit/ShopifyCheckoutKitPackage.kt | 28 + .../ShopifyCheckoutKitModuleTest.java | 975 ------------------ .../ShopifyCheckoutKitModuleTest.kt | 424 ++++++++ 15 files changed, 1225 insertions(+), 2151 deletions(-) rename platforms/android/lib/src/main/java/com/shopify/checkoutkit/{package-info.java => PackageDocumentation.kt} (72%) delete mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.kt delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.kt delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.kt delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.kt delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.kt delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.java create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.kt delete mode 100644 platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java create mode 100644 platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.kt diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/package-info.java b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PackageDocumentation.kt similarity index 72% rename from platforms/android/lib/src/main/java/com/shopify/checkoutkit/package-info.java rename to platforms/android/lib/src/main/java/com/shopify/checkoutkit/PackageDocumentation.kt index 93089802b..44b693f89 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/package-info.java +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/PackageDocumentation.kt @@ -1,4 +1,4 @@ /** * Package containing all of the classes that comprise Shopify Checkout Kit. */ -package com.shopify.checkoutkit; +package com.shopify.checkoutkit diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java deleted file mode 100644 index 24ea0e6bf..000000000 --- a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.java +++ /dev/null @@ -1,396 +0,0 @@ -package com.shopify.checkoutkit; - -import static org.assertj.core.api.Assertions.assertThat; - -import androidx.activity.ComponentActivity; -import androidx.annotation.NonNull; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.android.controller.ActivityController; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -import kotlin.Unit; - -@RunWith(RobolectricTestRunner.class) -public class InteropTest { - private Configuration initialConfiguration = null; - - @Before - public void setUp() { - initialConfiguration = ShopifyCheckoutKit.getConfiguration(); - } - - @After - public void tearDown() { - ShopifyCheckoutKit.configure(config -> { - config.setAppearance(initialConfiguration.getAppearance()); - config.setSheet(initialConfiguration.getSheet()); - config.setPreloading(initialConfiguration.getPreloading()); - config.setPlatform(initialConfiguration.getPlatform()); - config.setLogLevel(initialConfiguration.getLogLevel()); - config.setTitle(initialConfiguration.getTitle()); - }); - } - - @Test - public void canInstantiateCustomListener() { - DefaultCheckoutListener listener = new DefaultCheckoutListener() { - @Override - public void onCheckoutFailed(@NonNull CheckoutException error) { - - } - - @Override - public void onCheckoutDismissed() { - - } - }; - - assertThat(listener).isNotNull(); - } - - @Test - public void canConstructCheckoutExceptionWithJavaOverloads() { - CheckoutException withoutOptionalFields = new CheckoutException( - CheckoutErrorCode.UNKNOWN, - "Unknown checkout failure" - ); - CheckoutException withHttpStatusCode = new CheckoutException( - CheckoutErrorCode.HTTP_ERROR, - "Checkout request failed", - 500 - ); - - assertThat(withoutOptionalFields.getCode()).isEqualTo(CheckoutErrorCode.UNKNOWN); - assertThat(withoutOptionalFields.getHttpStatusCode()).isNull(); - assertThat(withHttpStatusCode.getHttpStatusCode()).isEqualTo(500); - } - - @Test - public void canConfigureCheckoutKit() { - ShopifyCheckoutKit.configure(configuration -> { - configuration.setAppearance(new CheckoutAppearance.App(new ColorScheme.Dark())); - }); - - Configuration configuration = ShopifyCheckoutKit.getConfiguration(); - - assertThat(configuration.getAppearance()).isEqualTo(new CheckoutAppearance.App(new ColorScheme.Dark())); - } - - @Test - public void canConfigureStorefrontAppearance() { - ShopifyCheckoutKit.configure(configuration -> { - configuration.setAppearance(new CheckoutAppearance.Storefront()); - }); - - Configuration configuration = ShopifyCheckoutKit.getConfiguration(); - - assertThat(configuration.getAppearance()).isEqualTo(new CheckoutAppearance.Storefront()); - } - - @Test - public void canCustomizeStorefrontAppearance() { - Color headerBackground = new Color.SRGB(0xFF008060); - - CheckoutAppearance.Storefront appearance = new CheckoutAppearance.Storefront().customize( - builder -> builder.setHeaderBackground(headerBackground) - ); - - assertThat(appearance).isNotEqualTo(new CheckoutAppearance.Storefront()); - } - - @Test - public void canConfigureTitle() { - ShopifyCheckoutKit.configure(configuration -> { - configuration.setTitle("Java Title"); - }); - - Configuration configuration = ShopifyCheckoutKit.getConfiguration(); - - assertThat(configuration.getTitle()).isEqualTo("Java Title"); - } - - @Test - public void canConfigurePreloading() { - ShopifyCheckoutKit.configure(configuration -> { - configuration.setPreloading(new Preloading(false)); - }); - - Configuration configuration = ShopifyCheckoutKit.getConfiguration(); - - assertThat(configuration.getPreloading().getEnabled()).isFalse(); - } - - @Test - public void canConfigureSheet() { - CheckoutSheetOptions sheet = new CheckoutSheetOptions( - 12f, - CheckoutSheetTitleAlignment.START, - 4f, - null, - new Color.ResourceId(android.R.color.holo_red_dark), - new Color.SRGB(0x52000000), - new CheckoutSheetDismissal(false, false), - new CheckoutSheetDragHandle(true), - Collections.singletonList(new CheckoutSheetSnapPoint.Expanded(12f)), - 480f - ); - - ShopifyCheckoutKit.configure(configuration -> configuration.setSheet(sheet)); - - Configuration configuration = ShopifyCheckoutKit.getConfiguration(); - - assertThat(configuration.getSheet()).isEqualTo(sheet); - } - - @Test - public void canConfigureSheetDismissal() { - CheckoutSheetDismissal dismissal = new CheckoutSheetDismissal(false, false); - - ShopifyCheckoutKit.configure(configuration -> configuration.setSheet( - new CheckoutSheetOptions( - 32f, - CheckoutSheetTitleAlignment.CENTER, - 0f, - null, - null, - new Color.SRGB(0x52000000), - dismissal, - new CheckoutSheetDragHandle(), - Collections.singletonList(CheckoutSheetSnapPoint.MaterialExpanded.INSTANCE) - ) - )); - - Configuration configuration = ShopifyCheckoutKit.getConfiguration(); - - assertThat(configuration.getSheet().getDismissal()).isEqualTo(dismissal); - } - - @Test - public void canPreloadAndInvalidate() { - try (ActivityController controller = Robolectric.buildActivity(ComponentActivity.class)) { - ComponentActivity activity = controller.get(); - - ShopifyCheckoutKit.preload("https://shopify.dev", activity); - ShopifyCheckoutKit.invalidate(); - } - } - - @Test - public void canPreloadWithListenerFromJava() { - try (ActivityController controller = Robolectric.buildActivity(ComponentActivity.class)) { - ComponentActivity activity = controller.get(); - - try (ActivityController finishingController = Robolectric.buildActivity(ComponentActivity.class)) { - ComponentActivity finishingActivity = finishingController.get(); - finishingActivity.finish(); - - CheckoutPreload skippedPreload = ShopifyCheckoutKit.preload( - "https://shopify.dev", - finishingActivity, - ignored -> {} - ); - - assertThat(skippedPreload).isNull(); - } - - List states = new ArrayList<>(); - CheckoutPreload preload = CheckoutWebView.Companion.preload( - "https://shopify.dev", - activity, - new FakeWebMessageTransport(), - states::add - ); - - assertThat(preload).isNotNull(); - preload.setListener(states::add); - assertThat(preload.getState()).isNotNull(); - assertThat(states).containsExactly(PreloadState.Loading.INSTANCE, PreloadState.Loading.INSTANCE); - ShopifyCheckoutKit.invalidate(); - } - } - - @Test - public void canCallPresentFromJava() { - try (ActivityController controller = Robolectric.buildActivity(ComponentActivity.class)) { - ComponentActivity activity = controller.get(); - // Avoid constructing a WebView; fake-transport Kotlin tests cover presentation behavior. - activity.finish(); - - CheckoutHandle checkout = ShopifyCheckoutKit.present( - "https://shopify.dev", - activity, - new DefaultCheckoutListener() { - @Override - public void onCheckoutFailed(@NonNull CheckoutException error) { - // do nothing - } - - @Override - public void onCheckoutDismissed() { - // do nothing - } - } - ); - - assertThat(checkout).isNull(); - } - } - - @Test - public void canDismissCheckoutHandleFromJava() { - AtomicBoolean dismissed = new AtomicBoolean(); - CheckoutHandle checkout = () -> dismissed.set(true); - - checkout.dismiss(); - - assertThat(dismissed).isTrue(); - } - - @Test - public void canCreateAndDestroyShopifyCheckoutFromJava() { - try (ActivityController controller = Robolectric.buildActivity(ComponentActivity.class)) { - ComponentActivity activity = controller.get(); - DefaultCheckoutListener listener = new DefaultCheckoutListener() { - @Override - public void onCheckoutFailed(@NonNull CheckoutException error) { - // do nothing - } - - @Override - public void onCheckoutDismissed() { - // do nothing - } - }; - - ShopifyCheckout checkout = new ShopifyCheckout(activity, "https://shopify.dev", listener); - checkout.destroy(); - - assertThat(checkout).isNotNull(); - } - } - - @Test - public void canCustomizeColorSchemeWithSingleBlock() { - ColorScheme.Light lightScheme = new ColorScheme.Light(); - Color tintColor = new Color.ResourceId(android.R.color.holo_red_dark); - - ColorScheme customized = lightScheme.customize(builder -> { - builder.setCloseIconTint(tintColor); - return Unit.INSTANCE; - }); - - assertThat(customized).isInstanceOf(ColorScheme.Light.class); - ColorScheme.Light customizedLight = (ColorScheme.Light) customized; - assertThat(customizedLight.getColors().getCloseIconTint()).isEqualTo(tintColor); - } - - @Test - public void canCustomizeColorSchemeWithLightAndDarkBlocks() { - ColorScheme.Automatic autoScheme = new ColorScheme.Automatic(); - Color lightTint = new Color.ResourceId(android.R.color.holo_orange_light); - Color darkTint = new Color.ResourceId(android.R.color.holo_blue_dark); - Color lightHandle = new Color.ResourceId(android.R.color.holo_green_light); - Color darkHandle = new Color.ResourceId(android.R.color.holo_green_dark); - DrawableResource customIcon = new DrawableResource(android.R.drawable.ic_menu_close_clear_cancel); - - ColorScheme customized = autoScheme.customize( - lightBuilder -> { - lightBuilder.setCloseIconTint(lightTint); - lightBuilder.setDragHandleColor(lightHandle); - return Unit.INSTANCE; - }, - darkBuilder -> { - darkBuilder.setCloseIcon(customIcon); - darkBuilder.setCloseIconTint(darkTint); - darkBuilder.setDragHandleColor(darkHandle); - return Unit.INSTANCE; - } - ); - - assertThat(customized).isInstanceOf(ColorScheme.Automatic.class); - ColorScheme.Automatic customizedAuto = (ColorScheme.Automatic) customized; - - assertThat(customizedAuto.getLightColors().getCloseIconTint()).isEqualTo(lightTint); - assertThat(customizedAuto.getLightColors().getCloseIcon()).isNull(); - - assertThat(customizedAuto.getDarkColors().getCloseIconTint()).isEqualTo(darkTint); - assertThat(customizedAuto.getDarkColors().getCloseIcon()).isEqualTo(customIcon); - assertThat(customizedAuto.getLightColors().getDragHandleColor()).isEqualTo(lightHandle); - assertThat(customizedAuto.getDarkColors().getDragHandleColor()).isEqualTo(darkHandle); - } - - @Test - public void canUseColorsBuilderDirectPropertyAssignment() { - ColorScheme.Dark darkScheme = new ColorScheme.Dark(); - Color headerColor = new Color.SRGB(0xFF123456); - Color tintColor = new Color.SRGB(0xFFABCDEF); - - ColorScheme customized = darkScheme.customize(builder -> { - builder.setHeaderBackground(headerColor); - builder.setCloseIconTint(tintColor); - return Unit.INSTANCE; - }); - - assertThat(customized).isInstanceOf(ColorScheme.Dark.class); - ColorScheme.Dark customizedDark = (ColorScheme.Dark) customized; - assertThat(customizedDark.getColors().getHeaderBackground()).isEqualTo(headerColor); - assertThat(customizedDark.getColors().getCloseIconTint()).isEqualTo(tintColor); - } - - @Test - public void canChainColorsBuilderMethods() { - ColorScheme.Light lightScheme = new ColorScheme.Light(); - Color webViewBg = new Color.ResourceId(android.R.color.white); - Color progressColor = new Color.ResourceId(android.R.color.holo_green_dark); - Color dragHandle = new Color.ResourceId(android.R.color.holo_blue_light); - Color headerBorder = new Color.ResourceId(android.R.color.darker_gray); - DrawableResource icon = new DrawableResource(android.R.drawable.ic_menu_close_clear_cancel); - - ColorScheme customized = lightScheme.customize(builder -> { - builder - .withWebViewBackground(webViewBg) - .withProgressIndicator(progressColor) - .withDragHandleColor(dragHandle) - .withHeaderBorderColor(headerBorder) - .withCloseIcon(icon); - return Unit.INSTANCE; - }); - - assertThat(customized).isInstanceOf(ColorScheme.Light.class); - ColorScheme.Light customizedLight = (ColorScheme.Light) customized; - Colors colors = customizedLight.getColors(); - - assertThat(colors.getWebViewBackground()).isEqualTo(webViewBg); - assertThat(colors.getProgressIndicator()).isEqualTo(progressColor); - assertThat(colors.getDragHandleColor()).isEqualTo(dragHandle); - assertThat(colors.getHeaderBorderColor()).isEqualTo(headerBorder); - assertThat(colors.getCloseIcon()).isEqualTo(icon); - } - - @Test - public void customizeLeavesDragHandleColorUnsetWhenOnlyHeaderFontChanges() { - ColorScheme.Light lightScheme = new ColorScheme.Light(); - Color headerFont = new Color.SRGB(0xFF336699); - - ColorScheme customized = lightScheme.customize(builder -> { - builder.setHeaderFont(headerFont); - return Unit.INSTANCE; - }); - - assertThat(customized).isInstanceOf(ColorScheme.Light.class); - Colors colors = ((ColorScheme.Light) customized).getColors(); - - assertThat(colors.getHeaderFont()).isEqualTo(headerFont); - assertThat(colors.getDragHandleColor()).isNull(); - } -} diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.kt new file mode 100644 index 000000000..3c0a8328f --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/InteropTest.kt @@ -0,0 +1,266 @@ +package com.shopify.checkoutkit + +import androidx.activity.ComponentActivity +import java.util.concurrent.atomic.AtomicBoolean +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class InteropTest { + private lateinit var initialConfiguration: Configuration + + @Before + fun setUp() { + initialConfiguration = ShopifyCheckoutKit.configuration + } + + @After + fun tearDown() { + ShopifyCheckoutKit.configure { config -> + config.appearance = initialConfiguration.appearance + config.sheet = initialConfiguration.sheet + config.preloading = initialConfiguration.preloading + config.platform = initialConfiguration.platform + config.logLevel = initialConfiguration.logLevel + config.title = initialConfiguration.title + } + } + + @Test + fun canInstantiateCustomListener() { + val listener = object : DefaultCheckoutListener() { + override fun onCheckoutFailed(error: CheckoutException) = Unit + override fun onCheckoutDismissed() = Unit + } + assertThat(listener).isNotNull + } + + @Test + fun canConstructCheckoutExceptionWithOptionalFields() { + val withoutOptionalFields = CheckoutException(CheckoutErrorCode.UNKNOWN, "Unknown checkout failure") + val withHttpStatusCode = CheckoutException(CheckoutErrorCode.HTTP_ERROR, "Checkout request failed", 500) + + assertThat(withoutOptionalFields.code).isEqualTo(CheckoutErrorCode.UNKNOWN) + assertThat(withoutOptionalFields.httpStatusCode).isNull() + assertThat(withHttpStatusCode.httpStatusCode).isEqualTo(500) + } + + @Test + fun canConfigureCheckoutKit() { + ShopifyCheckoutKit.configure { it.appearance = CheckoutAppearance.App(ColorScheme.Dark()) } + assertThat(ShopifyCheckoutKit.configuration.appearance) + .isEqualTo(CheckoutAppearance.App(ColorScheme.Dark())) + } + + @Test + fun canConfigureStorefrontAppearance() { + ShopifyCheckoutKit.configure { it.appearance = CheckoutAppearance.Storefront() } + assertThat(ShopifyCheckoutKit.configuration.appearance).isEqualTo(CheckoutAppearance.Storefront()) + } + + @Test + fun canCustomizeStorefrontAppearance() { + val appearance = CheckoutAppearance.Storefront().customize { + headerBackground = Color.SRGB(0xFF008060.toInt()) + } + assertThat(appearance).isNotEqualTo(CheckoutAppearance.Storefront()) + } + + @Test + fun canConfigureTitle() { + ShopifyCheckoutKit.configure { it.title = "Kotlin Title" } + assertThat(ShopifyCheckoutKit.configuration.title).isEqualTo("Kotlin Title") + } + + @Test + fun canConfigurePreloading() { + ShopifyCheckoutKit.configure { it.preloading = Preloading(false) } + assertThat(ShopifyCheckoutKit.configuration.preloading.enabled).isFalse() + } + + @Test + fun canConfigureSheet() { + val sheet = CheckoutSheetOptions( + cornerRadiusDp = 12f, + titleAlignment = CheckoutSheetTitleAlignment.START, + toolbarElevationDp = 4f, + closeIconTint = Color.ResourceId(android.R.color.holo_red_dark), + scrimColor = Color.SRGB(0x52000000), + dismissal = CheckoutSheetDismissal(false, false), + dragHandle = CheckoutSheetDragHandle(true), + snapPoints = listOf(CheckoutSheetSnapPoint.Expanded(12f)), + maxWidthDp = 480f, + ) + + ShopifyCheckoutKit.configure { it.sheet = sheet } + assertThat(ShopifyCheckoutKit.configuration.sheet).isEqualTo(sheet) + } + + @Test + fun canConfigureSheetDismissal() { + val dismissal = CheckoutSheetDismissal(false, false) + ShopifyCheckoutKit.configure { + it.sheet = CheckoutSheetOptions(dismissal = dismissal) + } + assertThat(ShopifyCheckoutKit.configuration.sheet.dismissal).isEqualTo(dismissal) + } + + @Test + fun canPreloadAndInvalidate() { + Robolectric.buildActivity(ComponentActivity::class.java).use { controller -> + ShopifyCheckoutKit.preload("https://shopify.dev", controller.get()) + ShopifyCheckoutKit.invalidate() + } + } + + @Test + fun canPreloadWithListener() { + Robolectric.buildActivity(ComponentActivity::class.java).use { controller -> + val activity = controller.get() + Robolectric.buildActivity(ComponentActivity::class.java).use { finishingController -> + val finishingActivity = finishingController.get().apply { finish() } + assertThat(ShopifyCheckoutKit.preload("https://shopify.dev", finishingActivity) {}).isNull() + } + + val states = mutableListOf() + val preload = CheckoutWebView.preload( + "https://shopify.dev", + activity, + FakeWebMessageTransport(), + states::add, + ) + + assertThat(preload).isNotNull + preload?.listener = PreloadStateListener(states::add) + assertThat(preload?.state).isNotNull + assertThat(states).containsExactly(PreloadState.Loading, PreloadState.Loading) + ShopifyCheckoutKit.invalidate() + } + } + + @Test + fun canCallPresent() { + Robolectric.buildActivity(ComponentActivity::class.java).use { controller -> + val activity = controller.get().apply { finish() } + val checkout = ShopifyCheckoutKit.present( + "https://shopify.dev", + activity, + object : DefaultCheckoutListener() { + override fun onCheckoutFailed(error: CheckoutException) = Unit + override fun onCheckoutDismissed() = Unit + }, + ) + assertThat(checkout).isNull() + } + } + + @Test + fun canDismissCheckoutHandle() { + val dismissed = AtomicBoolean() + val checkout = CheckoutHandle { dismissed.set(true) } + checkout.dismiss() + assertThat(dismissed).isTrue() + } + + @Test + fun canCreateAndDestroyShopifyCheckout() { + Robolectric.buildActivity(ComponentActivity::class.java).use { controller -> + val checkout = ShopifyCheckout( + controller.get(), + "https://shopify.dev", + object : DefaultCheckoutListener() { + override fun onCheckoutFailed(error: CheckoutException) = Unit + override fun onCheckoutDismissed() = Unit + }, + ) + checkout.destroy() + assertThat(checkout).isNotNull + } + } + + @Test + fun canCustomizeColorSchemeWithSingleBlock() { + val tintColor = Color.ResourceId(android.R.color.holo_red_dark) + val customized = ColorScheme.Light().customize { closeIconTint = tintColor } + + assertThat(customized).isInstanceOf(ColorScheme.Light::class.java) + assertThat((customized as ColorScheme.Light).colors.closeIconTint).isEqualTo(tintColor) + } + + @Test + fun canCustomizeColorSchemeWithLightAndDarkBlocks() { + val lightTint = Color.ResourceId(android.R.color.holo_orange_light) + val darkTint = Color.ResourceId(android.R.color.holo_blue_dark) + val lightHandle = Color.ResourceId(android.R.color.holo_green_light) + val darkHandle = Color.ResourceId(android.R.color.holo_green_dark) + val customIcon = DrawableResource(android.R.drawable.ic_menu_close_clear_cancel) + + val customized = ColorScheme.Automatic().customize( + light = { + closeIconTint = lightTint + dragHandleColor = lightHandle + }, + dark = { + closeIcon = customIcon + closeIconTint = darkTint + dragHandleColor = darkHandle + }, + ) as ColorScheme.Automatic + + assertThat(customized.lightColors.closeIconTint).isEqualTo(lightTint) + assertThat(customized.lightColors.closeIcon).isNull() + assertThat(customized.darkColors.closeIconTint).isEqualTo(darkTint) + assertThat(customized.darkColors.closeIcon).isEqualTo(customIcon) + assertThat(customized.lightColors.dragHandleColor).isEqualTo(lightHandle) + assertThat(customized.darkColors.dragHandleColor).isEqualTo(darkHandle) + } + + @Test + fun canUseColorsBuilderDirectPropertyAssignment() { + val headerColor = Color.SRGB(0xFF123456.toInt()) + val tintColor = Color.SRGB(0xFFABCDEF.toInt()) + val customized = ColorScheme.Dark().customize { + headerBackground = headerColor + closeIconTint = tintColor + } as ColorScheme.Dark + + assertThat(customized.colors.headerBackground).isEqualTo(headerColor) + assertThat(customized.colors.closeIconTint).isEqualTo(tintColor) + } + + @Test + fun canChainColorsBuilderMethods() { + val webViewBackground = Color.ResourceId(android.R.color.white) + val progressColor = Color.ResourceId(android.R.color.holo_green_dark) + val dragHandle = Color.ResourceId(android.R.color.holo_blue_light) + val headerBorder = Color.ResourceId(android.R.color.darker_gray) + val icon = DrawableResource(android.R.drawable.ic_menu_close_clear_cancel) + val customized = ColorScheme.Light().customize { + withWebViewBackground(webViewBackground) + .withProgressIndicator(progressColor) + .withDragHandleColor(dragHandle) + .withHeaderBorderColor(headerBorder) + .withCloseIcon(icon) + } as ColorScheme.Light + + assertThat(customized.colors.webViewBackground).isEqualTo(webViewBackground) + assertThat(customized.colors.progressIndicator).isEqualTo(progressColor) + assertThat(customized.colors.dragHandleColor).isEqualTo(dragHandle) + assertThat(customized.colors.headerBorderColor).isEqualTo(headerBorder) + assertThat(customized.colors.closeIcon).isEqualTo(icon) + } + + @Test + fun customizeLeavesDragHandleColorUnsetWhenOnlyHeaderFontChanges() { + val headerFont = Color.SRGB(0xFF336699.toInt()) + val customized = ColorScheme.Light().customize { this.headerFont = headerFont } as ColorScheme.Light + + assertThat(customized.colors.headerFont).isEqualTo(headerFont) + assertThat(customized.colors.dragHandleColor).isNull() + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java deleted file mode 100644 index d7668cbd4..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.shopify.reactnative.checkoutkit; - -import android.util.Log; -import android.webkit.GeolocationPermissions; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.shopify.checkoutkit.*; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import java.io.IOException; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; - -public class CustomCheckoutListener extends DefaultCheckoutListener { - private static final String TAG = "ShopifyCheckoutKit"; - - private final ObjectMapper mapper = new ObjectMapper(); - - private final DispatchHandle dispatch; - - // Geolocation-specific variables - - private String geolocationOrigin; - private GeolocationPermissions.Callback geolocationCallback; - - public CustomCheckoutListener(@NonNull DispatchCallback dispatch) { - this(new DispatchHandle(dispatch)); - } - - public CustomCheckoutListener(@NonNull DispatchHandle dispatch) { - this.dispatch = dispatch; - } - - // Public methods - - public void invokeGeolocationCallback(boolean allow) { - if (geolocationCallback != null) { - boolean retainGeolocationForFutureRequests = false; - geolocationCallback.invoke(geolocationOrigin, allow, retainGeolocationForFutureRequests); - geolocationCallback = null; - } - } - - public void release() { - dispatch.release(); - geolocationCallback = null; - geolocationOrigin = null; - } - - // Lifecycle events - - /** - * Called when the checkout sheet's webpage requests geolocation - * permissions. The platform callback is stored in memory; the dispatcher - * is invoked with a `geolocationRequest` envelope so JS can either route - * to a per-call handler or run the default permission flow. - * - * Multi-shot — the same checkout sheet may request geolocation multiple - * times during a single `present()` call, so the dispatcher is not - * nulled after invocation. - */ - @Override - public void onGeolocationPermissionsShowPrompt(@NonNull String origin, - @NonNull GeolocationPermissions.Callback callback) { - - if (dispatch.isReleased()) { - // Multi-shot geolocation requests can in principle arrive after a - // terminal event or explicit dismiss has released the dispatcher. Log - // so the silence is observable rather than mystifying. - Log.w(TAG, "Dropping geolocationRequest — dispatcher already released."); - return; - } - - this.geolocationCallback = callback; - this.geolocationOrigin = origin; - - try { - Map payload = new HashMap<>(); - payload.put("origin", origin); - dispatch.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload)); - } catch (IOException e) { - Log.e(TAG, "Error emitting \"geolocationRequest\" event", e); - } - } - - @Override - public void onGeolocationPermissionsHidePrompt() { - super.onGeolocationPermissionsHidePrompt(); - - this.geolocationCallback = null; - this.geolocationOrigin = null; - } - - @Override - public void onCheckoutFailed(CheckoutException checkoutError) { - if (dispatch.isReleased()) { - return; - } - try { - dispatch.invoke(buildEnvelope(DispatchEventTypes.FAIL, populateErrorDetails(checkoutError))); - } catch (IOException e) { - Log.e(TAG, "Error processing checkout failed event", e); - } finally { - release(); - } - } - - @Override - public void onCheckoutDismissed() { - if (dispatch.isReleased()) { - return; - } - try { - dispatch.invoke(buildEnvelope(DispatchEventTypes.CLOSE, null)); - } catch (IOException e) { - Log.e(TAG, "Error processing checkout dismissed event", e); - } finally { - release(); - } - } - - // Private - - private String buildEnvelope(String type, @Nullable Object payload) throws IOException { - ObjectNode envelope = mapper.createObjectNode(); - envelope.put("type", type); - if (payload != null) { - envelope.set("payload", mapper.valueToTree(payload)); - } - return mapper.writeValueAsString(envelope); - } - - private Map populateErrorDetails(CheckoutException checkoutError) { - Map errorMap = new HashMap<>(); - errorMap.put("message", checkoutError.getMessage()); - errorMap.put("code", checkoutError.getCode().name().toLowerCase(Locale.ROOT)); - - Integer httpStatusCode = checkoutError.getHttpStatusCode(); - if (httpStatusCode != null) { - errorMap.put("statusCode", httpStatusCode); - } - - return errorMap; - } - -} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.kt new file mode 100644 index 000000000..eb1a8461a --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.kt @@ -0,0 +1,107 @@ +package com.shopify.reactnative.checkoutkit + +import android.util.Log +import android.webkit.GeolocationPermissions +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.JsonNode +import com.shopify.checkoutkit.CheckoutException +import com.shopify.checkoutkit.DefaultCheckoutListener +import java.io.IOException +import java.util.Locale + +class CustomCheckoutListener( + private val dispatch: DispatchHandle, +) : DefaultCheckoutListener() { + constructor(dispatch: DispatchCallback) : this(DispatchHandle(dispatch)) + + private val mapper = ObjectMapper() + private var geolocationOrigin: String? = null + private var geolocationCallback: GeolocationPermissions.Callback? = null + + fun invokeGeolocationCallback(allow: Boolean) { + geolocationCallback?.invoke(geolocationOrigin, allow, false) + geolocationCallback = null + } + + fun release() { + dispatch.release() + geolocationCallback = null + geolocationOrigin = null + } + + /** + * Called when the checkout sheet's webpage requests geolocation permissions. The platform + * callback is stored in memory; the dispatcher is invoked with a `geolocationRequest` envelope + * so JS can either route to a per-call handler or run the default permission flow. + * + * Multi-shot — the same checkout sheet may request geolocation multiple times during a single + * `present()` call, so the dispatcher is not nulled after invocation. + */ + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback, + ) { + if (dispatch.isReleased()) { + Log.w(TAG, "Dropping geolocationRequest — dispatcher already released.") + return + } + + geolocationCallback = callback + geolocationOrigin = origin + + try { + dispatch.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, mapOf("origin" to origin))) + } catch (error: IOException) { + Log.e(TAG, "Error emitting \"geolocationRequest\" event", error) + } + } + + override fun onGeolocationPermissionsHidePrompt() { + super.onGeolocationPermissionsHidePrompt() + geolocationCallback = null + geolocationOrigin = null + } + + override fun onCheckoutFailed(error: CheckoutException) { + if (dispatch.isReleased()) return + + try { + dispatch.invoke(buildEnvelope(DispatchEventTypes.FAIL, populateErrorDetails(error))) + } catch (serializationError: IOException) { + Log.e(TAG, "Error processing checkout failed event", serializationError) + } finally { + release() + } + } + + override fun onCheckoutDismissed() { + if (dispatch.isReleased()) return + + try { + dispatch.invoke(buildEnvelope(DispatchEventTypes.CLOSE, null)) + } catch (error: IOException) { + Log.e(TAG, "Error processing checkout dismissed event", error) + } finally { + release() + } + } + + @Throws(IOException::class) + private fun buildEnvelope(type: String, payload: Any?): String { + val envelope = mapper.createObjectNode().put("type", type) + if (payload != null) { + envelope.set("payload", mapper.valueToTree(payload)) + } + return mapper.writeValueAsString(envelope) + } + + private fun populateErrorDetails(error: CheckoutException): Map = buildMap { + put("message", error.message.orEmpty()) + put("code", error.code.name.lowercase(Locale.ROOT)) + error.httpStatusCode?.let { put("statusCode", it) } + } + + private companion object { + const val TAG = "ShopifyCheckoutKit" + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java deleted file mode 100644 index aaca43b21..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.shopify.reactnative.checkoutkit; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -/** - * Canonical list of SDK lifecycle event types emitted by the - * per-{@code present()} dispatcher. - * - * Mirrors {@code SDK_LIFECYCLE_EVENT_TYPES} in the JS package and - * {@code DispatchEventType} on iOS. Exposed to JS via - * {@code getTypedExportedConstants()} so the JS layer can verify the - * two sides agree at construction time. - */ -public final class DispatchEventTypes { - public static final String CLOSE = "close"; - public static final String FAIL = "fail"; - public static final String GEOLOCATION_REQUEST = "geolocationRequest"; - - public static final List ALL = Collections.unmodifiableList( - Arrays.asList(CLOSE, FAIL, GEOLOCATION_REQUEST)); - - private DispatchEventTypes() {} -} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.kt new file mode 100644 index 000000000..70aa6cb45 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.kt @@ -0,0 +1,18 @@ +package com.shopify.reactnative.checkoutkit + +/** + * Canonical list of SDK lifecycle event types emitted by the per-[ShopifyCheckoutKitModule.present] + * dispatcher. + * + * Mirrors `SDK_LIFECYCLE_EVENT_TYPES` in the JS package and `DispatchEventType` on iOS. Exposed to + * JS via `getTypedExportedConstants()` so the JS layer can verify the two sides agree at + * construction time. + */ +object DispatchEventTypes { + const val CLOSE: String = "close" + const val FAIL: String = "fail" + const val GEOLOCATION_REQUEST: String = "geolocationRequest" + + @JvmField + val ALL: List = listOf(CLOSE, FAIL, GEOLOCATION_REQUEST) +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java deleted file mode 100644 index 28315e76f..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.shopify.reactnative.checkoutkit; - -import androidx.annotation.NonNull; - -/** - * Shared per-presentation dispatch handle. - * - * SDK lifecycle events and protocol events both invoke the same handle. Terminal - * lifecycle events release it so subsequent protocol emissions are dropped, - * matching the iOS pendingDispatchCallback lifecycle. - */ -public class DispatchHandle implements DispatchCallback { - private final DispatchCallback downstream; - private boolean released = false; - - public DispatchHandle(@NonNull DispatchCallback downstream) { - this.downstream = downstream; - } - - @Override - public synchronized void invoke(String json) { - if (!released) { - downstream.invoke(json); - } - } - - public synchronized void release() { - released = true; - } - - public synchronized boolean isReleased() { - return released; - } -} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.kt new file mode 100644 index 000000000..e22fc416d --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.kt @@ -0,0 +1,29 @@ +package com.shopify.reactnative.checkoutkit + +/** + * Shared per-presentation dispatch handle. + * + * SDK lifecycle events and protocol events both invoke the same handle. Terminal lifecycle events + * release it so subsequent protocol emissions are dropped, matching the iOS + * pendingDispatchCallback lifecycle. + */ +class DispatchHandle( + private val downstream: DispatchCallback, +) : DispatchCallback { + private var released: Boolean = false + + @Synchronized + override fun invoke(json: String) { + if (!released) { + downstream.invoke(json) + } + } + + @Synchronized + fun release() { + released = true + } + + @Synchronized + fun isReleased(): Boolean = released +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java deleted file mode 100644 index 3e2aeb9d1..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java +++ /dev/null @@ -1,519 +0,0 @@ -package com.shopify.reactnative.checkoutkit; - -import android.app.Activity; -import androidx.activity.ComponentActivity; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.WritableMap; -import com.shopify.checkoutkit.NativeShopifyCheckoutKitSpec; -import com.shopify.checkoutkit.*; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -import org.json.JSONException; -import org.json.JSONObject; - -public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { - - /** The JavaScript name for {@link CheckoutAppearance.Storefront}, which has no native id. */ - private static final String STOREFRONT_COLOR_SCHEME = "storefront"; - - public static Configuration checkoutConfig = new Configuration(); - - private CheckoutHandle checkoutSheet; - - private CustomCheckoutListener checkoutListener; - - private CheckoutPreload checkoutPreload; - - public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) { - super(reactContext); - - ShopifyCheckoutKit.configure(configuration -> { - configuration.setPlatform(new Platform.ReactNative()); - checkoutConfig = configuration; - }); - } - - @Override - public void invalidate() { - releaseCheckoutListener(); - releaseCheckoutPreload(); - super.invalidate(); - } - - @Override - protected Map getTypedExportedConstants() { - final Map constants = new HashMap<>(); - constants.put("version", ShopifyCheckoutKit.VERSION); - // Exposed so the JS layer can verify the SDK lifecycle event set - // it was built against matches what this native module emits. - constants.put("dispatchEventTypes", DispatchEventTypes.ALL); - return constants; - } - - @ReactMethod - public void addListener(String eventName) { - // No-op but required for RN to register module - } - - @ReactMethod - public void removeListeners(double count) { - // No-op but required for RN to register module - } - - @ReactMethod - public void present(String checkoutURL, ReadableArray subscribedMethods) { - releaseCheckoutListener(); - - Activity currentActivity = getReactApplicationContext().getCurrentActivity(); - if (currentActivity instanceof ComponentActivity) { - DispatchHandle dispatch = new DispatchHandle(json -> emitOnDispatch(json)); - CustomCheckoutListener listener = new CustomCheckoutListener(dispatch); - checkoutListener = listener; - - List methods = new ArrayList<>(); - for (int i = 0; i < subscribedMethods.size(); i++) { - String method = subscribedMethods.getString(i); - if (method != null) { - methods.add(method); - } - } - CheckoutProtocol.Client client = ProtocolRelay.makeClient(methods, dispatch); - - currentActivity.runOnUiThread(() -> { - if (checkoutListener != listener) { - return; - } - checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, - listener, client); - }); - } - } - - @ReactMethod - public void dismiss() { - releaseCheckoutListener(); - - if (checkoutSheet != null) { - checkoutSheet.dismiss(); - checkoutSheet = null; - } - } - - @ReactMethod - public void preload(String checkoutURL, String requestId) { - releaseCheckoutPreload(); - - Activity currentActivity = getReactApplicationContext().getCurrentActivity(); - if (currentActivity instanceof ComponentActivity) { - checkoutPreload = ShopifyCheckoutKit.preload( - checkoutURL, - (ComponentActivity) currentActivity, - state -> emitPreloadStateChange(requestId, state)); - - if (checkoutPreload == null) { - emitPreloadStateChange(requestId, PreloadState.Idle.INSTANCE); - } - } else { - emitPreloadStateChange(requestId, PreloadState.Idle.INSTANCE); - } - } - - @ReactMethod - public void invalidateCache() { - releaseCheckoutPreload(); - ShopifyCheckoutKit.invalidate(); - } - - private void emitPreloadStateChange(String requestId, PreloadState state) { - JSONObject event = new JSONObject(); - - try { - event.put("requestId", requestId); - - if (state instanceof PreloadState.Idle) { - event.put("type", "idle"); - } else if (state instanceof PreloadState.Loading) { - event.put("type", "loading"); - } else if (state instanceof PreloadState.Ready) { - event.put("type", "ready"); - } else if (state instanceof PreloadState.Expired) { - event.put("type", "expired"); - } else if (state instanceof PreloadState.Failed) { - PreloadState.FailureReason reason = ((PreloadState.Failed) state).getReason(); - event.put("type", "failed"); - - if (reason instanceof PreloadState.FailureReason.HttpError) { - event.put("reason", "httpError"); - event.put("statusCode", ((PreloadState.FailureReason.HttpError) reason).getStatusCode()); - } else if (reason instanceof PreloadState.FailureReason.NavigationFailed) { - event.put("reason", "navigationFailed"); - } else if (reason instanceof PreloadState.FailureReason.WebContentUnavailable) { - event.put("reason", "webContentUnavailable"); - } else if (reason instanceof PreloadState.FailureReason.ProtocolError) { - event.put("reason", "protocolError"); - } else { - event.put("reason", "unknown"); - } - } else { - return; - } - } catch (JSONException exception) { - throw new IllegalStateException("Failed to serialize preload state", exception); - } - - emitPreloadStateEvent(event.toString()); - } - - protected void emitPreloadStateEvent(String event) { - emitOnPreloadStateChange(event); - } - - private void releaseCheckoutListener() { - if (checkoutListener != null) { - checkoutListener.release(); - checkoutListener = null; - } - } - - private void releaseCheckoutPreload() { - if (checkoutPreload != null) { - checkoutPreload.setListener(null); - checkoutPreload = null; - } - } - - @ReactMethod(isBlockingSynchronousMethod = true) - public WritableMap getConfig() { - WritableMap resultConfig = Arguments.createMap(); - - resultConfig.putString("title", checkoutConfig.getTitle()); - resultConfig.putString("colorScheme", colorSchemeStringFor(checkoutConfig.getAppearance())); - resultConfig.putString("logLevel", logLevelStringFor(checkoutConfig.getLogLevel())); - resultConfig.putBoolean("preloading", checkoutConfig.getPreloading().getEnabled()); - resultConfig.putArray("allowedMessageOrigins", - Arguments.fromList(new ArrayList<>(checkoutConfig.getAllowedMessageOrigins()))); - - return resultConfig; - } - - @ReactMethod - public void setConfig(ReadableMap config) { - ShopifyCheckoutKit.configure(configuration -> { - if (config.hasKey("title")) { - configuration.setTitle(config.getString("title")); - } - - if (config.hasKey("preloading")) { - configuration.setPreloading(new Preloading(config.getBoolean("preloading"))); - } - - if (config.hasKey("allowedMessageOrigins")) { - configuration.setAllowedMessageOrigins(toStringSet(config.getArray("allowedMessageOrigins"))); - } - - if (config.hasKey("logLevel")) { - LogLevel logLevel = logLevelFor(config.getString("logLevel")); - - if (logLevel != null) { - configuration.setLogLevel(logLevel); - } - } - - if (config.hasKey("colorScheme")) { - String colorScheme = Objects.requireNonNull(config.getString("colorScheme")); - ReadableMap colorsConfig = config.hasKey("colors") ? config.getMap("colors") : null; - ReadableMap androidConfig = null; - - if (colorsConfig != null && colorsConfig.hasKey("android")) { - androidConfig = colorsConfig.getMap("android"); - } - - CheckoutAppearance appearance = appearanceFor(colorScheme, androidConfig); - - if (appearance != null) { - configuration.setAppearance(appearance); - } - } - - checkoutConfig = configuration; - }); - } - - private static Set toStringSet(ReadableArray array) { - Set values = new HashSet<>(); - if (array == null) { - return values; - } - for (int i = 0; i < array.size(); i++) { - String value = array.getString(i); - if (value != null) { - values.add(value); - } - } - return values; - } - - @ReactMethod(isBlockingSynchronousMethod = true) - public boolean configureAcceleratedCheckouts( - String storefrontDomain, - String storefrontAccessToken, - String customerEmail, - String customerPhoneNumber, - String customerAccessToken, - String applePayMerchantIdentifier, - ReadableArray applyPayContactFields, - ReadableArray supportedShippingCountries) { - // Accelerated checkouts not supported on Android - return false; - } - - @ReactMethod(isBlockingSynchronousMethod = true) - public boolean isAcceleratedCheckoutAvailable() { - // Accelerated checkouts not supported on Android - return false; - } - - @ReactMethod(isBlockingSynchronousMethod = true) - public boolean isApplePayAvailable() { - // Apple Pay not available on Android - return false; - } - - @ReactMethod - public void respondToGeolocationRequest(boolean allow) { - if (checkoutListener != null) { - checkoutListener.invokeGeolocationCallback(allow); - } - } - - // Private - - static CheckoutAppearance appearanceFor(String colorScheme, ReadableMap androidConfig) { - if (STOREFRONT_COLOR_SCHEME.equals(colorScheme)) { - return getStorefrontAppearance(androidConfig); - } - - ColorScheme scheme = colorSchemeFor(colorScheme); - - if (scheme == null) { - return null; - } - - if (isValidColorConfig(androidConfig)) { - ColorScheme schemeWithOverrides = getColors(scheme, androidConfig); - if (schemeWithOverrides != null) { - return new CheckoutAppearance.App(schemeWithOverrides); - } - } - - return new CheckoutAppearance.App(scheme); - } - - private static CheckoutAppearance getStorefrontAppearance(ReadableMap androidConfig) { - CheckoutAppearance.Storefront storefront = new CheckoutAppearance.Storefront(); - - Colors colors = createColorsFromConfig(androidConfig); - if (colors == null) { - return storefront; - } - - return storefront.customize(builder -> { - builder.withWebViewBackground(colors.getWebViewBackground()); - builder.withHeaderBackground(colors.getHeaderBackground()); - builder.withHeaderFont(colors.getHeaderFont()); - builder.withProgressIndicator(colors.getProgressIndicator()); - Color closeButtonColor = colors.getCloseIconTint(); - if (closeButtonColor != null) { - builder.withCloseIconTint(closeButtonColor); - } - }); - } - - private static ColorScheme colorSchemeFor(String colorScheme) { - if (colorScheme == null) { - return null; - } - - ColorScheme light = new ColorScheme.Light(); - ColorScheme dark = new ColorScheme.Dark(); - ColorScheme automatic = new ColorScheme.Automatic(); - - if (colorScheme.equals(light.getId())) { - return light; - } - - if (colorScheme.equals(dark.getId())) { - return dark; - } - - if (colorScheme.equals(automatic.getId())) { - return automatic; - } - - return null; - } - - static String colorSchemeStringFor(CheckoutAppearance appearance) { - if (appearance instanceof CheckoutAppearance.App) { - return ((CheckoutAppearance.App) appearance).getColorScheme().getId(); - } - return STOREFRONT_COLOR_SCHEME; - } - - static LogLevel logLevelFor(String logLevel) { - if (logLevel == null) { - return null; - } - - try { - return LogLevel.valueOf(logLevel.toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException unknownLogLevel) { - return null; - } - } - - static String logLevelStringFor(LogLevel logLevel) { - return logLevel.name().toLowerCase(Locale.ROOT); - } - - private static boolean isValidColorConfig(ReadableMap config) { - if (config == null) { - return false; - } - - String[] requiredColorKeys = { "backgroundColor", "progressIndicator", "headerTextColor", "headerBackgroundColor" }; - - for (String key : requiredColorKeys) { - if (!config.hasKey(key) || config.getString(key) == null || parseColor(config.getString(key)) == null) { - return false; - } - } - - // closeButtonColor is optional, so we only validate it if it's present - if (config.hasKey("closeButtonColor") && config.getString("closeButtonColor") != null) { - if (parseColor(config.getString("closeButtonColor")) == null) { - return false; - } - } - - return true; - } - - private static boolean isValidColorScheme(ColorScheme colorScheme, ReadableMap colorConfig) { - if (colorConfig == null) { - return false; - } - - if (colorScheme instanceof ColorScheme.Automatic) { - if (!colorConfig.hasKey("light") || !colorConfig.hasKey("dark")) { - return false; - } - - boolean validLight = isValidColorConfig(colorConfig.getMap("light")); - boolean validDark = isValidColorConfig(colorConfig.getMap("dark")); - - return validLight && validDark; - } - - return isValidColorConfig(colorConfig); - } - - private static Color parseColorFromConfig(ReadableMap config, String colorKey) { - if (config.hasKey(colorKey)) { - String colorStr = config.getString(colorKey); - return parseColor(colorStr); - } - - return null; - } - - private static Colors createColorsFromConfig(ReadableMap config) { - if (config == null) { - return null; - } - - Color webViewBackground = parseColorFromConfig(config, "backgroundColor"); - Color headerBackground = parseColorFromConfig(config, "headerBackgroundColor"); - Color headerFont = parseColorFromConfig(config, "headerTextColor"); - Color progressIndicator = parseColorFromConfig(config, "progressIndicator"); - Color closeButtonColor = parseColorFromConfig(config, "closeButtonColor"); - - if (webViewBackground != null && progressIndicator != null && headerFont != null && headerBackground != null) { - return new Colors( - webViewBackground, - headerBackground, - headerFont, - progressIndicator, - // Parameter allows passing a custom drawable, we'll just support custom color - // for now - null, - closeButtonColor, - null, - null); - } - - return null; - } - - private static ColorScheme getColors(ColorScheme colorScheme, ReadableMap config) { - if (!isValidColorScheme(colorScheme, config)) { - return null; - } - - if (colorScheme instanceof ColorScheme.Automatic && isValidColorScheme(colorScheme, config)) { - Colors lightColors = createColorsFromConfig(config.getMap("light")); - Colors darkColors = createColorsFromConfig(config.getMap("dark")); - - if (lightColors != null && darkColors != null) { - ColorScheme.Automatic automaticColorScheme = (ColorScheme.Automatic) colorScheme; - automaticColorScheme.setLightColors(lightColors); - automaticColorScheme.setDarkColors(darkColors); - return automaticColorScheme; - } - } - - Colors colors = createColorsFromConfig(config); - - if (colors != null) { - if (colorScheme instanceof ColorScheme.Light) { - ((ColorScheme.Light) colorScheme).setColors(colors); - } else if (colorScheme instanceof ColorScheme.Dark) { - ((ColorScheme.Dark) colorScheme).setColors(colors); - } - return colorScheme; - } - - return null; - } - - private static Color parseColor(String colorStr) { - try { - colorStr = colorStr.replace("#", ""); - - long color = Long.parseLong(colorStr, 16); - - if (colorStr.length() == 6) { - // If alpha is not included, assume full opacity - // "L" is not needed here on the end of the hex value - color = color | 0xFF000000; - } - - return new Color.SRGB((int) color); - } catch (NumberFormatException e) { - System.out.println("Warning: Invalid color string. Default color will be used."); - return null; - } - } -} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.kt new file mode 100644 index 000000000..ac5fa4aaf --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.kt @@ -0,0 +1,352 @@ +package com.shopify.reactnative.checkoutkit + +import androidx.activity.ComponentActivity +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.shopify.checkoutkit.CheckoutAppearance +import com.shopify.checkoutkit.CheckoutHandle +import com.shopify.checkoutkit.CheckoutPreload +import com.shopify.checkoutkit.Color +import com.shopify.checkoutkit.ColorScheme +import com.shopify.checkoutkit.Colors +import com.shopify.checkoutkit.Configuration +import com.shopify.checkoutkit.LogLevel +import com.shopify.checkoutkit.NativeShopifyCheckoutKitSpec +import com.shopify.checkoutkit.Platform +import com.shopify.checkoutkit.PreloadState +import com.shopify.checkoutkit.Preloading +import com.shopify.checkoutkit.ShopifyCheckoutKit +import java.util.Locale +import org.json.JSONObject + +open class ShopifyCheckoutKitModule( + reactContext: ReactApplicationContext, +) : NativeShopifyCheckoutKitSpec(reactContext) { + private var checkoutSheet: CheckoutHandle? = null + private var checkoutListener: CustomCheckoutListener? = null + private var checkoutPreload: CheckoutPreload? = null + + init { + ShopifyCheckoutKit.configure { configuration -> + configuration.platform = Platform.ReactNative() + checkoutConfig = configuration + } + } + + override fun invalidate() { + releaseCheckoutListener() + releaseCheckoutPreload() + super.invalidate() + } + + override fun getTypedExportedConstants(): Map = mapOf( + "version" to ShopifyCheckoutKit.VERSION, + "dispatchEventTypes" to DispatchEventTypes.ALL, + ) + + @ReactMethod + override fun addListener(eventName: String) = Unit + + @ReactMethod + override fun removeListeners(count: Double) = Unit + + @ReactMethod + override fun present(checkoutURL: String, subscribedMethods: ReadableArray) { + releaseCheckoutListener() + + val activity = reactApplicationContext.currentActivity as? ComponentActivity ?: return + val dispatch = DispatchHandle { json -> emitOnDispatch(json) } + val listener = CustomCheckoutListener(dispatch) + checkoutListener = listener + + val methods = buildList { + for (index in 0 until subscribedMethods.size()) { + subscribedMethods.getString(index)?.let(::add) + } + } + val client = ProtocolRelay.makeClient(methods, dispatch) + + activity.runOnUiThread { + if (checkoutListener !== listener) return@runOnUiThread + checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, activity, listener, client) + } + } + + @ReactMethod + override fun dismiss() { + releaseCheckoutListener() + checkoutSheet?.dismiss() + checkoutSheet = null + } + + @ReactMethod + override fun preload(checkoutURL: String, requestId: String) { + releaseCheckoutPreload() + + val activity = reactApplicationContext.currentActivity as? ComponentActivity + if (activity == null) { + emitPreloadStateChange(requestId, PreloadState.Idle) + return + } + + checkoutPreload = ShopifyCheckoutKit.preload(checkoutURL, activity) { state -> + emitPreloadStateChange(requestId, state) + } + if (checkoutPreload == null) { + emitPreloadStateChange(requestId, PreloadState.Idle) + } + } + + @ReactMethod + override fun invalidateCache() { + releaseCheckoutPreload() + ShopifyCheckoutKit.invalidate() + } + + private fun emitPreloadStateChange(requestId: String, state: PreloadState) { + val event = JSONObject().put("requestId", requestId) + when (state) { + PreloadState.Idle -> event.put("type", "idle") + PreloadState.Loading -> event.put("type", "loading") + PreloadState.Ready -> event.put("type", "ready") + PreloadState.Expired -> event.put("type", "expired") + is PreloadState.Failed -> { + event.put("type", "failed") + when (val reason = state.reason) { + is PreloadState.FailureReason.HttpError -> { + event.put("reason", "httpError") + event.put("statusCode", reason.statusCode) + } + PreloadState.FailureReason.NavigationFailed -> event.put("reason", "navigationFailed") + PreloadState.FailureReason.WebContentUnavailable -> + event.put("reason", "webContentUnavailable") + PreloadState.FailureReason.ProtocolError -> event.put("reason", "protocolError") + else -> event.put("reason", "unknown") + } + } + else -> return + } + emitPreloadStateEvent(event.toString()) + } + + protected open fun emitPreloadStateEvent(event: String) { + emitOnPreloadStateChange(event) + } + + private fun releaseCheckoutListener() { + checkoutListener?.release() + checkoutListener = null + } + + private fun releaseCheckoutPreload() { + checkoutPreload?.listener = null + checkoutPreload = null + } + + @ReactMethod(isBlockingSynchronousMethod = true) + override fun getConfig(): WritableMap = Arguments.createMap().apply { + putString("title", checkoutConfig.title) + putString("colorScheme", colorSchemeStringFor(checkoutConfig.appearance)) + putString("logLevel", logLevelStringFor(checkoutConfig.logLevel)) + putBoolean("preloading", checkoutConfig.preloading.enabled) + putArray("allowedMessageOrigins", Arguments.fromList(ArrayList(checkoutConfig.allowedMessageOrigins))) + } + + @ReactMethod + override fun setConfig(config: ReadableMap) { + ShopifyCheckoutKit.configure { configuration -> + if (config.hasKey("title")) { + configuration.title = config.getString("title") + } + if (config.hasKey("preloading")) { + configuration.preloading = Preloading(config.getBoolean("preloading")) + } + if (config.hasKey("allowedMessageOrigins")) { + configuration.allowedMessageOrigins = toStringSet(config.getArray("allowedMessageOrigins")) + } + if (config.hasKey("logLevel")) { + logLevelFor(config.getString("logLevel"))?.let { configuration.logLevel = it } + } + if (config.hasKey("colorScheme")) { + val colorScheme = requireNotNull(config.getString("colorScheme")) + val colorsConfig = if (config.hasKey("colors")) config.getMap("colors") else null + val androidConfig = colorsConfig?.takeIf { it.hasKey("android") }?.getMap("android") + appearanceFor(colorScheme, androidConfig)?.let { configuration.appearance = it } + } + checkoutConfig = configuration + } + } + + private fun toStringSet(array: ReadableArray?): Set = buildSet { + if (array == null) return@buildSet + for (index in 0 until array.size()) { + array.getString(index)?.let(::add) + } + } + + @ReactMethod(isBlockingSynchronousMethod = true) + override fun configureAcceleratedCheckouts( + storefrontDomain: String?, + storefrontAccessToken: String?, + customerEmail: String?, + customerPhoneNumber: String?, + customerAccessToken: String?, + applePayMerchantIdentifier: String?, + applyPayContactFields: ReadableArray?, + supportedShippingCountries: ReadableArray?, + ): Boolean = false + + @ReactMethod(isBlockingSynchronousMethod = true) + override fun isAcceleratedCheckoutAvailable(): Boolean = false + + @ReactMethod(isBlockingSynchronousMethod = true) + override fun isApplePayAvailable(): Boolean = false + + @ReactMethod + override fun respondToGeolocationRequest(allow: Boolean) { + checkoutListener?.invokeGeolocationCallback(allow) + } + + companion object { + const val NAME: String = "ShopifyCheckoutKit" + private const val STOREFRONT_COLOR_SCHEME = "storefront" + + @JvmField + var checkoutConfig: Configuration = currentConfiguration() + + private fun currentConfiguration(): Configuration { + var current: Configuration? = null + ShopifyCheckoutKit.configure { current = it } + return requireNotNull(current) + } + + @JvmStatic + internal fun appearanceFor(colorScheme: String?, androidConfig: ReadableMap?): CheckoutAppearance? { + if (colorScheme == STOREFRONT_COLOR_SCHEME) { + return getStorefrontAppearance(androidConfig) + } + + val scheme = colorSchemeFor(colorScheme) ?: return null + val schemeWithOverrides = androidConfig + ?.takeIf(::isValidColorConfig) + ?.let { getColors(scheme, it) } + return CheckoutAppearance.App(schemeWithOverrides ?: scheme) + } + + private fun getStorefrontAppearance(androidConfig: ReadableMap?): CheckoutAppearance { + val colors = createColorsFromConfig(androidConfig) ?: return CheckoutAppearance.Storefront() + return CheckoutAppearance.Storefront().customize { + withWebViewBackground(colors.webViewBackground) + withHeaderBackground(colors.headerBackground) + withHeaderFont(colors.headerFont) + withProgressIndicator(colors.progressIndicator) + colors.closeIconTint?.let(::withCloseIconTint) + } + } + + private fun colorSchemeFor(colorScheme: String?): ColorScheme? = when (colorScheme) { + ColorScheme.Light().id -> ColorScheme.Light() + ColorScheme.Dark().id -> ColorScheme.Dark() + ColorScheme.Automatic().id -> ColorScheme.Automatic() + else -> null + } + + @JvmStatic + internal fun colorSchemeStringFor(appearance: CheckoutAppearance?): String = + (appearance as? CheckoutAppearance.App)?.colorScheme?.id ?: STOREFRONT_COLOR_SCHEME + + @JvmStatic + internal fun logLevelFor(logLevel: String?): LogLevel? = try { + logLevel?.uppercase(Locale.ROOT)?.let(LogLevel::valueOf) + } catch (_: IllegalArgumentException) { + null + } + + @JvmStatic + internal fun logLevelStringFor(logLevel: LogLevel): String = logLevel.name.lowercase(Locale.ROOT) + + private fun isValidColorConfig(config: ReadableMap?): Boolean { + if (config == null) return false + + val requiredKeys = listOf( + "backgroundColor", + "progressIndicator", + "headerTextColor", + "headerBackgroundColor", + ) + if (requiredKeys.any { !config.hasKey(it) || parseColor(config.getString(it)) == null }) { + return false + } + + return !config.hasKey("closeButtonColor") || + config.getString("closeButtonColor") == null || + parseColor(config.getString("closeButtonColor")) != null + } + + private fun isValidColorScheme(colorScheme: ColorScheme, colorConfig: ReadableMap?): Boolean { + if (colorConfig == null) return false + if (colorScheme is ColorScheme.Automatic) { + return colorConfig.hasKey("light") && + colorConfig.hasKey("dark") && + isValidColorConfig(colorConfig.getMap("light")) && + isValidColorConfig(colorConfig.getMap("dark")) + } + return isValidColorConfig(colorConfig) + } + + private fun parseColorFromConfig(config: ReadableMap, colorKey: String): Color? = + if (config.hasKey(colorKey)) parseColor(config.getString(colorKey)) else null + + private fun createColorsFromConfig(config: ReadableMap?): Colors? { + if (config == null) return null + + val webViewBackground = parseColorFromConfig(config, "backgroundColor") ?: return null + val headerBackground = parseColorFromConfig(config, "headerBackgroundColor") ?: return null + val headerFont = parseColorFromConfig(config, "headerTextColor") ?: return null + val progressIndicator = parseColorFromConfig(config, "progressIndicator") ?: return null + return Colors( + webViewBackground = webViewBackground, + headerBackground = headerBackground, + headerFont = headerFont, + progressIndicator = progressIndicator, + closeIconTint = parseColorFromConfig(config, "closeButtonColor"), + ) + } + + private fun getColors(colorScheme: ColorScheme, config: ReadableMap): ColorScheme? { + if (!isValidColorScheme(colorScheme, config)) return null + + if (colorScheme is ColorScheme.Automatic) { + val lightColors = createColorsFromConfig(config.getMap("light")) ?: return null + val darkColors = createColorsFromConfig(config.getMap("dark")) ?: return null + colorScheme.lightColors = lightColors + colorScheme.darkColors = darkColors + return colorScheme + } + + val colors = createColorsFromConfig(config) ?: return null + when (colorScheme) { + is ColorScheme.Light -> colorScheme.colors = colors + is ColorScheme.Dark -> colorScheme.colors = colors + else -> Unit + } + return colorScheme + } + + private fun parseColor(colorString: String?): Color? = try { + val hexadecimal = requireNotNull(colorString).replace("#", "") + var color = hexadecimal.toLong(16) + if (hexadecimal.length == 6) { + color = color or 0xFF000000 + } + Color.SRGB(color.toInt()) + } catch (_: IllegalArgumentException) { + println("Warning: Invalid color string. Default color will be used.") + null + } + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.java deleted file mode 100644 index ad69b7dcd..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.shopify.reactnative.checkoutkit; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.facebook.react.TurboReactPackage; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.module.model.ReactModuleInfo; -import com.facebook.react.module.model.ReactModuleInfoProvider; -import com.facebook.react.uimanager.ViewManager; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class ShopifyCheckoutKitPackage extends TurboReactPackage { - - @NonNull - @Override - public List createViewManagers(@NonNull ReactApplicationContext reactContext) { - return Collections.emptyList(); - } - - @Nullable - @Override - public NativeModule getModule(@NonNull String name, @NonNull ReactApplicationContext reactContext) { - if (name.equals(ShopifyCheckoutKitModule.NAME)) { - return new ShopifyCheckoutKitModule(reactContext); - } - return null; - } - - @Override - public ReactModuleInfoProvider getReactModuleInfoProvider() { - return () -> { - final Map moduleInfos = new HashMap<>(); - moduleInfos.put( - ShopifyCheckoutKitModule.NAME, - new ReactModuleInfo( - ShopifyCheckoutKitModule.NAME, - ShopifyCheckoutKitModule.NAME, - false, // canOverrideExistingModule - false, // needsEagerInit - false, // isCxxModule - true // isTurboModule - )); - return moduleInfos; - }; - } -} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.kt new file mode 100644 index 000000000..fb517148c --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.kt @@ -0,0 +1,28 @@ +package com.shopify.reactnative.checkoutkit + +import com.facebook.react.TurboReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.facebook.react.uimanager.ViewManager + +class ShopifyCheckoutKitPackage : TurboReactPackage() { + override fun createViewManagers(reactContext: ReactApplicationContext): List> = emptyList() + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = + if (name == ShopifyCheckoutKitModule.NAME) ShopifyCheckoutKitModule(reactContext) else null + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider { + mapOf( + ShopifyCheckoutKitModule.NAME to ReactModuleInfo( + ShopifyCheckoutKitModule.NAME, + ShopifyCheckoutKitModule.NAME, + false, + false, + false, + true, + ), + ) + } +} diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java deleted file mode 100644 index adb78e178..000000000 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ /dev/null @@ -1,975 +0,0 @@ -package com.shopify.checkoutkit.reactnativedemo; - -import android.webkit.GeolocationPermissions; - -import androidx.activity.ComponentActivity; - -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.JavaOnlyArray; -import com.facebook.react.bridge.JavaOnlyMap; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.WritableMap; -import com.shopify.checkoutkit.CheckoutAppearance; -import com.shopify.checkoutkit.CheckoutErrorCode; -import com.shopify.checkoutkit.CheckoutException; -import com.shopify.checkoutkit.CheckoutPreload; -import com.shopify.checkoutkit.ShopifyCheckoutKit; -import com.shopify.checkoutkit.LogLevel; -import com.shopify.checkoutkit.PreloadState; -import com.shopify.checkoutkit.PreloadStateListener; -import com.shopify.checkoutkit.Preloading; -import com.shopify.reactnative.checkoutkit.ShopifyCheckoutKitModule; -import com.shopify.reactnative.checkoutkit.CustomCheckoutListener; -import com.shopify.reactnative.checkoutkit.DispatchCallback; - -import java.util.Locale; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; -import org.robolectric.RobolectricTestRunner; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - - - -@RunWith(RobolectricTestRunner.class) -public class ShopifyCheckoutKitModuleTest { - @Mock - private ReactApplicationContext mockReactContext; - @Mock - private ComponentActivity mockComponentActivity; - @Captor - ArgumentCaptor runnableCaptor; - @Captor - private ArgumentCaptor stringCaptor; - - private TestShopifyCheckoutKitModule shopifyCheckoutKitModule; - private AutoCloseable mocks; - - // Store initial configuration to restore after each test - private CheckoutAppearance initialAppearance; - private LogLevel initialLogLevel; - private Preloading initialPreloading; - - // Mock for Arguments.createMap() to avoid native library loading - private MockedStatic mockedArguments; - - // Test constants for color configuration - private static final String BACKGROUND_COLOR = "#FFFFFF"; - private static final String PROGRESS_INDICATOR = "#000000"; - private static final String HEADER_BACKGROUND_COLOR = "#FFFFFF"; - private static final String HEADER_TEXT_COLOR = "#000000"; - - // Dark theme colors - private static final String DARK_BACKGROUND_COLOR = "#000000"; - private static final String DARK_PROGRESS_INDICATOR = "#FFFFFF"; - private static final String DARK_HEADER_BACKGROUND_COLOR = "#000000"; - private static final String DARK_HEADER_TEXT_COLOR = "#FFFFFF"; - - private static final class TestShopifyCheckoutKitModule extends ShopifyCheckoutKitModule { - private String preloadStateEvent; - - TestShopifyCheckoutKitModule(ReactApplicationContext reactContext) { - super(reactContext); - } - - @Override - protected void emitPreloadStateEvent(String event) { - preloadStateEvent = event; - } - } - - @Before - public void setup() { - mocks = MockitoAnnotations.openMocks(this); - mockedArguments = Mockito.mockStatic(Arguments.class); - mockedArguments.when(Arguments::createMap).thenAnswer(invocation -> new JavaOnlyMap()); - mockedArguments.when(() -> Arguments.fromList(anyList())) - .thenAnswer(invocation -> JavaOnlyArray.from(invocation.getArgument(0))); - - when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); - shopifyCheckoutKitModule = new TestShopifyCheckoutKitModule(mockReactContext); - - // Capture initial configuration state to restore after each test - initialAppearance = ShopifyCheckoutKitModule.checkoutConfig.getAppearance(); - initialLogLevel = ShopifyCheckoutKitModule.checkoutConfig.getLogLevel(); - initialPreloading = ShopifyCheckoutKitModule.checkoutConfig.getPreloading(); - } - - @After - public void tearDown() throws Exception { - // Close mocked static - if (mockedArguments != null) { - mockedArguments.close(); - } - if (mocks != null) { - mocks.close(); - } - - // Reset configuration to initial state after each test - ShopifyCheckoutKit.configure(configuration -> { - configuration.setAppearance(initialAppearance); - configuration.setLogLevel(initialLogLevel); - configuration.setPreloading(initialPreloading); - ShopifyCheckoutKitModule.checkoutConfig = configuration; - }); - } - - /** - * Core Methods - */ - - @Test - public void testCanPresentCheckout() { - try (MockedStatic mockedShopifyCheckoutKit = Mockito - .mockStatic(ShopifyCheckoutKit.class)) { - String checkoutUrl = "https://shopify.com"; - // An empty JavaOnlyArray stands in for "no UCP methods subscribed", - // matching the JS-side default of `protocol = {}`. - shopifyCheckoutKitModule.present(checkoutUrl, new JavaOnlyArray()); - - verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); - runnableCaptor.getValue().run(); - - mockedShopifyCheckoutKit.verify(() -> { - // (url, activity, checkoutListener, protocolClient) — the protocol - // client is the new fourth arg from `ShopifyCheckoutKit.present` - // when UCP wiring is enabled. - ShopifyCheckoutKit.present(eq(checkoutUrl), any(), any(), any()); - }); - } - } - - @Test - public void testCanPreloadCheckout() { - try (MockedStatic mockedShopifyCheckoutKit = Mockito - .mockStatic(ShopifyCheckoutKit.class)) { - String checkoutUrl = "https://shopify.com"; - CheckoutPreload checkoutPreload = mock(CheckoutPreload.class); - mockedShopifyCheckoutKit - .when(() -> ShopifyCheckoutKit.preload( - eq(checkoutUrl), - eq(mockComponentActivity), - any(PreloadStateListener.class))) - .thenReturn(checkoutPreload); - - shopifyCheckoutKitModule.preload(checkoutUrl, "preload-request"); - - mockedShopifyCheckoutKit.verify(() -> ShopifyCheckoutKit.preload( - eq(checkoutUrl), - eq(mockComponentActivity), - any(PreloadStateListener.class))); - } - } - - @Test - public void testPreloadSerializesWebContentUnavailable() { - try (MockedStatic mockedShopifyCheckoutKit = Mockito - .mockStatic(ShopifyCheckoutKit.class)) { - String checkoutUrl = "https://shopify.com"; - CheckoutPreload checkoutPreload = mock(CheckoutPreload.class); - ArgumentCaptor listenerCaptor = - ArgumentCaptor.forClass(PreloadStateListener.class); - mockedShopifyCheckoutKit - .when(() -> ShopifyCheckoutKit.preload( - eq(checkoutUrl), - eq(mockComponentActivity), - any(PreloadStateListener.class))) - .thenReturn(checkoutPreload); - - shopifyCheckoutKitModule.preload(checkoutUrl, "preload-request"); - - mockedShopifyCheckoutKit.verify(() -> ShopifyCheckoutKit.preload( - eq(checkoutUrl), - eq(mockComponentActivity), - listenerCaptor.capture())); - listenerCaptor.getValue().onStateChanged(new PreloadState.Failed( - PreloadState.FailureReason.WebContentUnavailable.INSTANCE, - "Web content process terminated.")); - - assertThat(shopifyCheckoutKitModule.preloadStateEvent) - .contains("\"requestId\":\"preload-request\"") - .contains("\"type\":\"failed\"") - .contains("\"reason\":\"webContentUnavailable\""); - } - } - - @Test - public void testPreloadEmitsIdleWithoutComponentActivity() { - when(mockReactContext.getCurrentActivity()).thenReturn(null); - - try (MockedStatic mockedShopifyCheckoutKit = Mockito - .mockStatic(ShopifyCheckoutKit.class)) { - shopifyCheckoutKitModule.preload("https://shopify.com", "preload-request"); - - mockedShopifyCheckoutKit.verifyNoInteractions(); - assertThat(shopifyCheckoutKitModule.preloadStateEvent) - .contains("\"requestId\":\"preload-request\"") - .contains("\"type\":\"idle\""); - } - } - - @Test - public void testCanInvalidatePreloadCache() { - try (MockedStatic mockedShopifyCheckoutKit = Mockito - .mockStatic(ShopifyCheckoutKit.class)) { - CheckoutPreload checkoutPreload = mock(CheckoutPreload.class); - mockedShopifyCheckoutKit - .when(() -> ShopifyCheckoutKit.preload( - anyString(), - eq(mockComponentActivity), - any(PreloadStateListener.class))) - .thenReturn(checkoutPreload); - - shopifyCheckoutKitModule.preload("https://shopify.com", "preload-request"); - shopifyCheckoutKitModule.invalidateCache(); - - verify(checkoutPreload).setListener(null); - mockedShopifyCheckoutKit.verify(ShopifyCheckoutKit::invalidate); - } - } - - @Test - public void testModuleInvalidationDetachesPreloadListener() { - try (MockedStatic mockedShopifyCheckoutKit = Mockito - .mockStatic(ShopifyCheckoutKit.class)) { - CheckoutPreload checkoutPreload = mock(CheckoutPreload.class); - mockedShopifyCheckoutKit - .when(() -> ShopifyCheckoutKit.preload( - anyString(), - eq(mockComponentActivity), - any(PreloadStateListener.class))) - .thenReturn(checkoutPreload); - - shopifyCheckoutKitModule.preload("https://shopify.com", "preload-request"); - shopifyCheckoutKitModule.invalidate(); - - verify(checkoutPreload).setListener(null); - } - } - - @Test - public void testPresentForwardsOnCloseCallback() { - DispatchCallback dispatch = mock(DispatchCallback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onCheckoutDismissed(); - - verify(dispatch).invoke(stringCaptor.capture()); - assertThat(stringCaptor.getValue()).contains("\"type\":\"close\""); - } - - @Test - public void testOnCloseCallbackIsSingleShot() { - DispatchCallback dispatch = mock(DispatchCallback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onCheckoutDismissed(); - processor.onCheckoutDismissed(); - - verify(dispatch, times(1)).invoke(anyString()); - } - - @Test - public void testReleaseDropsPendingDispatchCallback() { - DispatchCallback dispatch = mock(DispatchCallback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.release(); - processor.onCheckoutDismissed(); - - verify(dispatch, never()).invoke(anyString()); - } - - @Test - public void testReleaseClearsPendingGeolocationCallback() { - DispatchCallback dispatch = mock(DispatchCallback.class); - GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - processor.release(); - processor.invokeGeolocationCallback(true); - - verify(permissionsCallback, never()).invoke(anyString(), anyBoolean(), anyBoolean()); - } - - @Test - public void testTerminalEventClearsPendingGeolocationCallback() { - DispatchCallback dispatch = mock(DispatchCallback.class); - GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - processor.onCheckoutDismissed(); - processor.invokeGeolocationCallback(true); - - verify(permissionsCallback, never()).invoke(anyString(), anyBoolean(), anyBoolean()); - } - - @Test - public void testGeolocationDispatchesEnvelopeWithOrigin() { - DispatchCallback dispatch = mock(DispatchCallback.class); - GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - - verify(dispatch).invoke(stringCaptor.capture()); - assertThat(stringCaptor.getValue()) - .contains("\"type\":\"geolocationRequest\"", "\"origin\":\"https://shopify.com\""); - } - - @Test - public void testGeolocationDispatchIsMultiShot() { - DispatchCallback dispatch = mock(DispatchCallback.class); - GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - - verify(dispatch, times(2)).invoke(anyString()); - } - - /** - * Module name and version - */ - - @Test - public void testModuleName() { - assertThat(shopifyCheckoutKitModule.getName()) - .isEqualTo("ShopifyCheckoutKit"); - } - - @Test - public void testConstants() { - assertThat(shopifyCheckoutKitModule.getConstants()) - .isNotNull() - .containsKey("version"); - } - - /** - * Configuration - */ - - @Test - public void testHasCorrectDefaultConfiguration() { - // Test that the module starts with sensible defaults - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("storefront"); - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getPreloading().getEnabled()) - .isTrue(); - } - - @Test - public void testCanSetDarkColorScheme() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("colorScheme", "dark"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("dark"); - } - - @Test - public void testUnknownColorSchemeKeepsTheCurrentAppearance() { - JavaOnlyMap darkConfig = new JavaOnlyMap(); - darkConfig.putString("colorScheme", "dark"); - shopifyCheckoutKitModule.setConfig(darkConfig); - - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("colorScheme", "sepia"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("dark"); - } - - @Test - public void testUnknownColorSchemeKeepsTheNativeDefaultAppearance() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("colorScheme", "sepia"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("storefront"); - } - - @Test - public void testAllowedMessageOriginsRoundTrip() { - JavaOnlyMap config = new JavaOnlyMap(); - JavaOnlyArray allowedMessageOrigins = new JavaOnlyArray(); - allowedMessageOrigins.pushString("https://example.com"); - allowedMessageOrigins.pushString("https://*.example.com"); - config.putArray("allowedMessageOrigins", allowedMessageOrigins); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getAllowedMessageOrigins()) - .containsExactlyInAnyOrder("https://example.com", "https://*.example.com"); - assertThat(shopifyCheckoutKitModule.getConfig().getArray("allowedMessageOrigins").toArrayList()) - .containsExactlyInAnyOrder("https://example.com", "https://*.example.com"); - } - - @Test - public void testCanConfigureLightColorSchemeWithValidColors() { - JavaOnlyMap androidColors = createValidLightColors(); - JavaOnlyMap config = createConfigWithAndroidColors("light", androidColors); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("light"); - } - - @Test - public void testCanConfigureDarkColorSchemeWithValidColors() { - JavaOnlyMap androidColors = createValidDarkColors(); - JavaOnlyMap config = createConfigWithAndroidColors("dark", androidColors); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("dark"); - } - - @Test - public void testCanConfigureAutomaticColorSchemeWithLightAndDarkColors() { - JavaOnlyMap lightColors = createValidLightColors(); - JavaOnlyMap darkColors = createValidDarkColors(); - - JavaOnlyMap androidColors = new JavaOnlyMap(); - androidColors.putMap("light", lightColors); - androidColors.putMap("dark", darkColors); - - JavaOnlyMap colorsConfig = new JavaOnlyMap(); - colorsConfig.putMap("android", androidColors); - - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("colorScheme", "automatic"); - config.putMap("colors", colorsConfig); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("automatic"); - } - - @Test - public void testInvalidColorConfigurationFallsBackToBasicScheme() { - JavaOnlyMap androidColors = new JavaOnlyMap(); - androidColors.putString("backgroundColor", "invalid-color"); - androidColors.putString("progressIndicator", PROGRESS_INDICATOR); - androidColors.putString("headerBackgroundColor", HEADER_BACKGROUND_COLOR); - androidColors.putString("headerTextColor", HEADER_TEXT_COLOR); - - JavaOnlyMap config = createConfigWithAndroidColors("light", androidColors); - - // Should not throw exception - shopifyCheckoutKitModule.setConfig(config); - - // Should fall back to basic light scheme without custom colors - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("light"); - } - - @Test - public void testPartialColorConfigurationIsRejected() { - JavaOnlyMap androidColors = new JavaOnlyMap(); - androidColors.putString("backgroundColor", BACKGROUND_COLOR); - // Missing other required colors - - JavaOnlyMap config = createConfigWithAndroidColors("light", androidColors); - - shopifyCheckoutKitModule.setConfig(config); - - // Should fall back to basic scheme since colors are incomplete - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("light"); - } - - @Test - public void testCanSetConfigWithCloseButtonColor() { - JavaOnlyMap androidColors = createValidLightColors(); - androidColors.putString("closeButtonColor", "#FF0000"); - - JavaOnlyMap config = createConfigWithAndroidColors("light", androidColors); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("light"); - } - - @Test - public void testCanSetConfigWithMissingCloseButtonColor() { - // Missing closeButtonColor - should not crash - JavaOnlyMap androidColors = createValidLightColors(); - JavaOnlyMap config = createConfigWithAndroidColors("light", androidColors); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("light"); - } - - @Test - public void testCanSetConfigWithInvalidCloseButtonColor() { - JavaOnlyMap androidColors = createValidLightColors(); - androidColors.putString("closeButtonColor", "invalid-color"); - JavaOnlyMap config = createConfigWithAndroidColors("light", androidColors); - - // The method should not throw an exception when given invalid close button - // color - shopifyCheckoutKitModule.setConfig(config); - - // Verify the color scheme was set correctly despite invalid close button color - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("light"); - } - - /** - * Log Level Configuration - */ - - @Test - public void testCanSetLogLevelDebug() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "debug"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(LogLevel.DEBUG); - } - - @Test - public void testCanSetLogLevelError() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "error"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(LogLevel.ERROR); - } - - @Test - public void testCanSetLogLevelNone() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "none"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(LogLevel.NONE); - } - - @Test - public void testCanSetLogLevelWarn() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "warn"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(LogLevel.WARN); - } - - @Test - public void testCanSetEveryNativeLogLevel() { - for (LogLevel logLevel : LogLevel.values()) { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", logLevel.name().toLowerCase(Locale.ROOT)); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(logLevel); - } - } - - @Test - public void testInvalidLogLevelKeepsTheCurrentLevel() { - JavaOnlyMap debugConfig = new JavaOnlyMap(); - debugConfig.putString("logLevel", "debug"); - shopifyCheckoutKitModule.setConfig(debugConfig); - - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "invalid"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(LogLevel.DEBUG); - } - - @Test - public void testLogLevelHandlesUppercaseDebug() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "DEBUG"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(LogLevel.DEBUG); - } - - @Test - public void testLogLevelHandlesMixedCaseDebug() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "Debug"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(LogLevel.DEBUG); - } - - @Test - public void testLogLevelHandlesUppercaseError() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "ERROR"); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(LogLevel.ERROR); - } - - @Test - public void testSetConfigWithoutLogLevelKeepsTheNativeLevel() { - JavaOnlyMap debugConfig = new JavaOnlyMap(); - debugConfig.putString("logLevel", "debug"); - shopifyCheckoutKitModule.setConfig(debugConfig); - - JavaOnlyMap config = new JavaOnlyMap(); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getLogLevel()) - .isEqualTo(LogLevel.DEBUG); - } - - @Test - public void testCanDisablePreloading() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putBoolean("preloading", false); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(ShopifyCheckoutKitModule.checkoutConfig.getPreloading().getEnabled()) - .isFalse(); - } - - @Test - public void testGetConfigIncludesPreloading() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putBoolean("preloading", false); - - shopifyCheckoutKitModule.setConfig(config); - - WritableMap result = shopifyCheckoutKitModule.getConfig(); - - assertThat(result).isNotNull(); - assertThat(result.getBoolean("preloading")).isFalse(); - } - - @Test - public void testGetConfigReturnsDebugForDebugLogLevel() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "debug"); - - shopifyCheckoutKitModule.setConfig(config); - - WritableMap result = shopifyCheckoutKitModule.getConfig(); - - assertThat(result).isNotNull(); - assertThat(result.getString("logLevel")).isEqualTo("debug"); - } - - @Test - public void testGetConfigReturnsErrorForErrorLogLevel() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "error"); - - shopifyCheckoutKitModule.setConfig(config); - - WritableMap result = shopifyCheckoutKitModule.getConfig(); - - assertThat(result).isNotNull(); - assertThat(result.getString("logLevel")).isEqualTo("error"); - } - - @Test - public void testGetConfigReturnsNoneForNoneLogLevel() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "none"); - - shopifyCheckoutKitModule.setConfig(config); - - WritableMap result = shopifyCheckoutKitModule.getConfig(); - - assertThat(result).isNotNull(); - assertThat(result.getString("logLevel")).isEqualTo("none"); - } - - @Test - public void testGetConfigReportsEveryNativeLogLevel() { - for (LogLevel logLevel : LogLevel.values()) { - String name = logLevel.name().toLowerCase(Locale.ROOT); - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", name); - - shopifyCheckoutKitModule.setConfig(config); - - assertThat(shopifyCheckoutKitModule.getConfig().getString("logLevel")) - .isEqualTo(name); - } - } - - @Test - public void testGetConfigKeepsTheCurrentLevelForInvalidLogLevel() { - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("logLevel", "invalid"); - - shopifyCheckoutKitModule.setConfig(config); - - WritableMap result = shopifyCheckoutKitModule.getConfig(); - - assertThat(result).isNotNull(); - assertThat(result.getString("logLevel")).isEqualTo("warn"); - } - - @Test - public void testGetConfigReturnsTheNativeDefaultLogLevel() { - WritableMap result = shopifyCheckoutKitModule.getConfig(); - - assertThat(result).isNotNull(); - assertThat(result.getString("logLevel")).isEqualTo("warn"); - } - - /** - * Events - */ - - /** - * Errors - */ - - @Test - public void testCanProcessCheckoutExpiredErrors() { - DispatchCallback dispatch = mock(DispatchCallback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onCheckoutFailed(cartExpired()); - - verify(dispatch).invoke(stringCaptor.capture()); - - assertThat(stringCaptor.getValue()) - .contains("\"type\":\"fail\"", "\"code\":\"cart_expired\"", "\"message\":\"Cart has expired\"") - .doesNotContain("__typename", "statusCode"); - } - - @Test - public void testCanProcessClientErrors() { - DispatchCallback dispatch = mock(DispatchCallback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onCheckoutFailed(new CheckoutException( - CheckoutErrorCode.CUSTOMER_ACCOUNT_REQUIRED, "Customer account required")); - - verify(dispatch).invoke(stringCaptor.capture()); - - assertThat(stringCaptor.getValue()) - .contains("\"type\":\"fail\"", "\"code\":\"customer_account_required\"", - "\"message\":\"Customer account required\"") - .doesNotContain("__typename", "statusCode"); - } - - @Test - public void testCanProcessHttpErrors() { - DispatchCallback dispatch = mock(DispatchCallback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onCheckoutFailed(new CheckoutException( - CheckoutErrorCode.HTTP_ERROR, "Not Found", 404)); - - verify(dispatch).invoke(stringCaptor.capture()); - - assertThat(stringCaptor.getValue()) - .contains("\"type\":\"fail\"", "\"code\":\"http_error\"", "\"message\":\"Not Found\"", - "\"statusCode\":404") - .doesNotContain("__typename"); - } - - @Test - public void testEveryErrorCodeSerialisesAsLowerSnakeCase() { - for (CheckoutErrorCode code : CheckoutErrorCode.values()) { - DispatchCallback dispatch = mock(DispatchCallback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - ArgumentCaptor envelopeCaptor = ArgumentCaptor.forClass(String.class); - - processor.onCheckoutFailed(new CheckoutException(code, "failed")); - - verify(dispatch).invoke(envelopeCaptor.capture()); - assertThat(envelopeCaptor.getValue()) - .contains("\"code\":\"" + code.name().toLowerCase(Locale.ROOT) + "\""); - } - } - - @Test - public void testOnFailCallbackIsSingleShot() { - DispatchCallback dispatch = mock(DispatchCallback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - - processor.onCheckoutFailed(cartExpired()); - processor.onCheckoutFailed(cartExpired()); - - verify(dispatch, times(1)).invoke(anyString()); - } - - /** - * Integration - */ - - @Test - public void testCompleteConfigurationAndEventFlow() { - // Set up configuration - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("colorScheme", "dark"); - - shopifyCheckoutKitModule.setConfig(config); - - // Verify configuration was applied - assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.getAppearance())) - .isEqualTo("dark"); - } - - /** - * Helpers - */ - - private static CheckoutException cartExpired() { - return new CheckoutException(CheckoutErrorCode.CART_EXPIRED, "Cart has expired"); - } - - private static String colorSchemeIdOf(CheckoutAppearance appearance) { - if (appearance instanceof CheckoutAppearance.App) { - return ((CheckoutAppearance.App) appearance).getColorScheme().getId(); - } - return "storefront"; - } - - private JavaOnlyMap createValidLightColors() { - JavaOnlyMap colors = new JavaOnlyMap(); - colors.putString("backgroundColor", BACKGROUND_COLOR); - colors.putString("progressIndicator", PROGRESS_INDICATOR); - colors.putString("headerBackgroundColor", HEADER_BACKGROUND_COLOR); - colors.putString("headerTextColor", HEADER_TEXT_COLOR); - return colors; - } - - private JavaOnlyMap createValidDarkColors() { - JavaOnlyMap colors = new JavaOnlyMap(); - colors.putString("backgroundColor", DARK_BACKGROUND_COLOR); - colors.putString("progressIndicator", DARK_PROGRESS_INDICATOR); - colors.putString("headerBackgroundColor", DARK_HEADER_BACKGROUND_COLOR); - colors.putString("headerTextColor", DARK_HEADER_TEXT_COLOR); - return colors; - } - - private JavaOnlyMap createConfigWithAndroidColors(String colorScheme, JavaOnlyMap androidColors) { - JavaOnlyMap colorsConfig = new JavaOnlyMap(); - colorsConfig.putMap("android", androidColors); - - JavaOnlyMap config = new JavaOnlyMap(); - config.putString("colorScheme", colorScheme); - config.putMap("colors", colorsConfig); - return config; - } - - private static class PromiseMock implements Promise { - public Object resolvedValue; - public String rejectedCode; - public String rejectedMessage; - public Throwable rejectedThrowable; - - @Override - public void resolve(Object value) { - resolvedValue = value; - } - - @Override - public void reject(String code, String message) { - rejectedCode = code; - rejectedMessage = message; - } - - @Override - public void reject(String code, Throwable throwable) { - rejectedCode = code; - rejectedThrowable = throwable; - } - - @Override - public void reject(String code, String message, Throwable throwable) { - rejectedCode = code; - rejectedMessage = message; - rejectedThrowable = throwable; - } - - @Override - public void reject(Throwable throwable) { - rejectedThrowable = throwable; - } - - @Override - public void reject(Throwable throwable, WritableMap userInfo) { - rejectedThrowable = throwable; - } - - @Override - public void reject(String code, WritableMap userInfo) { - rejectedCode = code; - } - - @Override - public void reject(String code, Throwable throwable, WritableMap userInfo) { - rejectedCode = code; - rejectedThrowable = throwable; - } - - @Override - public void reject(String code, String message, WritableMap userInfo) { - rejectedCode = code; - rejectedMessage = message; - } - - @Override - public void reject(String code, String message, Throwable throwable, WritableMap userInfo) { - rejectedCode = code; - rejectedMessage = message; - rejectedThrowable = throwable; - } - - @Override - public void reject(String message) { - rejectedMessage = message; - } - } -} diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.kt b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.kt new file mode 100644 index 000000000..b938498e3 --- /dev/null +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.kt @@ -0,0 +1,424 @@ +package com.shopify.checkoutkit.reactnativedemo + +import android.webkit.GeolocationPermissions +import androidx.activity.ComponentActivity +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReactApplicationContext +import com.shopify.checkoutkit.CheckoutAppearance +import com.shopify.checkoutkit.CheckoutErrorCode +import com.shopify.checkoutkit.CheckoutException +import com.shopify.checkoutkit.CheckoutPreload +import com.shopify.checkoutkit.ColorScheme +import com.shopify.checkoutkit.LogLevel +import com.shopify.checkoutkit.PreloadState +import com.shopify.checkoutkit.PreloadStateListener +import com.shopify.checkoutkit.Preloading +import com.shopify.checkoutkit.ShopifyCheckoutKit +import com.shopify.reactnative.checkoutkit.CustomCheckoutListener +import com.shopify.reactnative.checkoutkit.DispatchCallback +import com.shopify.reactnative.checkoutkit.ShopifyCheckoutKitModule +import java.util.Locale +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.ArgumentCaptor +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ShopifyCheckoutKitModuleTest { + private lateinit var reactContext: ReactApplicationContext + private lateinit var componentActivity: ComponentActivity + private lateinit var module: TestShopifyCheckoutKitModule + private lateinit var mockedArguments: MockedStatic + private lateinit var initialAppearance: CheckoutAppearance + private lateinit var initialLogLevel: LogLevel + private lateinit var initialPreloading: Preloading + + private class TestShopifyCheckoutKitModule( + reactContext: ReactApplicationContext, + ) : ShopifyCheckoutKitModule(reactContext) { + var preloadStateEvent: String? = null + + override fun emitPreloadStateEvent(event: String) { + preloadStateEvent = event + } + } + + @Before + fun setUp() { + mockedArguments = Mockito.mockStatic(Arguments::class.java) + mockedArguments.`when`(Arguments::createMap).thenAnswer { JavaOnlyMap() } + mockedArguments.`when` { Arguments.fromList(Mockito.anyList()) } + .thenAnswer { invocation -> JavaOnlyArray.from(invocation.getArgument(0)) } + reactContext = Mockito.mock(ReactApplicationContext::class.java) + componentActivity = Mockito.mock(ComponentActivity::class.java) + Mockito.`when`(reactContext.currentActivity).thenReturn(componentActivity) + module = TestShopifyCheckoutKitModule(reactContext) + initialAppearance = ShopifyCheckoutKitModule.checkoutConfig.appearance + initialLogLevel = ShopifyCheckoutKitModule.checkoutConfig.logLevel + initialPreloading = ShopifyCheckoutKitModule.checkoutConfig.preloading + } + + @After + fun tearDown() { + if (::mockedArguments.isInitialized) { + mockedArguments.close() + } + if (!::initialAppearance.isInitialized) return + ShopifyCheckoutKit.configure { configuration -> + configuration.appearance = initialAppearance + configuration.logLevel = initialLogLevel + configuration.preloading = initialPreloading + ShopifyCheckoutKitModule.checkoutConfig = configuration + } + } + + @Test + fun canPresentCheckout() { + Mockito.mockStatic(ShopifyCheckoutKit::class.java).use { checkoutKit -> + Mockito.doAnswer { invocation -> + invocation.getArgument(0).run() + null + }.`when`(componentActivity).runOnUiThread(Mockito.any()) + module.present("https://shopify.com", JavaOnlyArray()) + Mockito.verify(componentActivity).runOnUiThread(Mockito.any()) + } + } + + @Test + fun canPreload() { + Mockito.mockStatic(ShopifyCheckoutKit::class.java).use { checkoutKit -> + val checkoutPreload = Mockito.mock(CheckoutPreload::class.java) + checkoutKit.`when` { + ShopifyCheckoutKit.preload( + matching("https://shopify.com"), + matching(componentActivity), + anyPreloadStateListener(), + ) + }.thenReturn(checkoutPreload) + + module.preload("https://shopify.com", "preload-request") + + checkoutKit.verify { + ShopifyCheckoutKit.preload( + matching("https://shopify.com"), + matching(componentActivity), + anyPreloadStateListener(), + ) + } + } + } + + @Test + fun preloadSerializesWebContentUnavailable() { + Mockito.mockStatic(ShopifyCheckoutKit::class.java).use { checkoutKit -> + val checkoutPreload = Mockito.mock(CheckoutPreload::class.java) + val listenerCaptor = ArgumentCaptor.forClass(PreloadStateListener::class.java) + checkoutKit.`when` { + ShopifyCheckoutKit.preload( + matching("https://shopify.com"), + matching(componentActivity), + anyPreloadStateListener(), + ) + }.thenReturn(checkoutPreload) + + module.preload("https://shopify.com", "preload-request") + + checkoutKit.verify { + ShopifyCheckoutKit.preload( + matching("https://shopify.com"), + matching(componentActivity), + capturing(listenerCaptor), + ) + } + listenerCaptor.value.onStateChanged( + PreloadState.Failed( + PreloadState.FailureReason.WebContentUnavailable, + "Web content process terminated.", + ), + ) + assertThat(module.preloadStateEvent) + .contains("\"requestId\":\"preload-request\"") + .contains("\"type\":\"failed\"") + .contains("\"reason\":\"webContentUnavailable\"") + } + } + + @Test + fun preloadEmitsIdleWithoutComponentActivity() { + Mockito.`when`(reactContext.currentActivity).thenReturn(null) + Mockito.mockStatic(ShopifyCheckoutKit::class.java).use { checkoutKit -> + module.preload("https://shopify.com", "preload-request") + checkoutKit.verifyNoInteractions() + assertThat(module.preloadStateEvent) + .contains("\"requestId\":\"preload-request\"") + .contains("\"type\":\"idle\"") + } + } + + @Test + fun canInvalidatePreloadCache() { + Mockito.mockStatic(ShopifyCheckoutKit::class.java).use { checkoutKit -> + val checkoutPreload = mockPreload(checkoutKit) + module.preload("https://shopify.com", "preload-request") + module.invalidateCache() + + Mockito.verify(checkoutPreload).listener = null + checkoutKit.verify(ShopifyCheckoutKit::invalidate) + } + } + + @Test + fun moduleInvalidationDetachesPreloadListener() { + Mockito.mockStatic(ShopifyCheckoutKit::class.java).use { checkoutKit -> + val checkoutPreload = mockPreload(checkoutKit) + module.preload("https://shopify.com", "preload-request") + module.invalidate() + + Mockito.verify(checkoutPreload).listener = null + } + } + + @Test + fun closeDispatchIsSingleShot() { + val envelopes = mutableListOf() + val dispatch = DispatchCallback(envelopes::add) + val listener = CustomCheckoutListener(dispatch) + listener.onCheckoutDismissed() + listener.onCheckoutDismissed() + + assertThat(envelopes).hasSize(1) + assertThat(envelopes.single()).contains("\"type\":\"close\"") + } + + @Test + fun releaseDropsPendingDispatch() { + val dispatch = Mockito.mock(DispatchCallback::class.java) + val listener = CustomCheckoutListener(dispatch) + listener.release() + listener.onCheckoutDismissed() + Mockito.verify(dispatch, Mockito.never()).invoke(Mockito.anyString()) + } + + @Test + fun releaseClearsPendingGeolocationCallback() { + val dispatch = Mockito.mock(DispatchCallback::class.java) + val permissions = Mockito.mock(GeolocationPermissions.Callback::class.java) + val listener = CustomCheckoutListener(dispatch) + listener.onGeolocationPermissionsShowPrompt("https://shopify.com", permissions) + listener.release() + listener.invokeGeolocationCallback(true) + Mockito.verify(permissions, Mockito.never()).invoke(Mockito.anyString(), Mockito.anyBoolean(), Mockito.anyBoolean()) + } + + @Test + fun geolocationDispatchesMultipleEnvelopes() { + val envelopes = mutableListOf() + val dispatch = DispatchCallback(envelopes::add) + val permissions = Mockito.mock(GeolocationPermissions.Callback::class.java) + val listener = CustomCheckoutListener(dispatch) + listener.onGeolocationPermissionsShowPrompt("https://shopify.com", permissions) + listener.onGeolocationPermissionsShowPrompt("https://shopify.com", permissions) + + assertThat(envelopes).hasSize(2).allSatisfy { + assertThat(it).contains("\"type\":\"geolocationRequest\"", "\"origin\":\"https://shopify.com\"") + } + } + + @Test + fun exposesModuleNameAndConstants() { + assertThat(module.name).isEqualTo("ShopifyCheckoutKit") + assertThat(module.constants).containsKeys("version", "dispatchEventTypes") + } + + @Test + fun hasCorrectDefaultConfiguration() { + assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.appearance)).isEqualTo("storefront") + assertThat(ShopifyCheckoutKitModule.checkoutConfig.preloading.enabled).isTrue() + } + + @Test + fun allowedMessageOriginsRoundTrip() { + val origins = JavaOnlyArray().apply { + pushString("https://example.com") + pushString("https://*.example.com") + } + module.setConfig(JavaOnlyMap().apply { putArray("allowedMessageOrigins", origins) }) + + assertThat(ShopifyCheckoutKitModule.checkoutConfig.allowedMessageOrigins) + .containsExactlyInAnyOrder("https://example.com", "https://*.example.com") + assertThat(module.config.getArray("allowedMessageOrigins")?.toArrayList()) + .containsExactlyInAnyOrder("https://example.com", "https://*.example.com") + } + + @Test + fun configuresEveryColorScheme() { + listOf("light", "dark", "automatic", "storefront").forEach { colorScheme -> + module.setConfig(JavaOnlyMap().apply { putString("colorScheme", colorScheme) }) + assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.appearance)).isEqualTo(colorScheme) + } + } + + @Test + fun unknownColorSchemeKeepsCurrentAppearance() { + module.setConfig(JavaOnlyMap().apply { putString("colorScheme", "dark") }) + module.setConfig(JavaOnlyMap().apply { putString("colorScheme", "sepia") }) + assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.appearance)).isEqualTo("dark") + } + + @Test + fun configuresValidLightDarkAndAutomaticColors() { + listOf( + configWithAndroidColors("light", validLightColors()), + configWithAndroidColors("dark", validDarkColors()), + JavaOnlyMap().apply { + putString("colorScheme", "automatic") + putMap("colors", JavaOnlyMap().apply { + putMap("android", JavaOnlyMap().apply { + putMap("light", validLightColors()) + putMap("dark", validDarkColors()) + }) + }) + }, + ).forEach(module::setConfig) + } + + @Test + fun invalidOrPartialColorsFallBackToBasicScheme() { + val invalid = validLightColors().apply { putString("backgroundColor", "invalid") } + module.setConfig(configWithAndroidColors("light", invalid)) + assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.appearance)).isEqualTo("light") + + val partial = JavaOnlyMap().apply { putString("backgroundColor", BACKGROUND_COLOR) } + module.setConfig(configWithAndroidColors("dark", partial)) + assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.appearance)).isEqualTo("dark") + } + + @Test + fun acceptsOptionalCloseButtonColor() { + val colors = validLightColors().apply { putString("closeButtonColor", "#123456") } + module.setConfig(configWithAndroidColors("light", colors)) + assertThat(colorSchemeIdOf(ShopifyCheckoutKitModule.checkoutConfig.appearance)).isEqualTo("light") + } + + @Test + fun configuresAndReportsEveryLogLevel() { + LogLevel.entries.forEach { level -> + val name = level.name.lowercase(Locale.ROOT) + module.setConfig(JavaOnlyMap().apply { putString("logLevel", name) }) + assertThat(ShopifyCheckoutKitModule.checkoutConfig.logLevel).isEqualTo(level) + assertThat(module.config.getString("logLevel")).isEqualTo(name) + } + } + + @Test + fun logLevelsAreCaseInsensitiveAndInvalidValuesAreIgnored() { + module.setConfig(JavaOnlyMap().apply { putString("logLevel", "DeBuG") }) + assertThat(ShopifyCheckoutKitModule.checkoutConfig.logLevel).isEqualTo(LogLevel.DEBUG) + module.setConfig(JavaOnlyMap().apply { putString("logLevel", "invalid") }) + assertThat(ShopifyCheckoutKitModule.checkoutConfig.logLevel).isEqualTo(LogLevel.DEBUG) + } + + @Test + fun canDisableAndReadPreloading() { + module.setConfig(JavaOnlyMap().apply { putBoolean("preloading", false) }) + assertThat(ShopifyCheckoutKitModule.checkoutConfig.preloading.enabled).isFalse() + assertThat(module.config.getBoolean("preloading")).isFalse() + } + + @Test + fun serializesCheckoutErrors() { + val cases = listOf( + CheckoutException(CheckoutErrorCode.CART_EXPIRED, "Cart has expired") to "cart_expired", + CheckoutException(CheckoutErrorCode.CUSTOMER_ACCOUNT_REQUIRED, "Customer account required") to + "customer_account_required", + CheckoutException(CheckoutErrorCode.HTTP_ERROR, "Not Found", 404) to "http_error", + ) + + cases.forEach { (error, code) -> + val envelopes = mutableListOf() + val dispatch = DispatchCallback(envelopes::add) + CustomCheckoutListener(dispatch).onCheckoutFailed(error) + assertThat(envelopes.single()).contains("\"type\":\"fail\"", "\"code\":\"$code\"") + if (error.httpStatusCode != null) assertThat(envelopes.single()).contains("\"statusCode\":404") + } + } + + @Test + fun everyErrorCodeSerializesAsLowerSnakeCase() { + CheckoutErrorCode.entries.forEach { code -> + val envelopes = mutableListOf() + val dispatch = DispatchCallback(envelopes::add) + val listener = CustomCheckoutListener(dispatch) + listener.onCheckoutFailed(CheckoutException(code, "failed")) + assertThat(envelopes.single()).contains("\"code\":\"${code.name.lowercase(Locale.ROOT)}\"") + } + } + + private fun colorSchemeIdOf(appearance: CheckoutAppearance): String = + (appearance as? CheckoutAppearance.App)?.colorScheme?.id ?: "storefront" + + private fun validLightColors(): JavaOnlyMap = JavaOnlyMap().apply { + putString("backgroundColor", BACKGROUND_COLOR) + putString("progressIndicator", PROGRESS_INDICATOR) + putString("headerBackgroundColor", HEADER_BACKGROUND_COLOR) + putString("headerTextColor", HEADER_TEXT_COLOR) + } + + private fun validDarkColors(): JavaOnlyMap = JavaOnlyMap().apply { + putString("backgroundColor", DARK_BACKGROUND_COLOR) + putString("progressIndicator", DARK_PROGRESS_INDICATOR) + putString("headerBackgroundColor", DARK_HEADER_BACKGROUND_COLOR) + putString("headerTextColor", DARK_HEADER_TEXT_COLOR) + } + + private fun configWithAndroidColors(colorScheme: String, colors: JavaOnlyMap): JavaOnlyMap = + JavaOnlyMap().apply { + putString("colorScheme", colorScheme) + putMap("colors", JavaOnlyMap().apply { putMap("android", colors) }) + } + + private fun mockPreload(checkoutKit: MockedStatic): CheckoutPreload { + val checkoutPreload = Mockito.mock(CheckoutPreload::class.java) + checkoutKit.`when` { + ShopifyCheckoutKit.preload( + matching("https://shopify.com"), + matching(componentActivity), + anyPreloadStateListener(), + ) + }.thenReturn(checkoutPreload) + return checkoutPreload + } + + private fun matching(value: T): T { + Mockito.eq(value) + return value + } + + private fun anyPreloadStateListener(): PreloadStateListener { + Mockito.any(PreloadStateListener::class.java) + return PreloadStateListener {} + } + + private fun capturing(captor: ArgumentCaptor): PreloadStateListener { + captor.capture() + return PreloadStateListener {} + } + + private companion object { + const val BACKGROUND_COLOR = "#FFFFFF" + const val PROGRESS_INDICATOR = "#000000" + const val HEADER_BACKGROUND_COLOR = "#FFFFFF" + const val HEADER_TEXT_COLOR = "#000000" + const val DARK_BACKGROUND_COLOR = "#000000" + const val DARK_PROGRESS_INDICATOR = "#FFFFFF" + const val DARK_HEADER_BACKGROUND_COLOR = "#000000" + const val DARK_HEADER_TEXT_COLOR = "#FFFFFF" + } +}