diff --git a/packages/playground/.gitignore b/packages/playground/.gitignore index c35029f0..497443ba 100644 --- a/packages/playground/.gitignore +++ b/packages/playground/.gitignore @@ -6,6 +6,7 @@ dist/ coverage/ *.tsbuildinfo **/*.lynx.bundle +!android/app/src/main/assets/device-acceptance.lynx.bundle .sparkling/ # Android SDK artifacts diff --git a/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/AndroidCompatibilityDeviceGateTest.kt b/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/AndroidCompatibilityDeviceGateTest.kt new file mode 100644 index 00000000..5ecc5a5e --- /dev/null +++ b/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/AndroidCompatibilityDeviceGateTest.kt @@ -0,0 +1,470 @@ +// 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.app.Activity +import android.content.Intent +import android.os.SystemClock +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.lynx.tasm.ThreadStrategyForRendering +import com.lynx.tasm.resourceprovider.LynxResourceCallback +import com.lynx.tasm.resourceprovider.LynxResourceRequest +import com.lynx.tasm.resourceprovider.LynxResourceResponse +import com.lynx.tasm.resourceprovider.template.LynxTemplateResourceFetcher +import com.lynx.tasm.resourceprovider.template.TemplateProviderResult +import com.tiktok.sparkling.Sparkling +import com.tiktok.sparkling.SparklingActivity +import com.tiktok.sparkling.SparklingContext +import com.tiktok.sparkling.SparklingContextTransferStation +import com.tiktok.sparkling.SparklingLifecycleDelegate +import com.tiktok.sparkling.SparklingLynxConfigurationError +import com.tiktok.sparkling.SparklingLynxConfigurationException +import com.tiktok.sparkling.SparklingLynxViewCreatedListener +import com.tiktok.sparkling.SparklingResourceFetcherConfig +import com.tiktok.sparkling.SparklingResourceFetcherFactory +import com.tiktok.sparkling.SparklingThreadStrategy +import com.tiktok.sparkling.SparklingView +import com.tiktok.sparkling.hybridkit.HybridCommon +import com.tiktok.sparkling.hybridkit.HybridKit +import com.tiktok.sparkling.hybridkit.base.HybridKitError +import com.tiktok.sparkling.hybridkit.base.IKitView +import com.tiktok.sparkling.hybridkit.config.SparklingHybridConfig +import com.tiktok.sparkling.hybridkit.config.SparklingLynxConfig +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AndroidCompatibilityDeviceGateTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val targetContext = instrumentation.targetContext + private val application = targetContext.applicationContext as SparklingApplication + private lateinit var originalConfig: SparklingHybridConfig + private var activity: Activity? = null + private var sparklingView: SparklingView? = null + + @Before + fun setUp() { + originalConfig = requireNotNull(HybridCommon.hybridConfig) + } + + @After + fun tearDown() { + instrumentation.runOnMainSync { + sparklingView?.release() + sparklingView = null + activity?.finish() + activity = null + } + HybridKit.setHybridConfig(originalConfig, application) + instrumentation.waitForIdleSync() + } + + @Test + fun fixedViewportUsesSharedDensityDefaultThreadAndPageFetcher() { + val bundle = + targetContext.assets.open(BUNDLE_NAME).use { + it.readBytes() + } + val fetchCount = AtomicInteger() + val factoryCount = AtomicInteger() + val requestedUrl = AtomicReference() + val listenerBridgeReady = AtomicBoolean() + val listenerBeforeFetch = AtomicBoolean() + installConfig( + density = SHARED_DENSITY, + defaultThreadStrategy = SparklingThreadStrategy.PART_ON_LAYOUT, + resourceFetcherFactory = + SparklingResourceFetcherFactory { + factoryCount.incrementAndGet() + SparklingResourceFetcherConfig + .builder() + .setTemplateResourceFetcher( + successfulTemplateFetcher(bundle, fetchCount, requestedUrl), + ).build() + }, + ) + + val firstScreen = CountDownLatch(1) + val loadFinish = CountDownLatch(1) + val loadFailure = AtomicReference() + val createdLynxView = AtomicReference() + lateinit var context: SparklingContext + context = + SparklingContext().apply { + scheme = + "hybrid://lynxview_page?url=$BUNDLE_URL" + + "&width=$VIEWPORT_WIDTH_PX&height=$VIEWPORT_HEIGHT_PX" + lynxViewCreatedListener = + SparklingLynxViewCreatedListener { + listenerBridgeReady.set(context.bridge != null) + listenerBeforeFetch.set(fetchCount.get() == 0) + createdLynxView.set(it) + } + lifecycleDelegate = + object : SparklingLifecycleDelegate { + override fun onFirstScreen(view: IKitView) { + firstScreen.countDown() + } + + override fun onLoadFinish(view: IKitView) { + loadFinish.countDown() + } + + override fun onLoadFailed( + view: IKitView, + url: String, + error: HybridKitError, + ) { + loadFailure.set(error) + while (firstScreen.count > 0) { + firstScreen.countDown() + } + while (loadFinish.count > 0) { + loadFinish.countDown() + } + } + } + } + + val host = launchHostActivity() + instrumentation.runOnMainSync { + val sparkling = Sparkling.build(host, context) + sparkling.processSparklingContext(context) + sparklingView = requireNotNull(sparkling.createView()) + val root = FrameLayout(host) + root.addView( + sparklingView, + FrameLayout.LayoutParams(HOST_WIDTH_PX, HOST_HEIGHT_PX), + ) + host.setContentView(root) + root.measure( + View.MeasureSpec.makeMeasureSpec(HOST_WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HOST_HEIGHT_PX, View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, HOST_WIDTH_PX, HOST_HEIGHT_PX) + sparklingView?.loadUrl() + } + + assertTrue("Timed out waiting for first-screen", firstScreen.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + assertTrue("Timed out waiting for load-finish", loadFinish.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) + assertNull(loadFailure.get()?.errorReason, loadFailure.get()) + assertEquals(1, factoryCount.get()) + assertEquals(1, fetchCount.get()) + assertEquals(BUNDLE_URL, requestedUrl.get()) + assertTrue("listener ran before bridge initialization", listenerBridgeReady.get()) + assertTrue("listener ran after template fetch started", listenerBeforeFetch.get()) + instrumentation.runOnMainSync { + val lynxView = requireNotNull(createdLynxView.get()) + assertEquals(VIEWPORT_WIDTH_PX, lynxView.measuredWidth) + assertEquals(VIEWPORT_HEIGHT_PX, lynxView.measuredHeight) + assertEquals( + ThreadStrategyForRendering.PART_ON_LAYOUT, + lynxView.threadStrategyForRendering, + ) + assertEquals( + SHARED_DENSITY, + lynxView.lynxContext.screenMetrics.density, + 0.0001f, + ) + assertTrue(requireNotNull(sparklingView).isLoadSuccess()) + + val secondCreatedLynxView = AtomicReference() + val secondContext = + SparklingContext().apply { + scheme = "hybrid://lynxview_page?bundle=$BUNDLE_NAME" + lynxViewCreatedListener = + SparklingLynxViewCreatedListener { + secondCreatedLynxView.set(it) + } + } + val secondSparkling = Sparkling.build(host, secondContext) + secondSparkling.processSparklingContext(secondContext) + val secondSparklingView = requireNotNull(secondSparkling.createView()) + val secondLynxView = requireNotNull(secondCreatedLynxView.get()) + assertNotSame(lynxView, secondLynxView) + assertEquals( + SHARED_DENSITY, + secondLynxView.lynxContext.screenMetrics.density, + 0.0001f, + ) + secondSparklingView.release() + } + println( + "COMPAT_GATE event=pass viewport=${VIEWPORT_WIDTH_PX}x$VIEWPORT_HEIGHT_PX " + + "density=$SHARED_DENSITY density_views=2 " + + "thread=PART_ON_LAYOUT fetches=${fetchCount.get()} factory_calls=${factoryCount.get()} " + + "listener_bridge_ready=${listenerBridgeReady.get()} listener_before_fetch=${listenerBeforeFetch.get()}", + ) + } + + @Test + fun fixedViewportRejectsMultiThreadsBeforeViewCreation() { + installConfig( + density = null, + defaultThreadStrategy = SparklingThreadStrategy.MULTI_THREADS, + ) + val createdLynxView = AtomicReference() + val context = + SparklingContext().apply { + scheme = + "hybrid://lynxview_page?bundle=$BUNDLE_NAME" + + "&width=$VIEWPORT_WIDTH_PX&height=$VIEWPORT_HEIGHT_PX" + lynxViewCreatedListener = + SparklingLynxViewCreatedListener { + createdLynxView.set(it) + } + } + + val exception = + runCatching { + val sparkling = Sparkling.build(targetContext, context) + sparkling.processSparklingContext(context) + sparkling.createView() + }.exceptionOrNull() + + assertTrue(exception is SparklingLynxConfigurationException) + assertEquals( + SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS, + (exception as SparklingLynxConfigurationException).error, + ) + assertNull(createdLynxView.get()) + + val fullPageContext = + SparklingContext().apply { + scheme = + "hybrid://lynxview_page?bundle=$BUNDLE_NAME" + + "&width=$VIEWPORT_WIDTH_PX&height=$VIEWPORT_HEIGHT_PX" + } + val monitor = instrumentation.addMonitor(SparklingActivity::class.java.name, null, false) + val navigateException = + runCatching { + Sparkling.build(targetContext, fullPageContext).navigate() + }.exceptionOrNull() + val launchedActivity = instrumentation.waitForMonitorWithTimeout(monitor, 500) + instrumentation.removeMonitor(monitor) + + assertTrue(navigateException is SparklingLynxConfigurationException) + assertEquals( + SparklingLynxConfigurationError.FIXED_VIEWPORT_WITH_MULTI_THREADS, + (navigateException as SparklingLynxConfigurationException).error, + ) + assertNull(launchedActivity) + assertNull( + SparklingContextTransferStation.getSparklingContext(fullPageContext.containerId), + ) + println( + "COMPAT_GATE event=unsafe_rejected error=${exception.error} " + + "full_page_activity_started=${launchedActivity != null} transfer_saved=" + + "${SparklingContextTransferStation.getSparklingContext(fullPageContext.containerId) != null}", + ) + } + + @Test + fun pageThreadOverridesRenderRealBundles() { + val bundle = + targetContext.assets.open(BUNDLE_NAME).use { + it.readBytes() + } + installConfig( + density = null, + defaultThreadStrategy = SparklingThreadStrategy.PART_ON_LAYOUT, + ) + val host = launchHostActivity() + val strategies = + listOf( + SparklingThreadStrategy.ALL_ON_UI to ThreadStrategyForRendering.ALL_ON_UI, + SparklingThreadStrategy.MOST_ON_TASM to ThreadStrategyForRendering.MOST_ON_TASM, + SparklingThreadStrategy.MULTI_THREADS to ThreadStrategyForRendering.MULTI_THREADS, + ) + + strategies.forEachIndexed { index, (sparklingStrategy, lynxStrategy) -> + val firstScreen = CountDownLatch(1) + val loadFinish = CountDownLatch(1) + val loadFailure = AtomicReference() + val createdLynxView = AtomicReference() + val fetchCount = AtomicInteger() + val context = + SparklingContext().apply { + scheme = "hybrid://lynxview_page?url=$BUNDLE_URL?strategy=$index" + threadStrategy = sparklingStrategy + resourceFetcherConfig = + SparklingResourceFetcherConfig + .builder() + .setTemplateResourceFetcher( + successfulTemplateFetcher( + bundle, + fetchCount, + AtomicReference(), + ), + ).build() + lynxViewCreatedListener = + SparklingLynxViewCreatedListener { + createdLynxView.set(it) + } + lifecycleDelegate = + object : SparklingLifecycleDelegate { + override fun onFirstScreen(view: IKitView) { + firstScreen.countDown() + } + + override fun onLoadFinish(view: IKitView) { + loadFinish.countDown() + } + + override fun onLoadFailed( + view: IKitView, + url: String, + error: HybridKitError, + ) { + loadFailure.set(error) + while (firstScreen.count > 0) { + firstScreen.countDown() + } + while (loadFinish.count > 0) { + loadFinish.countDown() + } + } + } + } + lateinit var currentView: SparklingView + instrumentation.runOnMainSync { + val sparkling = Sparkling.build(host, context) + sparkling.processSparklingContext(context) + currentView = requireNotNull(sparkling.createView()) + val root = FrameLayout(host) + root.addView( + currentView, + FrameLayout.LayoutParams(HOST_WIDTH_PX, HOST_HEIGHT_PX), + ) + host.setContentView(root) + root.measure( + View.MeasureSpec.makeMeasureSpec(HOST_WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HOST_HEIGHT_PX, View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, HOST_WIDTH_PX, HOST_HEIGHT_PX) + currentView.loadUrl() + } + + assertTrue( + "$sparklingStrategy first-screen timed out", + firstScreen.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), + ) + assertTrue( + "$sparklingStrategy load-finish timed out", + loadFinish.await(TIMEOUT_SECONDS, TimeUnit.SECONDS), + ) + assertNull(loadFailure.get()?.errorReason, loadFailure.get()) + assertEquals(1, fetchCount.get()) + assertEquals(lynxStrategy, createdLynxView.get().threadStrategyForRendering) + assertTrue(currentView.isLoadSuccess()) + instrumentation.runOnMainSync { + currentView.release() + } + } + + assertFalse(strategies.isEmpty()) + println( + "COMPAT_GATE event=thread_matrix strategies=" + + strategies.joinToString(",") { it.first.name }, + ) + } + + private fun launchHostActivity(): DeviceValidationActivity { + val intent = + Intent(targetContext, DeviceValidationActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return (instrumentation.startActivitySync(intent) as DeviceValidationActivity).also { + activity = it + instrumentation.waitForIdleSync() + SystemClock.sleep(250) + } + } + + private fun installConfig( + density: Float?, + defaultThreadStrategy: SparklingThreadStrategy?, + resourceFetcherFactory: SparklingResourceFetcherFactory? = null, + ) { + val originalLynxConfig = requireNotNull(originalConfig.lynxConfig as SparklingLynxConfig) + val lynxConfig = + SparklingLynxConfig.build(application) { + setCheckPropsSetter(originalLynxConfig.isCheckPropsSetter) + setLibraryLoader(originalLynxConfig.libraryLoader) + setTemplateProvider(originalLynxConfig.templateProvider) + setResourceFetcherFactory( + resourceFetcherFactory ?: originalLynxConfig.resourceFetcherFactory, + ) + addBehaviors(originalLynxConfig.globalBehaviors) + addLynxModules(originalLynxConfig.globalModules) + setAdditionInit(originalLynxConfig.additionInit) + density?.let(::setSharedProcessDensityOverride) + setDefaultThreadStrategy(defaultThreadStrategy) + } + val config = + SparklingHybridConfig.build(originalConfig.baseInfoConfig) { + setLynxConfig(lynxConfig) + setWebConfig(originalConfig.webConfig) + setBridgeConfig(originalConfig.bridgeConfig) + setLogConfig(originalConfig.logConfig) + originalConfig.debugConfig?.let(::setDebugConfig) + setDefaultScreenOrientationPolicy(originalConfig.defaultScreenOrientationPolicy) + } + HybridKit.setHybridConfig(config, application) + } + + private fun successfulTemplateFetcher( + bundle: ByteArray, + fetchCount: AtomicInteger, + requestedUrl: AtomicReference, + ): LynxTemplateResourceFetcher = + object : LynxTemplateResourceFetcher() { + override fun fetchTemplate( + request: LynxResourceRequest, + callback: LynxResourceCallback, + ) { + fetchCount.incrementAndGet() + requestedUrl.set(request.url) + callback.onResponse( + LynxResourceResponse.onSuccess( + TemplateProviderResult.fromBinary(bundle), + ), + ) + } + + override fun fetchSSRData( + request: LynxResourceRequest, + callback: LynxResourceCallback, + ) { + callback.onResponse(LynxResourceResponse.onSuccess(bundle)) + } + } + + private companion object { + const val BUNDLE_NAME = "device-acceptance.lynx.bundle" + const val BUNDLE_URL = "https://device.acceptance/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 SHARED_DENSITY = 3.25f + const val TIMEOUT_SECONDS = 20L + } +} diff --git a/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/PlaygroundOrientationDeviceTest.kt b/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/PlaygroundOrientationDeviceTest.kt new file mode 100644 index 00000000..26f68cfc --- /dev/null +++ b/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/PlaygroundOrientationDeviceTest.kt @@ -0,0 +1,241 @@ +// 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.app.Activity +import android.content.Intent +import android.content.pm.ActivityInfo +import android.graphics.Bitmap +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.tiktok.sparkling.Sparkling +import com.tiktok.sparkling.SparklingActivity +import com.tiktok.sparkling.SparklingContext +import com.tiktok.sparkling.SparklingContextTransferStation +import com.tiktok.sparkling.SparklingScreenOrientationPolicy +import com.tiktok.sparkling.hybridkit.HybridCommon +import com.tiktok.sparkling.hybridkit.HybridKit +import com.tiktok.sparkling.hybridkit.config.SparklingHybridConfig +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlaygroundOrientationDeviceTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val targetContext = instrumentation.targetContext + private val application = targetContext.applicationContext as SparklingApplication + private var currentActivity: Activity? = null + private var activeContainerId: String? = null + + @After + fun tearDown() { + instrumentation.runOnMainSync { + currentActivity?.finish() + currentActivity = null + } + activeContainerId?.let(SparklingContextTransferStation::releaseSparklingContext) + activeContainerId = null + setGlobalDefault(null) + instrumentation.waitForIdleSync() + } + + @Test + fun typedLandscapeFullPage() { + val result = + launchFullPage( + name = "typed-landscape", + policy = SparklingScreenOrientationPolicy.LANDSCAPE, + expected = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, + screenshotName = "typed-landscape", + ) + assertTrue(result.screenshotWidth > result.screenshotHeight) + } + + @Test + fun typedPortraitFullPage() { + val result = + launchFullPage( + name = "typed-portrait", + policy = SparklingScreenOrientationPolicy.PORTRAIT, + expected = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, + screenshotName = "typed-portrait", + ) + assertTrue(result.screenshotHeight > result.screenshotWidth) + } + + @Test + fun explicitSystemOverridesGlobalLandscape() { + setGlobalDefault(SparklingScreenOrientationPolicy.LANDSCAPE) + launchFullPage( + name = "explicit-system-over-global", + policy = SparklingScreenOrientationPolicy.SYSTEM, + expected = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, + ) + } + + @Test + fun canonicalScreenOrientationLandscape() { + launchFullPage( + name = "canonical-landscape", + canonicalOrientation = "landscape", + expected = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, + ) + } + + @Test + fun unsetUsesAndroidSystemBehavior() { + launchFullPage( + name = "unset", + expected = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, + ) + } + + @Test + fun embeddedLandscapePolicyDoesNotChangeHostActivity() { + val intent = + Intent(targetContext, OrientationHostActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val host = instrumentation.startActivitySync(intent) as OrientationHostActivity + currentActivity = host + instrumentation.runOnMainSync { + host.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + val context = + SparklingContext().apply { + scheme = "hybrid://lynxview_page?bundle=$BUNDLE_NAME" + screenOrientationPolicy = SparklingScreenOrientationPolicy.LANDSCAPE + } + assertTrue(Sparkling.build(host, context).createView(withoutPrepare = true) != null) + } + instrumentation.waitForIdleSync() + assertEquals( + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT, + host.requestedOrientation, + ) + println( + "ORIENTATION_GATE event=embedded requested=${host.requestedOrientation} " + + "expected=${ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT}", + ) + } + + private fun launchFullPage( + name: String, + policy: SparklingScreenOrientationPolicy? = null, + canonicalOrientation: String? = null, + expected: Int, + screenshotName: String? = null, + ): FullPageResult { + val context = + SparklingContext().apply { + scheme = + buildString { + append("hybrid://lynxview_page?bundle=") + append(BUNDLE_NAME) + append("&hide_nav_bar=1") + canonicalOrientation?.let { + append("&screen_orientation=") + append(it) + } + } + screenOrientationPolicy = policy + } + activeContainerId = context.containerId + val monitor = + instrumentation.addMonitor(SparklingActivity::class.java.name, null, false) + assertTrue(Sparkling.build(targetContext, context).navigate()) + assertSame( + context, + SparklingContextTransferStation.getSparklingContext(context.containerId), + ) + val activity = + requireNotNull( + instrumentation.waitForMonitorWithTimeout(monitor, TIMEOUT_MILLIS), + ) as SparklingActivity + currentActivity = activity + assertTrue( + "Timed out waiting for $name requestedOrientation=$expected", + waitUntil { + activity.requestedOrientation == expected + }, + ) + instrumentation.waitForIdleSync() + SystemClock.sleep(ORIENTATION_SETTLE_MILLIS) + val screenshot = screenshotName?.let(::saveScreenshot) + assertEquals(expected, activity.requestedOrientation) + instrumentation.runOnMainSync { + activity.finish() + } + instrumentation.waitForIdleSync() + SparklingContextTransferStation.releaseSparklingContext(context.containerId) + assertNull(SparklingContextTransferStation.getSparklingContext(context.containerId)) + activeContainerId = null + currentActivity = null + println( + "ORIENTATION_GATE event=pass name=$name requested=$expected " + + "screen=${screenshot?.width ?: -1}x${screenshot?.height ?: -1}", + ) + return FullPageResult( + screenshotWidth = screenshot?.width ?: -1, + screenshotHeight = screenshot?.height ?: -1, + ) + } + + private fun setGlobalDefault(policy: SparklingScreenOrientationPolicy?) { + val current = requireNotNull(HybridCommon.hybridConfig) + val config = + SparklingHybridConfig.build(current.baseInfoConfig) { + setLynxConfig(current.lynxConfig) + setWebConfig(current.webConfig) + setBridgeConfig(current.bridgeConfig) + setLogConfig(current.logConfig) + current.debugConfig?.let(::setDebugConfig) + setDefaultScreenOrientationPolicy(policy) + } + HybridKit.setHybridConfig(config, application) + } + + private fun waitUntil(condition: () -> Boolean): Boolean { + val deadline = SystemClock.elapsedRealtime() + TIMEOUT_MILLIS + while (SystemClock.elapsedRealtime() < deadline) { + if (condition()) { + return true + } + SystemClock.sleep(50) + } + return condition() + } + + private fun saveScreenshot(name: String): Bitmap { + val directory = + requireNotNull( + targetContext.getExternalFilesDir("orientation-validation"), + ) + directory.mkdirs() + val bitmap = instrumentation.uiAutomation.takeScreenshot() + val output = directory.resolve("$name.png") + output.outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + println("ORIENTATION_GATE artifact=${output.absolutePath}") + return bitmap + } + + private data class FullPageResult( + val screenshotWidth: Int, + val screenshotHeight: Int, + ) + + private companion object { + const val BUNDLE_NAME = "device-acceptance.lynx.bundle" + val TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(20) + const val ORIENTATION_SETTLE_MILLIS = 700L + } +} diff --git a/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/SparklingRetryDeviceGateTest.kt b/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/SparklingRetryDeviceGateTest.kt new file mode 100644 index 00000000..2322f76f --- /dev/null +++ b/packages/playground/android/app/src/androidTest/java/com/tiktok/sparkling/playground/SparklingRetryDeviceGateTest.kt @@ -0,0 +1,378 @@ +// 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.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color +import android.os.SystemClock +import android.view.Gravity +import android.view.View +import android.widget.FrameLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.appcompat.widget.Toolbar +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.lynx.tasm.resourceprovider.LynxResourceCallback +import com.lynx.tasm.resourceprovider.LynxResourceRequest +import com.lynx.tasm.resourceprovider.LynxResourceResponse +import com.lynx.tasm.resourceprovider.template.LynxTemplateResourceFetcher +import com.lynx.tasm.resourceprovider.template.TemplateProviderResult +import com.tiktok.sparkling.Sparkling +import com.tiktok.sparkling.SparklingContext +import com.tiktok.sparkling.SparklingFailedViewRetry +import com.tiktok.sparkling.SparklingLifecycleDelegate +import com.tiktok.sparkling.SparklingResourceFetcherConfig +import com.tiktok.sparkling.SparklingRetryableErrorView +import com.tiktok.sparkling.SparklingUIProvider +import com.tiktok.sparkling.SparklingView +import com.tiktok.sparkling.hybridkit.base.HybridKitError +import com.tiktok.sparkling.hybridkit.base.IKitView +import com.tiktok.sparkling.hybridkit.base.IPerformanceView +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SparklingRetryDeviceGateTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val targetContext = instrumentation.targetContext + private val activities = CopyOnWriteArrayList() + private val views = CopyOnWriteArrayList() + private val bundle by lazy { + targetContext.assets.open(BUNDLE_NAME).use { + it.readBytes() + } + } + + @After + fun tearDown() { + instrumentation.runOnMainSync { + views.forEach { + if (!it.hasRelease()) { + it.release() + } + } + activities.forEach(Activity::finish) + } + instrumentation.waitForIdleSync() + } + + @Test + fun retryContractTransitionsFailFailSuccessAndInvalidatesCallbacks() { + val activity = launchHostActivity() + val fetcher = SequencedTemplateFetcher(bundle) + val retryableErrorView = RecordingRetryableErrorView(targetContext) + val load = launch(activity, fetcher, retryableErrorView) + + assertTrue("initial failure missing", waitUntil { load.failures.get() == 1 }) + assertTrue( + "retry #1 missing", + waitUntil { retryableErrorView.registrations.size == 1 }, + ) + val retry1 = retryableErrorView.registrations[0] + assertEquals(IPerformanceView.LoadStatus.FAIL, load.view.loadStatus()) + saveScreenshot("retry-1-fail") + + assertTrue("retry #1 was not accepted", invokeRetry(retry1)) + assertTrue("second failure missing", waitUntil { load.failures.get() == 2 }) + assertTrue( + "retry #2 missing", + waitUntil { retryableErrorView.registrations.size == 2 }, + ) + val retry2 = retryableErrorView.registrations[1] + assertNotSame(retry1, retry2) + assertFalse("stale retry #1 must be rejected", invokeRetry(retry1)) + assertEquals(IPerformanceView.LoadStatus.FAIL, load.view.loadStatus()) + saveScreenshot("retry-2-fail") + + assertTrue("retry #2 was not accepted", invokeRetry(retry2)) + assertTrue("first screen missing", waitUntil { load.firstScreen.get() }) + assertTrue("load finish missing", waitUntil { load.loadFinish.get() }) + assertTrue( + "success status missing", + waitUntil { + load.view.loadStatus() == IPerformanceView.LoadStatus.SUCCESS + }, + ) + assertTrue( + "retry callback not cleared", + waitUntil { retryableErrorView.current.get() == null }, + ) + assertTrue( + "error view remained visible", + waitUntil { retryableErrorView.visibility == View.GONE }, + ) + assertEquals(3, fetcher.fetchCount.get()) + saveScreenshot("retry-success") + release(load.view) + + val releaseFetcher = AlwaysFailingTemplateFetcher() + val releaseErrorView = RecordingRetryableErrorView(targetContext) + val releaseLoad = launch(activity, releaseFetcher, releaseErrorView) + assertTrue( + "release failure missing", + waitUntil { releaseLoad.failures.get() == 1 }, + ) + assertTrue( + "release retry missing", + waitUntil { releaseErrorView.registrations.size == 1 }, + ) + val retryBeforeRelease = releaseErrorView.registrations.single() + release(releaseLoad.view) + assertTrue(releaseLoad.view.hasRelease()) + assertTrue( + "release did not clear callback", + waitUntil { releaseErrorView.current.get() == null }, + ) + assertFalse( + "released callback must be rejected", + invokeRetry(retryBeforeRelease), + ) + + val legacyErrorView = + TextView(targetContext).apply { + text = "Plain legacy error view" + gravity = Gravity.CENTER + setTextColor(Color.WHITE) + setBackgroundColor(Color.DKGRAY) + } + val legacyLoad = + launch( + activity, + AlwaysFailingTemplateFetcher(), + legacyErrorView, + ) + assertTrue( + "legacy failure missing", + waitUntil { legacyLoad.failures.get() == 1 }, + ) + assertEquals(IPerformanceView.LoadStatus.FAIL, legacyLoad.view.loadStatus()) + saveScreenshot("plain-legacy-error") + release(legacyLoad.view) + + println( + "RETRY_GATE event=pass fetches=${fetcher.fetchCount.get()} " + + "failures=${load.failures.get()} status=${load.view.loadStatus()} " + + "release_invalidated=true legacy=true", + ) + } + + private fun launch( + activity: DeviceValidationActivity, + fetcher: LynxTemplateResourceFetcher, + errorView: View, + ): Load { + val failures = AtomicInteger() + val firstScreen = AtomicBoolean() + val loadFinish = AtomicBoolean() + val context = + SparklingContext().apply { + scheme = "hybrid://lynxview_page?url=$BUNDLE_URL" + resourceFetcherConfig = + SparklingResourceFetcherConfig + .builder() + .setTemplateResourceFetcher(fetcher) + .build() + sparklingUIProvider = + object : SparklingUIProvider { + override fun getLoadingView(context: Context): View = ProgressBar(context) + + override fun getErrorView(context: Context): View = errorView + + override fun getToolBar(context: Context): Toolbar? = null + } + lifecycleDelegate = + object : SparklingLifecycleDelegate { + override fun onLoadFailed( + view: IKitView, + url: String, + error: HybridKitError, + ) { + failures.incrementAndGet() + } + + override fun onFirstScreen(view: IKitView) { + firstScreen.set(true) + } + + override fun onLoadFinish(view: IKitView) { + loadFinish.set(true) + } + } + } + lateinit var view: SparklingView + instrumentation.runOnMainSync { + val sparkling = Sparkling.build(activity, context) + sparkling.processSparklingContext(context) + view = requireNotNull(sparkling.createView()) + val host = FrameLayout(activity) + host.addView( + view, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + activity.setContentView(host) + views += view + view.loadUrl() + } + return Load(view, failures, firstScreen, loadFinish) + } + + private fun launchHostActivity(): DeviceValidationActivity { + val intent = + Intent(targetContext, DeviceValidationActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return (instrumentation.startActivitySync(intent) as DeviceValidationActivity).also { + activities += it + instrumentation.waitForIdleSync() + } + } + + private fun release(view: SparklingView) { + instrumentation.runOnMainSync { + view.release() + } + instrumentation.waitForIdleSync() + } + + private fun invokeRetry(retry: SparklingFailedViewRetry): Boolean { + val accepted = AtomicBoolean() + instrumentation.runOnMainSync { + accepted.set(retry.retry()) + } + instrumentation.waitForIdleSync() + return accepted.get() + } + + private fun waitUntil(condition: () -> Boolean): Boolean { + val deadline = SystemClock.elapsedRealtime() + TIMEOUT_MS + while (SystemClock.elapsedRealtime() < deadline) { + if (condition()) { + return true + } + SystemClock.sleep(50) + } + return condition() + } + + private fun saveScreenshot(name: String) { + instrumentation.waitForIdleSync() + val directory = + requireNotNull( + targetContext.getExternalFilesDir("retry-validation"), + ) + directory.mkdirs() + val output = directory.resolve("$name.png") + output.outputStream().use { + instrumentation.uiAutomation + .takeScreenshot() + .compress(Bitmap.CompressFormat.PNG, 100, it) + } + println("RETRY_GATE artifact=${output.absolutePath}") + } + + private data class Load( + val view: SparklingView, + val failures: AtomicInteger, + val firstScreen: AtomicBoolean, + val loadFinish: AtomicBoolean, + ) + + private inner class RecordingRetryableErrorView( + context: Context, + ) : TextView(context), + SparklingRetryableErrorView { + val current = AtomicReference() + val registrations = CopyOnWriteArrayList() + + init { + text = "Retryable failure" + gravity = Gravity.CENTER + textSize = 28f + setTextColor(Color.WHITE) + setBackgroundColor(Color.rgb(180, 30, 60)) + } + + override fun setSparklingRetry(retry: SparklingFailedViewRetry?) { + current.set(retry) + retry?.let(registrations::add) + } + } + + private class SequencedTemplateFetcher( + private val bundle: ByteArray, + ) : LynxTemplateResourceFetcher() { + val fetchCount = AtomicInteger() + + override fun fetchTemplate( + request: LynxResourceRequest, + callback: LynxResourceCallback, + ) { + val attempt = fetchCount.incrementAndGet() + if (attempt <= FAILURE_COUNT) { + callback.onResponse( + failedResponse(IllegalStateException("missing attempt $attempt")), + ) + } else { + callback.onResponse( + LynxResourceResponse.onSuccess( + TemplateProviderResult.fromBinary(bundle), + ), + ) + } + } + + override fun fetchSSRData( + request: LynxResourceRequest, + callback: LynxResourceCallback, + ) { + callback.onResponse(LynxResourceResponse.onSuccess(bundle)) + } + } + + private class AlwaysFailingTemplateFetcher : LynxTemplateResourceFetcher() { + override fun fetchTemplate( + request: LynxResourceRequest, + callback: LynxResourceCallback, + ) { + callback.onResponse( + failedResponse(IllegalStateException("missing")), + ) + } + + override fun fetchSSRData( + request: LynxResourceRequest, + callback: LynxResourceCallback, + ) { + callback.onResponse( + failedResponse(IllegalStateException("missing")), + ) + } + } + + private companion object { + const val BUNDLE_NAME = "device-acceptance.lynx.bundle" + const val BUNDLE_URL = "https://device.acceptance/retry.lynx.bundle" + const val FAILURE_COUNT = 2 + const val TIMEOUT_MS = 30_000L + + @Suppress("UNCHECKED_CAST") + fun failedResponse(error: Throwable): LynxResourceResponse = LynxResourceResponse.onFailed(error) as LynxResourceResponse + } +} diff --git a/packages/playground/android/app/src/debug/AndroidManifest.xml b/packages/playground/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..c261094b --- /dev/null +++ b/packages/playground/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/packages/playground/android/app/src/debug/java/com/tiktok/sparkling/playground/DeviceValidationActivity.kt b/packages/playground/android/app/src/debug/java/com/tiktok/sparkling/playground/DeviceValidationActivity.kt new file mode 100644 index 00000000..b3ff5711 --- /dev/null +++ b/packages/playground/android/app/src/debug/java/com/tiktok/sparkling/playground/DeviceValidationActivity.kt @@ -0,0 +1,8 @@ +// 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.app.Activity + +class DeviceValidationActivity : Activity() diff --git a/packages/playground/android/app/src/debug/java/com/tiktok/sparkling/playground/OrientationHostActivity.kt b/packages/playground/android/app/src/debug/java/com/tiktok/sparkling/playground/OrientationHostActivity.kt new file mode 100644 index 00000000..0f8a8a81 --- /dev/null +++ b/packages/playground/android/app/src/debug/java/com/tiktok/sparkling/playground/OrientationHostActivity.kt @@ -0,0 +1,8 @@ +// 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.app.Activity + +class OrientationHostActivity : Activity() diff --git a/packages/playground/android/app/src/main/assets/device-acceptance.lynx.bundle b/packages/playground/android/app/src/main/assets/device-acceptance.lynx.bundle new file mode 100644 index 00000000..2d40f45b Binary files /dev/null and b/packages/playground/android/app/src/main/assets/device-acceptance.lynx.bundle differ diff --git a/scripts/android-device-acceptance.sh b/scripts/android-device-acceptance.sh new file mode 100755 index 00000000..ff042763 --- /dev/null +++ b/scripts/android-device-acceptance.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# +# Build and run the Sparkling Android compatibility acceptance matrix on a +# freshly leased Lynx Sandbox device. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ANDROID_ROOT="$REPO_ROOT/packages/playground/android" +APP_APK="$ANDROID_ROOT/app/build/outputs/apk/debug/app-debug.apk" +TEST_APK="$ANDROID_ROOT/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" +PACKAGE="com.tiktok.sparkling.playground" +RUNNER="$PACKAGE.test/androidx.test.runner.AndroidJUnitRunner" +SANDBOX_BASE_URL="${SANDBOX_BASE_URL:-https://lynx-sandbox.byted.org}" +ANDROID_HOME="${ANDROID_HOME:-/data00/home/xuan.huang/android-sdk}" +EVIDENCE_ROOT="${EVIDENCE_ROOT:-/tmp/sparkling-android-device-acceptance-$(date +%s)}" +ISSUER="${ISSUER:-$(git -C "$REPO_ROOT" config --get user.email || true)}" +ISSUE_ID="${ISSUE_ID:-sparkling-android-device-acceptance-$(date +%s)}" +EXPECTED_HEAD="${EXPECTED_HEAD:-}" +SERIAL="" +CONNECTED=0 +RELEASED=0 + +if [[ -z "$ISSUER" ]]; then + ISSUER="sparkling-android-device-acceptance" +fi + +mkdir -p "$EVIDENCE_ROOT/logs" "$EVIDENCE_ROOT/screenshots" + +release_device() { + local strict=${1:-0} + local release_failed=0 + if [[ -n "$SERIAL" && $RELEASED -eq 0 ]]; then + local encoded_serial + encoded_serial="$( + python3 - "$SERIAL" <<'PY' +import sys +import urllib.parse + +print(urllib.parse.quote(sys.argv[1], safe="")) +PY + )" + if ! curl -fsS -X DELETE \ + "$SANDBOX_BASE_URL/pool/lease?serial=$encoded_serial" \ + >"$EVIDENCE_ROOT/release.json"; then + echo "error: failed to release Sandbox lease $SERIAL" >&2 + release_failed=1 + elif ! python3 - "$EVIDENCE_ROOT/release.json" "$SERIAL" <<'PY' +import json +import pathlib +import sys + +response = json.loads(pathlib.Path(sys.argv[1]).read_text()) +if response.get("acquired") != sys.argv[2]: + raise SystemExit(f"unexpected release response: {response!r}") +PY + then + echo "error: Sandbox release response did not confirm $SERIAL" >&2 + release_failed=1 + fi + if [[ $CONNECTED -eq 1 ]]; then + adb disconnect "$SERIAL" \ + >"$EVIDENCE_ROOT/logs/adb-disconnect.txt" 2>&1 || true + fi + RELEASED=1 + fi + if [[ $strict -eq 1 && $release_failed -ne 0 ]]; then + return 1 + fi + return "$release_failed" +} + +cleanup() { + local exit_code=$? + set +e + release_device 0 + local release_code=$? + if [[ $exit_code -eq 0 && $release_code -ne 0 ]]; then + exit "$release_code" + fi + exit "$exit_code" +} +trap cleanup EXIT + +run_instrumentation() { + local name=$1 + local class_filter=$2 + local expected_tests=$3 + local marker=$4 + local instrumentation_log="$EVIDENCE_ROOT/logs/$name.instrumentation.txt" + local logcat_log="$EVIDENCE_ROOT/logs/$name.logcat.txt" + + adb -s "$SERIAL" shell pm clear "$PACKAGE" >/dev/null + adb -s "$SERIAL" logcat -c + adb -s "$SERIAL" shell am instrument -w -r \ + -e class "$class_filter" \ + "$RUNNER" | tee "$instrumentation_log" + adb -s "$SERIAL" logcat -d -v threadtime >"$logcat_log" + + grep -Fq "OK ($expected_tests test" "$instrumentation_log" + grep -Fq "INSTRUMENTATION_CODE: -1" "$instrumentation_log" + if grep -Fq "FAILURES!!!" "$instrumentation_log"; then + echo "error: instrumentation failed for $name" >&2 + return 1 + fi + grep -Fq "$marker" "$logcat_log" +} + +echo "==> Build Playground APKs on $(hostname)" +ACTUAL_HEAD="$(git -C "$REPO_ROOT" rev-parse HEAD)" +if [[ -n "$EXPECTED_HEAD" && "$ACTUAL_HEAD" != "$EXPECTED_HEAD" ]]; then + echo "error: expected HEAD $EXPECTED_HEAD, got $ACTUAL_HEAD" >&2 + exit 1 +fi +if [[ -n "$(git -C "$REPO_ROOT" status --short --untracked-files=all)" ]]; then + echo "error: acceptance must run from a clean checkout" >&2 + git -C "$REPO_ROOT" status --short --untracked-files=all >&2 + exit 1 +fi +( + cd "$ANDROID_ROOT" + unset ANDROID_SDK_ROOT + export ANDROID_HOME + ./gradlew --no-daemon :app:assembleDebug :app:assembleDebugAndroidTest +) 2>&1 | tee "$EVIDENCE_ROOT/logs/gradle-build.txt" + +sha256sum "$APP_APK" "$TEST_APK" \ + >"$EVIDENCE_ROOT/apk-sha256.txt" +unzip -p "$APP_APK" assets/device-acceptance.lynx.bundle | + sha256sum >"$EVIDENCE_ROOT/bundle-sha256.txt" +printf '%s\n' "$ACTUAL_HEAD" >"$EVIDENCE_ROOT/git-head.txt" +git -C "$REPO_ROOT" status --short --untracked-files=all \ + >"$EVIDENCE_ROOT/git-status.txt" + +echo "==> Lease a fresh Lynx Sandbox device" +curl -fsS -X POST "$SANDBOX_BASE_URL/pool/lease" \ + -H "Content-Type: application/json" \ + -H "X-Issuer: $ISSUER" \ + -H "X-Issue-Id: $ISSUE_ID" \ + -d '{}' | + tee "$EVIDENCE_ROOT/lease.json" +SERIAL="$( + python3 - "$EVIDENCE_ROOT/lease.json" <<'PY' +import json +import pathlib +import sys + +print(json.loads(pathlib.Path(sys.argv[1]).read_text())["acquired"]) +PY +)" +printf '%s\n' "$SERIAL" >"$EVIDENCE_ROOT/serial.txt" + +adb connect "$SERIAL" | tee "$EVIDENCE_ROOT/logs/adb-connect.txt" +CONNECTED=1 +adb -s "$SERIAL" wait-for-device +adb devices -l | grep -F "$SERIAL" \ + >"$EVIDENCE_ROOT/adb-device.txt" +{ + echo "product=$(adb -s "$SERIAL" shell getprop ro.product.name | tr -d '\r')" + echo "model=$(adb -s "$SERIAL" shell getprop ro.product.model | tr -d '\r')" + echo "release=$(adb -s "$SERIAL" shell getprop ro.build.version.release | tr -d '\r')" + echo "sdk=$(adb -s "$SERIAL" shell getprop ro.build.version.sdk | tr -d '\r')" +} >"$EVIDENCE_ROOT/device.txt" + +echo "==> Install APKs" +adb -s "$SERIAL" install -r -t "$APP_APK" | + tee "$EVIDENCE_ROOT/logs/install-app.txt" +adb -s "$SERIAL" install -r -t "$TEST_APK" | + tee "$EVIDENCE_ROOT/logs/install-test.txt" + +echo "==> Run viewport, density, thread, and fetcher gates" +run_instrumentation \ + "compatibility" \ + "$PACKAGE.AndroidCompatibilityDeviceGateTest" \ + 3 \ + "COMPAT_GATE event=pass" +grep -Fq \ + "COMPAT_GATE event=unsafe_rejected" \ + "$EVIDENCE_ROOT/logs/compatibility.logcat.txt" +grep -Fq \ + "COMPAT_GATE event=thread_matrix" \ + "$EVIDENCE_ROOT/logs/compatibility.logcat.txt" + +echo "==> Run failed-view retry gate" +run_instrumentation \ + "retry" \ + "$PACKAGE.SparklingRetryDeviceGateTest" \ + 1 \ + "RETRY_GATE event=pass" +adb -s "$SERIAL" pull \ + "/sdcard/Android/data/$PACKAGE/files/retry-validation" \ + "$EVIDENCE_ROOT/screenshots/retry" \ + >"$EVIDENCE_ROOT/logs/pull-retry-screenshots.txt" + +echo "==> Run orientation matrix" +ORIENTATION_CLASS="$PACKAGE.PlaygroundOrientationDeviceTest" +for test_case in \ + typedLandscapeFullPage \ + typedPortraitFullPage \ + explicitSystemOverridesGlobalLandscape \ + canonicalScreenOrientationLandscape \ + unsetUsesAndroidSystemBehavior \ + embeddedLandscapePolicyDoesNotChangeHostActivity; do + run_instrumentation \ + "orientation-$test_case" \ + "$ORIENTATION_CLASS#$test_case" \ + 1 \ + "ORIENTATION_GATE event=" + if [[ "$test_case" == "typedLandscapeFullPage" || "$test_case" == "typedPortraitFullPage" ]]; then + adb -s "$SERIAL" pull \ + "/sdcard/Android/data/$PACKAGE/files/orientation-validation" \ + "$EVIDENCE_ROOT/screenshots/orientation-$test_case" \ + >"$EVIDENCE_ROOT/logs/pull-$test_case-screenshot.txt" + fi +done + +find "$EVIDENCE_ROOT/screenshots" -type f -print0 | + sort -z | + xargs -0 sha256sum >"$EVIDENCE_ROOT/screenshot-sha256.txt" + +cat >"$EVIDENCE_ROOT/verdict.txt" <"$archive.sha256" + +echo "PASS: 10 Android compatibility acceptance tests" +echo "Evidence: $EVIDENCE_ROOT" +echo "Archive: $archive"