Check link click (#4463)

* Warn when opening a suspicious link.

Upgrade RTE to 2.38.3

* Update screenshots

* Add tests on LinkPresenter and LinkView.

* Format file

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2025-03-25 18:17:20 +01:00
committed by GitHub
parent 59225a6bf4
commit 9ab6144001
40 changed files with 658 additions and 39 deletions

View File

@@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@@ -96,6 +97,7 @@ class MessagesPresenter @AssistedInject constructor(
@Assisted private val timelinePresenter: Presenter<TimelineState>,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
private val linkPresenter: Presenter<LinkState>,
@Assisted private val actionListPresenter: Presenter<ActionListState>,
private val customReactionPresenter: Presenter<CustomReactionState>,
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
@@ -136,6 +138,7 @@ class MessagesPresenter @AssistedInject constructor(
val timelineProtectionState = timelineProtectionPresenter.present()
val identityChangeState = identityChangeStatePresenter.present()
val actionListState = actionListPresenter.present()
val linkState = linkPresenter.present()
val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
@@ -245,6 +248,7 @@ class MessagesPresenter @AssistedInject constructor(
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
linkState = linkState,
actionListState = actionListState,
customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState,

View File

@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
@@ -38,6 +39,7 @@ data class MessagesState(
val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState,
val identityChangeState: IdentityChangeState,
val linkState: LinkState,
val actionListState: ActionListState,
val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState,

View File

@@ -12,6 +12,8 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.link.aLinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@@ -103,6 +105,7 @@ fun aMessagesState(
),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
identityChangeState: IdentityChangeState = anIdentityChangeState(),
linkState: LinkState = aLinkState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(),
customReactionState: CustomReactionState = aCustomReactionState(),
@@ -124,6 +127,7 @@ fun aMessagesState(
voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
linkState = linkState,
timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
actionListState = actionListState,

View File

@@ -56,6 +56,8 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
import io.element.android.features.messages.impl.link.LinkEvents
import io.element.android.features.messages.impl.link.LinkView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@@ -104,6 +106,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
import kotlin.random.Random
@@ -207,7 +210,14 @@ fun MessagesView(
onContentClick = ::onContentClick,
onMessageLongClick = ::onMessageLongClick,
onUserDataClick = { hidingKeyboard { onUserDataClick(it) } },
onLinkClick = onLinkClick,
onLinkClick = { link, customTab ->
if (customTab) {
onLinkClick(link.url, true)
// Do not check those links, they are internal link only
} else {
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
}
},
onReactionClick = ::onEmojiReactionClick,
onReactionLongClick = ::onEmojiReactionLongClick,
onMoreReactionsClick = ::onMoreReactionsClick,
@@ -258,6 +268,12 @@ fun MessagesView(
onUserDataClick = onUserDataClick,
)
ReinviteDialog(state = state)
LinkView(
onLinkValid = { link ->
onLinkClick(link.url, false)
},
state = state.linkState,
)
}
@Composable
@@ -279,7 +295,7 @@ private fun MessagesViewContent(
state: MessagesState,
onContentClick: (TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String, Boolean) -> Unit,
onLinkClick: (Link, Boolean) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@@ -353,7 +369,7 @@ private fun MessagesViewContent(
state = state.timelineState,
timelineProtectionState = state.timelineProtectionState,
onUserDataClick = onUserDataClick,
onLinkClick = { url -> onLinkClick(url, false) },
onLinkClick = { link -> onLinkClick(link, false) },
onContentClick = onContentClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
@@ -388,7 +404,7 @@ private fun MessagesViewContent(
MessagesViewComposerBottomSheetContents(
subcomposing = subcomposing,
state = state,
onLinkClick = onLinkClick,
onLinkClick = { url, customTab -> onLinkClick(Link(url), customTab) },
)
},
sheetContentKey = sheetResizeContentKey.intValue,

View File

@@ -14,6 +14,8 @@ import io.element.android.features.messages.impl.crypto.identity.IdentityChangeS
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.link.LinkPresenter
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
@@ -46,6 +48,9 @@ interface MessagesModule {
@Binds
fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter<TimelineProtectionState>
@Binds
fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState>
@Binds
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.wysiwyg.link.Link
data class ConfirmingLinkClick(
val link: Link,
) : AsyncAction.Confirming

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.containsRtLOverride
import io.element.android.libraries.di.AppScope
import io.element.android.wysiwyg.link.Link
import java.net.URI
import javax.inject.Inject
interface LinkChecker {
fun isSafe(link: Link): Boolean
}
@ContributesBinding(AppScope::class)
class DefaultLinkChecker @Inject constructor() : LinkChecker {
override fun isSafe(link: Link): Boolean {
return if (link.url.containsRtLOverride()) {
false
} else {
val textUrl = tryOrNull { URI(link.text).toURL() }
val urlUrl = tryOrNull { URI(link.url).toURL() }
if (textUrl == null || urlUrl == null) {
// The text is not a Url, or the url is not valid
true
} else {
// the hosts must match
textUrl.host == urlUrl.host
}
}
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import io.element.android.wysiwyg.link.Link
sealed interface LinkEvents {
data class OnLinkClick(val link: Link) : LinkEvents
data object Confirm : LinkEvents
data object Cancel : LinkEvents
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.wysiwyg.link.Link
import javax.inject.Inject
class LinkPresenter @Inject constructor(
private val linkChecker: LinkChecker,
) : Presenter<LinkState> {
@Composable
override fun present(): LinkState {
val linkClick: MutableState<AsyncAction<Link>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
fun handleEvents(linkEvents: LinkEvents) {
when (linkEvents) {
is LinkEvents.OnLinkClick -> {
linkClick.value = AsyncAction.Loading
val result = linkChecker.isSafe(linkEvents.link)
if (result) {
linkClick.value = AsyncAction.Success(linkEvents.link)
} else {
// Confirm first
linkClick.value = ConfirmingLinkClick(linkEvents.link)
}
}
LinkEvents.Confirm -> {
linkClick.value = (linkClick.value as? ConfirmingLinkClick)
?.let { AsyncAction.Success(it.link) }
?: AsyncAction.Uninitialized
}
LinkEvents.Cancel -> {
linkClick.value = AsyncAction.Uninitialized
}
}
}
return LinkState(
linkClick = linkClick.value,
eventSink = ::handleEvents,
)
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.wysiwyg.link.Link
data class LinkState(
val linkClick: AsyncAction<Link>,
val eventSink: (LinkEvents) -> Unit,
)

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.wysiwyg.link.Link
open class LinkStateProvider : PreviewParameterProvider<LinkState> {
override val values: Sequence<LinkState>
get() = sequenceOf(
aLinkState(),
aLinkState(
linkClick = ConfirmingLinkClick(
Link(
url = "https://evil.io",
text = "https://element.io"
),
),
),
)
}
fun aLinkState(
linkClick: AsyncAction<Link> = AsyncAction.Uninitialized,
eventSink: (LinkEvents) -> Unit = {},
) = LinkState(
linkClick = linkClick,
eventSink = eventSink,
)

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.extensions.ensureEndsLeftToRight
import io.element.android.libraries.core.extensions.filterDirectionOverrides
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
@Composable
fun LinkView(
state: LinkState,
onLinkValid: (Link) -> Unit,
modifier: Modifier = Modifier,
) {
when (state.linkClick) {
AsyncAction.Uninitialized,
AsyncAction.Loading,
is AsyncAction.Failure -> Unit
is AsyncAction.Confirming -> {
if (state.linkClick is ConfirmingLinkClick) {
ConfirmationDialog(
modifier = modifier,
title = stringResource(CommonStrings.dialog_confirm_link_title),
content = stringResource(
CommonStrings.dialog_confirm_link_message,
state.linkClick.link.text.ensureEndsLeftToRight(),
state.linkClick.link.url.filterDirectionOverrides(),
),
submitText = stringResource(CommonStrings.action_continue),
onSubmitClick = {
state.eventSink(LinkEvents.Confirm)
},
onDismiss = {
state.eventSink(LinkEvents.Cancel)
},
)
}
}
is AsyncAction.Success -> {
val latestOnLinkValid by rememberUpdatedState(onLinkValid)
LaunchedEffect(state.linkClick.data) {
latestOnLinkValid(state.linkClick.data)
state.eventSink(LinkEvents.Cancel)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun LinkViewPreview(@PreviewParameter(LinkStateProvider::class) state: LinkState) = ElementPreview {
LinkView(
state = state,
onLinkValid = {},
)
}

View File

@@ -109,13 +109,13 @@ class PinnedMessagesListNode @AssistedInject constructor(
onBackClick = ::navigateUp,
onEventClick = ::onEventClick,
onUserDataClick = ::onUserDataClick,
onLinkClick = { url -> onLinkClick(context, url) },
onLinkClick = { link -> onLinkClick(context, link.url) },
onLinkLongClick = {
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
it,
it.url,
context.getString(CommonStrings.common_copied_to_clipboard)
)
},

View File

@@ -25,6 +25,7 @@ import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
@@ -63,6 +64,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
timelineItemsFactoryCreator: TimelineItemsFactory.Creator,
private val timelineProvider: PinnedEventsTimelineProvider,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val linkPresenter: Presenter<LinkState>,
private val snackbarDispatcher: SnackbarDispatcher,
@Assisted private val actionListPresenter: Presenter<ActionListState>,
private val appCoroutineScope: CoroutineScope,
@@ -106,6 +108,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
)
}
val timelineProtectionState = timelineProtectionPresenter.present()
val linkState = linkPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
@@ -127,6 +130,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
return pinnedMessagesListState(
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
linkState = linkState,
userEventPermissions = userEventPermissions,
timelineItems = pinnedMessageItems,
eventSink = ::handleEvents
@@ -223,6 +227,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private fun pinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
linkState: LinkState,
userEventPermissions: UserEventPermissions,
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
eventSink: (PinnedMessagesListEvents) -> Unit
@@ -238,6 +243,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
linkState = linkState,
userEventPermissions = userEventPermissions,
timelineItems = timelineItems.data,
actionListState = actionListState,

View File

@@ -13,6 +13,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
@@ -31,6 +32,7 @@ sealed interface PinnedMessagesListState {
val userEventPermissions: UserEventPermissions,
val timelineItems: ImmutableList<TimelineItem>,
val actionListState: ActionListState,
val linkState: LinkState,
val eventSink: (PinnedMessagesListEvents) -> Unit,
) : PinnedMessagesListState {
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }

View File

@@ -11,6 +11,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.link.aLinkState
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
@@ -86,6 +88,7 @@ fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty
fun aLoadedPinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
linkState: LinkState = aLinkState(),
timelineItems: List<TimelineItem> = emptyList(),
actionListState: ActionListState = anActionListState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
@@ -93,6 +96,7 @@ fun aLoadedPinnedMessagesListState(
) = PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
linkState = linkState,
timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState,
userEventPermissions = aUserEventPermissions,

View File

@@ -28,6 +28,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.link.LinkEvents
import io.element.android.features.messages.impl.link.LinkView
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
@@ -50,6 +52,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import io.element.android.wysiwyg.link.Link
@Composable
fun PinnedMessagesListView(
@@ -57,8 +60,8 @@ fun PinnedMessagesListView(
onBackClick: () -> Unit,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@@ -113,8 +116,8 @@ private fun PinnedMessagesListContent(
state: PinnedMessagesListState,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onErrorDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -169,8 +172,8 @@ private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
modifier: Modifier = Modifier,
) {
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
@@ -220,7 +223,9 @@ private fun PinnedMessagesListLoaded(
isLastOutgoingMessage = false,
focusedEventId = null,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkClick = { link ->
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
},
onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick,
onLongClick = ::onMessageLongClick,
@@ -238,7 +243,9 @@ private fun PinnedMessagesListLoaded(
timelineProtectionState = state.timelineProtectionState,
onContentClick = { onEventClick(event) },
onLongClick = { onMessageLongClick(event) },
onLinkClick = onLinkClick,
onLinkClick = { link ->
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
},
onLinkLongClick = onLinkLongClick,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
@@ -247,6 +254,10 @@ private fun PinnedMessagesListLoaded(
)
}
}
LinkView(
state.linkState,
onLinkValid = onLinkClick,
)
}
@Composable
@@ -254,8 +265,8 @@ private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onLongClick: (() -> Unit)?,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,

View File

@@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
@@ -92,7 +93,7 @@ fun TimelineView(
state: TimelineState,
timelineProtectionState: TimelineProtectionState,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
@@ -134,12 +135,12 @@ fun TimelineView(
state.eventSink(TimelineEvents.FocusOnEvent(eventId))
}
fun onLinkLongClick(link: String) {
fun onLinkLongClick(link: Link) {
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
link,
link.url,
context.getString(CommonStrings.common_copied_to_clipboard)
)
}

View File

@@ -94,6 +94,7 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -117,8 +118,8 @@ fun TimelineItemEventRow(
isHighlighted: Boolean,
onEventClick: () -> Unit,
onLongClick: () -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,

View File

@@ -32,6 +32,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.link.Link
@Composable
fun TimelineItemGroupedEventsRow(
@@ -45,8 +46,8 @@ fun TimelineItemGroupedEventsRow(
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
@@ -114,8 +115,8 @@ private fun TimelineItemGroupedEventsRowContent(
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,

View File

@@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.link.Link
@Composable
internal fun TimelineItemRow(
@@ -47,8 +48,8 @@ internal fun TimelineItemRow(
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,

View File

@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.wysiwyg.link.Link
@Composable
fun TimelineItemEventContentView(
@@ -39,8 +40,8 @@ fun TimelineItemEventContentView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (url: String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},

View File

@@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -64,8 +65,8 @@ fun TimelineItemImageView(
hideMediaContent: Boolean,
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onShowContentClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,

View File

@@ -41,12 +41,13 @@ import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.libraries.textcomposer.mentions.updateMentionStyles
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@Composable
fun TimelineItemTextView(
content: TimelineItemTextBasedContent,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {

View File

@@ -64,6 +64,7 @@ import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -73,8 +74,8 @@ fun TimelineItemVideoView(
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (String) -> Unit,
onLinkLongClick: (String) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {

View File

@@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.link.aLinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
@@ -1159,6 +1160,7 @@ class MessagesPresenterTest {
reactionSummaryPresenter = { aReactionSummaryState() },
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
identityChangeStatePresenter = { anIdentityChangeState() },
linkPresenter = { aLinkState() },
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
roomCallStatePresenter = { aStandByCallState() },
syncService = FakeSyncService(),

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import com.google.common.truth.Truth.assertThat
import io.element.android.wysiwyg.link.Link
import org.junit.Test
class DefaultLinkCheckerTest {
private val sut = DefaultLinkChecker()
@Test
fun `when url and text are identical, the link is safe`() {
assertThat(sut.isSafe(Link("url", "url"))).isTrue()
}
@Test
fun `when url is not safe, the link is safe`() {
assertThat(sut.isSafe(Link("url", "https://example.org"))).isTrue()
}
@Test
fun `when text is a url, and url is identical the link is safe`() {
assertThat(sut.isSafe(Link("https://example.org", "https://example.org"))).isTrue()
}
@Test
fun `when url contains RtL char, the link is not safe`() {
assertThat(sut.isSafe(Link("https://example\u202E.org", "text"))).isFalse()
}
@Test
fun `when text is not a url, the link is safe`() {
assertThat(sut.isSafe(Link("https://example.org", "url"))).isTrue()
}
@Test
fun `when text is a url and hosts match, the link is safe`() {
assertThat(sut.isSafe(Link("https://example.org/some/path", "https://example.org"))).isTrue()
}
@Test
fun `when text is a url and hosts do not match, the link is safe`() {
assertThat(sut.isSafe(Link("https://example.org", "https://evil.org"))).isFalse()
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.wysiwyg.link.Link
class FakeLinkChecker(
private val isSafeResult: (Link) -> Boolean = { lambdaError() }
) : LinkChecker {
override fun isSafe(link: Link) = isSafeResult(link)
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
val aLink = Link(url = "url", text = "text")
class LinkPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - safe link case`() = runTest {
val isSafeResult = lambdaRecorder<Link, Boolean> {
true
}
val presenter = createPresenter(
linkChecker = FakeLinkChecker(isSafeResult = isSafeResult)
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(LinkEvents.OnLinkClick(aLink))
assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading)
val state = awaitItem()
assertThat(state.linkClick).isEqualTo(AsyncAction.Success(aLink))
isSafeResult.assertions().isCalledOnce().with(value(aLink))
}
}
@Test
fun `present - suspicious link case - cancel`() = runTest {
val presenter = createPresenter(
linkChecker = FakeLinkChecker(isSafeResult = { false })
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(LinkEvents.OnLinkClick(aLink))
assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading)
val state = awaitItem()
assertThat(state.linkClick).isEqualTo(ConfirmingLinkClick(aLink))
state.eventSink(LinkEvents.Cancel)
val finalState = awaitItem()
assertThat(finalState.linkClick).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - suspicious link case - confirm`() = runTest {
val presenter = createPresenter(
linkChecker = FakeLinkChecker(isSafeResult = { false })
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(LinkEvents.OnLinkClick(aLink))
assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading)
val state = awaitItem()
assertThat(state.linkClick).isEqualTo(ConfirmingLinkClick(aLink))
state.eventSink(LinkEvents.Confirm)
val finalState = awaitItem()
assertThat(finalState.linkClick).isEqualTo(AsyncAction.Success(aLink))
}
}
private fun createPresenter(
linkChecker: LinkChecker = FakeLinkChecker(),
) = LinkPresenter(
linkChecker = linkChecker,
)
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2025 New Vector 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.messages.impl.link
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.wysiwyg.link.Link
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LinkViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on cancel emits the expected event`() {
val eventsRecorder = EventsRecorder<LinkEvents>()
rule.setLinkView(
aLinkState(
linkClick = ConfirmingLinkClick(aLink),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(
LinkEvents.Cancel
)
}
@Test
fun `clicking on continue emits the expected event`() {
val eventsRecorder = EventsRecorder<LinkEvents>()
rule.setLinkView(
aLinkState(
linkClick = ConfirmingLinkClick(aLink),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(
LinkEvents.Confirm
)
}
@Test
fun `success state invokes the callback and emits the expected event`() {
val eventsRecorder = EventsRecorder<LinkEvents>()
ensureCalledOnceWithParam(aLink) { callback ->
rule.setLinkView(
aLinkState(
linkClick = AsyncAction.Success(aLink),
eventSink = eventsRecorder,
),
onLinkValid = callback,
)
}
eventsRecorder.assertSingle(
LinkEvents.Cancel
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLinkView(
state: LinkState,
onLinkValid: (Link) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
LinkView(
state = state,
onLinkValid = onLinkValid,
)
}
}

View File

@@ -12,6 +12,7 @@ import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.link.aLinkState
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
@@ -315,6 +316,7 @@ class PinnedMessagesListPresenterTest {
timelineProtectionPresenter = { aTimelineProtectionState() },
snackbarDispatcher = SnackbarDispatcher(),
actionListPresenter = { anActionListState() },
linkPresenter = { aLinkState() },
analyticsService = analyticsService,
appCoroutineScope = this,
)

View File

@@ -29,6 +29,7 @@ import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.setSafeContent
import io.element.android.wysiwyg.link.Link
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@@ -99,8 +100,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
onBackClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
) {
setSafeContent {
PinnedMessagesListView(

View File

@@ -34,6 +34,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import org.junit.Rule
@@ -175,7 +176,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
state: TimelineState,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),

View File

@@ -46,7 +46,7 @@ coil = "3.1.0"
showkase = "1.0.3"
appyx = "1.6.0"
sqldelight = "2.0.2"
wysiwyg = "2.38.2"
wysiwyg = "2.38.3"
telephoto = "0.15.1"
# Dependency analysis

View File

@@ -89,3 +89,12 @@ fun String.withoutAccents(): String {
return Normalizer.normalize(this, Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "")
}
private const val RTL_OVERRIDE_CHAR = '\u202E'
private const val LTR_OVERRIDE_CHAR = '\u202D'
fun String.ensureEndsLeftToRight() = if (containsRtLOverride()) "$this$LTR_OVERRIDE_CHAR" else this
fun String.containsRtLOverride() = contains(RTL_OVERRIDE_CHAR)
fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || it == LTR_OVERRIDE_CHAR }

View File

@@ -8,6 +8,8 @@
package io.element.android.libraries.core.extensions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BasicExtensionsTest {
@@ -43,4 +45,32 @@ class BasicExtensionsTest {
val output = input.ellipsize(5)
assertEquals(input, output)
}
@Test
fun `given text with RtL unicode override, when checking contains RtL Override, then returns true`() {
val textWithRtlOverride = "hello\u202Eworld"
val result = textWithRtlOverride.containsRtLOverride()
assertTrue(result)
}
@Test
fun `given text without RtL unicode override, when checking contains RtL Override, then returns false`() {
val textWithRtlOverride = "hello world"
val result = textWithRtlOverride.containsRtLOverride()
assertFalse(result)
}
@Test
fun `given text with RtL unicode override, when ensuring ends LtR, then appends a LtR unicode override`() {
val textWithRtlOverride = "123\u202E456"
val result = textWithRtlOverride.ensureEndsLeftToRight()
assertEquals("$textWithRtlOverride\u202D", result)
}
@Test
fun `given text with unicode direction overrides, when filtering direction overrides, then removes all overrides`() {
val textWithDirectionOverrides = "123\u202E456\u202d789"
val result = textWithDirectionOverrides.filterDirectionOverrides()
assertEquals("123456789", result)
}
}