diff --git a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/LocalSnapshots.kt b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/LocalSnapshots.kt index dec4fec0..fe58d69e 100644 --- a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/LocalSnapshots.kt +++ b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/LocalSnapshots.kt @@ -139,6 +139,9 @@ abstract class LocalSnapshots : DefaultTask() { it.add(key) it.add(value) } + it.add("-e") + it.add("class") + it.add("com.emergetools.snapshots.v2.EmergeComposeSnapshotInvoker") if (composeSnapshotsJson.exists()) { push( localFile = composeSnapshotsJson.absolutePath, diff --git a/snapshots/sample/ui-module/src/main/kotlin/com/emergetools/snapshots/sample/ui/theme/Theme.kt b/snapshots/sample/ui-module/src/main/kotlin/com/emergetools/snapshots/sample/ui/theme/Theme.kt index 0a162e99..7c480e50 100644 --- a/snapshots/sample/ui-module/src/main/kotlin/com/emergetools/snapshots/sample/ui/theme/Theme.kt +++ b/snapshots/sample/ui-module/src/main/kotlin/com/emergetools/snapshots/sample/ui/theme/Theme.kt @@ -56,9 +56,10 @@ fun SnapshotsSampleTheme( val view = LocalView.current if (!view.isInEditMode) { SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + (view.context as? Activity)?.window?.let { + it.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(it, view).isAppearanceLightStatusBars = darkTheme + } } } diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/BitmapPool.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/BitmapPool.kt new file mode 100644 index 00000000..fae45948 --- /dev/null +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/BitmapPool.kt @@ -0,0 +1,67 @@ +package com.emergetools.snapshots.v2 + +import android.graphics.Bitmap +import android.util.Log +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +class BitmapPool { + private val semaphore = SuspendingSemaphore(MAX_RELEASED_BITMAPS) + private val bitmaps = Collections.synchronizedList(ArrayList()) + private val releasedBitmaps = ConcurrentHashMap() + + @Synchronized + fun clear() { + bitmaps.clear() + } + + @Synchronized + fun acquire(width: Int, height: Int): Bitmap { + if (width > 1000 || height > 1000) { + Log.d(TAG, "Requesting a large bitmap for " + width + "x" + height) + } + + val blockedStartTime = System.currentTimeMillis() + semaphore.acquire() + val waitingTimeMs = System.currentTimeMillis() - blockedStartTime + if (waitingTimeMs > 100) { + Log.d(TAG, "Waited ${waitingTimeMs}ms for a bitmap.") + } + + val bitmap = synchronized(bitmaps) { + bitmaps + .firstOrNull { it.width >= width && it.height >= height } + ?.also { bitmaps.remove(it) } + } ?: createNewBitmap(width, height) + + val croppedBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height) + releasedBitmaps[croppedBitmap] = bitmap + + return croppedBitmap + } + + @Synchronized + fun release(bitmap: Bitmap) { + + val originalBitmap = releasedBitmaps.remove(bitmap) ?: throw IllegalArgumentException("Unable to find original bitmap.") + originalBitmap.eraseColor(0) + + bitmaps += originalBitmap + semaphore.release() + } + + private fun createNewBitmap(width: Int, height: Int): Bitmap { + // Make the bitmap at least as large as the screen so we don't wind up with a fragmented pool of + // bitmap sizes. We'll crop the right size out of it before returning it in acquire(). + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + } + + companion object { + // The maximum number of bitmaps that are allowed out at a time. + // If this limit is reached a thread must wait for another bitmap to be returned. + // Bitmaps are expensive, and if we aren't careful we can easily allocate too many bitmaps + // since coroutines run parallelized. + private const val MAX_RELEASED_BITMAPS = 4 + private const val TAG = "BitmapPool" + } +} diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/ComposeSnapshotter.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/ComposeSnapshotter.kt new file mode 100644 index 00000000..f0d4f67f --- /dev/null +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/ComposeSnapshotter.kt @@ -0,0 +1,167 @@ +package com.emergetools.snapshots.v2 + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import androidx.compose.runtime.currentComposer +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.IntSize +import com.emergetools.snapshots.EmergeSnapshots +import com.emergetools.snapshots.SnapshotErrorType +import com.emergetools.snapshots.compose.ComposableInvoker +import com.emergetools.snapshots.compose.DeviceSpec +import com.emergetools.snapshots.compose.SnapshotVariantProvider +import com.emergetools.snapshots.compose.configToDeviceSpec +import com.emergetools.snapshots.compose.previewparams.PreviewParamUtils +import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +suspend fun snapshot( + activity: Activity, + snapshotRule: EmergeSnapshots, + previewConfig: ComposePreviewSnapshotConfig, +) = withContext(Dispatchers.Default) { + // If no preview params, fallback to a single array of one null item to ensure we + // snapshot the composable. + val previewParameters = + PreviewParamUtils.getPreviewProviderParameters(previewConfig) ?: arrayOf(null) + + val deviceSpec = configToDeviceSpec(previewConfig) + + for (index in previewParameters.indices) { + val prevParam = previewParameters[index] + Log.d( + "EmergeComposeSnapshotInvoker (V2)", + "Invoking composable method with preview parameter: $prevParam" + ) + val composeView = ComposeView(activity) + composeView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + val args = if (prevParam != null) arrayOf(prevParam) else emptyArray() + + val saveablePreviewConfig = previewConfig.copy( + previewParameter = previewConfig.previewParameter?.copy(index = index) + ) + + // Update activity window size if device is specified + if (deviceSpec != null) { + updateActivityBounds(activity, deviceSpec) + } + + composeView.setContent { + SnapshotVariantProvider(previewConfig, deviceSpec?.scalingFactor) { + ComposableInvoker.invokeComposable( + className = previewConfig.fullyQualifiedClassName, + methodName = previewConfig.originalFqn.substringAfterLast("."), + composer = currentComposer, + args = args, + ) + } + } + + // Add the ComposeView to the activity + activity.addContentView( + composeView, + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + ) + + composeView.post { + val size = measureViewSize(composeView, previewConfig) + val bitmap = captureBitmap(composeView, size.width, size.height) + + bitmap?.let { + snapshotRule.take(it, saveablePreviewConfig) + } ?: run { + snapshotRule.saveError( + errorType = SnapshotErrorType.EMPTY_SNAPSHOT, + composePreviewSnapshotConfig = saveablePreviewConfig + ) + } + + // Reset activity content view + (composeView.parent as? ViewGroup)?.removeView(composeView) + } + } +} + +fun measureViewSize( + view: View, + previewConfig: ComposePreviewSnapshotConfig +): IntSize { + val deviceSpec = configToDeviceSpec(previewConfig) + + // Use exact measurements when we have them + val scalingFactor = deviceSpec?.scalingFactor ?: view.resources.displayMetrics.density + + val widthMeasureSpec = when { + previewConfig.widthDp != null -> { + View.MeasureSpec.makeMeasureSpec( + dpToPx(previewConfig.widthDp!!, scalingFactor), + View.MeasureSpec.EXACTLY + ) + } + + deviceSpec?.widthPixels != null && deviceSpec.widthPixels > 0 -> + View.MeasureSpec.makeMeasureSpec(deviceSpec.widthPixels, View.MeasureSpec.EXACTLY) + + else -> + View.MeasureSpec.makeMeasureSpec(view.width, View.MeasureSpec.AT_MOST) + } + + val heightMeasureSpec = when { + previewConfig.heightDp != null -> { + View.MeasureSpec.makeMeasureSpec( + dpToPx(previewConfig.heightDp!!, scalingFactor), + View.MeasureSpec.EXACTLY + ) + } + + deviceSpec?.heightPixels != null && deviceSpec.heightPixels > 0 -> + View.MeasureSpec.makeMeasureSpec(deviceSpec.heightPixels, View.MeasureSpec.EXACTLY) + + else -> + View.MeasureSpec.makeMeasureSpec(view.height, View.MeasureSpec.AT_MOST) + } + + view.measure(widthMeasureSpec, heightMeasureSpec) + return IntSize(view.measuredWidth, view.measuredHeight) +} + +fun updateActivityBounds(activity: Activity, deviceSpec: DeviceSpec) { + // Apply the device spec dimensions to the activity window + val width = deviceSpec.widthPixels + val height = deviceSpec.heightPixels + + if (width > 0 && height > 0) { + activity.window.setLayout(width, height) + } +} + +fun dpToPx(dp: Int, scalingFactor: Float): Int { + return (dp * scalingFactor).toInt() +} + +fun captureBitmap( + view: View, + width: Int, + height: Int, +): Bitmap? { + try { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + view.layout(0, 0, width, height) + view.draw(canvas) + return bitmap + } catch (e: IllegalArgumentException) { + Log.e( + "EmergeComposeSnapshotInvoker (V2)", + "Error capturing bitmap", + e, + ) + return null + } +} diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/EmergeComposeSnapshotInvoker.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/EmergeComposeSnapshotInvoker.kt new file mode 100644 index 00000000..aba12346 --- /dev/null +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/EmergeComposeSnapshotInvoker.kt @@ -0,0 +1,92 @@ +package com.emergetools.snapshots.v2 + + +import android.content.ComponentCallbacks2 +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.res.Configuration +import android.util.Log +import androidx.compose.ui.tooling.PreviewActivity +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.platform.app.InstrumentationRegistry +import com.emergetools.snapshots.EmergeSnapshots +import com.emergetools.snapshots.shared.ComposeSnapshots +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.io.File + +class EmergeComposeSnapshotInvoker { + + companion object { + const val TAG = "EmergeComposeSnapshotInvoker" + const val ARG_REFLECTIVE_INVOKE_DATA_PATH = "invoke_data_path" + } + + lateinit var testCaseContext: SnapshotTestCaseContext + + @get:Rule + val scenarioRule = ActivityScenarioRule(PreviewActivity::class.java) + + @get:Rule + val snapshotRule: EmergeSnapshots = EmergeSnapshots() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + testCaseContext = object : SnapshotTestCaseContext { + override val context: Context = context + override val snapshotRule: EmergeSnapshots = this@EmergeComposeSnapshotInvoker.snapshotRule + override val bitmapPool: BitmapPool = BitmapPool() + override fun onActivity(callback: (PreviewActivity) -> Unit) { + scenarioRule.scenario.onActivity(callback) + } + } + scenarioRule.scenario.onActivity { activity -> + activity.registerComponentCallbacks(object : ComponentCallbacks2 { + override fun onConfigurationChanged(newConfig: Configuration) {} + + override fun onLowMemory() { + testCaseContext.bitmapPool.clear() + } + + override fun onTrimMemory(level: Int) { + testCaseContext.bitmapPool.clear() + } + + }) + } + } + + @Test + fun composableInvoker() = runBlocking { + // Force application to be debuggable to ensure PreviewActivity doesn't early exit + val applicationInfo = InstrumentationRegistry.getInstrumentation().targetContext.applicationInfo + applicationInfo.flags = applicationInfo.flags or ApplicationInfo.FLAG_DEBUGGABLE + + val args = InstrumentationRegistry.getArguments() + val invokeDataPath = args.getString(ARG_REFLECTIVE_INVOKE_DATA_PATH) ?: run { + Log.w(TAG, "Missing invoke_data_path arg") + return@runBlocking + } + + val invokeDataFile = File(invokeDataPath) + if (!invokeDataFile.exists()) { + error("Unable to find file at $invokeDataPath") + } + + val json = Json { + ignoreUnknownKeys = true + } + + val snapshots = json.decodeFromString(invokeDataFile.readText()).snapshots + + snapshots.forEach { previewConfig -> + Log.i(TAG, "Running snapshot test ${previewConfig.keyName()}") + testCaseContext.snapshotComposable(previewConfig) + } + } +} diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/SnapshotTestCaseContext.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/SnapshotTestCaseContext.kt new file mode 100644 index 00000000..40e45b00 --- /dev/null +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/SnapshotTestCaseContext.kt @@ -0,0 +1,128 @@ +package com.emergetools.snapshots.v2 + +import android.content.Context +import android.content.MutableContextWrapper +import android.graphics.Canvas +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.tooling.PreviewActivity +import com.emergetools.snapshots.EmergeSnapshots +import com.emergetools.snapshots.SnapshotErrorType +import com.emergetools.snapshots.compose.ComposableInvoker +import com.emergetools.snapshots.compose.SnapshotVariantProvider +import com.emergetools.snapshots.compose.configToDeviceSpec +import com.emergetools.snapshots.compose.previewparams.PreviewParamUtils +import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + + +interface SnapshotTestCaseContext { + val context: Context + val snapshotRule: EmergeSnapshots + val bitmapPool: BitmapPool + fun onActivity(callback: (PreviewActivity) -> Unit) +} + +/** + * Use this to signal that the composition is not ready to be snapshot yet. + * This use useful if you are using things like `rememberLottieComposition` which parses a composition asynchronously. + */ +//val LocalSnapshotReady = compositionLocalOf { MutableStateFlow(true) } + +suspend fun SnapshotTestCaseContext.snapshotComposable( + previewConfig: ComposePreviewSnapshotConfig, +) = withContext(Dispatchers.Default) { + val previewParameters = + PreviewParamUtils.getPreviewProviderParameters(previewConfig) ?: arrayOf(null) + + val deviceSpec = configToDeviceSpec(previewConfig) + + for (index in previewParameters.indices) { + val prevParam = previewParameters[index] + Log.d( + "EmergeComposeSnapshotInvoker (V2)", + "Invoking composable method with preview parameter: $prevParam" + ) + val mutableContext = MutableContextWrapper(context) + val composeView = ComposeView(mutableContext) + composeView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + val args = if (prevParam != null) arrayOf(prevParam) else emptyArray() + + val saveablePreviewConfig = previewConfig.copy( + previewParameter = previewConfig.previewParameter?.copy(index = index) + ) + + // Update activity window size if device is specified + val readyFlow = MutableStateFlow(null) + composeView.setContent { + SnapshotVariantProvider(previewConfig, deviceSpec?.scalingFactor) { + ComposableInvoker.invokeComposable( + className = previewConfig.fullyQualifiedClassName, + methodName = previewConfig.originalFqn.substringAfterLast("."), + composer = currentComposer, + args = args, + ) + } + val readyFlowValue by readyFlow.collectAsState() + LaunchedEffect(readyFlowValue) { + if (readyFlowValue == null) { + readyFlow.value = true + } + } + } + onActivity { activity -> + if (deviceSpec != null) { + updateActivityBounds(activity, deviceSpec) + } + mutableContext.baseContext = activity + activity.addContentView( + composeView, + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + ) + } + readyFlow.first { it == true } + composeView.awaitFrame() + Log.d("SnapshotTestCaseContext", "Drawing ${previewConfig.keyName()}") + + // val size = measureViewSize(composeView, previewConfig) + var bitmap = bitmapPool.acquire(composeView.width, composeView.height) + var canvas = Canvas(bitmap) + withContext(Dispatchers.Main) { + composeView.draw(canvas) + } + bitmap?.let { + snapshotRule.take(it, saveablePreviewConfig) + } ?: run { + snapshotRule.saveError( + errorType = SnapshotErrorType.EMPTY_SNAPSHOT, + composePreviewSnapshotConfig = saveablePreviewConfig + ) + } + bitmapPool.release(bitmap) + + onActivity { _ -> + // Reset activity content view + (composeView.parent as? ViewGroup)?.removeView(composeView) + } + } +} + +private suspend fun View.awaitFrame() { + suspendCancellableCoroutine { cont -> + post { + cont.resume(Unit) + } + } +} diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/SuspendingSemaphore.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/SuspendingSemaphore.kt new file mode 100644 index 00000000..e052736d --- /dev/null +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/v2/SuspendingSemaphore.kt @@ -0,0 +1,32 @@ +package com.emergetools.snapshots.v2 + + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking + +/** + * Semaphore that suspends instead of sleeps. + */ +class SuspendingSemaphore(limit: Int) { + // The actual object sent in the channel is arbitrary and is unused, + // we just rely on the buffering mechanism to limit how many items can be acquired at once. + private val bufferedChannel = Channel(limit) + + /** + * Returns when the number of current acquired count goes below the limit. + */ + fun acquire() { + runBlocking { + bufferedChannel.send(0) + } + } + + /** + * Must be matched with a call to [acquire] after the item is done being used. + */ + fun release() { + runBlocking { + bufferedChannel.receive() + } + } +}