diff --git a/docs/en/apis/sparkling-sdk-android.md b/docs/en/apis/sparkling-sdk-android.md index f3ce6b9c..26938007 100644 --- a/docs/en/apis/sparkling-sdk-android.md +++ b/docs/en/apis/sparkling-sdk-android.md @@ -171,3 +171,44 @@ Interface for customizing container UI. Applies to both full-page and embedded c | `getLoadingView(context)` | Returns a custom loading view, or `null` for the default. | | `getErrorView(context)` | Returns a custom error view, or `null` for the default. | | `getToolBar(context)` | Returns a custom `Toolbar` for `SparklingActivity` (full-page only). | + +### Failed-view retry + +To let a custom error view retry through Sparkling's SDK-owned load path, make +the view returned by `getErrorView(context)` implement +`SparklingRetryableErrorView`. This is an optional capability; existing +`SparklingUIProvider` implementations and plain error views remain compatible. + +```java +public final class AppErrorView extends FrameLayout + implements SparklingRetryableErrorView { + private SparklingFailedViewRetry retry; + + @Override + public void setSparklingRetry(SparklingFailedViewRetry retry) { + this.retry = retry; + retryButton.setOnClickListener( + ignored -> { + SparklingFailedViewRetry current = this.retry; + if (current != null && current.retry()) { + this.retry = null; + } + }); + } +} +``` + +Sparkling registers a new single-use `SparklingFailedViewRetry` for each +current load failure. `retry()` must be called on the Android main thread and +returns `true` only when that exact current failure is atomically accepted. +Double taps, stale requests, off-main calls, and calls after container release +return `false`. An accepted retry clears the error UI and invokes Sparkling's +owned reload path without reopening the route. + +If the retry fails, Sparkling returns the container to `FAIL` and registers a +new retry request. If it succeeds, the container reaches `SUCCESS`. Sparkling +also calls `setSparklingRetry(null)` when a request becomes invalid, including +on a new load, success, accepted retry, or release. Implementations must replace +their previous listener/request and must not retain the supplied `Context`. +The same contract is used by full-page `SparklingActivity` containers and +embedded `SparklingView` containers. diff --git a/docs/zh/apis/sparkling-sdk-android.md b/docs/zh/apis/sparkling-sdk-android.md index 7128592a..25b0d2d5 100644 --- a/docs/zh/apis/sparkling-sdk-android.md +++ b/docs/zh/apis/sparkling-sdk-android.md @@ -135,3 +135,40 @@ Lynx SDK 的默认策略。 | `getLoadingView(context)` | 返回自定义加载视图,返回 `null` 使用默认。 | | `getErrorView(context)` | 返回自定义错误视图,返回 `null` 使用默认。 | | `getToolBar(context)` | 返回 `SparklingActivity` 使用的自定义 `Toolbar`(仅全页容器)。 | + +### 失败页重试 + +如果自定义错误页需要通过 Sparkling SDK 自己的加载链路重试,让 +`getErrorView(context)` 返回的 View 实现 `SparklingRetryableErrorView`。 +这是可选能力;已有 `SparklingUIProvider` 和普通错误 View 无需修改。 + +```java +public final class AppErrorView extends FrameLayout + implements SparklingRetryableErrorView { + private SparklingFailedViewRetry retry; + + @Override + public void setSparklingRetry(SparklingFailedViewRetry retry) { + this.retry = retry; + retryButton.setOnClickListener( + ignored -> { + SparklingFailedViewRetry current = this.retry; + if (current != null && current.retry()) { + this.retry = null; + } + }); + } +} +``` + +每次当前加载失败时,Sparkling 都会注册一个新的、只能成功使用一次的 +`SparklingFailedViewRetry`。`retry()` 必须在 Android 主线程调用;只有 +该请求仍对应当前失败且被原子接受时才返回 `true`。双击、过期请求、 +非主线程调用以及容器释放后的调用都会返回 `false`。接受后 Sparkling +会清除错误 UI,并通过 SDK 自己的 reload 链路重试,不重新打开路由。 + +如果重试仍失败,容器会重新进入 `FAIL` 并注册新的 retry;如果成功, +容器会进入 `SUCCESS`。在新加载、成功、接受重试或释放等请求失效时, +Sparkling 也会调用 `setSparklingRetry(null)`。实现方必须替换之前的 +监听器或请求,并且不能持有传入的 `Context`。全页 +`SparklingActivity` 和嵌入式 `SparklingView` 使用相同契约。 diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFailedViewRetry.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFailedViewRetry.kt new file mode 100644 index 00000000..bb012b22 --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFailedViewRetry.kt @@ -0,0 +1,27 @@ +// 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 + +/** + * A single-use request to retry the current failed load through Sparkling. + * + * Returns `true` only when this request belongs to the current failed load and + * Sparkling accepts it. A request returns `false` after it has been used, after + * a newer load or failure supersedes it, after the container is released, or + * when called off the Android main thread. + */ +fun interface SparklingFailedViewRetry { + fun retry(): Boolean +} + +/** + * Optional capability for a custom error view returned by [SparklingUIProvider]. + * + * Sparkling supplies a new retry request for each current load failure and + * clears it with `null` when it is no longer valid. Implementations should + * replace any previously registered click listener or retry request. + */ +interface SparklingRetryableErrorView { + fun setSparklingRetry(retry: SparklingFailedViewRetry?) +} diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFragment.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFragment.kt index 49bc00e9..6a8ade26 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFragment.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/SparklingFragment.kt @@ -63,6 +63,12 @@ class SparklingFragment : Fragment() { sparklingView?.getKitView()?.onHide() } + override fun onDestroyView() { + sparklingView?.release() + sparklingView = null + super.onDestroyView() + } + fun loadUrl() { sparklingView?.loadUrl() } 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 c7bd4b41..758ed224 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 @@ -29,6 +29,7 @@ import com.tiktok.sparkling.hybridkit.base.IHybridView import com.tiktok.sparkling.hybridkit.base.IKitView import com.tiktok.sparkling.hybridkit.base.IPerformanceView import com.tiktok.sparkling.hybridkit.utils.ColorUtil +import java.lang.ref.WeakReference import org.json.JSONObject class SparklingView( @@ -77,6 +78,9 @@ class SparklingView( private var loadStatus = IPerformanceView.LoadStatus.INIT private var isReleased = false + private val retryStateLock = Any() + private var retryGeneration = 0L + private var activeRetryGeneration: Long? = null private val defaultErrorText = "Oops, something went wrong!" private val kitLayoutChangeListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> @@ -144,6 +148,7 @@ class SparklingView( if (isReleased) { return } + invalidateFailedViewRetry() loadStatus = IPerformanceView.LoadStatus.LOADING runOnMain { errorView?.visibility = GONE @@ -249,6 +254,7 @@ class SparklingView( return } loadStatus = IPerformanceView.LoadStatus.SUCCESS + invalidateFailedViewRetry() runOnMain { removeLoadingView() errorView?.visibility = GONE @@ -261,6 +267,7 @@ class SparklingView( if (isReleased) { return } + invalidateFailedViewRetry() loadStatus = IPerformanceView.LoadStatus.LOADING runOnMain { errorView?.visibility = GONE @@ -401,7 +408,13 @@ class SparklingView( override fun release() { if (isReleased) return - isReleased = true + synchronized(retryStateLock) { + if (isReleased) return + isReleased = true + retryGeneration++ + activeRetryGeneration = null + } + clearFailedViewRetry() observedKitRealView?.removeOnLayoutChangeListener(kitLayoutChangeListener) observedKitRealView = null kitViewDelegate?.destroy(true) @@ -506,7 +519,11 @@ class SparklingView( if (isReleased) { return } + if (view !== kitViewDelegate) { + return + } loadStatus = IPerformanceView.LoadStatus.FAIL + registerFailedViewRetry(view) updateErrorMessage(url, hybridKitError.errorReason) runOnMain { removeLoadingView() @@ -515,6 +532,68 @@ class SparklingView( sparklingContext?.lifecycleDelegate?.onLoadFailed(view, url, hybridKitError) } + private fun registerFailedViewRetry(view: IKitView) { + val generation = + synchronized(retryStateLock) { + retryGeneration++ + activeRetryGeneration = retryGeneration + retryGeneration + } + val owner = WeakReference(this) + val retry = SparklingFailedViewRetry { owner.get()?.retryFailedLoad(generation) ?: false } + runOnMain { + val isCurrent = + synchronized(retryStateLock) { + !isReleased && + activeRetryGeneration == generation && + loadStatus == IPerformanceView.LoadStatus.FAIL && + kitViewDelegate === view + } + if (isCurrent) { + (errorView as? SparklingRetryableErrorView)?.setSparklingRetry(retry) + } + } + } + + private fun retryFailedLoad(generation: Long): Boolean { + if (Looper.myLooper() != Looper.getMainLooper()) { + return false + } + val kitView = + synchronized(retryStateLock) { + if ( + isReleased || + activeRetryGeneration != generation || + loadStatus != IPerformanceView.LoadStatus.FAIL + ) { + return false + } + val currentKitView = kitViewDelegate ?: return false + activeRetryGeneration = null + currentKitView + } + loadStatus = IPerformanceView.LoadStatus.LOADING + (errorView as? SparklingRetryableErrorView)?.setSparklingRetry(null) + errorView?.visibility = GONE + showLoadingView() + kitView.reload() + return true + } + + private fun invalidateFailedViewRetry() { + synchronized(retryStateLock) { + retryGeneration++ + activeRetryGeneration = null + } + clearFailedViewRetry() + } + + private fun clearFailedViewRetry() { + runOnMain { + (errorView as? SparklingRetryableErrorView)?.setSparklingRetry(null) + } + } + private fun removeLoadingView() { if (!disableAutoRemoveLoading) { loadingView?.visibility = GONE diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxKitView.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxKitView.kt index 6d1a7024..48763f03 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxKitView.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxKitView.kt @@ -37,6 +37,7 @@ class SimpleLynxKitView : var rawUrl: String? = null var lynxKitInitParams: LynxKitInitParams? = null private var hasDestroyed = false + private val simpleLynxViewClient: SimpleLynxViewClient constructor( context: Context, @@ -47,7 +48,8 @@ class SimpleLynxKitView : ) : super(context, builder) { this.hybridContext = hybridContext this.lynxKitLifeCycle = lifeCycle - addLynxViewClient(SimpleLynxViewClient(this, this.lynxKitLifeCycle)) + simpleLynxViewClient = SimpleLynxViewClient(this, this.lynxKitLifeCycle) + addLynxViewClient(simpleLynxViewClient) KitViewManager.addKitView(this) rawUrl = hybridContext.hybridSchemeParam?.bundle } @@ -71,6 +73,7 @@ class SimpleLynxKitView : } rawUrl = uri runCatching { + simpleLynxViewClient.beginLoad(uri) this.renderTemplateUrl(uri, hybridContext.initData()) updateGlobalProps(GlobalPropsUtils.instance.getGlobalProps(hybridContext.containerId)) }.onFailure { diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxViewClient.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxViewClient.kt index bbf8eacd..acceacae 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxViewClient.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxViewClient.kt @@ -24,6 +24,10 @@ class SimpleLynxViewClient( override fun onPageStart(url: String?) { super.onPageStart(url) + beginLoad(url) + } + + internal fun beginLoad(url: String?) { uri = url?.toUri() loadFinished = false loadFailed = false diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingFailedViewRetryJavaApiTest.java b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingFailedViewRetryJavaApiTest.java new file mode 100644 index 00000000..3cebeaf1 --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingFailedViewRetryJavaApiTest.java @@ -0,0 +1,73 @@ +// 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.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import android.content.Context; +import android.view.View; +import androidx.appcompat.widget.Toolbar; +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 SparklingFailedViewRetryJavaApiTest { + @Test + public void javaCanImplementRetryableErrorView() { + Context context = RuntimeEnvironment.getApplication(); + JavaRetryableErrorView errorView = new JavaRetryableErrorView(context); + SparklingFailedViewRetry retry = () -> false; + + errorView.setSparklingRetry(retry); + + assertSame(retry, errorView.retry); + assertFalse(errorView.retry.retry()); + errorView.setSparklingRetry(null); + assertNull(errorView.retry); + } + + @Test + public void existingJavaUiProviderNeedsNoNewMethod() { + Context context = RuntimeEnvironment.getApplication(); + SparklingUIProvider provider = + new SparklingUIProvider() { + @Override + public View getLoadingView(Context context) { + return new View(context); + } + + @Override + public View getErrorView(Context context) { + return new View(context); + } + + @Override + public Toolbar getToolBar(Context context) { + return null; + } + }; + + assertFalse(provider.getErrorView(context) instanceof SparklingRetryableErrorView); + } + + private static final class JavaRetryableErrorView extends View + implements SparklingRetryableErrorView { + private SparklingFailedViewRetry retry; + + JavaRetryableErrorView(Context context) { + super(context); + } + + @Override + public void setSparklingRetry(SparklingFailedViewRetry retry) { + this.retry = retry; + } + } +} diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingFailedViewRetryTest.kt b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingFailedViewRetryTest.kt new file mode 100644 index 00000000..726083e6 --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/SparklingFailedViewRetryTest.kt @@ -0,0 +1,429 @@ +// 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.content.Context +import android.view.View +import android.widget.FrameLayout +import androidx.appcompat.widget.Toolbar +import com.tiktok.sparkling.hybridkit.HybridContext +import com.tiktok.sparkling.hybridkit.HybridKit +import com.tiktok.sparkling.hybridkit.base.IHybridKitLifeCycle +import com.tiktok.sparkling.hybridkit.base.IKitView +import com.tiktok.sparkling.hybridkit.base.IPerformanceView +import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicBoolean + +@RunWith(RobolectricTestRunner::class) +@Config( + sdk = [33], + packageName = "com.tiktok.sparkling", +) +class SparklingFailedViewRetryTest { + private lateinit var context: Context + private lateinit var sparklingContext: SparklingContext + + @Before + fun setUp() { + clearAllMocks() + mockkObject(HybridKit) + context = RuntimeEnvironment.getApplication() + sparklingContext = + SparklingContext().apply { + hybridSchemeParam = HybridSchemeParam() + } + } + + @After + fun tearDown() { + SparklingContextTransferStation.clearAllContexts() + unmockkAll() + } + + @Test + fun failureRegistersRetryThatReloadsExactlyOnce() { + val fixture = prepareRetryableView() + + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "network error") + + val retry = fixture.errorView.currentRetry + assertTrue(retry != null) + assertTrue(retry!!.retry()) + assertFalse(retry.retry()) + assertEquals(1, fixture.kitView.reloadCount) + assertNull(fixture.errorView.currentRetry) + } + + @Test + fun multipleFailuresProvideOneRetryPerFailure() { + val fixture = prepareRetryableView() + + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "first") + val firstRetry = fixture.errorView.currentRetry!! + assertTrue(firstRetry.retry()) + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "second") + val secondRetry = fixture.errorView.currentRetry!! + + assertFalse(firstRetry.retry()) + assertTrue(secondRetry.retry()) + assertFalse(secondRetry.retry()) + assertEquals(2, fixture.kitView.reloadCount) + } + + @Test + fun retryFailureReturnsToFailAndRegistersNewRetry() { + val fixture = prepareRetryableView() + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "missing template") + val firstRetry = fixture.errorView.currentRetry!! + + assertTrue(firstRetry.retry()) + assertEquals(IPerformanceView.LoadStatus.LOADING, fixture.sparklingView.loadStatus()) + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "still missing") + + assertEquals(IPerformanceView.LoadStatus.FAIL, fixture.sparklingView.loadStatus()) + val secondRetry = fixture.errorView.currentRetry + assertTrue(secondRetry != null) + assertFalse(firstRetry.retry()) + assertTrue(secondRetry!!.retry()) + assertEquals(2, fixture.kitView.reloadCount) + } + + @Test + fun retrySuccessTransitionsFromLoadingToSuccess() { + val fixture = prepareRetryableView() + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "missing template") + + assertTrue(fixture.errorView.currentRetry!!.retry()) + assertEquals(IPerformanceView.LoadStatus.LOADING, fixture.sparklingView.loadStatus()) + fixture.lifecycle.onLoadFinish(fixture.kitView) + + assertEquals(IPerformanceView.LoadStatus.SUCCESS, fixture.sparklingView.loadStatus()) + assertNull(fixture.errorView.currentRetry) + } + + @Test + fun newerFailureInvalidatesUnconsumedRetry() { + val fixture = prepareRetryableView() + + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "first") + val staleRetry = fixture.errorView.currentRetry!! + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "second") + val currentRetry = fixture.errorView.currentRetry!! + + assertFalse(staleRetry.retry()) + assertTrue(currentRetry.retry()) + assertEquals(1, fixture.kitView.reloadCount) + } + + @Test + fun releaseClearsAndInvalidatesRetry() { + val fixture = prepareRetryableView() + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "network error") + val retry = fixture.errorView.currentRetry!! + + fixture.sparklingView.release() + + assertNull(fixture.errorView.currentRetry) + assertFalse(retry.retry()) + assertEquals(0, fixture.kitView.reloadCount) + assertEquals(1, fixture.kitView.destroyCount) + } + + @Test + fun retryOffMainThreadIsRejectedWithoutConsumingCurrentFailure() { + val fixture = prepareRetryableView() + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "network error") + val retry = fixture.errorView.currentRetry!! + val backgroundResult = AtomicBoolean(true) + val thread = + Thread { + backgroundResult.set(retry.retry()) + } + + thread.start() + thread.join() + + assertFalse(backgroundResult.get()) + assertEquals(0, fixture.kitView.reloadCount) + assertTrue(retry.retry()) + assertEquals(1, fixture.kitView.reloadCount) + } + + @Test + fun staleKitViewFailureCannotReplaceCurrentContainerRetry() { + val fixture = prepareRetryableView() + fixture.lifecycle.onLoadFailed(fixture.kitView, TEST_URL, "current failure") + val currentRetry = fixture.errorView.currentRetry!! + val otherKitView = RecordingKitView(context) + + fixture.lifecycle.onLoadFailed(otherKitView, TEST_URL, "stale failure") + + assertSame(currentRetry, fixture.errorView.currentRetry) + assertTrue(currentRetry.retry()) + assertEquals(1, fixture.kitView.reloadCount) + assertEquals(0, otherKitView.reloadCount) + } + + @Test + fun retriesRemainIsolatedAcrossEmbeddedContainers() { + val first = prepareRetryableView() + val second = prepareRetryableView() + first.lifecycle.onLoadFailed(first.kitView, TEST_URL, "first") + second.lifecycle.onLoadFailed(second.kitView, TEST_URL, "second") + + assertTrue(first.errorView.currentRetry!!.retry()) + + assertEquals(1, first.kitView.reloadCount) + assertEquals(0, second.kitView.reloadCount) + assertTrue(second.errorView.currentRetry!!.retry()) + assertEquals(1, second.kitView.reloadCount) + } + + @Test + fun fullPageFragmentReleaseInvalidatesRegisteredRetry() { + val kitView = RecordingKitView(context) + val errorView = RecordingRetryableErrorView(context) + val lifecycleSlot = slot() + every { + HybridKit.createKitView(any(), any(), any(), capture(lifecycleSlot)) + } returns kitView + val fullPageContext = + SparklingContext().apply { + containerId = "full-page-retry" + hybridSchemeParam = HybridSchemeParam() + sparklingUIProvider = + object : SparklingUIProvider { + override fun getLoadingView(context: Context): View = View(context) + + override fun getErrorView(context: Context): View = errorView + + override fun getToolBar(context: Context): Toolbar? = null + } + } + SparklingContextTransferStation.saveSparklingContext(fullPageContext) + val intent = + android.content.Intent(context, SparklingActivity::class.java).apply { + putExtra(Sparkling.SPARKLING_CONTEXT_CONTAINER_ID, fullPageContext.containerId) + } + val controller = + Robolectric + .buildActivity(SparklingActivity::class.java, intent) + .create() + .start() + .resume() + controller.get().supportFragmentManager.executePendingTransactions() + lifecycleSlot.captured.onLoadFailed(kitView, TEST_URL, "network error") + val retry = errorView.currentRetry!! + + controller.pause().stop().destroy() + + assertNull(errorView.currentRetry) + assertFalse(retry.retry()) + assertEquals(1, kitView.destroyCount) + } + + @Test + fun fragmentViewRecreationReleasesOldViewAndCreatesFreshRetryState() { + val firstKitView = RecordingKitView(context) + val secondKitView = RecordingKitView(context) + val lifecycles = mutableListOf() + every { + HybridKit.createKitView(any(), any(), any(), any()) + } answers { + lifecycles += arg(3) + if (lifecycles.size == 1) firstKitView else secondKitView + } + val errorViews = mutableListOf() + val retainedContext = + SparklingContext().apply { + containerId = "full-page-recreate" + hybridSchemeParam = HybridSchemeParam() + sparklingUIProvider = + object : SparklingUIProvider { + override fun getLoadingView(context: Context): View = View(context) + + override fun getErrorView(context: Context): View = RecordingRetryableErrorView(context).also(errorViews::add) + + override fun getToolBar(context: Context): Toolbar? = null + } + } + SparklingContextTransferStation.saveSparklingContext(retainedContext) + val intent = + android.content.Intent(context, SparklingActivity::class.java).apply { + putExtra(Sparkling.SPARKLING_CONTEXT_CONTAINER_ID, retainedContext.containerId) + } + val controller = + Robolectric + .buildActivity(SparklingActivity::class.java, intent) + .create() + .start() + .resume() + val activity = controller.get() + activity.supportFragmentManager.executePendingTransactions() + val fragment = + activity.supportFragmentManager.findFragmentById(R.id.main_view_container) + as SparklingFragment + lifecycles.single().onLoadFailed(firstKitView, TEST_URL, "first failure") + val staleRetry = errorViews.single().currentRetry!! + + activity.supportFragmentManager + .beginTransaction() + .detach(fragment) + .commitNow() + + assertEquals(1, firstKitView.destroyCount) + assertNull(errorViews.first().currentRetry) + assertFalse(staleRetry.retry()) + + activity.supportFragmentManager + .beginTransaction() + .attach(fragment) + .commitNow() + lifecycles.last().onLoadFailed(secondKitView, TEST_URL, "second failure") + + assertEquals(2, errorViews.size) + assertTrue(errorViews.last().currentRetry!!.retry()) + assertEquals(0, firstKitView.reloadCount) + assertEquals(1, secondKitView.reloadCount) + assertEquals(0, secondKitView.destroyCount) + + controller.pause().stop().destroy() + + assertEquals(1, firstKitView.destroyCount) + assertEquals(1, secondKitView.destroyCount) + } + + @Test + fun legacyProviderWithPlainErrorViewRemainsCompatible() { + val kitView = RecordingKitView(context) + val lifecycleSlot = slot() + every { + HybridKit.createKitView(any(), any(), any(), capture(lifecycleSlot)) + } returns kitView + sparklingContext.sparklingUIProvider = + object : SparklingUIProvider { + override fun getLoadingView(context: Context): View = View(context) + + override fun getErrorView(context: Context): View = View(context) + + override fun getToolBar(context: Context): Toolbar? = null + } + val sparklingView = SparklingView(context) + sparklingView.prepare(sparklingContext) + + lifecycleSlot.captured.onLoadFailed(kitView, TEST_URL, "network error") + + assertEquals(0, kitView.reloadCount) + assertEquals(IPerformanceView.LoadStatus.FAIL, sparklingView.loadStatus()) + } + + private fun prepareRetryableView(): RetryFixture { + val kitView = RecordingKitView(context) + val errorView = RecordingRetryableErrorView(context) + val lifecycleSlot = slot() + every { + HybridKit.createKitView(any(), any(), any(), capture(lifecycleSlot)) + } returns kitView + sparklingContext = + SparklingContext().apply { + hybridSchemeParam = HybridSchemeParam() + sparklingUIProvider = + object : SparklingUIProvider { + override fun getLoadingView(context: Context): View = View(context) + + override fun getErrorView(context: Context): View = errorView + + override fun getToolBar(context: Context): Toolbar? = null + } + } + val sparklingView = SparklingView(context) + sparklingView.prepare(sparklingContext) + return RetryFixture(sparklingView, kitView, errorView, lifecycleSlot.captured) + } + + private data class RetryFixture( + val sparklingView: SparklingView, + val kitView: RecordingKitView, + val errorView: RecordingRetryableErrorView, + val lifecycle: IHybridKitLifeCycle, + ) + + private class RecordingRetryableErrorView( + context: Context, + ) : FrameLayout(context), + SparklingRetryableErrorView { + val registrations = mutableListOf() + var currentRetry: SparklingFailedViewRetry? = null + private set + + override fun setSparklingRetry(retry: SparklingFailedViewRetry?) { + registrations += retry + currentRetry = retry + } + } + + private class RecordingKitView( + context: Context, + ) : IKitView { + override var hybridContext: HybridContext = HybridContext() + private val view = View(context) + var reloadCount = 0 + var destroyCount = 0 + + override fun realView(): View = view + + override fun load() = Unit + + override fun load(uri: String) = Unit + + override fun reload() { + reloadCount++ + } + + override fun updateGlobalPropsByIncrement(data: Map) = Unit + + override fun onShow() = Unit + + override fun onHide() = Unit + + override fun destroy(clearContext: Boolean) { + destroyCount++ + } + + override fun hasDestroyed(): Boolean = destroyCount > 0 + + override fun getGlobalProps(): MutableMap? = null + + override fun getScheme(): String? = null + + override fun onLoadSuccess() = Unit + + override fun sendEventByJSON( + eventName: String, + params: org.json.JSONObject?, + ) = Unit + } + + private companion object { + const val TEST_URL = "https://example.com/main.lynx.bundle" + } +} diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxViewClientTest.kt b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxViewClientTest.kt index 08b5735d..222fe879 100644 --- a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxViewClientTest.kt +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/lynx/SimpleLynxViewClientTest.kt @@ -68,6 +68,31 @@ class SimpleLynxViewClientTest { verify(exactly = 2) { kitView.onLoadSuccess() } } + @Test + fun beginLoadAllowsFailureAgainWhenReloadHasNoPageStart() { + val firstUrl = "https://example.com/first.lynx.bundle" + val retryUrl = "https://example.com/retry.lynx.bundle" + client.beginLoad(firstUrl) + client.onReceivedError(fatalLynxError()) + + client.beginLoad(retryUrl) + client.onReceivedError(fatalLynxError()) + + verify(exactly = 1) { lifeCycle.onLoadFailed(kitView, firstUrl, any()) } + verify(exactly = 1) { lifeCycle.onLoadFailed(kitView, retryUrl, any()) } + } + + @Test + fun beginLoadAllowsSuccessAfterPreviousFailureWithoutPageStart() { + client.beginLoad("https://example.com/missing.lynx.bundle") + client.onReceivedError(fatalLynxError()) + + client.beginLoad("https://example.com/available.lynx.bundle") + client.onLoadSuccess() + + verify(exactly = 1) { kitView.onLoadSuccess() } + } + @Test fun onReceivedErrorKeepsFatalErrorOnFailedPath() { val errorSlot = slot()