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 d9e398f3..c8a83aa2 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 @@ -1,7 +1,7 @@ package com.emergetools.android.gradle.tasks.snapshots -import com.emergetools.android.gradle.tasks.snapshots.utils.PreviewUtils import com.emergetools.android.gradle.tasks.base.ArtifactMetadata +import com.emergetools.android.gradle.tasks.snapshots.utils.PreviewUtils import com.emergetools.android.gradle.util.adb.AdbHelper import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -30,11 +30,12 @@ abstract class LocalSnapshots : DefaultTask() { private val arguments = mutableMapOf() @Option( - option = "class", - description = "A single class, a single method or a comma-separated list of classes" + option = "preview", + description = "A single fully qualified preview method" + + " or a comma-separated list of fully qualified preview methods" ) - fun setClazz(clazz: String) { - arguments["class"] = clazz + fun setPreviews(previewFunctions: String) { + arguments["previews"] = previewFunctions } @get:Inject @@ -100,11 +101,14 @@ abstract class LocalSnapshots : DefaultTask() { outputDir = extractedApkDir ) + val previews = arguments["previews"]?.split(",")?.map(String::trim) ?: emptyList() val composeSnapshots = PreviewUtils.findPreviews( extractedApkDir, includePrivatePreviews.getOrElse(true), + previews, logger ) + logger.info("Found ${composeSnapshots.snapshots.size} Compose Preview snapshots") val composeSnapshotsJson = File(previewExtractionDir, COMPOSE_SNAPSHOTS_FILENAME) composeSnapshotsJson.writeText(Json.encodeToString(composeSnapshots)) diff --git a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/utils/ComposeSnapshots.kt b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/utils/ComposeSnapshots.kt index 85eeee83..3756a18c 100644 --- a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/utils/ComposeSnapshots.kt +++ b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/utils/ComposeSnapshots.kt @@ -12,6 +12,7 @@ data class ComposeSnapshots( data class ComposePreviewSnapshotConfig( val originalFqn: String, val fullyQualifiedClassName: String, + val previewParameter: PreviewParameter? = null, // Preview annotation params: val name: String?, val group: String?, @@ -24,4 +25,14 @@ data class ComposePreviewSnapshotConfig( val backgroundColor: Long?, val showSystemUi: Boolean?, val device: String? = null, + val apiLevel: Int? = null, + val wallpaper: Int? = null, +) + +@Serializable +data class PreviewParameter( + val parameterName: String, + val providerClassFqn: String, + val limit: Int? = null, + val index: Int? = null, ) diff --git a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/utils/PreviewUtils.kt b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/utils/PreviewUtils.kt index 911e746d..3e215b31 100644 --- a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/utils/PreviewUtils.kt +++ b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/utils/PreviewUtils.kt @@ -4,6 +4,7 @@ import org.jf.dexlib2.AccessFlags import org.jf.dexlib2.DexFileFactory import org.jf.dexlib2.dexbacked.DexBackedClassDef import org.jf.dexlib2.dexbacked.DexBackedMethod +import org.jf.dexlib2.dexbacked.value.DexBackedTypeEncodedValue import org.jf.dexlib2.iface.Annotation import org.jf.dexlib2.iface.DexFile import org.jf.dexlib2.iface.value.AnnotationEncodedValue @@ -15,6 +16,7 @@ import org.jf.dexlib2.iface.value.LongEncodedValue import org.jf.dexlib2.iface.value.StringEncodedValue import org.slf4j.Logger import java.io.File +import kotlin.math.log object PreviewUtils { @@ -23,6 +25,7 @@ object PreviewUtils { "Landroidx/compose/ui/tooling/preview/Preview\$Container;" const val IGNORE_SNAPSHOT_ANNOTATION = "Lcom/emergetools/snapshots/annotations/IgnoreEmergeSnapshot;" + const val PREVIEW_PARAMETER_ANNOTATION = "Landroidx/compose/ui/tooling/preview/PreviewParameter;" const val COMPOSER_SIGNATURE = "Landroidx/compose/runtime/Composer;" @@ -31,6 +34,7 @@ object PreviewUtils { fun findPreviews( extractedApkDirectory: File, includePrivatePreviews: Boolean, + previewFunctions: List, logger: Logger, ): ComposeSnapshots { @@ -73,25 +77,25 @@ object PreviewUtils { return@forEach } - if (method.parameters.size != 2 || method.parameters.map { it.type } != listOf( - COMPOSER_SIGNATURE, "I" - )) { - logger.info( - "Ignoring snapshot for method: $methodKey as it does not have a no-arg signature" - ) + if (!hasSupportedMethodParameters(method, logger)) { + logger.info("Ignoring snapshot for method: $methodKey as params are not supported.") return@forEach } val configs = previewAnnotations.flatMap { previewAnnotation -> - composePreviewSnapshotConfigsFromPreviewAnnotation(method, previewAnnotation) + composePreviewSnapshotConfigsFromPreviewAnnotation(method, previewAnnotation, logger) } methodsWithConfigs[methodKey] = configs } } + val methods = methodsWithConfigs.values.flatten().filter { + previewFunctions.isEmpty() || previewFunctions.contains(it.originalFqn) + } + return ComposeSnapshots( - snapshots = methodsWithConfigs.values.flatten() + snapshots = methods ) } @@ -99,6 +103,40 @@ object PreviewUtils { return signature.replace('/', '.').substring(1, signature.length - 1) } + private fun hasSupportedMethodParameters(method: DexBackedMethod, logger: Logger): Boolean { + // Only supporting 0-1 preview params + val methodParamCount = method.parameters.size + if (methodParamCount < 2 || methodParamCount > 3) { + logger.info("Method ${method.name} has $methodParamCount parameters, expected 2 or 3") + return false + } + + // Check last 2 params are (Composer, int) + val paramTypes = method.parameters.map { it.type } + if (paramTypes.takeLast(2) != listOf(COMPOSER_SIGNATURE, "I")) { + logger.info("Method ${method.name} has unsupported parameter types: $paramTypes") + return false + } + + if (methodParamCount == 3) { + // Check first param annotated with @PreviewParameter + val firstParam = method.parameters[0] + val hasPreviewParameterAnnotation = firstParam.annotations.any { annotation -> + annotation.type == PREVIEW_PARAMETER_ANNOTATION + } + + if (!hasPreviewParameterAnnotation) { + logger.info("Method ${method.name} has 3 parameters, but the first one is not annotated with @PreviewParameter") + return false + } + + logger.info("Method ${method.name} has 3 parameters, and the first one is annotated with @PreviewParameter!") + } + + logger.info("Method ${method.name} has supported parameters!") + return true + } + /** * Strip Kt synthetic suffix from class name if it exists to ensure FQN matches the source package. */ @@ -149,10 +187,28 @@ object PreviewUtils { private fun composePreviewSnapshotConfigsFromPreviewAnnotation( method: DexBackedMethod, annotation: Annotation, + logger: Logger, ): List { val className = classSignatureToFqn(method.definingClass) val originalFqn = fqnForPreviewMethod(method) + var previewParameter: PreviewParameter? = null + if (method.parameters.size == 3) { + val firstParam = method.parameters[0] + val paramName = firstParam.name ?: throw IllegalStateException("Preview parameter must have a name") + + val previewParamAnnotation = firstParam.annotations.first { it.type == PREVIEW_PARAMETER_ANNOTATION } + val providerClassSignature = previewParamAnnotation.elements.first { it.name == "provider" }.value as DexBackedTypeEncodedValue + val previewParamLimit = previewParamAnnotation.elements.firstOrNull { it.name == "limit" }?.value as? IntEncodedValue + + logger.info("Found @PreviewParameter annotation for method ${method.name}, parameter: $paramName, provider: ${providerClassSignature.value}, limit: ${previewParamLimit?.value}") + previewParameter = PreviewParameter( + parameterName = paramName, + providerClassFqn = classSignatureToFqn(providerClassSignature.value), + limit = previewParamLimit?.value, + ) + } + return when (annotation.type) { PREVIEW_ANNOTATION -> listOf( ComposePreviewSnapshotConfig( @@ -169,6 +225,7 @@ object PreviewUtils { backgroundColor = (annotation.elements.firstOrNull { it.name == "backgroundColor" }?.value as? LongEncodedValue)?.value, showSystemUi = (annotation.elements.firstOrNull { it.name == "showSystemUi" }?.value as? BooleanEncodedValue)?.value, device = (annotation.elements.firstOrNull { it.name == "device" }?.value as? StringEncodedValue)?.value, + previewParameter = previewParameter, ) ) @@ -190,6 +247,7 @@ object PreviewUtils { backgroundColor = (preview.elements.firstOrNull { it.name == "backgroundColor" }?.value as? LongEncodedValue)?.value, showSystemUi = (preview.elements.firstOrNull { it.name == "showSystemUi" }?.value as? BooleanEncodedValue)?.value, device = (preview.elements.firstOrNull { it.name == "device" }?.value as? StringEncodedValue)?.value, + previewParameter = previewParameter, ) }.orEmpty() } diff --git a/snapshots/sample/app/src/main/kotlin/com/emergetools/snapshots/sample/ui/UserRow.kt b/snapshots/sample/app/src/main/kotlin/com/emergetools/snapshots/sample/ui/UserRow.kt new file mode 100644 index 00000000..8202cf49 --- /dev/null +++ b/snapshots/sample/app/src/main/kotlin/com/emergetools/snapshots/sample/ui/UserRow.kt @@ -0,0 +1,100 @@ +package com.emergetools.snapshots.sample.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.emergetools.snapshots.sample.ui.theme.SnapshotsSampleTheme + +data class User(val name: String, val email: String) + +@Composable +fun UserRow(user: User) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Profile picture + Surface( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(Color.Cyan), + color = Color.Transparent + ) { + // Center content vertically and horizontally + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = user.name.firstOrNull()?.uppercase() ?: "", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + // User details + Column { + Text(text = user.name, style = MaterialTheme.typography.bodyMedium) + Text(text = user.email, style = MaterialTheme.typography.bodySmall, color = Color.Gray) + } + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun UserRowPreview( + @PreviewParameter(UserRowPreviewProvider::class) user: User, +) { + SnapshotsSampleTheme { + UserRow(user = user) + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun UserRowPreviewWithLimit( + @PreviewParameter(UserRowPreviewProvider::class, limit = 2) user: User, +) { + SnapshotsSampleTheme { + UserRow(user = user) + } +} + +class UserRowPreviewProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + User(name = "Ryan", email = "ryan@emergetools.com"), + User(name = "Trevor", email = "trevor@emergetools.com"), + User(name = "Josh", email = "josh@emergetools.com"), + ) +} diff --git a/snapshots/snapshots-shared/src/main/kotlin/com/emergetools/snapshots/shared/ComposePreviewSnapshotConfig.kt b/snapshots/snapshots-shared/src/main/kotlin/com/emergetools/snapshots/shared/ComposePreviewSnapshotConfig.kt index 9943fa93..bb4b1eec 100644 --- a/snapshots/snapshots-shared/src/main/kotlin/com/emergetools/snapshots/shared/ComposePreviewSnapshotConfig.kt +++ b/snapshots/snapshots-shared/src/main/kotlin/com/emergetools/snapshots/shared/ComposePreviewSnapshotConfig.kt @@ -9,6 +9,7 @@ data class ComposePreviewSnapshotConfig( val fullyQualifiedClassName: String, val originalFqn: String, val isAppStoreSnapshot: Boolean? = null, + val previewParameter: PreviewParameter? = null, // Preview annotation params: val name: String? = null, val group: String? = null, @@ -41,7 +42,8 @@ data class ComposePreviewSnapshotConfig( showSystemUi == null && device == null && apiLevel == null && - wallpaper == null + wallpaper == null && + previewParameter == null } /** @@ -65,6 +67,18 @@ data class ComposePreviewSnapshotConfig( device?.let { append("_dev_$it") } apiLevel?.let { append("_api_$it") } wallpaper?.let { append("_wp_$it") } + previewParameter?.let { + append("_param_${it.parameterName}") + it.index?.let { idx -> append("_idx_$idx") } + } } } } + +@Serializable +data class PreviewParameter( + val parameterName: String, + val providerClassFqn: String, + val limit: Int? = null, + val index: Int? = null, +) diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposeSnapshotter.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposeSnapshotter.kt index d15cfdf3..87b6a3d3 100644 --- a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposeSnapshotter.kt +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposeSnapshotter.kt @@ -5,20 +5,26 @@ 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.Composer import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.reflect.ComposableMethod +import androidx.compose.runtime.reflect.getDeclaredComposableMethod import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.tooling.PreviewActivity +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.IntSize import com.emergetools.snapshots.EmergeSnapshots import com.emergetools.snapshots.SnapshotErrorType import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig +import java.lang.reflect.Modifier +import java.lang.reflect.ParameterizedType import kotlin.math.max -@Suppress("TooGenericExceptionCaught") +@Suppress("TooGenericExceptionCaught", "ThrowsCount") fun snapshotComposable( snapshotRule: EmergeSnapshots, - activity: Activity, + activity: PreviewActivity, previewConfig: ComposePreviewSnapshotConfig, ) { try { @@ -28,41 +34,148 @@ fun snapshotComposable( "Found class for ${previewConfig.fullyQualifiedClassName}: ${klass.name}" ) val methodName = previewConfig.originalFqn.substringAfterLast(".") - val composableMethod = klass.methods.find { + + val previewProviderClass: Class>? = + previewConfig.previewParameter?.providerClassFqn?.let { + val clazz = Class.forName(it) + require(PreviewParameterProvider::class.java.isAssignableFrom(clazz)) { + "Preview parameter provider class must implement PreviewParameterProvider" + } + clazz as Class> + } + + val composableMethod: ComposableMethod = previewProviderClass?.let { previewProvider -> Log.d( EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, - "Checking method in class ${klass.name}: ${it.name}" + "Looking for parameterized composable method: $methodName in class: ${klass.name}" + ) + val providerType = previewProvider.genericInterfaces + .filterIsInstance() + .firstOrNull { it.rawType == PreviewParameterProvider::class.java } + ?.actualTypeArguments?.firstOrNull() as? Class<*> ?: throw IllegalArgumentException( + "Unable to determine type argument for PreviewParameterProvider" ) - it.name == methodName - } ?: klass.getDeclaredMethod(methodName, Composer::class.java, Int::class.javaPrimitiveType) - if (composableMethod != null && !composableMethod.isAccessible) { + klass.getDeclaredComposableMethod(methodName, providerType) + } ?: run { + Log.d( + EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, + "Looking for composable method: $methodName in class: ${klass.name}" + ) + klass.getDeclaredComposableMethod(methodName) + } + + Log.d( + EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, + "Found composable method for ${previewConfig.originalFqn}: ${composableMethod.javaClass.simpleName}" + ) + val backingMethod = composableMethod.asMethod() + if (!backingMethod.isAccessible) { Log.i( EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, "Marking composable method as accessible: ${previewConfig.originalFqn}" ) - composableMethod.isAccessible = true + backingMethod.isAccessible = true } Log.d( EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, - "Invoking composable method: ${composableMethod?.name}" + "Invoking composable method: ${backingMethod.name}" + ) + + // Fallback having a list of a single null item is intentional to ensure we run at least one iteration of previews + val previewParams = previewProviderClass?.let { + val params = getPreviewProviderParameters(it) + val limit = previewConfig.previewParameter?.limit ?: params.size + params.take(limit) + } ?: listOf(null) + + Log.d( + EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, + "Found ${previewParams.size} preview parameters for ${previewConfig.originalFqn}" + ) + + snapshot( + activity = activity, + snapshotRule = snapshotRule, + previewConfig = previewConfig, + composableMethod = composableMethod, + composableClass = klass, + previewParams = previewParams, + ) + } catch (e: Exception) { + Log.e( + EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, + "Error invoking composable method", + e, + ) + snapshotRule.saveError( + errorType = SnapshotErrorType.GENERAL, + composePreviewSnapshotConfig = previewConfig, + ) + // Re-throw to fail the test + throw e + } +} + +private fun getPreviewProviderParameters( + parameterProviderClass: Class>, +): List { + val constructor = parameterProviderClass.constructors + .singleOrNull { it.parameterTypes.isEmpty() } + ?.apply { isAccessible = true } + ?: throw IllegalArgumentException( + "PreviewParameterProvider constructor can not" + " have parameters" ) + val params = constructor.newInstance() as PreviewParameterProvider<*> + return params.values.toList() +} +const val DEFAULT_DENSITY_PPI = 160 + +private fun snapshot( + activity: Activity, + snapshotRule: EmergeSnapshots, + composableMethod: ComposableMethod, + composableClass: Class<*>, + previewConfig: ComposePreviewSnapshotConfig, + previewParams: List = listOf(null), +) { + previewParams.forEachIndexed { index, prevParam -> val composeView = ComposeView(activity) composeView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + Log.d( + EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, + "Invoking composable method with preview parameter: $prevParam" + ) + val args = buildList { + prevParam?.let(this::add) + add(0) + }.toTypedArray() + composeView.setContent { - composableMethod?.let { - it.isAccessible = true - SnapshotVariantProvider(previewConfig) { - it.invoke(null, currentComposer, 0) + SnapshotVariantProvider(previewConfig) { + @Suppress("SpreadOperator") + if (Modifier.isStatic(composableMethod.asMethod().modifiers)) { + // This is a top level or static method + composableMethod.invoke(currentComposer, null, *args) + } else { + // The method is part of a class. We try to instantiate the class with an empty + // constructor. + val instance = composableClass.getConstructor().newInstance() + composableMethod.invoke(currentComposer, instance, *args) } - } ?: error("Unable to find composable method: ${previewConfig.originalFqn}") + } } - activity.setContentView(composeView) + activity.addContentView(composeView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + + // Need to update to accommodate param index in case when preview param is present + val saveablePreviewConfig = previewConfig.copy( + previewParameter = previewConfig.previewParameter?.copy(index = index) + ) composeView.post { // Measure the composable agnostic of the parent constraints to layout properly in activity @@ -73,31 +186,20 @@ fun snapshotComposable( height = composableSize.height, ) bitmap?.let { - snapshotRule.take(bitmap, previewConfig) + snapshotRule.take(bitmap, saveablePreviewConfig) } ?: run { snapshotRule.saveError( errorType = SnapshotErrorType.EMPTY_SNAPSHOT, - composePreviewSnapshotConfig = previewConfig, + composePreviewSnapshotConfig = saveablePreviewConfig, ) } + + // Remove the view from the activity to ensure it doesn't interfere with the next preview param + (composeView.parent as? ViewGroup)?.removeView(composeView) } - } catch (e: Exception) { - Log.e( - EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, - "Error invoking composable method", - e, - ) - snapshotRule.saveError( - errorType = SnapshotErrorType.GENERAL, - composePreviewSnapshotConfig = previewConfig, - ) - // Re-throw to fail the test - throw e } } -const val DEFAULT_DENSITY_PPI = 160 - private fun measureComposableSize( view: ComposeView, previewConfig: ComposePreviewSnapshotConfig, diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/EMGLocale.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/EMGLocale.kt deleted file mode 100644 index 01f1c96d..00000000 --- a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/EMGLocale.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.emergetools.snapshots.compose - -import java.util.Locale - -object EMGLocale { - // Android's default `Locale` class doesn't seem to play nicely with regions syntax, like `es-rES` - // Instead, we can manually split the locale string ourselves and pass into the appropriate constructor - // which seems to work better. - // Android Studio has completely separate code for parsing locale codes. - fun forLanguageCode(code: String): Locale { - val split = code.split("-") - if (split.size > 1) { - return Locale(split[0], split[1]) - } - return Locale.forLanguageTag(code) - } -} diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/SnapshotVariantProvider.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/SnapshotVariantProvider.kt index cd6d2b2b..1adb32e4 100644 --- a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/SnapshotVariantProvider.kt +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/SnapshotVariantProvider.kt @@ -34,7 +34,7 @@ fun SnapshotVariantProvider( density = dimensionSpec.scalingFactor ?: LocalDensity.current.density, ) - val locale = config.locale?.let { EMGLocale.forLanguageCode(it) } ?: Locale.getDefault() + val locale = config.locale?.let(::localeForLanguageCode) ?: Locale.getDefault() val wrappedContext = SnapshotVariantContextWrapper( LocalContext.current, @@ -118,3 +118,15 @@ class SnapshotVariantContextWrapper( return createConfigurationContext(config) } } + +// Android's default `Locale` class doesn't seem to play nicely with regions syntax, like `es-rES` +// Instead, we can manually split the locale string ourselves and pass into the appropriate constructor +// which seems to work better. +// Android Studio has completely separate code for parsing locale codes. +fun localeForLanguageCode(code: String): Locale { + val split = code.split("-") + if (split.size > 1) { + return Locale(split[0], split[1]) + } + return Locale.forLanguageTag(code) +} diff --git a/snapshots/snapshots/src/test/kotlin/com/emergetools/snapshots/UtilsTest.kt b/snapshots/snapshots/src/test/kotlin/com/emergetools/snapshots/UtilsTest.kt index d3592e77..3a7d8f52 100644 --- a/snapshots/snapshots/src/test/kotlin/com/emergetools/snapshots/UtilsTest.kt +++ b/snapshots/snapshots/src/test/kotlin/com/emergetools/snapshots/UtilsTest.kt @@ -1,6 +1,6 @@ package com.emergetools.snapshots -import com.emergetools.snapshots.compose.EMGLocale +import com.emergetools.snapshots.compose.localeForLanguageCode import org.junit.Assert.assertEquals import org.junit.Test @@ -8,49 +8,49 @@ class UtilsTest { @Test fun `makes en locale`() { - val locale = EMGLocale.forLanguageCode("en") + val locale = localeForLanguageCode("en") assertEquals("en", locale.language) assertEquals("", locale.country) } @Test fun `makes en-US locale`() { - val locale = EMGLocale.forLanguageCode("en-US") + val locale = localeForLanguageCode("en-US") assertEquals("en", locale.language) assertEquals("US", locale.country) } @Test fun `makes es locale`() { - val locale = EMGLocale.forLanguageCode("es") + val locale = localeForLanguageCode("es") assertEquals("es", locale.language) assertEquals("", locale.country) } @Test fun `makes es-ES locale`() { - val locale = EMGLocale.forLanguageCode("es-ES") + val locale = localeForLanguageCode("es-ES") assertEquals("es", locale.language) assertEquals("ES", locale.country) } @Test fun `makes es-rES locale`() { - val locale = EMGLocale.forLanguageCode("es-rES") + val locale = localeForLanguageCode("es-rES") assertEquals("es", locale.language) assertEquals("RES", locale.country) } @Test fun `makes de locale`() { - val locale = EMGLocale.forLanguageCode("de") + val locale = localeForLanguageCode("de") assertEquals("de", locale.language) assertEquals("", locale.country) } @Test fun `makes de-DE locale`() { - val locale = EMGLocale.forLanguageCode("de-DE") + val locale = localeForLanguageCode("de-DE") assertEquals("de", locale.language) assertEquals("DE", locale.country) }