diff --git a/docs/en/apis/scheme.md b/docs/en/apis/scheme.md index 8bd034e3..b65b663a 100644 --- a/docs/en/apis/scheme.md +++ b/docs/en/apis/scheme.md @@ -72,6 +72,12 @@ screen size or density. The pair is atomic. If either parameter is missing, non-integer, zero, negative, or too large for an Android measure spec, Sparkling ignores both and keeps the default full-size behavior. +A valid fixed viewport cannot be used with the effective Android +`SparklingThreadStrategy.MULTI_THREADS` rendering strategy. Sparkling resolves +the page strategy before the global default and rejects that unsafe +combination with a typed `SparklingLynxConfigurationException` before Lynx +view construction. + ``` hybrid://lynxview_page?bundle=main.lynx.bundle&width=720&height=1280 ``` diff --git a/docs/en/apis/sparkling-sdk-android.md b/docs/en/apis/sparkling-sdk-android.md index 26938007..15078f71 100644 --- a/docs/en/apis/sparkling-sdk-android.md +++ b/docs/en/apis/sparkling-sdk-android.md @@ -21,7 +21,7 @@ val baseInfoConfig = BaseInfoConfig(isDebug = BuildConfig.DEBUG) val lynxConfig = SparklingLynxConfig.build(this) { // optional: add global Lynx behaviors/modules, template provider, etc. // setSharedProcessDensityOverride(2.0f) - setDefaultThreadStrategy(SparklingThreadStrategy.MULTI_THREADS) + setDefaultThreadStrategy(SparklingThreadStrategy.PART_ON_LAYOUT) setResourceFetcherFactory { sparklingContext -> SparklingResourceFetcherConfig.builder() .setGenericResourceFetcher(createGenericFetcher(sparklingContext)) @@ -58,8 +58,8 @@ Entry point for creating containers. See [Containers](../guide/containers.md) fo | Method | Description | |--------|-------------| | `Sparkling.build(context, sparklingContext)` | Creates a `Sparkling` instance from an Android `Context` and a `SparklingContext`. | -| `navigate()` | Starts `SparklingActivity` (full-page container). Returns `true` on success. | -| `createView(withoutPrepare)` | Creates a `SparklingView` (embedded container). Returns `null` on failure. | +| `navigate()` | Starts `SparklingActivity` (full-page container). Returns `true` on success and throws a typed exception for incompatible Lynx configuration. | +| `createView(withoutPrepare)` | Creates a `SparklingView` (embedded container). Returns `null` for other creation failures and throws a typed exception for incompatible Lynx configuration when prepare is enabled. | ## SparklingView @@ -162,6 +162,43 @@ one container with `SparklingContext.threadStrategy`. The per-container value takes precedence. If neither value is set, Sparkling leaves the Lynx SDK default unchanged. +### Fixed viewport compatibility + +Do not combine an effective fixed viewport with the effective +`SparklingThreadStrategy.MULTI_THREADS` strategy. This combination can crash +inside the native Lynx SDK. Sparkling rejects it before constructing the +`LynxView` and throws `SparklingLynxConfigurationException` with +`SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS`. +Both `navigate()` and prepared `createView(false)` calls fail synchronously +before starting or constructing a container. + +Sparkling resolves all typed configuration before validating it: + +1. viewport: `LynxKitInitParams.lynxViewport`, then + `SparklingContext.lynxViewport`, then canonical scheme `width`/`height`; +2. thread strategy: `SparklingContext.threadStrategy`, then + `SparklingLynxConfig.defaultThreadStrategy`, then the unchanged Lynx + default. + +Validation therefore does not depend on whether the viewport or strategy +setter ran first. A safe page strategy can override a global +`MULTI_THREADS` default. `MULTI_THREADS` without a fixed viewport and fixed +viewports with `ALL_ON_UI`, `MOST_ON_TASM`, `PART_ON_LAYOUT`, or no explicit +strategy keep their existing behavior. + +Java callers can catch and inspect the typed failure: + +```java +try { + SparklingView view = Sparkling.build(context, sparklingContext).createView(false); +} catch (SparklingLynxConfigurationException exception) { + if (exception.getError() + == SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS) { + // Select a safe strategy or remove the fixed viewport. + } +} +``` + ## SparklingUIProvider Interface for customizing container UI. Applies to both full-page and embedded containers. diff --git a/docs/zh/apis/scheme.md b/docs/zh/apis/scheme.md index d1378d89..37c5aa6b 100644 --- a/docs/zh/apis/scheme.md +++ b/docs/zh/apis/scheme.md @@ -69,6 +69,11 @@ Sparkling 会使用精确的 preset measure spec 创建 LynxView,并以完全 这两个参数是原子配置。如果任意一个缺失、不是整数、为零、为负数,或超出 Android measure spec 的安全范围,Sparkling 会同时忽略二者并保留默认的全尺寸行为。 +有效的固定 viewport 不能与 Android 最终生效的 +`SparklingThreadStrategy.MULTI_THREADS` 渲染策略同时使用。Sparkling 会先按 +页面级配置优先于全局默认值的规则解析线程策略,再在构造 LynxView 前通过类型安全的 +`SparklingLynxConfigurationException` 拒绝该危险组合。 + ``` hybrid://lynxview_page?bundle=main.lynx.bundle&width=720&height=1280 ``` diff --git a/docs/zh/apis/sparkling-sdk-android.md b/docs/zh/apis/sparkling-sdk-android.md index 25b0d2d5..a146c3a8 100644 --- a/docs/zh/apis/sparkling-sdk-android.md +++ b/docs/zh/apis/sparkling-sdk-android.md @@ -21,7 +21,7 @@ val baseInfoConfig = BaseInfoConfig(isDebug = BuildConfig.DEBUG) val lynxConfig = SparklingLynxConfig.build(this) { // 可选:添加全局 Lynx 行为/模块、模板提供者等 // setSharedProcessDensityOverride(2.0f) - setDefaultThreadStrategy(SparklingThreadStrategy.MULTI_THREADS) + setDefaultThreadStrategy(SparklingThreadStrategy.PART_ON_LAYOUT) } val hybridConfig = SparklingHybridConfig.build(baseInfoConfig) { setLynxConfig(lynxConfig) @@ -51,8 +51,8 @@ Sparkling 创建的 `LynxView` 也必须使用完全相同的 density 构造** | 方法 | 说明 | |------|------| | `Sparkling.build(context, sparklingContext)` | 通过 Android `Context` 和 `SparklingContext` 创建 `Sparkling` 实例。 | -| `navigate()` | 启动 `SparklingActivity`(全页容器)。成功返回 `true`。 | -| `createView(withoutPrepare)` | 创建 `SparklingView`(嵌入式容器)。失败返回 `null`。 | +| `navigate()` | 启动 `SparklingActivity`(全页容器)。成功返回 `true`;Lynx 配置不兼容时抛出类型安全异常。 | +| `createView(withoutPrepare)` | 创建 `SparklingView`(嵌入式容器)。其他创建失败返回 `null`;启用 prepare 时遇到不兼容 Lynx 配置会抛出类型安全异常。 | ## SparklingView @@ -126,6 +126,41 @@ init params、`SparklingContext.lynxViewport`、canonical scheme 的 `width` 和 为单个容器覆盖。容器级配置优先;两者都未设置时,Sparkling 不改变 Lynx SDK 的默认策略。 +### 固定 viewport 兼容性 + +不要同时使用最终生效的固定 viewport 和 +`SparklingThreadStrategy.MULTI_THREADS`。这个组合可能在 Lynx SDK +native 内部触发崩溃。Sparkling 会在构造 `LynxView` 前拒绝该组合,并抛出 +`SparklingLynxConfigurationException`;其 `error` 为 +`SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS`。 +`navigate()` 和会执行 prepare 的 `createView(false)` 都会在启动或构造容器前同步失败。 + +Sparkling 会先解析所有类型安全配置,再执行校验: + +1. viewport:`LynxKitInitParams.lynxViewport`、其次 + `SparklingContext.lynxViewport`、最后 canonical scheme 的 + `width`/`height`; +2. 线程策略:`SparklingContext.threadStrategy`、其次 + `SparklingLynxConfig.defaultThreadStrategy`、最后保持 Lynx 默认值不变。 + +因此,无论先设置 viewport 还是线程策略,校验结果都相同。安全的页面级策略 +可以覆盖全局 `MULTI_THREADS` 默认值。未使用固定 viewport 的 +`MULTI_THREADS`,以及搭配 `ALL_ON_UI`、`MOST_ON_TASM`、 +`PART_ON_LAYOUT` 或未显式设置线程策略的固定 viewport,均保持原有行为。 + +Java 调用方可以捕获并检查类型安全异常: + +```java +try { + SparklingView view = Sparkling.build(context, sparklingContext).createView(false); +} catch (SparklingLynxConfigurationException exception) { + if (exception.getError() + == SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS) { + // 改用安全的线程策略,或移除固定 viewport。 + } +} +``` + ## SparklingUIProvider 自定义容器 UI 的接口。适用于全页和嵌入式容器。 diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/Sparkling.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/Sparkling.kt index 2b7676a6..f09f6688 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/Sparkling.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/Sparkling.kt @@ -6,6 +6,8 @@ package com.tiktok.sparkling import android.content.Context import android.content.Intent import android.util.Log +import com.tiktok.sparkling.hybridkit.HybridCommon +import com.tiktok.sparkling.hybridkit.config.SparklingLynxConfig import com.tiktok.sparkling.utils.SchemeParser class Sparkling private constructor( @@ -42,16 +44,22 @@ class Sparkling private constructor( /** * Navigate to a Sparkling activity. * @return true if navigation was successful, false otherwise + * @throws SparklingLynxConfigurationException if the resolved Lynx options are incompatible */ fun navigate(): Boolean = try { processSparklingContext(sparklingContext) + sparklingContext.validateLynxConfiguration( + (HybridCommon.hybridConfig?.lynxConfig as? SparklingLynxConfig)?.defaultThreadStrategy, + ) val intent = Intent(context, SparklingActivity::class.java) intent.putExtra(SPARKLING_CONTEXT_CONTAINER_ID, sparklingContext.containerId) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK SparklingContextTransferStation.saveSparklingContext(sparklingContext) context.startActivity(intent) true + } catch (e: SparklingLynxConfigurationException) { + throw e } catch (e: Exception) { Log.e(TAG, "Failed to navigate: ${e.message}") false @@ -75,7 +83,8 @@ class Sparkling private constructor( /** * Create a SparklingView. * @param withoutPrepare If true, skip the prepare step - * @return SparklingView instance, or null if creation fails + * @return SparklingView instance, or null for non-configuration creation failures + * @throws SparklingLynxConfigurationException if the resolved Lynx options are incompatible */ fun createView(withoutPrepare: Boolean = false): SparklingView? = try { @@ -84,6 +93,8 @@ class Sparkling private constructor( view.prepare(sparklingContext) } view + } catch (e: SparklingLynxConfigurationException) { + throw e } catch (e: Exception) { Log.e(TAG, "Failed to create view: ${e.message}") null diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingLynxConfigurationException.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingLynxConfigurationException.kt new file mode 100644 index 00000000..383424c1 --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingLynxConfigurationException.kt @@ -0,0 +1,35 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling + +/** + * Identifies an unsupported combination of typed Sparkling Lynx options. + */ +enum class SparklingLynxConfigurationError { + FIXED_VIEWPORT_WITH_MULTI_THREADS, +} + +/** + * Thrown before Lynx view construction when typed Sparkling options cannot be used together. + */ +class SparklingLynxConfigurationException( + val error: SparklingLynxConfigurationError, +) : IllegalArgumentException(error.message()) + +internal fun SparklingContext.validateLynxConfiguration(defaultThreadStrategy: SparklingThreadStrategy?) { + val lynxViewport = resolveLynxViewport() + val threadStrategy = threadStrategy ?: defaultThreadStrategy + if (lynxViewport != null && threadStrategy == SparklingThreadStrategy.MULTI_THREADS) { + throw SparklingLynxConfigurationException( + SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS, + ) + } +} + +private fun SparklingLynxConfigurationError.message(): String = + when (this) { + SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS -> { + "A fixed Lynx viewport cannot be used with the MULTI_THREADS rendering strategy." + } + } diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKit.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKit.kt index b600c462..e9e7695a 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKit.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKit.kt @@ -18,6 +18,7 @@ import com.tiktok.sparkling.SparklingContext import com.tiktok.sparkling.SparklingThreadStrategy import com.tiktok.sparkling.applyLynxViewport import com.tiktok.sparkling.resolveLynxViewport +import com.tiktok.sparkling.validateLynxConfiguration import com.tiktok.sparkling.hybridkit.HybridCommon import com.tiktok.sparkling.hybridkit.HybridContext import com.tiktok.sparkling.hybridkit.base.IHybridKitLifeCycle @@ -99,12 +100,16 @@ object HybridLynxKit { kitInitParams.loadUri = hybridContext.resolveFullScheme()?.toUri() } - val viewBuilder = createLynxViewBuilder(lynxConfig) val sparklingContext = hybridContext as? SparklingContext - sparklingContext?.resolveLynxViewport()?.let { viewport -> + val lynxViewport = sparklingContext?.resolveLynxViewport() + val threadStrategy = resolveThreadStrategy(sparklingContext?.threadStrategy, lynxConfig?.defaultThreadStrategy) + sparklingContext?.validateLynxConfiguration(lynxConfig?.defaultThreadStrategy) + + val viewBuilder = createLynxViewBuilder(lynxConfig) + lynxViewport?.let { viewport -> viewBuilder.applyLynxViewport(viewport) } - applyThreadStrategy(viewBuilder, sparklingContext?.threadStrategy, lynxConfig?.defaultThreadStrategy) + applyThreadStrategy(viewBuilder, threadStrategy) var lynxViewRef: SimpleLynxKitView? = null val resourceFetcherConfig = SparklingResourceFetcherConfigurator.resolve(sparklingContext, lynxConfig) @@ -140,11 +145,15 @@ object HybridLynxKit { internal fun applyThreadStrategy( viewBuilder: LynxViewBuilder, - pageThreadStrategy: SparklingThreadStrategy?, - defaultThreadStrategy: SparklingThreadStrategy?, + threadStrategy: SparklingThreadStrategy?, ) { - (pageThreadStrategy ?: defaultThreadStrategy)?.let { + threadStrategy?.let { viewBuilder.setThreadStrategyForRendering(it.toLynxThreadStrategy()) } } + + internal fun resolveThreadStrategy( + pageThreadStrategy: SparklingThreadStrategy?, + defaultThreadStrategy: SparklingThreadStrategy?, + ): SparklingThreadStrategy? = pageThreadStrategy ?: defaultThreadStrategy } diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingLynxConfigurationJavaApiTest.java b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingLynxConfigurationJavaApiTest.java new file mode 100644 index 00000000..f220ccad --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingLynxConfigurationJavaApiTest.java @@ -0,0 +1,50 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import android.app.Application; +import com.tiktok.sparkling.hybridkit.HybridKit; +import com.tiktok.sparkling.hybridkit.base.HybridKitType; +import com.tiktok.sparkling.hybridkit.config.BaseInfoConfig; +import com.tiktok.sparkling.hybridkit.config.SparklingHybridConfig; +import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 33, packageName = "com.tiktok.sparkling") +public class SparklingLynxConfigurationJavaApiTest { + @Test + public void javaReceivesTypedFailureForUnsafeCombination() { + Application application = RuntimeEnvironment.getApplication(); + HybridKit.INSTANCE.init(application); + HybridKit.INSTANCE.setHybridConfig( + new SparklingHybridConfig.Builder(new BaseInfoConfig(false)).build(), application); + SparklingContext sparklingContext = new SparklingContext(); + HybridSchemeParam scheme = new HybridSchemeParam(); + scheme.setEngineType(HybridKitType.LYNX); + scheme.setBundle("main.lynx.bundle"); + sparklingContext.setHybridSchemeParam(scheme); + sparklingContext.setLynxViewport(new SparklingLynxViewport(320, 480)); + sparklingContext.setThreadStrategy(SparklingThreadStrategy.MULTI_THREADS); + Sparkling sparkling = Sparkling.build(application, sparklingContext); + + SparklingLynxConfigurationException exception = + assertThrows( + SparklingLynxConfigurationException.class, + () -> sparkling.createView(false)); + + assertEquals( + SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS, + exception.getError()); + assertTrue(exception instanceof IllegalArgumentException); + } +} diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKitConfigurationTest.kt b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKitConfigurationTest.kt new file mode 100644 index 00000000..c5dd227f --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKitConfigurationTest.kt @@ -0,0 +1,269 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.hybridkit.lynx + +import android.app.Application +import android.content.Context +import android.view.Display +import android.view.WindowManager +import android.view.accessibility.AccessibilityManager +import com.lynx.tasm.LynxView +import com.lynx.tasm.ThreadStrategyForRendering +import com.tiktok.sparkling.SparklingContext +import com.tiktok.sparkling.SparklingLynxConfigurationError +import com.tiktok.sparkling.SparklingLynxConfigurationException +import com.tiktok.sparkling.SparklingLynxViewport +import com.tiktok.sparkling.SparklingThreadStrategy +import com.tiktok.sparkling.hybridkit.HybridCommon +import com.tiktok.sparkling.hybridkit.HybridEnvironment +import com.tiktok.sparkling.hybridkit.HybridKit +import com.tiktok.sparkling.hybridkit.base.HybridKitType +import com.tiktok.sparkling.hybridkit.config.BaseInfoConfig +import com.tiktok.sparkling.hybridkit.config.SparklingHybridConfig +import com.tiktok.sparkling.hybridkit.config.SparklingLynxConfig +import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class HybridLynxKitConfigurationTest { + private lateinit var application: Application + private lateinit var context: Context + + @Before + fun setUp() { + clearAllMocks() + application = mockk(relaxed = true) + context = mockk(relaxed = true) + + val windowManager = mockk(relaxed = true) + val display = mockk(relaxed = true) + every { display.rotation } returns 0 + every { windowManager.defaultDisplay } returns display + every { context.getSystemService(Context.WINDOW_SERVICE) } returns windowManager + every { application.getSystemService(Context.WINDOW_SERVICE) } returns windowManager + + val accessibilityManager = mockk(relaxed = true) + every { context.getSystemService(Context.ACCESSIBILITY_SERVICE) } returns accessibilityManager + every { application.getSystemService(Context.ACCESSIBILITY_SERVICE) } returns accessibilityManager + + HybridEnvironment.instance.context = application + installGlobalThreadStrategy(null) + } + + @Test + fun globalMultiThreadsRejectsSchemeViewport() { + installGlobalThreadStrategy(SparklingThreadStrategy.MULTI_THREADS) + val scheme = scheme(viewport = VIEWPORT) + val sparklingContext = + SparklingContext().apply { + hybridSchemeParam = scheme + } + + assertUnsafeConfiguration { + HybridKit.createKitView(scheme, sparklingContext, context) + } + } + + @Test + fun pageMultiThreadsRejectsContextViewportRegardlessOfSetterOrder() { + listOf( + { sparklingContext: SparklingContext -> + sparklingContext.lynxViewport = VIEWPORT + sparklingContext.threadStrategy = SparklingThreadStrategy.MULTI_THREADS + }, + { sparklingContext: SparklingContext -> + sparklingContext.threadStrategy = SparklingThreadStrategy.MULTI_THREADS + sparklingContext.lynxViewport = VIEWPORT + }, + ).forEach { configure -> + val scheme = scheme() + val sparklingContext = + SparklingContext().apply { + hybridSchemeParam = scheme + } + configure(sparklingContext) + + assertUnsafeConfiguration { + HybridKit.createKitView(scheme, sparklingContext, context) + } + } + } + + @Test + fun globalConfigurationOrderDoesNotAffectPageViewportValidation() { + listOf( + true, + false, + ).forEach { installGlobalFirst -> + installGlobalThreadStrategy(null) + val scheme = scheme() + val sparklingContext = + SparklingContext().apply { + hybridSchemeParam = scheme + } + + if (installGlobalFirst) { + installGlobalThreadStrategy(SparklingThreadStrategy.MULTI_THREADS) + sparklingContext.lynxViewport = VIEWPORT + } else { + sparklingContext.lynxViewport = VIEWPORT + installGlobalThreadStrategy(SparklingThreadStrategy.MULTI_THREADS) + } + + assertUnsafeConfiguration { + HybridKit.createKitView(scheme, sparklingContext, context) + } + } + } + + @Test + fun globalMultiThreadsRejectsInitParamsViewport() { + installGlobalThreadStrategy(SparklingThreadStrategy.MULTI_THREADS) + val scheme = scheme() + val sparklingContext = + SparklingContext().apply { + hybridSchemeParam = scheme + lynxViewport = SparklingLynxViewport(100, 150) + hybridParams = + LynxKitInitParams(loadUri = null).apply { + lynxViewport = VIEWPORT + } + } + + assertUnsafeConfiguration { + HybridKit.createKitView(scheme, sparklingContext, context) + } + } + + @Test + fun safePageStrategyOverridesUnsafeGlobalDefault() { + installGlobalThreadStrategy(SparklingThreadStrategy.MULTI_THREADS) + val scheme = scheme(viewport = VIEWPORT) + val sparklingContext = + SparklingContext().apply { + hybridSchemeParam = scheme + threadStrategy = SparklingThreadStrategy.PART_ON_LAYOUT + } + + val result = HybridKit.createKitView(scheme, sparklingContext, context) + + assertNotNull(result) + assertEquals( + ThreadStrategyForRendering.PART_ON_LAYOUT, + (result as LynxView).threadStrategyForRendering, + ) + } + + @Test + fun unsafePageStrategyOverridesSafeGlobalDefaultAndIsRejected() { + installGlobalThreadStrategy(SparklingThreadStrategy.PART_ON_LAYOUT) + val scheme = scheme(viewport = VIEWPORT) + val sparklingContext = + SparklingContext().apply { + hybridSchemeParam = scheme + threadStrategy = SparklingThreadStrategy.MULTI_THREADS + } + + assertUnsafeConfiguration { + HybridKit.createKitView(scheme, sparklingContext, context) + } + } + + @Test + fun fullPageNavigationRejectsUnsafeResolvedConfigurationBeforeLaunch() { + installGlobalThreadStrategy(SparklingThreadStrategy.MULTI_THREADS) + val sparklingContext = + SparklingContext().apply { + scheme = "hybrid://lynxview_page?bundle=main.lynx.bundle&width=320&height=480" + } + + assertUnsafeConfiguration { + com.tiktok.sparkling.Sparkling + .build(context, sparklingContext) + .navigate() + } + } + + @Test + fun fixedViewportRemainsSupportedWithEverySafeResolvedStrategy() { + listOf( + null, + SparklingThreadStrategy.ALL_ON_UI, + SparklingThreadStrategy.MOST_ON_TASM, + SparklingThreadStrategy.PART_ON_LAYOUT, + ).forEach { strategy -> + installGlobalThreadStrategy(strategy) + val scheme = scheme(viewport = VIEWPORT) + val sparklingContext = + SparklingContext().apply { + hybridSchemeParam = scheme + } + + assertNotNull(HybridKit.createKitView(scheme, sparklingContext, context)) + } + } + + @Test + fun multiThreadsRemainsSupportedWithoutFixedViewport() { + installGlobalThreadStrategy(SparklingThreadStrategy.MULTI_THREADS) + val scheme = scheme() + val sparklingContext = + SparklingContext().apply { + hybridSchemeParam = scheme + } + + val result = HybridKit.createKitView(scheme, sparklingContext, context) + + assertNotNull(result) + assertEquals( + ThreadStrategyForRendering.MULTI_THREADS, + (result as LynxView).threadStrategyForRendering, + ) + } + + private fun assertUnsafeConfiguration(block: () -> Unit) { + val exception = + assertThrows(SparklingLynxConfigurationException::class.java) { + block() + } + assertEquals( + SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS, + exception.error, + ) + } + + private fun installGlobalThreadStrategy(threadStrategy: SparklingThreadStrategy?) { + val lynxConfig = + SparklingLynxConfig.build(application) { + setDefaultThreadStrategy(threadStrategy) + } + HybridCommon.setHybridConfig( + SparklingHybridConfig.build(BaseInfoConfig(isDebug = false)) { + setLynxConfig(lynxConfig) + }, + application, + ) + } + + private fun scheme(viewport: SparklingLynxViewport? = null): HybridSchemeParam = + HybridSchemeParam( + engineType = HybridKitType.LYNX, + bundle = "https://example.com/main.lynx.bundle", + ).apply { + lynxViewport = viewport + } + + private companion object { + val VIEWPORT = SparklingLynxViewport(320, 480) + } +} diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKitThreadStrategyTest.kt b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKitThreadStrategyTest.kt index 669e8d48..0fc6b596 100644 --- a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKitThreadStrategyTest.kt +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/HybridLynxKitThreadStrategyTest.kt @@ -19,7 +19,6 @@ class HybridLynxKitThreadStrategyTest { HybridLynxKit.applyThreadStrategy( builder, SparklingThreadStrategy.MULTI_THREADS, - SparklingThreadStrategy.PART_ON_LAYOUT, ) assertEquals(ThreadStrategyForRendering.MULTI_THREADS, builder.threadStrategy) @@ -31,7 +30,6 @@ class HybridLynxKitThreadStrategyTest { HybridLynxKit.applyThreadStrategy( builder, - null, SparklingThreadStrategy.MOST_ON_TASM, ) @@ -42,10 +40,21 @@ class HybridLynxKitThreadStrategyTest { fun lynxDefaultRemainsUntouchedWhenBothStrategiesAreUnset() { val builder = mockk(relaxed = true) - HybridLynxKit.applyThreadStrategy(builder, null, null) + HybridLynxKit.applyThreadStrategy(builder, null) verify(exactly = 0) { builder.setThreadStrategyForRendering(any()) } } + + @Test + fun pageStrategyOverridesGlobalDefaultDuringResolution() { + assertEquals( + SparklingThreadStrategy.PART_ON_LAYOUT, + HybridLynxKit.resolveThreadStrategy( + SparklingThreadStrategy.PART_ON_LAYOUT, + SparklingThreadStrategy.MULTI_THREADS, + ), + ) + } }