diff --git a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/EmergePlugin.kt b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/EmergePlugin.kt index a630909f..209971e3 100644 --- a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/EmergePlugin.kt +++ b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/EmergePlugin.kt @@ -282,6 +282,7 @@ class EmergePlugin : Plugin { ) addItem("tag (optional): ${extension.snapshotOptions.tag.orEmpty()}", snapshotsHeading) addItem("enabled: ${extension.snapshotOptions.enabled.getOrElse(true)}", snapshotsHeading) + addItem("profile: ${extension.snapshotOptions.profile.getOrElse(false)}", snapshotsHeading) val reaperHeading = addHeading("reaper") addItem( diff --git a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/EmergePluginExtension.kt b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/EmergePluginExtension.kt index 2079b702..f4785dba 100644 --- a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/EmergePluginExtension.kt +++ b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/EmergePluginExtension.kt @@ -201,6 +201,11 @@ abstract class SnapshotOptions : ProductOptions() { * defaults to true */ abstract val includePreviewParamPreviews: Property + + /** + * Record profiling information for snapshot tests, defaults to false. + */ + abstract val profile: Property } abstract class ReaperOptions : ProductOptions() { 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..1369eb67 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 @@ -68,6 +68,10 @@ abstract class LocalSnapshots : DefaultTask() { @get:Optional abstract val includePreviewParamPreviews: Property + @get:Input + @get:Optional + abstract val profile: Property + @TaskAction fun execute() { val artifactMetadataFiles = packageDir.asFileTree.matching { @@ -149,6 +153,11 @@ abstract class LocalSnapshots : DefaultTask() { it.add("invoke_data_path") it.add("/data/local/tmp/$COMPOSE_SNAPSHOTS_FILENAME") } + if (profile.getOrElse(false)) { + it.add("-e") + it.add("save_profile") + it.add("true") + } it.add("${testAppId}/${testInstrumentationRunner.get()}") } diff --git a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/Register.kt b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/Register.kt index b4a60a6e..8388ad05 100644 --- a/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/Register.kt +++ b/gradle-plugin/plugin/src/main/kotlin/com/emergetools/android/gradle/tasks/snapshots/Register.kt @@ -127,6 +127,7 @@ private fun registerSnapshotLocalTask( it.testInstrumentationRunner.set(testInstrumentationRunner) it.includePrivatePreviews.set(extension.snapshotOptions.includePrivatePreviews) it.includePreviewParamPreviews.set(extension.snapshotOptions.includePreviewParamPreviews) + it.profile.set(extension.snapshotOptions.profile) it.dependsOn(packageTask) } } diff --git a/snapshots/sample/app/build.gradle.kts b/snapshots/sample/app/build.gradle.kts index 795e7f0e..97d271c2 100644 --- a/snapshots/sample/app/build.gradle.kts +++ b/snapshots/sample/app/build.gradle.kts @@ -17,6 +17,8 @@ emerge { snapshots { tag.setFromVariant() + + profile.set(true) } } diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/SnapshotSaver.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/SnapshotSaver.kt index 1c7f8d6c..935b6840 100644 --- a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/SnapshotSaver.kt +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/SnapshotSaver.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.util.Log import androidx.test.platform.app.InstrumentationRegistry import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig +import com.emergetools.snapshots.util.Profiler import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File @@ -38,6 +39,7 @@ internal object SnapshotSaver { fqn: String, composePreviewSnapshotConfig: ComposePreviewSnapshotConfig, ) { + Profiler.startSpan("save") val snapshotsDir = File(filesDir, SNAPSHOTS_DIR_NAME) if (!snapshotsDir.exists() && !snapshotsDir.mkdirs()) { error("Unable to create snapshots storage directory.") @@ -52,6 +54,7 @@ internal object SnapshotSaver { keyName = keyName, bitmap = bitmap ) + if (saveMetadata) { saveMetadata( snapshotsDir = snapshotsDir, @@ -61,6 +64,7 @@ internal object SnapshotSaver { composePreviewSnapshotConfig = composePreviewSnapshotConfig, ) } + Profiler.endSpan() } fun saveError( @@ -69,6 +73,7 @@ internal object SnapshotSaver { errorType: SnapshotErrorType, composePreviewSnapshotConfig: ComposePreviewSnapshotConfig, ) { + Profiler.startSpan("saveError") val snapshotsDir = File(filesDir, SNAPSHOTS_DIR_NAME) if (!snapshotsDir.exists() && !snapshotsDir.mkdirs()) { error("Unable to create snapshots storage directory.") @@ -83,6 +88,7 @@ internal object SnapshotSaver { composePreviewSnapshotConfig = composePreviewSnapshotConfig, ) } + Profiler.endSpan() } private fun saveImage( @@ -90,9 +96,11 @@ internal object SnapshotSaver { keyName: String, bitmap: Bitmap, ) { + Profiler.startSpan("saveImage") saveFile(snapshotsDir, "$keyName$PNG_EXTENSION") { bitmap.compress(Bitmap.CompressFormat.PNG, DEFAULT_PNG_QUALITY, this) } + Profiler.endSpan() } private fun saveMetadata( @@ -102,6 +110,7 @@ internal object SnapshotSaver { fqn: String, composePreviewSnapshotConfig: ComposePreviewSnapshotConfig, ) { + Profiler.startSpan("saveMetadata") val metadata: SnapshotMetadata = SnapshotMetadata.SuccessMetadata( name = keyName, displayName = displayName, @@ -116,6 +125,7 @@ internal object SnapshotSaver { saveFile(snapshotsDir, "$keyName$JSON_EXTENSION") { write(jsonString.toByteArray(Charset.defaultCharset())) } + Profiler.endSpan() } private fun saveErrorMetadata( @@ -125,6 +135,7 @@ internal object SnapshotSaver { errorType: SnapshotErrorType, composePreviewSnapshotConfig: ComposePreviewSnapshotConfig, ) { + Profiler.startSpan("saveErrorMetadata") val keyName = composePreviewSnapshotConfig.keyName() val metadata: SnapshotMetadata = SnapshotMetadata.ErrorMetadata( name = composePreviewSnapshotConfig.keyName(), @@ -140,6 +151,7 @@ internal object SnapshotSaver { saveFile(snapshotsDir, "$keyName$JSON_EXTENSION") { write(jsonString.toByteArray(Charset.defaultCharset())) } + Profiler.endSpan() } private fun saveFile( @@ -147,6 +159,7 @@ internal object SnapshotSaver { filenameWithExtension: String, writer: FileOutputStream.() -> Unit, ) { + Profiler.startSpan("saveFile") val outputFile = File(dir, filenameWithExtension) if (outputFile.exists()) { @@ -156,6 +169,7 @@ internal object SnapshotSaver { Log.d(TAG, "Saving file to ${outputFile.path}") outputFile.outputStream().use { writer(it) } + Profiler.endSpan() } private const val PNG_EXTENSION = ".png" diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposableInvoker.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposableInvoker.kt index 91063933..0921d8a1 100644 --- a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposableInvoker.kt +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposableInvoker.kt @@ -2,6 +2,7 @@ package com.emergetools.snapshots.compose import android.util.Log import androidx.compose.runtime.Composer +import com.emergetools.snapshots.util.Profiler import java.lang.reflect.Method import java.lang.reflect.Modifier import kotlin.math.ceil @@ -17,6 +18,7 @@ object ComposableInvoker { composer: Composer, vararg args: Any? ) { + Profiler.startSpan("invokeComposable") try { val composableClass = Class.forName(className) val method = composableClass.findComposableMethod(methodName, *args) @@ -35,6 +37,8 @@ object ComposableInvoker { } catch (e: Exception) { Log.w(TAG, "Failed to invoke Composable Method '$className.$methodName'") throw e + } finally { + Profiler.endSpan() } } @@ -98,6 +102,7 @@ object ComposableInvoker { methodName: String, vararg previewParamArgs: Any? ): Method? { + Profiler.startSpan("findComposableMethod") val argsArray: Array> = previewParamArgs.mapNotNull { it?.javaClass }.toTypedArray() return try { @@ -121,6 +126,8 @@ object ComposableInvoker { Log.w(TAG, "Method $methodName not found in class ${this.simpleName}") null } + } finally { + Profiler.endSpan() } } @@ -134,6 +141,7 @@ object ComposableInvoker { composer: Composer, vararg args: Any? ): Any? { + Profiler.startSpan("Method.invokeComposableMethod") val composerIndex = parameterTypes.indexOfLast { it == Composer::class.java } val realParams = composerIndex val thisParams = if (instance != null) 1 else 0 @@ -175,7 +183,10 @@ object ComposableInvoker { else -> error("Unexpected index") } } - return invoke(instance, *arguments) + + val result = invoke(instance, *arguments) + Profiler.endSpan() + return result } /** 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 404a1849..4a3336aa 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 @@ -15,6 +15,7 @@ import com.emergetools.snapshots.EmergeSnapshots import com.emergetools.snapshots.SnapshotErrorType import com.emergetools.snapshots.compose.previewparams.PreviewParamUtils import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig +import com.emergetools.snapshots.util.Profiler @Suppress("TooGenericExceptionCaught") fun snapshotComposable( @@ -22,6 +23,7 @@ fun snapshotComposable( activity: PreviewActivity, previewConfig: ComposePreviewSnapshotConfig, ) { + Profiler.startSpan("snapshotComposable") try { snapshot( activity = activity, @@ -40,6 +42,8 @@ fun snapshotComposable( ) // Re-throw to fail the test throw e + } finally { + Profiler.endSpan() } } @@ -53,14 +57,20 @@ private fun snapshot( val previewParameters = PreviewParamUtils.getPreviewProviderParameters(previewConfig) ?: arrayOf(null) + Profiler.startSpan("configToDeviceSpec") val deviceSpec = configToDeviceSpec(previewConfig) + Profiler.endSpan() for (index in previewParameters.indices) { + Profiler.startSpan("previewParam_$index") val prevParam = previewParameters[index] Log.d( EmergeComposeSnapshotReflectiveParameterizedInvoker.TAG, "Invoking composable method with preview parameter: $prevParam" ) + + Profiler.startSpan("setupComposeView") + val composeView = ComposeView(activity) composeView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) val args = if (prevParam != null) arrayOf(prevParam) else emptyArray() @@ -69,6 +79,8 @@ private fun snapshot( previewParameter = previewConfig.previewParameter?.copy(index = index) ) + Profiler.endSpan() + // Update activity window size if device is specified if (deviceSpec != null) { updateActivityBounds(activity, deviceSpec) @@ -86,12 +98,15 @@ private fun snapshot( } // Add the ComposeView to the activity + Profiler.startSpan("addContentView") activity.addContentView( composeView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) ) + Profiler.endSpan() composeView.post { + Profiler.startSpan("post") val size = measureViewSize(composeView, previewConfig) val bitmap = captureBitmap(composeView, size.width, size.height) @@ -106,7 +121,9 @@ private fun snapshot( // Reset activity content view (composeView.parent as? ViewGroup)?.removeView(composeView) + Profiler.endSpan() } + Profiler.endSpan() } } @@ -114,6 +131,7 @@ private fun measureViewSize( view: View, previewConfig: ComposePreviewSnapshotConfig ): IntSize { + Profiler.startSpan("measureViewSize") val deviceSpec = configToDeviceSpec(previewConfig) // Use exact measurements when we have them @@ -149,11 +167,17 @@ private fun measureViewSize( View.MeasureSpec.makeMeasureSpec(view.height, View.MeasureSpec.AT_MOST) } + Profiler.startSpan("view.measure") view.measure(widthMeasureSpec, heightMeasureSpec) - return IntSize(view.measuredWidth, view.measuredHeight) + Profiler.endSpan() + + val size = IntSize(view.measuredWidth, view.measuredHeight) + Profiler.endSpan() + return size } private fun updateActivityBounds(activity: Activity, deviceSpec: DeviceSpec) { + Profiler.startSpan("updateActivityBounds") // Apply the device spec dimensions to the activity window val width = deviceSpec.widthPixels val height = deviceSpec.heightPixels @@ -161,6 +185,7 @@ private fun updateActivityBounds(activity: Activity, deviceSpec: DeviceSpec) { if (width > 0 && height > 0) { activity.window.setLayout(width, height) } + Profiler.endSpan() } private fun dpToPx(dp: Int, scalingFactor: Float): Int { @@ -172,6 +197,7 @@ fun captureBitmap( width: Int, height: Int, ): Bitmap? { + Profiler.startSpan("captureBitmap") try { val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) @@ -185,5 +211,7 @@ fun captureBitmap( e, ) return null + } finally { + Profiler.endSpan() } } diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/EmergeComposeSnapshotReflectiveParameterizedInvoker.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/EmergeComposeSnapshotReflectiveParameterizedInvoker.kt index ab0e6571..2ee29744 100644 --- a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/EmergeComposeSnapshotReflectiveParameterizedInvoker.kt +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/EmergeComposeSnapshotReflectiveParameterizedInvoker.kt @@ -8,6 +8,7 @@ import androidx.test.platform.app.InstrumentationRegistry import com.emergetools.snapshots.EmergeSnapshots import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig import com.emergetools.snapshots.shared.ComposeSnapshots +import com.emergetools.snapshots.util.Profiler import kotlinx.serialization.json.Json import org.junit.Rule import org.junit.Test @@ -79,8 +80,18 @@ class EmergeComposeSnapshotReflectiveParameterizedInvoker( @get:Rule val snapshotRule: EmergeSnapshots = EmergeSnapshots() + @get:Rule + val profiler = Profiler.getInstance(parameter.previewConfig) + + fun someMethod() { + Profiler.startSpan("someMethod") + // some code + Profiler.endSpan() + } + @Test fun reflectiveComposableInvoker() { + Profiler.startSpan("reflectiveComposableInvoker") Log.i(TAG, "Running snapshot test ${parameter.previewConfig.keyName()}") // Force application to be debuggable to ensure PreviewActivity doesn't early exit val applicationInfo = InstrumentationRegistry.getInstrumentation().targetContext.applicationInfo @@ -90,5 +101,6 @@ class EmergeComposeSnapshotReflectiveParameterizedInvoker( scenarioRule.scenario.onActivity { activity -> snapshotComposable(snapshotRule, activity, previewConfig) } + Profiler.endSpan() } } diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/previewparams/PreviewParamUtils.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/previewparams/PreviewParamUtils.kt index f8b2a6c3..624a943e 100644 --- a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/previewparams/PreviewParamUtils.kt +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/previewparams/PreviewParamUtils.kt @@ -3,6 +3,7 @@ package com.emergetools.snapshots.compose.previewparams import android.util.Log import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig +import com.emergetools.snapshots.util.Profiler // Inspired by https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/PreviewUtils.android.kt object PreviewParamUtils { @@ -12,13 +13,16 @@ object PreviewParamUtils { internal fun getPreviewProviderParameters( previewConfig: ComposePreviewSnapshotConfig ): Array? { + Profiler.startSpan("getPreviewProviderParameters") if (previewConfig.previewParameter == null) { Log.d(TAG, "No PreviewParameterProvider found for ${previewConfig.originalFqn}") + Profiler.endSpan() return null } if (previewConfig.previewParameter?.providerClassFqn.isNullOrEmpty()) { Log.e(TAG, "PreviewParameterProvider class name is empty for ${previewConfig.originalFqn}") + Profiler.endSpan() return null } @@ -31,6 +35,7 @@ object PreviewParamUtils { " for ${previewConfig.originalFqn}", e ) + Profiler.endSpan() return null } @@ -40,6 +45,7 @@ object PreviewParamUtils { "PreviewProvider '${previewConfig.previewParameter!!.providerClassFqn}'" + " is not a PreviewParameterProvider for ${previewConfig.originalFqn}" ) + Profiler.endSpan() return null } @@ -51,10 +57,13 @@ object PreviewParamUtils { ) val params = constructor.newInstance() as PreviewParameterProvider<*> - return params.values.toArray(params.count) + val values = params.values.toArray(params.count) .map { unwrapIfInline(it) } .take(previewConfig.previewParameter?.limit ?: params.count) .toTypedArray() + + Profiler.endSpan() + return values } private fun Sequence.toArray(size: Int): Array { diff --git a/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/util/Profiler.kt b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/util/Profiler.kt new file mode 100644 index 00000000..805653da --- /dev/null +++ b/snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/util/Profiler.kt @@ -0,0 +1,149 @@ +package com.emergetools.snapshots.util + +import android.content.Context +import android.os.Bundle +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import com.emergetools.snapshots.shared.ComposePreviewSnapshotConfig +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import java.io.File +import java.nio.charset.Charset + +/** + * Helper to generate a simple folded stacks flamegraph of a given test. + * + * Note: While we wanted to use Profiler.trace(lambda: () -> Unit) for this, the lambda wrapping + * seems to cause issues in timing/the rendering pipeling which manifest in tiny + * rendering diffs (i.e. no anti-aliasing). This is a simpler version that just + * requires manual startSpan/endSpan calls to avoid + */ +class Profiler( + private val parameter: ComposePreviewSnapshotConfig, +) : TestRule { + + companion object { + private const val TAG = "Profiler" + private const val ARG_KEY_SAVE_PROFILE = "save_profile" + + @Volatile + private var instance: Profiler? = null + + fun getInstance(previewConfig: ComposePreviewSnapshotConfig): Profiler = + instance ?: synchronized(this) { + instance ?: Profiler(previewConfig).also { instance = it } + } + + fun startSpan(name: String) { + return instance?.startSpanInternal(name) ?: Unit + } + + fun endSpan() { + return instance?.endSpanInternal() ?: Unit + } + } + + private val targetContext: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val filesDir: File + get() = targetContext.getExternalFilesDir(null) ?: error("External files dir is null") + + private val args: Bundle + get() = InstrumentationRegistry.getArguments() + + private val profilingEnabled: Boolean by lazy { + args.getBoolean(ARG_KEY_SAVE_PROFILE, false) || + args.getString(ARG_KEY_SAVE_PROFILE, "false").toBoolean() + } + + private data class SpanInfo( + val name: String, + val startTime: Long + ) + + private val stack = mutableListOf() + private val foldedStacks = mutableListOf() + + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + try { + startSpanInternal(parameter.keyName()) + base.evaluate() + endSpanInternal() + } finally { + saveProfile() + // Reset the instance to ensure future tests + instance = null + } + } + } + } + + private fun startSpanInternal(name: String) { + if (!profilingEnabled) { + Log.d(TAG, "Profiling disabled, skipping startSpan") + return + } + + stack.add( + SpanInfo( + name = name, + startTime = System.currentTimeMillis() + ) + ) + } + + private fun endSpanInternal() { + if (!profilingEnabled) { + Log.d(TAG, "Profiling disabled, skipping stopSpan") + return + } + + if (stack.isNotEmpty()) { + val endTime = System.currentTimeMillis() + val spanInfo = stack.removeAt(stack.size - 1) + + var foldedStack = stack.joinToString(";") { it.name } + if (foldedStack.isNotEmpty()) { + foldedStack = "$foldedStack;" + } + + val duration = endTime - spanInfo.startTime + foldedStacks.add("$foldedStack${spanInfo.name} $duration") + } + } + + fun saveProfile() { + while (stack.isNotEmpty()) { + // Finish any hanging stacks + endSpanInternal() + } + + if (foldedStacks.isEmpty()) { + Log.d(TAG, "No profiling data to save") + return + } + + val snapshotsDir = File(filesDir, "snapshots") + if (!snapshotsDir.exists() && !snapshotsDir.mkdirs()) { + error("Unable to create snapshots storage directory.") + } + + val outputFileName = "${parameter.keyName()}.folded" + val outputFile = File(snapshotsDir, outputFileName) + + if (outputFile.exists()) { + Log.e(TAG, "File with name $outputFileName already exists, skipping save.") + return + } + + Log.d(TAG, "Saving profile to ${outputFile.path}") + + val profileData = foldedStacks.joinToString("\n") + outputFile.outputStream().use { it.write(profileData.toByteArray(Charset.defaultCharset())) } + } +}