diff --git a/docs/en/apis/scheme.md b/docs/en/apis/scheme.md index 17c9fdca..8bd034e3 100644 --- a/docs/en/apis/scheme.md +++ b/docs/en/apis/scheme.md @@ -57,6 +57,25 @@ Only the following parameters are guaranteed to have an effect on **both Android | `loading_bg_color` | `#RRGGBB` (encoded) | platform default | Loading view background color. Use 6-digit RGB only. | | `hide_error` | `0`/`1` | `0` | Hide the error view when set to `1`. | +## Android fixed Lynx viewport + +Android accepts `width` and `height` as a pair of positive physical-pixel integers. When both are +valid, Sparkling creates the Lynx view with exact preset measure specs and hosts it at exactly that +size. This preserves the historical Lynx Explorer viewport semantic; it does not change the device +screen size or density. + +| Param | Type | Default | Meaning | +| --- | --- | --- | --- | +| `width` | positive integer | container width | Fixed Lynx viewport width in physical pixels. | +| `height` | positive integer | container height | Fixed Lynx viewport height in physical pixels. | + +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. + +``` +hybrid://lynxview_page?bundle=main.lynx.bundle&width=720&height=1280 +``` + ### Color format (cross-platform) Use **6-digit RGB** hex colors: `#RRGGBB` (encode `#` as `%23` in a URL). diff --git a/docs/en/apis/sparkling-sdk-android.md b/docs/en/apis/sparkling-sdk-android.md index e01a6333..cf8d5a38 100644 --- a/docs/en/apis/sparkling-sdk-android.md +++ b/docs/en/apis/sparkling-sdk-android.md @@ -65,8 +65,13 @@ Configuration object passed to both container types. | `scheme` | The `hybrid://...` URL to load. | | `sparklingUIProvider` | Implements `SparklingUIProvider` for custom loading/error/toolbar views. | | `hybridSchemeParam` | Parsed scheme parameters (auto-populated from `scheme`). | +| `lynxViewport` | Optional `SparklingLynxViewport(widthPx, heightPx)` fixed viewport in physical pixels. Programmatic configuration overrides parsed scheme dimensions. | | `containerId` | Unique container identifier (auto-generated). | +For advanced hosts that already provide `LynxKitInitParams`, set its `lynxViewport` property. Init +params take precedence over `SparklingContext.lynxViewport`, which takes precedence over canonical +scheme `width` and `height`. All three paths require a complete positive width/height pair. + ## 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 bf489e0c..d1378d89 100644 --- a/docs/zh/apis/scheme.md +++ b/docs/zh/apis/scheme.md @@ -55,6 +55,24 @@ hybrid://lynxview_page?bundle=main.lynx.bundle&title=Home&title_color=%23000000& | `loading_bg_color` | `#RRGGBB`(编码后) | 平台默认值 | 加载视图背景颜色。仅使用 6 位 RGB。 | | `hide_error` | `0`/`1` | `0` | 设为 `1` 时隐藏错误视图。 | +## Android 固定 Lynx viewport + +Android 将 `width` 和 `height` 作为一组成对的正物理像素整数处理。两者均有效时, +Sparkling 会使用精确的 preset measure spec 创建 LynxView,并以完全相同的尺寸将其挂载到 +容器中。这与历史 Lynx Explorer 的 viewport 语义一致,不会修改设备屏幕尺寸或 density。 + +| 参数 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `width` | 正整数 | 容器宽度 | 固定 Lynx viewport 宽度,单位为物理像素。 | +| `height` | 正整数 | 容器高度 | 固定 Lynx viewport 高度,单位为物理像素。 | + +这两个参数是原子配置。如果任意一个缺失、不是整数、为零、为负数,或超出 Android +measure spec 的安全范围,Sparkling 会同时忽略二者并保留默认的全尺寸行为。 + +``` +hybrid://lynxview_page?bundle=main.lynx.bundle&width=720&height=1280 +``` + ### 颜色格式(跨平台) 使用 **6 位 RGB** 十六进制颜色:`#RRGGBB`(在 URL 中将 `#` 编码为 `%23`)。 diff --git a/docs/zh/apis/sparkling-sdk-android.md b/docs/zh/apis/sparkling-sdk-android.md index 4c25cb85..7503ab8b 100644 --- a/docs/zh/apis/sparkling-sdk-android.md +++ b/docs/zh/apis/sparkling-sdk-android.md @@ -65,8 +65,13 @@ HybridKit.initLynxKit() | `scheme` | 要加载的 `hybrid://...` URL。 | | `sparklingUIProvider` | 实现 `SparklingUIProvider` 以自定义加载/错误/工具栏视图。 | | `hybridSchemeParam` | 解析后的 scheme 参数(从 `scheme` 自动填充)。 | +| `lynxViewport` | 可选的 `SparklingLynxViewport(widthPx, heightPx)`,以物理像素指定固定 viewport。程序化配置会覆盖 scheme 中解析的尺寸。 | | `containerId` | 唯一的容器标识符(自动生成)。 | +高级宿主如果已经使用 `LynxKitInitParams`,也可以设置其 `lynxViewport` 属性。优先级依次为: +init params、`SparklingContext.lynxViewport`、canonical scheme 的 `width` 和 `height`。 +三种入口都只接受完整的正数宽高组合。 + ## SparklingUIProvider 自定义容器 UI 的接口。适用于全页和嵌入式容器。 diff --git a/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/FixedLynxViewportInstrumentedTest.kt b/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/FixedLynxViewportInstrumentedTest.kt new file mode 100644 index 00000000..dc2d94d8 --- /dev/null +++ b/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/FixedLynxViewportInstrumentedTest.kt @@ -0,0 +1,151 @@ +// 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.playground + +import android.view.View +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.lynx.tasm.LynxView +import com.tiktok.sparkling.Sparkling +import com.tiktok.sparkling.SparklingContext +import com.tiktok.sparkling.SparklingLifecycleDelegate +import com.tiktok.sparkling.SparklingLynxViewCreatedListener +import com.tiktok.sparkling.SparklingLynxViewport +import com.tiktok.sparkling.SparklingView +import com.tiktok.sparkling.hybridkit.base.HybridKitError +import com.tiktok.sparkling.hybridkit.base.IKitView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +@RunWith(AndroidJUnit4::class) +class FixedLynxViewportInstrumentedTest { + @Test + fun canonicalSchemeCreatesExactPhysicalPixelLynxViewport() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val targetContext = instrumentation.targetContext + val firstScreenSeen = AtomicBoolean(false) + val loadFinishSeen = AtomicBoolean(false) + val renderLatch = CountDownLatch(2) + val loadFailure = AtomicReference() + val bundleAvailable = + runCatching { + targetContext.assets.open(BUNDLE_NAME).close() + true + }.getOrDefault(false) + var createdLynxView: LynxView? = null + var sparklingView: SparklingView? = null + + instrumentation.runOnMainSync { + val context = + SparklingContext().apply { + scheme = + "hybrid://lynxview_page?" + + "bundle=$BUNDLE_NAME&width=$VIEWPORT_WIDTH_PX&height=$VIEWPORT_HEIGHT_PX" + lynxViewCreatedListener = + SparklingLynxViewCreatedListener { lynxView -> + createdLynxView = lynxView + } + lifecycleDelegate = + object : SparklingLifecycleDelegate { + override fun onFirstScreen(view: IKitView) { + if (firstScreenSeen.compareAndSet(false, true)) { + renderLatch.countDown() + } + } + + override fun onLoadFinish(view: IKitView) { + if (loadFinishSeen.compareAndSet(false, true)) { + renderLatch.countDown() + } + } + + override fun onLoadFailed( + view: IKitView, + url: String, + error: HybridKitError, + ) { + loadFailure.set(error) + while (renderLatch.count > 0) { + renderLatch.countDown() + } + } + } + } + val sparkling = Sparkling.build(targetContext, context) + sparkling.processSparklingContext(context) + assertEquals( + SparklingLynxViewport(VIEWPORT_WIDTH_PX, VIEWPORT_HEIGHT_PX), + context.hybridSchemeParam?.lynxViewport, + ) + sparklingView = requireNotNull(sparkling.createView()) + val host = FrameLayout(targetContext) + host.addView( + sparklingView, + FrameLayout.LayoutParams(HOST_WIDTH_PX, HOST_HEIGHT_PX), + ) + + host.measure( + View.MeasureSpec.makeMeasureSpec(HOST_WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HOST_HEIGHT_PX, View.MeasureSpec.EXACTLY), + ) + host.layout(0, 0, HOST_WIDTH_PX, HOST_HEIGHT_PX) + + val lynxView = createdLynxView + assertFixedViewport(sparklingView, lynxView) + if (bundleAvailable) { + sparklingView?.loadUrl() + } + } + + if (bundleAvailable) { + assertTrue( + "Timed out waiting for Lynx first-screen and load-finish callbacks", + renderLatch.await(RENDER_TIMEOUT_SECONDS, TimeUnit.SECONDS), + ) + assertNull(loadFailure.get()?.errorReason, loadFailure.get()) + assertTrue("Lynx first-screen callback was not received", firstScreenSeen.get()) + assertTrue("Lynx load-finish callback was not received", loadFinishSeen.get()) + instrumentation.waitForIdleSync() + instrumentation.runOnMainSync { + assertFixedViewport(sparklingView, createdLynxView) + assertTrue(requireNotNull(sparklingView).isLoadSuccess()) + } + } + + instrumentation.runOnMainSync { + sparklingView?.release() + } + } + + private fun assertFixedViewport( + sparklingView: SparklingView?, + lynxView: LynxView?, + ) { + assertNotNull(lynxView) + assertSame(sparklingView, lynxView?.parent) + assertEquals(VIEWPORT_WIDTH_PX, lynxView?.layoutParams?.width) + assertEquals(VIEWPORT_HEIGHT_PX, lynxView?.layoutParams?.height) + assertEquals(VIEWPORT_WIDTH_PX, lynxView?.measuredWidth) + assertEquals(VIEWPORT_HEIGHT_PX, lynxView?.measuredHeight) + } + + private companion object { + const val BUNDLE_NAME = "main.lynx.bundle" + const val VIEWPORT_WIDTH_PX = 320 + const val VIEWPORT_HEIGHT_PX = 480 + const val HOST_WIDTH_PX = 900 + const val HOST_HEIGHT_PX = 1600 + const val RENDER_TIMEOUT_SECONDS = 15L + } +} diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingContext.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingContext.kt index 2509701a..5e061979 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingContext.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingContext.kt @@ -103,4 +103,5 @@ class SparklingContext : HybridContext() { var sparklingUIProvider: SparklingUIProvider? = null var lifecycleDelegate: SparklingLifecycleDelegate? = null var lynxViewCreatedListener: SparklingLynxViewCreatedListener? = null + var lynxViewport: SparklingLynxViewport? = null } diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingLynxViewport.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingLynxViewport.kt new file mode 100644 index 00000000..a336cecd --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingLynxViewport.kt @@ -0,0 +1,86 @@ +// 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 android.os.Parcel +import android.os.Parcelable +import android.view.View +import com.lynx.tasm.LynxViewBuilder +import com.tiktok.sparkling.hybridkit.lynx.LynxKitInitParams +import java.io.Serializable + +/** + * A fixed Lynx viewport in physical pixels. + * + * Both dimensions must be positive because Sparkling applies them as exact Lynx measure specs and + * as the hosted Lynx view's layout size. + */ +data class SparklingLynxViewport( + val widthPx: Int, + val heightPx: Int, +) : Parcelable, + Serializable { + init { + require(widthPx in 1..MAX_MEASURE_SPEC_SIZE_PX) { + "Viewport width must be between 1 and $MAX_MEASURE_SPEC_SIZE_PX" + } + require(heightPx in 1..MAX_MEASURE_SPEC_SIZE_PX) { + "Viewport height must be between 1 and $MAX_MEASURE_SPEC_SIZE_PX" + } + } + + private constructor(parcel: Parcel) : this( + widthPx = parcel.readInt(), + heightPx = parcel.readInt(), + ) + + override fun writeToParcel( + parcel: Parcel, + flags: Int, + ) { + parcel.writeInt(widthPx) + parcel.writeInt(heightPx) + } + + override fun describeContents(): Int = 0 + + companion object { + private const val MAX_MEASURE_SPEC_SIZE_PX = 0x3fffffff + + @JvmField + val CREATOR: Parcelable.Creator = + object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SparklingLynxViewport = SparklingLynxViewport(parcel) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + + internal fun fromRawDimensions( + width: String?, + height: String?, + ): SparklingLynxViewport? { + val widthPx = width?.toIntOrNull() ?: return null + val heightPx = height?.toIntOrNull() ?: return null + if ( + widthPx !in 1..MAX_MEASURE_SPEC_SIZE_PX || + heightPx !in 1..MAX_MEASURE_SPEC_SIZE_PX + ) { + return null + } + return SparklingLynxViewport(widthPx, heightPx) + } + } +} + +internal fun SparklingContext.resolveLynxViewport(): SparklingLynxViewport? = + (hybridParams as? LynxKitInitParams)?.lynxViewport + ?: lynxViewport + ?: hybridSchemeParam?.lynxViewport + +internal fun LynxViewBuilder.applyLynxViewport(viewport: SparklingLynxViewport) { + setPresetMeasuredSpec( + View.MeasureSpec.makeMeasureSpec(viewport.widthPx, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(viewport.heightPx, View.MeasureSpec.EXACTLY), + ) +} diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingView.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingView.kt index 5dcef3d5..c7bd4b41 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingView.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingView.kt @@ -313,7 +313,10 @@ class SparklingView( }, ) kitViewDelegate = kitView - addView(kitView?.realView()) + val kitRealView = kitView?.realView() + kitRealView?.let { + addView(it, it.resolveLynxLayoutParams(sparklingContext)) + } observeKitViewLayout(kitView) handleUI() @@ -611,6 +614,15 @@ class SparklingView( return Size(resolvedWidth, resolvedHeight) } + private fun View.resolveLynxLayoutParams(sparklingContext: SparklingContext): LayoutParams { + val viewport = sparklingContext.resolveLynxViewport() + return if (viewport == null) { + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } else { + LayoutParams(viewport.widthPx, viewport.heightPx) + } + } + private fun runOnMain(action: () -> Unit) { if (Looper.myLooper() != Looper.getMainLooper()) { post { 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 96a559fe..73d168bb 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 @@ -15,6 +15,8 @@ import com.lynx.tasm.behavior.Behavior import com.lynx.tasm.behavior.BehaviorBundle import com.lynx.tasm.service.LynxServiceCenter import com.tiktok.sparkling.SparklingContext +import com.tiktok.sparkling.applyLynxViewport +import com.tiktok.sparkling.resolveLynxViewport import com.tiktok.sparkling.hybridkit.HybridCommon import com.tiktok.sparkling.hybridkit.HybridContext import com.tiktok.sparkling.hybridkit.base.IHybridKitLifeCycle @@ -97,6 +99,9 @@ object HybridLynxKit { } val viewBuilder = LynxViewBuilder() + (hybridContext as? SparklingContext)?.resolveLynxViewport()?.let { viewport -> + viewBuilder.applyLynxViewport(viewport) + } var lynxViewRef: SimpleLynxKitView? = null (lynxConfig?.templateProvider ?: LynxEnv.inst().templateProvider)?.let { templateProvider -> viewBuilder.setTemplateProvider( diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/LynxKitInitParams.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/LynxKitInitParams.kt index f73e360b..5e0cd272 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/LynxKitInitParams.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/LynxKitInitParams.kt @@ -9,6 +9,7 @@ import com.lynx.tasm.LynxViewClient import com.lynx.tasm.TemplateBundle import com.lynx.tasm.TemplateData import com.lynx.tasm.behavior.Behavior +import com.tiktok.sparkling.SparklingLynxViewport import com.tiktok.sparkling.hybridkit.base.HybridKitType import com.tiktok.sparkling.hybridkit.base.IKitInitParam import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam @@ -47,6 +48,7 @@ open class LynxKitInitParams( var kitBridgeService: IKitBridgeService? = null var hybridSchemaParams: HybridSchemeParam? = null var lynxBackgroundRuntime: LynxBackgroundRuntime? = null + var lynxViewport: SparklingLynxViewport? = null private val globalProps = ConcurrentHashMap() private var lynxClientDelegate: CopyOnWriteArrayList = CopyOnWriteArrayList() diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/scheme/HybridSchemeParam.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/scheme/HybridSchemeParam.kt index 8c74d5fe..a59defa1 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/scheme/HybridSchemeParam.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/scheme/HybridSchemeParam.kt @@ -5,6 +5,7 @@ package com.tiktok.sparkling.hybridkit.scheme import android.os.Parcel import android.os.Parcelable +import com.tiktok.sparkling.SparklingLynxViewport import com.tiktok.sparkling.hybridkit.base.HybridContainerType import com.tiktok.sparkling.hybridkit.base.HybridKitType import java.io.Serializable @@ -31,6 +32,8 @@ open class HybridSchemeParam( ) : BaseSchemeParam(engineType), Serializable, Parcelable { + var lynxViewport: SparklingLynxViewport? = null + constructor(parcel: Parcel) : this( HybridKitType.values()[parcel.readInt()], HybridContainerType.values()[parcel.readInt()], @@ -49,7 +52,14 @@ open class HybridSchemeParam( parcel.readByte() != 0.toByte(), parcel.readString(), if (parcel.dataAvail() > 0) parcel.readByte() != 0.toByte() else false, - ) + ) { + lynxViewport = + if (parcel.dataAvail() > 0) { + parcel.readParcelable(SparklingLynxViewport::class.java.classLoader) + } else { + null + } + } override fun writeToParcel( parcel: Parcel, @@ -73,6 +83,7 @@ open class HybridSchemeParam( // parcel.writeString(fallbackUrl) parcel.writeString(forceThemeStyle) parcel.writeByte(if (disableAutoRemoveLoading) 1 else 0) + parcel.writeParcelable(lynxViewport, flags) } override fun describeContents(): Int = 0 diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/scheme/SchemeConstants.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/scheme/SchemeConstants.kt index 735900f2..513a69b8 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/scheme/SchemeConstants.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/scheme/SchemeConstants.kt @@ -39,6 +39,8 @@ object SchemeConstants { const val SHOW_NAV_BAR_IN_TRANS_STATUS_BAR = "show_nav_bar_in_trans_status_bar" const val HIDE_ERROR = "hide_error" const val FORCE_THEME_STYLE = "force_theme_style" + const val WIDTH = "width" + const val HEIGHT = "height" } object Value { diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/utils/SchemeParser.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/utils/SchemeParser.kt index 2106b31b..cef61ba7 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/utils/SchemeParser.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/utils/SchemeParser.kt @@ -6,6 +6,7 @@ package com.tiktok.sparkling.utils import android.content.res.Configuration import android.net.Uri import androidx.core.net.toUri +import com.tiktok.sparkling.SparklingLynxViewport import com.tiktok.sparkling.hybridkit.base.HybridContainerType import com.tiktok.sparkling.hybridkit.base.HybridKitType import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam @@ -130,7 +131,24 @@ object SchemeParser { params.containerBgColor = resolveThemedColor(uri, SchemeConstants.Param.CONTAINER_BG_COLOR, params.forceThemeStyle) params.showNavBarInTransStatusBar = uri.safeGetQueryParameter(SchemeConstants.Param.SHOW_NAV_BAR_IN_TRANS_STATUS_BAR) == SchemeConstants.Value.ENABLED params.hideError = uri.safeGetQueryParameter(SchemeConstants.Param.HIDE_ERROR) == SchemeConstants.Value.ENABLED + params.lynxViewport = + SparklingLynxViewport.fromRawDimensions( + uri.lastValuedQueryParameter(SchemeConstants.Param.WIDTH), + uri.lastValuedQueryParameter(SchemeConstants.Param.HEIGHT), + ) return params } + + private fun Uri.lastValuedQueryParameter(key: String): String? { + val components = encodedQuery?.split('&') ?: return null + for (component in components.asReversed()) { + val separator = component.indexOf('=') + if (separator < 0 || Uri.decode(component.substring(0, separator)) != key) { + continue + } + return Uri.decode(component.substring(separator + 1)) + } + return null + } } diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingLynxViewportTest.kt b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingLynxViewportTest.kt new file mode 100644 index 00000000..cd535b82 --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingLynxViewportTest.kt @@ -0,0 +1,123 @@ +// 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 android.os.Parcel +import android.view.View +import com.lynx.tasm.LynxViewBuilder +import com.tiktok.sparkling.hybridkit.lynx.LynxKitInitParams +import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SparklingLynxViewportTest { + @Test + fun viewportRejectsUnsafeDimensions() { + assertThrows { SparklingLynxViewport(0, 100) } + assertThrows { SparklingLynxViewport(100, -1) } + assertThrows { SparklingLynxViewport(Int.MAX_VALUE, 100) } + } + + @Test + fun rawDimensionsRequireCompletePositiveIntegerPair() { + assertEquals( + SparklingLynxViewport(320, 640), + SparklingLynxViewport.fromRawDimensions("320", "640"), + ) + assertNull(SparklingLynxViewport.fromRawDimensions("320", null)) + assertNull(SparklingLynxViewport.fromRawDimensions(null, "640")) + assertNull(SparklingLynxViewport.fromRawDimensions("0", "640")) + assertNull(SparklingLynxViewport.fromRawDimensions("320.5", "640")) + assertNull(SparklingLynxViewport.fromRawDimensions("2147483647", "640")) + } + + @Test + fun viewportRoundTripsThroughParcel() { + val parcel = Parcel.obtain() + val expected = SparklingLynxViewport(375, 812) + expected.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val actual = SparklingLynxViewport.CREATOR.createFromParcel(parcel) + + assertEquals(expected, actual) + parcel.recycle() + } + + @Test + fun schemeViewportRoundTripsThroughParcel() { + val parcel = Parcel.obtain() + val expected = + HybridSchemeParam().apply { + lynxViewport = SparklingLynxViewport(375, 812) + } + expected.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val actual = HybridSchemeParam.CREATOR.createFromParcel(parcel) + + assertEquals(expected.lynxViewport, actual.lynxViewport) + parcel.recycle() + } + + @Test + fun initParamsOverrideContextAndSchemeViewport() { + val context = + SparklingContext().apply { + lynxViewport = SparklingLynxViewport(200, 300) + hybridSchemeParam = + HybridSchemeParam().apply { + lynxViewport = SparklingLynxViewport(100, 150) + } + hybridParams = + LynxKitInitParams(loadUri = null).apply { + lynxViewport = SparklingLynxViewport(400, 500) + } + } + + assertEquals(SparklingLynxViewport(400, 500), context.resolveLynxViewport()) + } + + @Test + fun contextViewportOverridesSchemeViewport() { + val context = + SparklingContext().apply { + lynxViewport = SparklingLynxViewport(200, 300) + hybridSchemeParam = + HybridSchemeParam().apply { + lynxViewport = SparklingLynxViewport(100, 150) + } + } + + assertEquals(SparklingLynxViewport(200, 300), context.resolveLynxViewport()) + } + + @Test + fun builderReceivesExactViewportMeasureSpecs() { + val builder = LynxViewBuilder() + + builder.applyLynxViewport(SparklingLynxViewport(360, 780)) + + assertEquals(View.MeasureSpec.EXACTLY, View.MeasureSpec.getMode(builder.presetWidthMeasureSpec)) + assertEquals(360, View.MeasureSpec.getSize(builder.presetWidthMeasureSpec)) + assertEquals(View.MeasureSpec.EXACTLY, View.MeasureSpec.getMode(builder.presetHeightMeasureSpec)) + assertEquals(780, View.MeasureSpec.getSize(builder.presetHeightMeasureSpec)) + } + + private inline fun assertThrows(block: () -> Unit) { + try { + block() + } catch (throwable: Throwable) { + if (throwable is T) { + return + } + throw AssertionError("Expected ${T::class.java.name}, got ${throwable::class.java.name}", throwable) + } + throw AssertionError("Expected ${T::class.java.name}") + } +} diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingViewTest.kt b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingViewTest.kt index b0885740..0e98680a 100644 --- a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingViewTest.kt +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingViewTest.kt @@ -115,6 +115,33 @@ class SparklingViewTest { assertEquals(sparklingView, loadingView.parent) } + @Test + fun prepareUsesFixedViewportForHostedLynxViewLayout() { + val kitView = RecordingKitView(context) + every { HybridKit.createKitView(any(), any(), any(), any()) } returns kitView + baseContext.lynxViewport = SparklingLynxViewport(320, 480) + val sparklingView = SparklingView(context) + + sparklingView.prepare(baseContext) + + val layoutParams = kitView.realView().layoutParams + assertEquals(320, layoutParams.width) + assertEquals(480, layoutParams.height) + } + + @Test + fun preparePreservesDefaultFullSizeHostedLynxViewLayout() { + val kitView = RecordingKitView(context) + every { HybridKit.createKitView(any(), any(), any(), any()) } returns kitView + val sparklingView = SparklingView(context) + + sparklingView.prepare(baseContext) + + val layoutParams = kitView.realView().layoutParams + assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, layoutParams.width) + assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, layoutParams.height) + } + @Test fun loadUrlDelegatesToKitView() { val kitView = RecordingKitView(context) diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/LynxKitInitParamsTest.kt b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/LynxKitInitParamsTest.kt index 5d10f7c8..3a4f07cf 100644 --- a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/LynxKitInitParamsTest.kt +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/LynxKitInitParamsTest.kt @@ -150,5 +150,6 @@ class LynxKitInitParamsTest { assertNull(params.extraInfoCallback) assertNull(params.kitBridgeService) assertNull(params.lynxBackgroundRuntime) + assertNull(params.lynxViewport) } } diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/utils/SchemeParserTest.kt b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/utils/SchemeParserTest.kt index c39d2930..cec4a383 100644 --- a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/utils/SchemeParserTest.kt +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/utils/SchemeParserTest.kt @@ -3,6 +3,7 @@ // LICENSE file in the root directory of this source tree. package com.tiktok.sparkling.utils +import com.tiktok.sparkling.SparklingLynxViewport import com.tiktok.sparkling.hybridkit.base.HybridContainerType import com.tiktok.sparkling.hybridkit.base.HybridKitType import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam @@ -67,6 +68,66 @@ class SchemeParserTest { assertEquals("http://10.0.2.2:5969/main.lynx.bundle", result.bundle) } + @Test + fun testParseSchemeWithFixedLynxViewport() { + val result = + SchemeParser.parseScheme( + "hybrid://lynxview_page?bundle=test_bundle&width=720&height=1280", + ) + + assertEquals(SparklingLynxViewport(720, 1280), result?.lynxViewport) + } + + @Test + fun testParseSchemeUsesLastValuedDuplicateViewportDimensions() { + val result = + SchemeParser.parseScheme( + "hybrid://lynxview_page?bundle=test_bundle&" + + "width=320&height=480&width=720&height=1280", + ) + + assertEquals(SparklingLynxViewport(720, 1280), result?.lynxViewport) + } + + @Test + fun testParseSchemeIgnoresTrailingValuelessDuplicateViewportDimensions() { + val result = + SchemeParser.parseScheme( + "hybrid://lynxview_page?bundle=test_bundle&" + + "width=720&height=1280&width&height", + ) + + assertEquals(SparklingLynxViewport(720, 1280), result?.lynxViewport) + } + + @Test + fun testParseSchemeLetsTrailingEmptyDuplicateInvalidateViewport() { + val result = + SchemeParser.parseScheme( + "hybrid://lynxview_page?bundle=test_bundle&" + + "width=720&height=1280&width=", + ) + + assertNull(result?.lynxViewport) + } + + @Test + fun testParseSchemeIgnoresIncompleteOrInvalidViewport() { + val schemes = + listOf( + "hybrid://lynxview_page?bundle=test_bundle&width=720", + "hybrid://lynxview_page?bundle=test_bundle&height=1280", + "hybrid://lynxview_page?bundle=test_bundle&width=0&height=1280", + "hybrid://lynxview_page?bundle=test_bundle&width=-1&height=1280", + "hybrid://lynxview_page?bundle=test_bundle&width=wide&height=1280", + "hybrid://lynxview_page?bundle=test_bundle&width=2147483647&height=1280", + ) + + schemes.forEach { scheme -> + assertNull("Unexpected viewport for $scheme", SchemeParser.parseScheme(scheme)?.lynxViewport) + } + } + @Test fun testParseSchemeWithWebView() { val scheme = "hybrid://webview?bundle=web_bundle&title=Web%20Title"