Merge remote-tracking branch 'upstream/develop' into fix/7397-keep-thread-after-redaction

# Conflicts:
#	features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
#	features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt
#	features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt
#	libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt
This commit is contained in:
hayaksi1
2026-08-27 16:27:18 +03:00
181 changed files with 2761 additions and 335 deletions
@@ -53,8 +53,6 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
@@ -80,7 +78,7 @@ import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGra
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.text.AdaptativeTitle
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.Icon
@@ -141,15 +139,21 @@ fun HomeTopBar(
else -> stringResource(selectedNavigationItem.labelRes)
}
}
HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes)
HomeNavigationBarItem.Spaces -> null
}
displayTitle?.let {
val style = when (spaceFiltersState) {
// Space name
is SpaceFiltersState.Selected -> ElementTheme.typography.fontHeadingSmMedium
// "Chats"
else -> ElementTheme.typography.fontHeadingLgBold
}
AdaptativeTitle(
title = displayTitle,
style = style,
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
)
}
Text(
modifier = Modifier.semantics {
heading()
},
style = ElementTheme.typography.aliasScreenTitle,
text = displayTitle,
)
},
navigationIcon = {
NavigationIcon(
@@ -179,7 +183,9 @@ fun HomeTopBar(
TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) {
RoomListFiltersView(
state = filtersState,
modifier = Modifier.padding(bottom = 16.dp).padding(contentPadding)
modifier = Modifier
.padding(bottom = 16.dp)
.padding(contentPadding)
)
}
}
@@ -348,45 +354,45 @@ private fun AccountIcon(
),
contentAlignment = Alignment.Center,
) {
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
} else {
stringResource(CommonStrings.common_settings)
}
} else {
null
},
}
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
} else {
Modifier
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
} else {
stringResource(CommonStrings.common_settings)
}
} else {
null
},
)
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
@@ -19,6 +19,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.dmUserStatus
@@ -47,7 +48,7 @@ class RoomListRoomSummaryFactory(
mode = DateFormatterMode.TimeOrDate,
useRelative = true,
),
latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm),
latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.hasOnlyTwoMembers()),
avatarData = avatarData,
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
hasRoomCall = roomInfo.hasRoomCall,
@@ -81,6 +82,10 @@ class RoomListRoomSummaryFactory(
)
}
private fun RoomInfo.hasOnlyTwoMembers(): Boolean {
return isDm || activeMembersCount <= 2
}
private fun computeLatestEvent(latestEvent: LatestEventValue, dm: Boolean): LatestEvent {
return when (latestEvent) {
is LatestEventValue.None -> {
@@ -143,7 +143,7 @@ private fun EmptySpaceHomeView(
)
Text(
text = stringResource(CommonStrings.screen_space_list_empty_state_title),
style = ElementTheme.typography.fontHeadingLgBold,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
@@ -8,10 +8,36 @@
package io.element.android.features.home.impl.datasource
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomSummary
import org.junit.Test
class RoomListRoomSummaryFactoryTest {
@Test
fun `a room with two members does not prefix its preview with the sender`() {
val formatter = FakeRoomLatestEventFormatter()
val factory = aRoomListRoomSummaryFactory(roomLatestEventFormatter = formatter)
factory.create(aRoomSummary(info = aRoomInfo(isDm = false, activeMembersCount = 2)))
assertThat(formatter.lastIsDmRoom).isTrue()
}
@Test
fun `a room with more than two members prefixes its preview with the sender`() {
val formatter = FakeRoomLatestEventFormatter()
val factory = aRoomListRoomSummaryFactory(roomLatestEventFormatter = formatter)
factory.create(aRoomSummary(info = aRoomInfo(isDm = false, activeMembersCount = 3)))
assertThat(formatter.lastIsDmRoom).isFalse()
}
}
fun aRoomListRoomSummaryFactory(
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
@@ -51,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState
@@ -377,7 +378,7 @@ class MessagesPresenter(
TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ReplyInThread -> {
@@ -546,10 +547,15 @@ class MessagesPresenter(
private suspend fun handleActionEditCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
enableTextFormatting: Boolean,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = targetEvent.content.captionOrNull().orEmpty(),
content = if (enableTextFormatting) {
targetEvent.content.htmlCaptionOrNull() ?: targetEvent.content.captionOrNull()
} else {
targetEvent.content.captionOrNull()
}.orEmpty(),
)
composerState.eventSink(
MessageComposerEvent.SetMode(composerMode)
@@ -108,6 +108,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -132,6 +133,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -174,6 +176,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -198,6 +201,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -216,6 +220,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -235,6 +240,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -345,6 +351,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited,
items = galleryItems.toImmutableList(),
)
@@ -402,6 +409,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited,
attachments = attachments.toImmutableList(),
)
@@ -14,6 +14,7 @@ data class TimelineItemAttachmentsContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean,
val attachments: ImmutableList<AttachmentItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -17,6 +17,7 @@ data class TimelineItemAudioContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -33,6 +33,9 @@ sealed interface TimelineItemEventContentWithAttachment :
val fileSize: Long?
val caption: String?
val formattedCaption: CharSequence?
/** The caption as it was written, in HTML, so that editing it can start from the formatted text. */
val htmlCaption: String?
val mediaSource: MediaSource
val mimeType: String
val formattedFileSize: String
@@ -127,6 +130,13 @@ fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (thi
else -> null
}
fun TimelineItemEventContent.htmlCaptionOrNull(): String? = when (this) {
is TimelineItemEventContentWithAttachment -> htmlCaption
is TimelineItemGalleryContent -> htmlCaption
is TimelineItemAttachmentsContent -> htmlCaption
else -> null
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
@@ -16,6 +16,7 @@ data class TimelineItemFileContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemGalleryContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean,
val items: ImmutableList<GalleryItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -19,6 +19,7 @@ data class TimelineItemImageContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -28,6 +28,7 @@ fun aTimelineItemImageContent(
blurhash: String? = A_BLUR_HASH,
filename: String = "A picture.jpg",
caption: String? = null,
htmlCaption: String? = null,
width: Int? = null,
height: Int? = 300,
) = TimelineItemImageContent(
@@ -35,6 +36,7 @@ fun aTimelineItemImageContent(
fileSize = 4 * 1024 * 1024L,
caption = caption,
formattedCaption = null,
htmlCaption = htmlCaption,
isEdited = false,
mediaSource = MediaSource(""),
thumbnailSource = null,
@@ -15,6 +15,7 @@ data class TimelineItemStickerContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemVideoContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -19,6 +19,7 @@ data class TimelineItemVoiceContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -989,6 +989,33 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - handle action edit caption starts from the formatted caption`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = "Hello world",
htmlCaption = "<b>Hello</b> world",
)
)
val composerRecorder = EventsRecorder<MessageComposerEvent>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder, showTextFormatting = true) },
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.EditCaption, messageEvent))
awaitItem()
composerRecorder.assertSingle(
MessageComposerEvent.SetMode(
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = "<b>Hello</b> world",
)
)
)
}
}
@Test
fun `present - handle action add caption`() = runTest {
val composerRecorder = EventsRecorder<MessageComposerEvent>()
@@ -51,7 +51,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -69,6 +68,7 @@ import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@@ -880,7 +880,7 @@ class TimelinePresenterTest {
(0 until 3).map { index ->
MatrixTimelineItem.Event(
uniqueId = UniqueId("redacted_$index"),
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent(threadInfo = null)),
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = aRedactedContent()),
)
}
),
@@ -20,9 +20,10 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -89,6 +90,42 @@ class TimelineItemsFactoryTest {
).inOrder()
}
@Test
fun `redacted message keeps its thread info`() = runTest {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
val items = listOf(
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
timestamp = 0L,
content = aRedactedContent(
threadInfo = EventThreadInfo.ThreadResponse(A_THREAD_ID),
),
),
)
)
var threadInfo: TimelineItemThreadInfo? = null
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = emptyList(),
renderReadReceipts = false,
)
threadInfo = awaitItem()
.filterIsInstance<TimelineItem.Event>()
.firstOrNull()
?.threadInfo
cancelAndIgnoreRemainingEvents()
}
assertThat(threadInfo).isEqualTo(TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
}
@Test
fun `a key verification request is not rendered`() = runTest {
val factory = aTimelineItemsFactory(
@@ -146,7 +183,7 @@ class TimelineItemsFactoryTest {
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
content = RedactedContent(
content = aRedactedContent(
threadInfo = EventThreadInfo.ThreadRoot(
summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 3),
),
@@ -297,6 +297,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 555L,
caption = "body.mp4 caption",
formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true,
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
@@ -539,6 +540,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 888L,
caption = "body.jpg caption",
formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
@@ -16,11 +16,11 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.core.FakeSendHandle
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -88,7 +88,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
sender = A_USER_ID,
senderProfile = ProfileDetails.Unavailable,
timestamp = 9442,
content = RedactedContent(threadInfo = null),
content = aRedactedContent(),
origin = null,
timelineItemDebugInfoProvider = {
TimelineItemDebugInfo(
@@ -64,6 +64,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
@@ -91,6 +92,7 @@ dependencies {
implementation(libs.color.picker)
implementation(libs.androidx.browser)
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json)
api(projects.features.preferences.api)
implementation(libs.showkase)
@@ -108,6 +110,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.libraries.roomselect.test)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.libraries.troubleshoot.test)
testImplementation(projects.features.deactivation.test)
testImplementation(projects.features.enterprise.test)
@@ -40,10 +40,12 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.canPop
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
import kotlinx.parcelize.Parcelize
@@ -56,6 +58,7 @@ class PreferencesFlowNode(
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint,
private val pushHistoryEntryPoint: PushHistoryEntryPoint,
private val fileViewerEntryPoint: FileViewerEntryPoint,
private val logoutEntryPoint: LogoutEntryPoint,
private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint,
private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint,
@@ -95,6 +98,9 @@ class PreferencesFlowNode(
@Parcelize
data object PushHistory : NavTarget
@Parcelize
data class PushRules(val filename: String, val content: String) : NavTarget
@Parcelize
data object LockScreenSettings : NavTarget
@@ -187,6 +193,10 @@ class PreferencesFlowNode(
}
NavTarget.DeveloperSettings -> {
val developerSettingsCallback = object : DeveloperSettingsNode.Callback {
override fun navigateToPushRules(filename: String, content: String) {
backstack.push(NavTarget.PushRules(filename = filename, content = content))
}
override fun navigateToPushHistory() {
backstack.push(NavTarget.PushHistory)
}
@@ -270,6 +280,26 @@ class PreferencesFlowNode(
},
)
}
is NavTarget.PushRules -> {
fileViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = FileViewerEntryPoint.Params(
filename = navTarget.filename,
mimeType = MimeTypes.Json,
content = navTarget.content,
),
callback = object : FileViewerEntryPoint.Callback {
override fun onDone() {
if (backstack.canPop()) {
backstack.pop()
} else {
navigateUp()
}
}
},
)
}
is NavTarget.EditDefaultNotificationSetting -> {
val callback = object : EditDefaultNotificationSettingNode.Callback {
override fun navigateToRoomNotificationSettings(roomId: RoomId) {
@@ -17,4 +17,6 @@ sealed interface DeveloperSettingsEvent {
data object VacuumStores : DeveloperSettingsEvent
data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : DeveloperSettingsEvent
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent
data object OpenPushRules : DeveloperSettingsEvent
data object DismissPushRulesError : DeveloperSettingsEvent
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer
/**
* Navigation performed by the developer settings screen, when data has to be loaded first.
*/
fun interface DeveloperSettingsNavigator {
fun openPushRules(filename: String, content: String)
}
@@ -28,15 +28,22 @@ import io.element.android.libraries.di.SessionScope
class DeveloperSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: DeveloperSettingsPresenter,
presenterFactory: DeveloperSettingsPresenter.Factory,
private val extraDeveloperOptionsRenderer: ExtraDeveloperOptionsRenderer,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins),
DeveloperSettingsNavigator {
interface Callback : Plugin {
fun navigateToPushRules(filename: String, content: String)
fun navigateToPushHistory()
fun onDone()
}
private val callback: Callback = callback()
private val presenter = presenterFactory.create(navigator = this)
override fun openPushRules(filename: String, content: String) {
callback.navigateToPushRules(filename = filename, content = content)
}
@Composable
override fun View(modifier: Modifier) {
@@ -17,7 +17,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
@@ -30,17 +32,23 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@Inject
@AssistedInject
class DeveloperSettingsPresenter(
@Assisted private val navigator: DeveloperSettingsNavigator,
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
private val sessionId: SessionId,
private val deviceId: DeviceId,
@@ -52,7 +60,13 @@ class DeveloperSettingsPresenter(
private val fileSizeFormatter: FileSizeFormatter,
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
private val buildMeta: BuildMeta,
private val notificationSettingsService: NotificationSettingsService,
) : Presenter<DeveloperSettingsState> {
@AssistedFactory
fun interface Factory {
fun create(navigator: DeveloperSettingsNavigator): DeveloperSettingsPresenter
}
@Composable
override fun present(): DeveloperSettingsState {
val cacheSize = remember {
@@ -67,6 +81,9 @@ class DeveloperSettingsPresenter(
val markAllRoomsAsReadAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val pushRulesAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
var showColorPicker by remember {
mutableStateOf(false)
}
@@ -109,6 +126,10 @@ class DeveloperSettingsPresenter(
DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation -> {
markAllRoomsAsReadAction.value = AsyncAction.Uninitialized
}
DeveloperSettingsEvent.OpenPushRules -> coroutineScope.openPushRules(pushRulesAction)
DeveloperSettingsEvent.DismissPushRulesError -> {
pushRulesAction.value = AsyncAction.Uninitialized
}
}
}
@@ -119,6 +140,7 @@ class DeveloperSettingsPresenter(
databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value,
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
pushRulesAction = pushRulesAction.value,
isEnterpriseBuild = buildMeta.isEnterpriseBuild,
showColorPicker = showColorPicker,
deviceId = deviceId,
@@ -164,4 +186,40 @@ class DeveloperSettingsPresenter(
markAllRoomsAsRead().getOrThrow()
}.runCatchingUpdatingState(state = markAllRoomsAsReadAction)
}
private fun CoroutineScope.openPushRules(pushRulesAction: MutableState<AsyncAction<Unit>>) = launch {
pushRulesAction.value = AsyncAction.Loading
notificationSettingsService.getRawPushRules()
.onSuccess { content ->
pushRulesAction.value = AsyncAction.Uninitialized
navigator.openPushRules(
filename = pushRulesFilename(),
content = content.orEmpty().prettyPrintJson(),
)
}
.onFailure {
pushRulesAction.value = AsyncAction.Failure(it)
}
}
/**
* The user id contains a colon, which is not a valid character for a file name on all the file systems,
* so replace it by an underscore.
*/
private fun pushRulesFilename() = "push_rules${sessionId.value.replace(':', '_')}.json"
}
@OptIn(ExperimentalSerializationApi::class)
private val prettyPrintJson = Json {
prettyPrint = true
// Keep the indentation small, for a better rendering on mobile.
prettyPrintIndent = " "
}
/**
* Pretty print this json content, so that it is readable when rendered.
* Return the content as is if it is not valid json.
*/
private fun String.prettyPrintJson(): String = runCatchingExceptions {
prettyPrintJson.encodeToString(JsonElement.serializer(), prettyPrintJson.parseToJsonElement(this))
}.getOrDefault(this)
@@ -20,10 +20,13 @@ data class DeveloperSettingsState(
val databaseSizes: AsyncData<ImmutableMap<String, String>>,
val clearCacheAction: AsyncAction<Unit>,
val markAllRoomsAsReadAction: AsyncAction<Unit>,
val pushRulesAction: AsyncAction<Unit>,
val isEnterpriseBuild: Boolean,
val showColorPicker: Boolean,
val deviceId: DeviceId,
val eventSink: (DeveloperSettingsEvent) -> Unit
) {
val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading
val showLoader = clearCacheAction is AsyncAction.Loading ||
markAllRoomsAsReadAction is AsyncAction.Loading ||
pushRulesAction is AsyncAction.Loading
}
@@ -32,6 +32,9 @@ open class DeveloperSettingsStatePreviewParam : PreviewParameterProvider<Develop
// at io.mhssn.colorpicker.pickers.ClassicColorPickerKt$ClassicColorPicker$1$1.invokeSuspend(ClassicColorPicker.kt:53)
showColorPicker = false,
),
aDeveloperSettingsState(
pushRulesAction = AsyncAction.Failure(Exception("A failure"))
),
)
}
@@ -39,6 +42,7 @@ fun aDeveloperSettingsState(
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
pushRulesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
isEnterpriseBuild: Boolean = false,
showColorPicker: Boolean = false,
deviceId: DeviceId = DeviceId("ILAKNDNASDLK"),
@@ -49,6 +53,7 @@ fun aDeveloperSettingsState(
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
clearCacheAction = clearCacheAction,
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
pushRulesAction = pushRulesAction,
isEnterpriseBuild = isEnterpriseBuild,
showColorPicker = showColorPicker,
deviceId = deviceId,
@@ -23,8 +23,10 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -60,6 +62,12 @@ fun DeveloperSettingsView(
onDismiss = { state.eventSink(DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation) },
)
}
(state.pushRulesAction as? AsyncAction.Failure)?.let { failure ->
ErrorDialog(
content = failure.error.message ?: stringResource(CommonStrings.error_unknown),
onSubmit = { state.eventSink(DeveloperSettingsEvent.DismissPushRulesError) },
)
}
BackHandler(
enabled = !state.showLoader,
onBack = onBackClick,
@@ -79,7 +87,10 @@ fun DeveloperSettingsView(
onOpenShowkase = onOpenShowkase,
)
SessionCategory(deviceId = state.deviceId)
NotificationCategory(onPushHistoryClick)
NotificationCategory(
onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) },
onPushHistoryClick = onPushHistoryClick,
)
MarkAllRoomsAsReadCategory(state)
if (state.isEnterpriseBuild) {
@@ -217,8 +228,17 @@ private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) {
}
@Composable
private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
private fun NotificationCategory(
onPushRulesClick: () -> Unit,
onPushHistoryClick: () -> Unit,
) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) {
ListItem(
content = {
Text("Push rules")
},
onClick = onPushRulesClick,
)
ListItem(
content = {
Text(stringResource(R.string.troubleshoot_notifications_entry_point_push_history_title))
@@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
@@ -43,6 +44,7 @@ class DefaultPreferencesEntryPointTest {
lockScreenEntryPoint = FakeLockScreenEntryPoint(),
notificationTroubleShootEntryPoint = FakeNotificationTroubleShootEntryPoint(),
pushHistoryEntryPoint = FakePushHistoryEntryPoint(),
fileViewerEntryPoint = FakeFileViewerEntryPoint(),
logoutEntryPoint = FakeLogoutEntryPoint(),
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(),
@@ -28,10 +28,13 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
@@ -41,6 +44,19 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
private const val A_PUSH_RULES_CONTENT = """{"global":{"override":[{"rule_id":".m.rule.master","enabled":false}]}}"""
private const val A_PUSH_RULES_CONTENT_PRETTY_PRINTED = """{
"global": {
"override": [
{
"rule_id": ".m.rule.master",
"enabled": false
}
]
}
}"""
private val AN_EXCEPTION = Exception("A failure")
class DeveloperSettingsPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -170,7 +186,72 @@ class DeveloperSettingsPresenterTest {
}
}
@Test
fun `present - OpenPushRules event navigates to the push rules content`() = runTest {
val openPushRulesLambda = lambdaRecorder<String, String, Unit> { _, _ -> }
val presenter = createDeveloperSettingsPresenter(
navigator = DeveloperSettingsNavigator(openPushRulesLambda),
notificationSettingsService = FakeNotificationSettingsService(
getRawPushRulesResult = { Result.success(A_PUSH_RULES_CONTENT) },
),
)
presenter.test {
val state = awaitItem()
assertThat(state.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
state.eventSink(DeveloperSettingsEvent.OpenPushRules)
skipItems(1)
awaitItem().also {
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
}
openPushRulesLambda.assertions().isCalledOnce().with(
value("push_rules@alice_server.org.json"),
value(A_PUSH_RULES_CONTENT_PRETTY_PRINTED),
)
}
}
@Test
fun `present - OpenPushRules event keeps the content as is when it is not valid json`() = runTest {
val openPushRulesLambda = lambdaRecorder<String, String, Unit> { _, _ -> }
val presenter = createDeveloperSettingsPresenter(
navigator = DeveloperSettingsNavigator(openPushRulesLambda),
notificationSettingsService = FakeNotificationSettingsService(
getRawPushRulesResult = { Result.success("not json") },
),
)
presenter.test {
awaitItem().eventSink(DeveloperSettingsEvent.OpenPushRules)
skipItems(2)
openPushRulesLambda.assertions().isCalledOnce().with(
value("push_rules@alice_server.org.json"),
value("not json"),
)
}
}
@Test
fun `present - OpenPushRules event failure can be dismissed`() = runTest {
val presenter = createDeveloperSettingsPresenter(
notificationSettingsService = FakeNotificationSettingsService(
getRawPushRulesResult = { Result.failure(AN_EXCEPTION) },
),
)
presenter.test {
val state = awaitItem()
state.eventSink(DeveloperSettingsEvent.OpenPushRules)
skipItems(1)
awaitItem().also {
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
it.eventSink(DeveloperSettingsEvent.DismissPushRulesError)
}
awaitItem().also {
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
private fun createDeveloperSettingsPresenter(
navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() },
sessionId: SessionId = A_SESSION_ID,
deviceId: DeviceId = A_DEVICE_ID,
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
@@ -180,8 +261,10 @@ class DeveloperSettingsPresenterTest {
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
buildMeta: BuildMeta = aBuildMeta(),
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter(
navigator = navigator,
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
sessionId = sessionId,
deviceId = deviceId,
@@ -193,6 +276,7 @@ class DeveloperSettingsPresenterTest {
fileSizeFormatter = FakeFileSizeFormatter(),
markAllRoomsAsRead = markAllRoomsAsRead,
buildMeta = buildMeta,
notificationSettingsService = notificationSettingsService,
)
}
}
@@ -57,6 +57,19 @@ class DeveloperSettingsViewTest : RobolectricTest() {
}
}
@Config(qualifiers = "h2400dp")
@Test
fun `clicking on push rules emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvent>()
setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
)
onNodeWithText("Push rules").performScrollTo().performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvent.OpenPushRules)
}
@Config(qualifiers = "h2000dp")
@Test
fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest {
@@ -16,6 +16,7 @@ import android.provider.MediaStore
import androidx.annotation.RequiresApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.file.saveWithUniqueFileName
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -25,6 +26,7 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
interface FileSave {
suspend fun save(
@@ -61,18 +63,18 @@ class DefaultFileSave(
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveOnDiskUsingMediaStore(path: String) {
val file = File(path)
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (outputUri != null) {
file.inputStream().use { input ->
resolver.openOutputStream(outputUri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
val outputUri = saveWithUniqueFileName(file.name) { displayName ->
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
} ?: throw IOException("Unable to create the destination file")
file.inputStream().use { input ->
resolver.openOutputStream(outputUri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
}
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.viewfolder.impl.file
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.fake.registerFakeMediaStoreContentProvider
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.robolectric.RuntimeEnvironment
import java.io.File
class DefaultFileSaveTest : RobolectricTest() {
@Test
fun `save inserts the file in the Downloads collection under its own name`() = runTest {
val mediaStore = registerFakeMediaStoreContentProvider()
createFileSave().save(aFileToSave().path)
assertThat(mediaStore.insertedDisplayNames).containsExactly("a-file.txt")
assertThat(mediaStore.savedContent("a-file.txt")).isEqualTo("Some content")
}
@Test
fun `save retries with a suffixed name when the name cannot be made unique`() = runTest {
val mediaStore = registerFakeMediaStoreContentProvider().apply {
conflictingDisplayNames = setOf("a-file.txt")
}
createFileSave().save(aFileToSave().path)
assertThat(mediaStore.insertedDisplayNames).hasSize(2)
assertThat(mediaStore.insertedDisplayNames.last()).matches("a-file_\\d+\\.txt")
assertThat(mediaStore.savedContent(mediaStore.insertedDisplayNames.last())).isEqualTo("Some content")
}
@Test
fun `save writes nothing when the destination cannot be created`() = runTest {
val mediaStore = registerFakeMediaStoreContentProvider().apply {
returnsNoUriOnInsert = true
}
createFileSave().save(aFileToSave().path)
assertThat(mediaStore.insertedDisplayNames).containsExactly("a-file.txt")
assertThat(mediaStore.savedFileNames()).isEmpty()
}
private fun aFileToSave(): File {
return File(RuntimeEnvironment.getApplication().cacheDir, "a-file.txt").apply {
writeText("Some content")
}
}
private fun TestScope.createFileSave() = DefaultFileSave(
context = RuntimeEnvironment.getApplication(),
dispatchers = testCoroutineDispatchers(),
)
}