From 7bdb310ceb6d549b99435e2f1acf916e9c6724a6 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Mon, 27 Nov 2023 23:13:19 +0100 Subject: [PATCH] Merge TimelineItemEventRow's textForInReplyTo and attachmentThumbnailInfoForInReplyTo functions (#1859) The flow is somewhat misleading so its logic has been merged into `InReplyToDetails.metadata()`. --- features/messages/impl/build.gradle.kts | 7 + .../components/TimelineItemEventRow.kt | 84 +---- .../impl/timeline/model/InReplyToMetadata.kt | 108 ++++++ .../TimelineItemGroupPositionProvider.kt | 28 -- .../timeline/model/InReplyToMetadataKtTest.kt | 321 ++++++++++++++++++ 5 files changed, 445 insertions(+), 103 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt delete mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPositionProvider.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 6faf07f916..d9eb0fd801 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -23,6 +23,11 @@ plugins { android { namespace = "io.element.android.features.messages.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -88,6 +93,8 @@ dependencies { testImplementation(projects.libraries.voicerecorder.test) testImplementation(projects.libraries.mediaplayer.test) testImplementation(libs.test.mockk) + testImplementation(libs.test.junitext) + testImplementation(libs.test.robolectric) ksp(libs.showkase.processor) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 17582aa905..0c5b33747b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.constraintlayout.compose.ConstrainScope import androidx.constraintlayout.compose.ConstraintLayout +import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView @@ -66,6 +67,7 @@ import io.element.android.features.messages.impl.timeline.components.event.toExt import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.model.InReplyToDetails +import io.element.android.features.messages.impl.timeline.model.InReplyToMetadata import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState @@ -75,6 +77,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.metadata import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.avatar.Avatar @@ -89,18 +92,7 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent -import io.element.android.libraries.matrix.api.timeline.item.event.PollContent -import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail -import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo -import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType -import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch import kotlin.math.abs @@ -538,13 +530,10 @@ private fun MessageEventBubbleContent( } val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value - val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyTo) - val text = textForInReplyTo(inReplyTo) val topPadding = if (showThreadDecoration) 0.dp else 8.dp ReplyToContent( senderName = senderName, - text = text, - attachmentThumbnailInfo = attachmentThumbnailInfo, + metadata = inReplyTo.metadata(), modifier = Modifier .padding(top = topPadding, start = 8.dp, end = 8.dp) .clip(RoundedCornerShape(6.dp)) @@ -585,11 +574,10 @@ private fun MessageEventBubbleContent( @Composable private fun ReplyToContent( senderName: String, - text: String?, - attachmentThumbnailInfo: AttachmentThumbnailInfo?, + metadata: InReplyToMetadata?, modifier: Modifier = Modifier, ) { - val paddings = if (attachmentThumbnailInfo != null) { + val paddings = if (metadata is InReplyToMetadata.Thumbnail) { PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) } else { PaddingValues(horizontal = 12.dp, vertical = 4.dp) @@ -599,9 +587,9 @@ private fun ReplyToContent( .background(MaterialTheme.colorScheme.surface) .padding(paddings) ) { - if (attachmentThumbnailInfo != null) { + if (metadata is InReplyToMetadata.Thumbnail) { AttachmentThumbnail( - info = attachmentThumbnailInfo, + info = metadata.attachmentThumbnailInfo, backgroundColor = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier .size(36.dp) @@ -619,7 +607,7 @@ private fun ReplyToContent( overflow = TextOverflow.Ellipsis, ) Text( - text = text.orEmpty(), + text = metadata?.text.orEmpty(), style = ElementTheme.typography.fontBodyMdRegular, textAlign = TextAlign.Start, color = ElementTheme.materialColors.secondary, @@ -630,60 +618,6 @@ private fun ReplyToContent( } } -private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyToDetails): AttachmentThumbnailInfo? { - return when (val eventContent = inReplyTo.eventContent) { - is MessageContent -> when (val type = eventContent.type) { - is ImageMessageType -> AttachmentThumbnailInfo( - thumbnailSource = type.info?.thumbnailSource ?: type.source, - textContent = eventContent.body, - type = AttachmentThumbnailType.Image, - blurHash = type.info?.blurhash, - ) - is VideoMessageType -> AttachmentThumbnailInfo( - thumbnailSource = type.info?.thumbnailSource, - textContent = eventContent.body, - type = AttachmentThumbnailType.Video, - blurHash = type.info?.blurhash, - ) - is FileMessageType -> AttachmentThumbnailInfo( - thumbnailSource = type.info?.thumbnailSource, - textContent = eventContent.body, - type = AttachmentThumbnailType.File, - ) - is LocationMessageType -> AttachmentThumbnailInfo( - textContent = eventContent.body, - type = AttachmentThumbnailType.Location, - ) - is AudioMessageType -> AttachmentThumbnailInfo( - textContent = eventContent.body, - type = AttachmentThumbnailType.Audio, - ) - is VoiceMessageType -> AttachmentThumbnailInfo( - type = AttachmentThumbnailType.Voice, - ) - else -> null - } - is PollContent -> AttachmentThumbnailInfo( - textContent = eventContent.question, - type = AttachmentThumbnailType.Poll, - ) - else -> null - } -} - -@Composable -private fun textForInReplyTo(inReplyTo: InReplyToDetails): String { - return when (val eventContent = inReplyTo.eventContent) { - is MessageContent -> when (eventContent.type) { - is LocationMessageType -> stringResource(CommonStrings.common_shared_location) - is VoiceMessageType -> stringResource(CommonStrings.common_voice_message) - else -> inReplyTo.textContent ?: eventContent.body - } - is PollContent -> eventContent.question - else -> "" - } -} - @PreviewsDayNight @Composable internal fun TimelineItemEventRowPreview() = ElementPreview { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt new file mode 100644 index 0000000000..63775d5ac2 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.ui.strings.CommonStrings + +@Immutable +internal sealed interface InReplyToMetadata { + + val text: String? + + data class Thumbnail( + val attachmentThumbnailInfo: AttachmentThumbnailInfo + ) : InReplyToMetadata { + override val text: String? = attachmentThumbnailInfo.textContent + } + + data class Text( + override val text: String + ) : InReplyToMetadata +} + +/** + * Computes metadata for the in reply to message. + * + * Metadata can be either a thumbnail with a text OR just a text. + */ +@Composable +internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventContent) { + is MessageContent -> when (val type = eventContent.type) { + is ImageMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + thumbnailSource = type.info?.thumbnailSource ?: type.source, + textContent = eventContent.body, + type = AttachmentThumbnailType.Image, + blurHash = type.info?.blurhash, + ) + ) + is VideoMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + thumbnailSource = type.info?.thumbnailSource, + textContent = eventContent.body, + type = AttachmentThumbnailType.Video, + blurHash = type.info?.blurhash, + ) + ) + is FileMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + thumbnailSource = type.info?.thumbnailSource, + textContent = eventContent.body, + type = AttachmentThumbnailType.File, + ) + ) + is LocationMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + textContent = stringResource(CommonStrings.common_shared_location), + type = AttachmentThumbnailType.Location, + ) + ) + is AudioMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + textContent = eventContent.body, + type = AttachmentThumbnailType.Audio, + ) + ) + is VoiceMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + textContent = stringResource(CommonStrings.common_voice_message), + type = AttachmentThumbnailType.Voice, + ) + ) + else -> InReplyToMetadata.Text(textContent ?: eventContent.body) + } + is PollContent -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + textContent = eventContent.question, + type = AttachmentThumbnailType.Poll, + ) + ) + else -> null +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPositionProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPositionProvider.kt deleted file mode 100644 index 8d8d1231ec..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPositionProvider.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.features.messages.impl.timeline.model - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider - -internal class TimelineItemGroupPositionProvider : PreviewParameterProvider { - override val values = sequenceOf( - TimelineItemGroupPosition.First, - TimelineItemGroupPosition.Middle, - TimelineItemGroupPosition.Last, - TimelineItemGroupPosition.None, - ) -} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt new file mode 100644 index 0000000000..4f4fa296bc --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.model + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.media.aMediaSource +import io.element.android.libraries.matrix.test.room.aMessageContent +import io.element.android.libraries.matrix.test.room.aPollContent +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InReplyToMetadataKtTest { + @Test + fun `any message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetails(eventContent = aMessageContent()).metadata() + }.test { + awaitItem().let { + Truth.assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent")) + } + } + } + + @Test + fun `an image message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetails( + eventContent = aMessageContent( + messageType = ImageMessageType( + body = "body", + source = aMediaSource(), + info = null, + ) + ) + ).metadata() + }.test { + awaitItem().let { + Truth.assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = aMediaSource(), + textContent = "body", + type = AttachmentThumbnailType.Image, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a video message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetails( + eventContent = aMessageContent( + messageType = VideoMessageType( + body = "body", + source = aMediaSource(), + info = VideoInfo( + duration = null, + height = null, + width = null, + mimetype = null, + size = null, + thumbnailInfo = null, + thumbnailSource = aMediaSource(), + blurhash = null + ), + ) + ) + ).metadata() + }.test { + awaitItem().let { + Truth.assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = aMediaSource(), + textContent = "body", + type = AttachmentThumbnailType.Video, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a file message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetails( + eventContent = aMessageContent( + messageType = FileMessageType( + body = "body", + source = aMediaSource(), + info = FileInfo( + mimetype = null, + size = null, + thumbnailInfo = null, + thumbnailSource = aMediaSource(), + ), + ) + ) + ).metadata() + }.test { + awaitItem().let { + Truth.assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = aMediaSource(), + textContent = "body", + type = AttachmentThumbnailType.File, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a audio message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetails( + eventContent = aMessageContent( + messageType = AudioMessageType( + body = "body", + source = aMediaSource(), + info = AudioInfo( + duration = null, + size = null, + mimetype = null + ), + ) + ) + ).metadata() + }.test { + awaitItem().let { + Truth.assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + textContent = "body", + type = AttachmentThumbnailType.Audio, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a location message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + testEnv { + anInReplyToDetails( + eventContent = aMessageContent( + messageType = LocationMessageType( + body = "body", + geoUri = "geo:3.0,4.0;u=5.0", + description = null, + ) + ) + ).metadata() + } + }.test { + awaitItem().let { + Truth.assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "Shared location", + type = AttachmentThumbnailType.Location, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a voice message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + testEnv { + anInReplyToDetails( + eventContent = aMessageContent( + messageType = VoiceMessageType( + body = "body", + source = aMediaSource(), + info = null, + details = null, + ) + ) + ).metadata() + } + }.test { + awaitItem().let { + Truth.assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "Voice message", + type = AttachmentThumbnailType.Voice, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a poll content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetails( + eventContent = aPollContent() + ).metadata() + }.test { + awaitItem().let { + Truth.assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "Do you like polls?", + type = AttachmentThumbnailType.Poll, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `any other content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetails( + eventContent = RedactedContent + ).metadata() + }.test { + awaitItem().let { + Truth.assertThat(it).isEqualTo(null) + } + } + } +} + +fun anInReplyToDetails( + eventId: EventId = AN_EVENT_ID, + senderId: UserId = A_USER_ID, + senderDisplayName: String? = "senderDisplayName", + senderAvatarUrl: String? = "senderAvatarUrl", + eventContent: EventContent? = aMessageContent(), + textContent: String? = "textContent", +) = InReplyToDetails( + eventId = eventId, + senderId = senderId, + senderDisplayName = senderDisplayName, + senderAvatarUrl = senderAvatarUrl, + eventContent = eventContent, + textContent = textContent, +) + +@Composable +private fun testEnv(content: @Composable () -> Any?): Any? { + var result: Any? = null + CompositionLocalProvider( + LocalConfiguration provides Configuration(), + LocalContext provides ApplicationProvider.getApplicationContext(), + ) { + content().apply { + result = this + } + } + return result +}