Merge branch 'develop' into feature/fga/join_space

This commit is contained in:
ganfra 2025-09-19 16:35:55 +02:00
commit c4308e9810
446 changed files with 5669 additions and 2617 deletions

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -33,12 +34,7 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
api(libs.androidx.browser)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.test.ext.junit)
testCommonDependencies(libs)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
testImplementation(projects.services.toolbox.test)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -25,7 +26,5 @@ dependencies {
api(libs.androidx.lifecycle.runtime)
api(libs.molecule.runtime)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
}

View file

@ -11,6 +11,6 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
interface AssistedNodeFactory<NODE : Node> {
fun interface AssistedNodeFactory<NODE : Node> {
fun create(buildContext: BuildContext, plugins: List<Plugin>): NODE
}

View file

@ -18,6 +18,6 @@ interface FeatureEntryPoint
/**
* Can be used when the feature only exposes a simple node without the need of plugins.
*/
interface SimpleFeatureEntryPoint : FeatureEntryPoint {
fun interface SimpleFeatureEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext): Node
}

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.architecture
import android.content.Context
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -22,17 +21,9 @@ inline fun <reified N : Node> Node.createNode(
return bindings.createNode(buildContext, plugins)
}
inline fun <reified N : Node> Context.createNode(
buildContext: BuildContext,
plugins: List<Plugin> = emptyList()
): N {
val bindings: NodeFactoriesBindings = bindings()
return bindings.createNode(buildContext, plugins)
}
inline fun <reified N : Node> NodeFactoriesBindings.createNode(
buildContext: BuildContext,
plugins: List<Plugin> = emptyList()
plugins: List<Plugin>,
): N {
val nodeClass = N::class
val nodeFactoryMap = nodeFactories()
@ -46,8 +37,7 @@ inline fun <reified N : Node> NodeFactoriesBindings.createNode(
return node as N
}
// @BindingContainer
interface NodeFactoriesBindings {
fun interface NodeFactoriesBindings {
@Multibinds
fun nodeFactories(): Map<KClass<out Node>, AssistedNodeFactory<*>>
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -21,6 +22,5 @@ dependencies {
implementation(projects.libraries.di)
api(projects.libraries.cryptography.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
}

View file

@ -1,3 +1,5 @@
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
*
@ -13,7 +15,6 @@ android {
namespace = "io.element.android.libraries.dateformatter.api"
dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -40,13 +41,8 @@ android {
api(projects.libraries.dateformatter.api)
api(libs.datetime)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
}
}

View file

@ -9,6 +9,6 @@ package io.element.android.libraries.deeplink.api.usecase
import android.app.Activity
interface InviteFriendsUseCase {
fun interface InviteFriendsUseCase {
fun execute(activity: Activity)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -28,9 +29,6 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -1,3 +1,5 @@
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
*
@ -42,10 +44,6 @@ android {
ksp(libs.showkase.processor)
implementation(libs.showkase)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
}
}

View file

@ -38,6 +38,18 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
/**
* A progress dialog, with a spinner, and optional text content.
*
* @param modifier
* @param text Optional text to show under the spinner.
* @param type
* @param properties
* @param showCancelButton
* @param onDismissRequest
* @param content Optional additional content to show under the spinner, and above the cancel button (if shown). If both `text` and `content` are supplied,
* `text` is shown above `content`.
*/
@Composable
fun ProgressDialog(
modifier: Modifier = Modifier,
@ -46,6 +58,7 @@ fun ProgressDialog(
properties: DialogProperties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
showCancelButton: Boolean = false,
onDismissRequest: () -> Unit = {},
content: @Composable () -> Unit = {},
) {
DisposableEffect(Unit) {
onDispose {
@ -75,7 +88,8 @@ fun ProgressDialog(
)
}
}
}
},
content,
)
}
}
@ -96,7 +110,8 @@ private fun ProgressDialogContent(
CircularProgressIndicator(
color = ElementTheme.colors.iconPrimary
)
}
},
content: @Composable () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
@ -118,6 +133,7 @@ private fun ProgressDialogContent(
color = ElementTheme.colors.textPrimary,
)
}
content()
if (showCancelButton) {
Spacer(modifier = Modifier.height(24.dp))
Box(
@ -138,7 +154,7 @@ private fun ProgressDialogContent(
@Composable
internal fun ProgressDialogContentPreview() = ElementThemedPreview {
DialogPreview {
ProgressDialogContent(text = "test dialog content", showCancelButton = true)
ProgressDialogContent(text = "test dialog content", showCancelButton = true, content = {})
}
}
@ -147,3 +163,34 @@ internal fun ProgressDialogContentPreview() = ElementThemedPreview {
internal fun ProgressDialogPreview() = ElementPreview {
ProgressDialog(text = "test dialog content", showCancelButton = true)
}
@PreviewsDayNight
@Composable
internal fun ProgressDialogWithContentPreview() = ElementPreview {
ProgressDialog(showCancelButton = true) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Heading",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingSmMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Subtext",
color = ElementTheme.colors.textSecondary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@PreviewsDayNight
@Composable
internal fun ProgressDialogWithTextAndContentPreview() = ElementPreview {
ProgressDialog(text = "Text Content") {
Text(
text = "blah blah",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingSmMedium,
)
}
}

View file

@ -142,7 +142,7 @@ internal fun SimpleAlertDialogContent(
Text(
text = titleText,
style = ElementTheme.typography.fontHeadingSmMedium,
textAlign = TextAlign.Center,
textAlign = if (icon != null) TextAlign.Center else TextAlign.Start,
)
}
},
@ -510,3 +510,43 @@ internal fun DialogWithThirdButtonPreview() {
}
}
}
@Preview(group = PreviewGroup.Dialogs, name = "Dialog with a very long title")
@Composable
@Suppress("MaxLineLength")
internal fun DialogWithVeryLongTitlePreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
SimpleAlertDialogContent(
title = "Dialog Title that takes more than one line",
content = "A dialog is a type of modal window that appears in front of app content to provide critical information," +
" or prompt for a decision to be made. Learn more",
submitText = "OK",
onSubmitClick = {},
)
}
}
}
@Preview(group = PreviewGroup.Dialogs, name = "Dialog with a very long title and icon")
@Composable
@Suppress("MaxLineLength")
internal fun DialogWithVeryLongTitleAndIconPreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
SimpleAlertDialogContent(
icon = {
Icon(
imageVector = CompoundIcons.NotificationsSolid(),
contentDescription = null
)
},
title = "Dialog Title that takes more than one line",
content = "A dialog is a type of modal window that appears in front of app content to provide critical information," +
" or prompt for a decision to be made. Learn more",
submitText = "OK",
onSubmitClick = {},
)
}
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -32,9 +33,7 @@ dependencies {
implementation(projects.services.toolbox.api)
api(projects.libraries.eventformatter.api)
testCommonDependencies(libs)
testImplementation(projects.services.toolbox.impl)
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -102,7 +102,7 @@ class DefaultBaseRoomLastMessageFormatterTest {
val info = ImageInfo(null, null, null, null, null, null, null)
val message = createRoomEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url")))
val result = formatter.format(message, false)
val expectedBody = someoneElseId.toString() + ": Sticker (a sticker body)"
val expectedBody = someoneElseId.value + ": Sticker (a sticker body)"
assertThat(result.toString()).isEqualTo(expectedBody)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -27,9 +28,7 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api)
implementation(libs.coroutines.core)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2024 New Vector Ltd.
@ -27,19 +28,10 @@ dependencies {
implementation(projects.services.toolbox.api)
implementation(libs.androidx.datastore.preferences)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.tests.testutils)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.testtags)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.mockk)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.services.toolbox.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -26,11 +27,7 @@ dependencies {
api(projects.libraries.indicator.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
}

View file

@ -1,6 +1,7 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -54,7 +55,6 @@ dependencies {
implementation(libs.coroutines.core)
api(projects.libraries.architecture)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -156,11 +156,6 @@ interface MatrixClient {
*/
suspend fun currentSlidingSyncVersion(): Result<SlidingSyncVersion>
/**
* Returns the available sliding sync versions for the current user.
*/
suspend fun availableSlidingSyncVersions(): Result<List<SlidingSyncVersion>>
fun canDeactivateAccount(): Boolean
suspend fun deactivateAccount(password: String, eraseData: Boolean): Result<Unit>

View file

@ -13,14 +13,9 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.LoggedInState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface MatrixAuthenticationService {
fun loggedInStateFlow(): Flow<LoggedInState>
suspend fun getLatestSessionId(): SessionId?
/**
* Restore a session from a [sessionId].
* Do not restore anything it the access token is not valid anymore.

View file

@ -151,7 +151,7 @@ object MatrixPatterns {
val urlMatch = match.groupValues[1]
when (val permalink = permalinkParser.parse(urlMatch)) {
is PermalinkData.UserLink -> {
add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.toString(), match.range.first, match.range.last + 1))
add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.value, match.range.first, match.range.last + 1))
}
is PermalinkData.RoomLink -> {
when (permalink.roomIdOrAlias) {

View file

@ -49,9 +49,10 @@ sealed interface NotificationContent {
val senderId: UserId,
) : MessageLike
data class CallNotify(
data class RtcNotification(
val senderId: UserId,
val type: CallNotifyType,
val type: RtcNotificationType,
val expirationTimestampMillis: Long
) : MessageLike
data object CallHangup : MessageLike
@ -118,7 +119,7 @@ sealed interface NotificationContent {
) : NotificationContent
}
enum class CallNotifyType {
enum class RtcNotificationType {
RING,
NOTIFY
}

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
@ -239,7 +240,11 @@ interface BaseRoom : Closeable {
*/
suspend fun reportRoom(reason: String?): Result<Unit>
/**
suspend fun declineCall(notificationEventId: EventId): Result<Unit>
suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId>
/**
* Destroy the room and release all resources associated to it.
*/
fun destroy()

View file

@ -12,7 +12,7 @@ enum class MessageEventType {
CALL_INVITE,
CALL_HANGUP,
CALL_CANDIDATES,
CALL_NOTIFY,
RTC_NOTIFICATION,
KEY_VERIFICATION_READY,
KEY_VERIFICATION_START,
KEY_VERIFICATION_CANCEL,

View file

@ -17,7 +17,6 @@ data class RoomMember(
val membership: RoomMembershipState,
val isNameAmbiguous: Boolean,
val powerLevel: Long,
val normalizedPowerLevel: Long,
val isIgnored: Boolean,
val role: Role,
val membershipChangeReason: String?,

View file

@ -151,7 +151,7 @@ interface Timeline : AutoCloseable {
suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit>
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit>
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean>
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>

View file

@ -15,5 +15,6 @@ object EventType {
// Call Events
const val CALL_INVITE = "m.call.invite"
const val CALL_NOTIFY = "m.call.notify"
const val RTC_NOTIFICATION = "org.matrix.msc4075.rtc.notification"
}

View file

@ -11,12 +11,9 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
@SingleIn(SessionScope::class)
@Inject
class CurrentSessionIdHolder(matrixClient: MatrixClient) {
val current = matrixClient.sessionId
fun isCurrentSession(sessionId: SessionId?): Boolean = current == sessionId
}

View file

@ -15,5 +15,6 @@ interface CallWidgetSettingsProvider {
widgetId: String = UUID.randomUUID().toString(),
encrypted: Boolean,
direct: Boolean,
hasActiveCall: Boolean,
): MatrixWidgetSettings
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -41,9 +42,7 @@ dependencies {
implementation(libs.serialization.json)
implementation(libs.kotlinx.collections.immutable)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
@ -51,7 +50,4 @@ dependencies {
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.turbine)
}

View file

@ -287,7 +287,6 @@ class RustMatrixClient(
}
override suspend fun getRoom(roomId: RoomId): BaseRoom? = withContext(sessionDispatcher) {
innerClient.rooms()
roomFactory.getBaseRoom(roomId)
}
@ -689,12 +688,6 @@ class RustMatrixClient(
})
}.buffer(Channel.UNLIMITED)
override suspend fun availableSlidingSyncVersions(): Result<List<SlidingSyncVersion>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.availableSlidingSyncVersions().map { it.map() }
}
}
override suspend fun currentSlidingSyncVersion(): Result<SlidingSyncVersion> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.session().slidingSyncVersion.map()

View file

@ -33,11 +33,9 @@ import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator
import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
@ -83,14 +81,6 @@ class RustMatrixAuthenticationService(
.also { sessionPaths = it }
}
override fun loggedInStateFlow(): Flow<LoggedInState> {
return sessionStore.isLoggedIn()
}
override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) {
sessionStore.getLatestSession()?.userId?.let { SessionId(it) }
}
override suspend fun restoreSession(sessionId: SessionId): Result<MatrixClient> = withContext(coroutineDispatchers.io) {
runCatchingExceptions {
val sessionData = sessionStore.getSession(sessionId.value)
@ -158,7 +148,7 @@ class RustMatrixAuthenticationService(
)
val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.storeData(sessionData)
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
@ -182,7 +172,7 @@ class RustMatrixAuthenticationService(
sessionPaths = currentSessionPaths,
)
clear()
sessionStore.storeData(sessionData)
sessionStore.addSession(sessionData)
SessionId(sessionData.userId)
}
}
@ -250,7 +240,7 @@ class RustMatrixAuthenticationService(
val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.storeData(sessionData)
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
@ -295,7 +285,7 @@ class RustMatrixAuthenticationService(
)
val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.storeData(sessionData)
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null

View file

@ -10,16 +10,16 @@ package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
import org.matrix.rustcomponents.sdk.MessageLikeEventContent
import org.matrix.rustcomponents.sdk.NotifyType
import org.matrix.rustcomponents.sdk.StateEventContent
import org.matrix.rustcomponents.sdk.TimelineEvent
import org.matrix.rustcomponents.sdk.TimelineEventType
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.RtcNotificationType as SdkRtcNotificationType
class TimelineEventToNotificationContentMapper {
fun map(timelineEvent: TimelineEvent): Result<NotificationContent> {
@ -78,7 +78,11 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon
MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates
MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup
MessageLikeEventContent.CallInvite -> NotificationContent.MessageLike.CallInvite(senderId)
is MessageLikeEventContent.CallNotify -> NotificationContent.MessageLike.CallNotify(senderId, notifyType.map())
is MessageLikeEventContent.RtcNotification -> NotificationContent.MessageLike.RtcNotification(
senderId = senderId,
type = notificationType.map(),
expirationTimestampMillis = expirationTs.toLong()
)
MessageLikeEventContent.KeyVerificationAccept -> NotificationContent.MessageLike.KeyVerificationAccept
MessageLikeEventContent.KeyVerificationCancel -> NotificationContent.MessageLike.KeyVerificationCancel
MessageLikeEventContent.KeyVerificationDone -> NotificationContent.MessageLike.KeyVerificationDone
@ -101,7 +105,7 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon
}
}
private fun NotifyType.map(): CallNotifyType = when (this) {
NotifyType.NOTIFY -> CallNotifyType.NOTIFY
NotifyType.RING -> CallNotifyType.RING
private fun SdkRtcNotificationType.map(): RtcNotificationType = when (this) {
SdkRtcNotificationType.NOTIFICATION -> RtcNotificationType.NOTIFY
SdkRtcNotificationType.RING -> RtcNotificationType.RING
}

View file

@ -15,7 +15,7 @@ fun MessageEventType.map(): MessageLikeEventType = when (this) {
MessageEventType.CALL_INVITE -> MessageLikeEventType.CALL_INVITE
MessageEventType.CALL_HANGUP -> MessageLikeEventType.CALL_HANGUP
MessageEventType.CALL_CANDIDATES -> MessageLikeEventType.CALL_CANDIDATES
MessageEventType.CALL_NOTIFY -> MessageLikeEventType.CALL_NOTIFY
MessageEventType.RTC_NOTIFICATION -> MessageLikeEventType.RTC_NOTIFICATION
MessageEventType.KEY_VERIFICATION_READY -> MessageLikeEventType.KEY_VERIFICATION_READY
MessageEventType.KEY_VERIFICATION_START -> MessageLikeEventType.KEY_VERIFICATION_START
MessageEventType.KEY_VERIFICATION_CANCEL -> MessageLikeEventType.KEY_VERIFICATION_CANCEL
@ -41,7 +41,7 @@ fun MessageLikeEventType.map(): MessageEventType = when (this) {
MessageLikeEventType.CALL_INVITE -> MessageEventType.CALL_INVITE
MessageLikeEventType.CALL_HANGUP -> MessageEventType.CALL_HANGUP
MessageLikeEventType.CALL_CANDIDATES -> MessageEventType.CALL_CANDIDATES
MessageLikeEventType.CALL_NOTIFY -> MessageEventType.CALL_NOTIFY
MessageLikeEventType.RTC_NOTIFICATION -> MessageEventType.RTC_NOTIFICATION
MessageLikeEventType.KEY_VERIFICATION_READY -> MessageEventType.KEY_VERIFICATION_READY
MessageLikeEventType.KEY_VERIFICATION_START -> MessageEventType.KEY_VERIFICATION_START
MessageLikeEventType.KEY_VERIFICATION_CANCEL -> MessageEventType.KEY_VERIFICATION_CANCEL

View file

@ -38,10 +38,12 @@ import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.CallDeclineListener
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
@ -300,4 +302,20 @@ class RustBaseRoom(
innerRoom.reportRoom(reason.orEmpty())
}
}
override suspend fun declineCall(notificationEventId: EventId): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.declineCall(notificationEventId.value)
}
}
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> = withContext(roomDispatcher) {
mxCallbackFlow {
innerRoom.subscribeToCallDeclineEvents(notificationEventId.value, object : CallDeclineListener {
override fun call(declinerUserId: String) {
trySend(UserId(declinerUserId))
}
})
}
}
}

View file

@ -20,7 +20,7 @@ fun RustAllowRule.map(): AllowRule {
fun AllowRule.map(): RustAllowRule {
return when (this) {
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.toString())
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.value)
is AllowRule.Custom -> RustAllowRule.Custom(json)
}
}

View file

@ -25,7 +25,6 @@ object RoomMemberMapper {
membership = mapMembership(roomMember.membership),
isNameAmbiguous = roomMember.isNameAmbiguous,
powerLevel = powerLevel,
normalizedPowerLevel = roomMember.normalizedPowerLevel.into(),
isIgnored = roomMember.isIgnored,
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
membershipChangeReason = roomMember.membershipChangeReason

View file

@ -431,7 +431,7 @@ class RustTimeline(
}
}
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit> = withContext(dispatcher) {
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean> = withContext(dispatcher) {
runCatchingExceptions {
inner.toggleReaction(
key = emoji,

View file

@ -149,7 +149,7 @@ class TimelineEventContentMapper(
)
}
is TimelineItemContent.CallInvite -> LegacyCallInviteContent
is TimelineItemContent.CallNotify -> CallNotifyContent
is TimelineItemContent.RtcNotification -> CallNotifyContent
}
}
}

View file

@ -18,10 +18,10 @@ import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.first
import org.matrix.rustcomponents.sdk.newVirtualElementCallWidget
import timber.log.Timber
import uniffi.matrix_sdk.EncryptionSystem
import uniffi.matrix_sdk.HeaderStyle
import uniffi.matrix_sdk.NotificationType
import uniffi.matrix_sdk.VirtualElementCallWidgetOptions
import uniffi.matrix_sdk.VirtualElementCallWidgetConfig
import uniffi.matrix_sdk.VirtualElementCallWidgetProperties
import uniffi.matrix_sdk.Intent as CallIntent
@ContributesBinding(AppScope::class)
@ -31,19 +31,14 @@ class DefaultCallWidgetSettingsProvider(
private val callAnalyticsCredentialsProvider: CallAnalyticCredentialsProvider,
private val analyticsService: AnalyticsService,
) : CallWidgetSettingsProvider {
override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean, direct: Boolean): MatrixWidgetSettings {
override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean, direct: Boolean, hasActiveCall: Boolean): MatrixWidgetSettings {
val isAnalyticsEnabled = analyticsService.userConsentFlow.first()
val options = VirtualElementCallWidgetOptions(
val properties = VirtualElementCallWidgetProperties(
elementCallUrl = baseUrl,
widgetId = widgetId,
preload = null,
fontScale = null,
appPrompt = false,
confineToRoom = true,
font = null,
encryption = if (encrypted) EncryptionSystem.PerParticipantKeys else EncryptionSystem.Unencrypted,
intent = CallIntent.START_CALL,
hideScreensharing = false,
posthogUserId = callAnalyticsCredentialsProvider.posthogUserId.takeIf { isAnalyticsEnabled },
posthogApiHost = callAnalyticsCredentialsProvider.posthogApiHost.takeIf { isAnalyticsEnabled },
posthogApiKey = callAnalyticsCredentialsProvider.posthogApiKey.takeIf { isAnalyticsEnabled },
@ -51,13 +46,25 @@ class DefaultCallWidgetSettingsProvider(
sentryDsn = callAnalyticsCredentialsProvider.sentryDsn.takeIf { isAnalyticsEnabled },
sentryEnvironment = if (buildMeta.buildType == BuildType.RELEASE) "RELEASE" else "DEBUG",
parentUrl = null,
// For backwards compatibility, it'll be ignored in recent versions of Element Call
hideHeader = true,
controlledMediaDevices = true,
header = HeaderStyle.APP_BAR,
sendNotificationType = if (direct) NotificationType.RING else NotificationType.NOTIFICATION,
)
val rustWidgetSettings = newVirtualElementCallWidget(options)
val config = VirtualElementCallWidgetConfig(
// TODO remove this once we have the next EC version
preload = false,
// TODO remove this once we have the next EC version
skipLobby = null,
intent = when {
direct && hasActiveCall -> CallIntent.JOIN_EXISTING_DM
hasActiveCall -> CallIntent.JOIN_EXISTING
direct -> CallIntent.START_CALL_DM
else -> CallIntent.START_CALL
}.also {
Timber.d("Starting/joining call with intent: $it")
}
)
val rustWidgetSettings = newVirtualElementCallWidget(
props = properties,
config = config,
)
return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings)
}
}

View file

@ -10,8 +10,10 @@ package io.element.android.libraries.matrix.impl
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder
import org.matrix.rustcomponents.sdk.ClientBuilder
class FakeClientBuilderProvider : ClientBuilderProvider {
class FakeClientBuilderProvider(
private val provideResult: () -> ClientBuilder = { FakeFfiClientBuilder() }
) : ClientBuilderProvider {
override fun provide(): ClientBuilder {
return FakeFfiClientBuilder()
return provideResult()
}
}

View file

@ -39,6 +39,7 @@ fun TestScope.createRustMatrixClientFactory(
baseDirectory: File = File("/base"),
cacheDirectory: File = File("/cache"),
sessionStore: SessionStore = InMemorySessionStore(),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
) = RustMatrixClientFactory(
baseDirectory = baseDirectory,
cacheDirectory = cacheDirectory,
@ -52,5 +53,5 @@ fun TestScope.createRustMatrixClientFactory(
analyticsService = FakeAnalyticsService(),
featureFlagService = FakeFeatureFlagService(),
timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(),
clientBuilderProvider = FakeClientBuilderProvider(),
clientBuilderProvider = clientBuilderProvider,
)

View file

@ -8,14 +8,17 @@
package io.element.android.libraries.matrix.impl.auth
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.ClientBuilderProvider
import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider
import io.element.android.libraries.matrix.impl.createRustMatrixClientFactory
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@ -24,18 +27,28 @@ import java.io.File
class RustMatrixAuthenticationServiceTest {
@Test
fun `getLatestSessionId should return the value from the store`() = runTest {
val sessionStore = InMemorySessionStore()
fun `setHomeserver is successful`() = runTest {
val sut = createRustMatrixAuthenticationService(
sessionStore = sessionStore,
clientBuilderProvider = FakeClientBuilderProvider(
provideResult = {
FakeFfiClientBuilder(
buildResult = {
FakeFfiClient(
homeserverLoginDetailsResult = {
FakeFfiHomeserverLoginDetails()
}
)
}
)
}
),
)
assertThat(sut.getLatestSessionId()).isNull()
sessionStore.storeData(aSessionData(sessionId = "@alice:server.org"))
assertThat(sut.getLatestSessionId()).isEqualTo(SessionId("@alice:server.org"))
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
}
private fun TestScope.createRustMatrixAuthenticationService(
sessionStore: SessionStore = InMemorySessionStore(),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
): RustMatrixAuthenticationService {
val baseDirectory = File("/base")
val cacheDirectory = File("/cache")
@ -43,6 +56,7 @@ class RustMatrixAuthenticationServiceTest {
baseDirectory = baseDirectory,
cacheDirectory = cacheDirectory,
sessionStore = sessionStore,
clientBuilderProvider = clientBuilderProvider,
)
return RustMatrixAuthenticationService(
sessionPathsFactory = SessionPathsFactory(baseDirectory, cacheDirectory),

View file

@ -30,7 +30,6 @@ fun aRustRoomMember(
membership = membership,
isNameAmbiguous = isNameAmbiguous,
powerLevel = powerLevel,
normalizedPowerLevel = powerLevel,
isIgnored = isIgnored,
suggestedRoleForPowerLevel = role,
membershipChangeReason = membershipChangeReason,

View file

@ -43,4 +43,5 @@ fun aRustSpaceRoom(
childrenCount = childrenCount,
state = state,
heroes = heroes,
via = emptyList()
)

View file

@ -15,6 +15,7 @@ import io.element.android.tests.testutils.simulateLongTask
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.NotificationClient
@ -41,6 +42,7 @@ class FakeFfiClient(
private val session: Session = aRustSession(),
private val clearCachesResult: () -> Unit = { lambdaError() },
private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() },
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val closeResult: () -> Unit = {},
) : Client(NoPointer) {
override fun userId(): String = userId
@ -71,6 +73,7 @@ class FakeFfiClient(
override suspend fun ignoredUsers(): List<String> {
return emptyList()
}
override fun subscribeToIgnoredUsers(listener: IgnoredUsersListener): TaskHandle {
return FakeFfiTaskHandle()
}
@ -78,5 +81,10 @@ class FakeFfiClient(
override suspend fun getProfile(userId: String): UserProfile {
return UserProfile(userId = userId, displayName = null, avatarUrl = null)
}
override suspend fun homeserverLoginDetails(): HomeserverLoginDetails {
return homeserverLoginDetailsResult()
}
override fun close() = closeResult()
}

View file

@ -17,7 +17,9 @@ import uniffi.matrix_sdk.BackupDownloadStrategy
import uniffi.matrix_sdk_crypto.CollectStrategy
import uniffi.matrix_sdk_crypto.DecryptionSettings
class FakeFfiClientBuilder : ClientBuilder(NoPointer) {
class FakeFfiClientBuilder(
val buildResult: () -> Client = { FakeFfiClient(withUtdHook = {}) }
) : ClientBuilder(NoPointer) {
override fun addRootCertificates(certificates: List<ByteArray>) = this
override fun autoEnableBackups(autoEnableBackups: Boolean) = this
override fun autoEnableCrossSigning(autoEnableCrossSigning: Boolean) = this
@ -41,7 +43,5 @@ class FakeFfiClientBuilder : ClientBuilder(NoPointer) {
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
override suspend fun build(): Client {
return FakeFfiClient(withUtdHook = {})
}
override suspend fun build() = buildResult()
}

View file

@ -0,0 +1,58 @@
/*
* 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.libraries.matrix.impl.fixtures.fakes
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.SpaceListUpdate
import org.matrix.rustcomponents.sdk.SpaceRoom
import org.matrix.rustcomponents.sdk.SpaceRoomList
import org.matrix.rustcomponents.sdk.SpaceRoomListEntriesListener
import org.matrix.rustcomponents.sdk.SpaceRoomListPaginationStateListener
import org.matrix.rustcomponents.sdk.TaskHandle
import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState
class FakeFfiSpaceRoomList(
private val paginateResult: () -> Unit = { lambdaError() },
private val paginationStateResult: () -> SpaceRoomListPaginationState = { lambdaError() },
private val roomsResult: () -> List<SpaceRoom> = { lambdaError() },
) : SpaceRoomList(NoPointer) {
private var spaceRoomListPaginationStateListener: SpaceRoomListPaginationStateListener? = null
private var spaceRoomListEntriesListener: SpaceRoomListEntriesListener? = null
override suspend fun paginate() = simulateLongTask {
paginateResult()
}
override fun paginationState(): SpaceRoomListPaginationState {
return paginationStateResult()
}
override fun rooms(): List<SpaceRoom> {
return roomsResult()
}
override fun subscribeToPaginationStateUpdates(listener: SpaceRoomListPaginationStateListener): TaskHandle {
spaceRoomListPaginationStateListener = listener
return FakeFfiTaskHandle()
}
fun triggerPaginationStateUpdate(state: SpaceRoomListPaginationState) {
spaceRoomListPaginationStateListener?.onUpdate(state)
}
override fun subscribeToRoomUpdate(listener: SpaceRoomListEntriesListener): TaskHandle {
spaceRoomListEntriesListener = listener
return FakeFfiTaskHandle()
}
fun triggerRoomListUpdate(rooms: List<SpaceListUpdate>) {
spaceRoomListEntriesListener?.onUpdate(rooms)
}
}

View file

@ -19,7 +19,7 @@ class MessageEventTypeKtTest {
assertThat(MessageLikeEventType.CALL_INVITE.map()).isEqualTo(MessageEventType.CALL_INVITE)
assertThat(MessageLikeEventType.CALL_HANGUP.map()).isEqualTo(MessageEventType.CALL_HANGUP)
assertThat(MessageLikeEventType.CALL_CANDIDATES.map()).isEqualTo(MessageEventType.CALL_CANDIDATES)
assertThat(MessageLikeEventType.CALL_NOTIFY.map()).isEqualTo(MessageEventType.CALL_NOTIFY)
assertThat(MessageLikeEventType.RTC_NOTIFICATION.map()).isEqualTo(MessageEventType.RTC_NOTIFICATION)
assertThat(MessageLikeEventType.KEY_VERIFICATION_READY.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_READY)
assertThat(MessageLikeEventType.KEY_VERIFICATION_START.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_START)
assertThat(MessageLikeEventType.KEY_VERIFICATION_CANCEL.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_CANCEL)
@ -46,7 +46,7 @@ class MessageEventTypeKtTest {
assertThat(MessageEventType.CALL_INVITE.map()).isEqualTo(MessageLikeEventType.CALL_INVITE)
assertThat(MessageEventType.CALL_HANGUP.map()).isEqualTo(MessageLikeEventType.CALL_HANGUP)
assertThat(MessageEventType.CALL_CANDIDATES.map()).isEqualTo(MessageLikeEventType.CALL_CANDIDATES)
assertThat(MessageEventType.CALL_NOTIFY.map()).isEqualTo(MessageLikeEventType.CALL_NOTIFY)
assertThat(MessageEventType.RTC_NOTIFICATION.map()).isEqualTo(MessageLikeEventType.RTC_NOTIFICATION)
assertThat(MessageEventType.KEY_VERIFICATION_READY.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_READY)
assertThat(MessageEventType.KEY_VERIFICATION_START.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_START)
assertThat(MessageEventType.KEY_VERIFICATION_CANCEL.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_CANCEL)

View file

@ -0,0 +1,118 @@
/*
* 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.matrix.impl.spaces
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoom
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSpaceRoomList
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.SpaceListUpdate
import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState
import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList
class RustSpaceRoomListTest {
@Test
fun `paginationStatusFlow emits values`() = runTest {
val innerSpaceRoomList = FakeFfiSpaceRoomList(
paginationStateResult = { SpaceRoomListPaginationState.Idle(false) }
)
val sut = createRustSpaceRoomList(
innerSpaceRoomList = innerSpaceRoomList,
)
sut.paginationStatusFlow.test {
// First value is the initial one
assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false))
// First value after the subscription occurs
assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true))
innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Loading)
assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Loading)
innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Idle(true))
assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false))
innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Idle(false))
assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true))
}
}
@Test
fun `spaceRoomsFlow emits values`() = runTest {
val innerSpaceRoomList = FakeFfiSpaceRoomList(
paginationStateResult = { SpaceRoomListPaginationState.Idle(false) }
)
val sut = createRustSpaceRoomList(
innerSpaceRoomList = innerSpaceRoomList,
)
sut.spaceRoomsFlow.test {
// Give time for the subscription to be set
runCurrent()
innerSpaceRoomList.triggerRoomListUpdate(
listOf(
SpaceListUpdate.PushBack(aRustSpaceRoom(roomId = A_ROOM_ID_2))
)
)
val rooms = awaitItem()
assertThat(rooms).hasSize(1)
assertThat(rooms[0].roomId).isEqualTo(A_ROOM_ID_2)
}
}
@Test
fun `paginate invokes paginate on the inner class`() = runTest {
val paginateResult = lambdaRecorder<Unit> { }
val innerSpaceRoomList = FakeFfiSpaceRoomList(
paginateResult = paginateResult,
)
val sut = createRustSpaceRoomList(
innerSpaceRoomList = innerSpaceRoomList,
)
sut.paginate()
paginateResult.assertions().isCalledOnce()
}
@Test
fun `currentSpaceFlow reads value from the SpaceRoomCache`() = runTest {
val spaceRoomCache = SpaceRoomCache()
val sut = createRustSpaceRoomList(
spaceRoomCache = spaceRoomCache,
)
sut.currentSpaceFlow().test {
assertThat(awaitItem()).isNull()
val spaceRoom = aSpaceRoom(roomId = A_ROOM_ID)
spaceRoomCache.update(listOf(spaceRoom))
assertThat(awaitItem()).isEqualTo(spaceRoom)
}
}
private fun TestScope.createRustSpaceRoomList(
roomId: RoomId = A_ROOM_ID,
innerSpaceRoomList: InnerSpaceRoomList = FakeFfiSpaceRoomList(),
innerProvider: suspend () -> InnerSpaceRoomList = { innerSpaceRoomList },
spaceRoomMapper: SpaceRoomMapper = SpaceRoomMapper(),
spaceRoomCache: SpaceRoomCache = SpaceRoomCache(),
): RustSpaceRoomList {
return RustSpaceRoomList(
roomId = roomId,
innerProvider = innerProvider,
sessionCoroutineScope = backgroundScope,
spaceRoomMapper = spaceRoomMapper,
spaceRoomCache = spaceRoomCache,
)
}
}

View file

@ -90,7 +90,6 @@ class FakeMatrixClient(
private val canDeactivateAccountResult: () -> Boolean = { lambdaError() },
private val deactivateAccountResult: (String, Boolean) -> Result<Unit> = { _, _ -> lambdaError() },
private val currentSlidingSyncVersionLambda: () -> Result<SlidingSyncVersion> = { lambdaError() },
private val availableSlidingSyncVersionsLambda: () -> Result<List<SlidingSyncVersion>> = { lambdaError() },
private val ignoreUserResult: (UserId) -> Result<Unit> = { lambdaError() },
private var unIgnoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
private val canReportRoomLambda: () -> Boolean = { false },
@ -339,10 +338,6 @@ class FakeMatrixClient(
return currentSlidingSyncVersionLambda()
}
override suspend fun availableSlidingSyncVersions(): Result<List<SlidingSyncVersion>> {
return availableSlidingSyncVersionsLambda()
}
override suspend fun canReportRoom(): Boolean {
return canReportRoomLambda()
}

View file

@ -25,6 +25,7 @@ const val A_USER_NAME_2 = "Bob"
const val A_PASSWORD = "password"
const val A_PASSPHRASE = "passphrase"
const val A_SECRET = "secret"
const val AN_APPLICATION_NAME = "AppName"
val A_USER_ID = UserId("@alice:server.org")
val A_USER_ID_2 = UserId("@bob:server.org")

View file

@ -19,14 +19,11 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
val A_OIDC_DATA = OidcDetails(url = "a-url")
@ -44,14 +41,6 @@ class FakeMatrixAuthenticationService(
private var matrixClient: MatrixClient? = null
private var onAuthenticationListener: ((MatrixClient) -> Unit)? = null
var getLatestSessionIdLambda: (() -> SessionId?) = { null }
override fun loggedInStateFlow(): Flow<LoggedInState> {
return flowOf(LoggedInState.NotLoggedIn)
}
override suspend fun getLatestSessionId(): SessionId? = getLatestSessionIdLambda()
override suspend fun restoreSession(sessionId: SessionId): Result<MatrixClient> {
matrixClientResult?.let {
return it.invoke(sessionId)

View file

@ -29,6 +29,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.TestScope
@ -77,6 +79,12 @@ class FakeBaseRoom(
_roomInfoFlow.tryEmit(roomInfo)
}
private val declineCallFlowMap: MutableMap<EventId, MutableSharedFlow<UserId>> = mutableMapOf()
suspend fun givenDecliner(userId: UserId, forNotificationEventId: EventId) {
declineCallFlowMap[forNotificationEventId]?.emit(userId)
}
override val membersStateFlow: MutableStateFlow<RoomMembersState> = MutableStateFlow(RoomMembersState.Unknown)
override suspend fun updateMembers() = updateMembersResult()
@ -222,6 +230,15 @@ class FakeBaseRoom(
override suspend fun reportRoom(reason: String?) = reportRoomResult(reason)
override suspend fun declineCall(notificationEventId: EventId): Result<Unit> {
return Result.success(Unit)
}
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> {
val flow = declineCallFlowMap.getOrPut(notificationEventId, { MutableSharedFlow() })
return flow
}
override fun predecessorRoom(): PredecessorRoom? = predecessorRoomResult()
fun givenUpdateMembersResult(result: () -> Unit) {

View file

@ -19,7 +19,6 @@ fun aRoomMember(
membership: RoomMembershipState = RoomMembershipState.JOIN,
isNameAmbiguous: Boolean = false,
powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null,
@ -30,7 +29,6 @@ fun aRoomMember(
membership = membership,
isNameAmbiguous = isNameAmbiguous,
powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored,
role = role,
membershipChangeReason = membershipChangeReason,

View file

@ -23,11 +23,11 @@ class FakeSpaceRoomList(
initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading,
private val paginateResult: () -> Result<Unit> = { lambdaError() },
) : SpaceRoomList {
private val _currentSpaceFlow: MutableStateFlow<Optional<SpaceRoom>> = MutableStateFlow(Optional.ofNullable(initialSpaceFlowValue))
override fun currentSpaceFlow() = _currentSpaceFlow.asStateFlow()
private val currentSpaceMutableStateFlow: MutableStateFlow<Optional<SpaceRoom>> = MutableStateFlow(Optional.ofNullable(initialSpaceFlowValue))
override fun currentSpaceFlow(): StateFlow<Optional<SpaceRoom>> = currentSpaceMutableStateFlow.asStateFlow()
fun emitCurrentSpace(value: SpaceRoom?) {
_currentSpaceFlow.value = Optional.ofNullable(value)
currentSpaceMutableStateFlow.value = Optional.ofNullable(value)
}
private val _spaceRoomsFlow: MutableStateFlow<List<SpaceRoom>> = MutableStateFlow(initialSpaceRoomsValue)

View file

@ -304,9 +304,9 @@ class FakeTimeline(
)
}
var toggleReactionLambda: (emoji: String, eventOrTransactionId: EventOrTransactionId) -> Result<Unit> = { _, _ -> lambdaError() }
var toggleReactionLambda: (emoji: String, eventOrTransactionId: EventOrTransactionId) -> Result<Boolean> = { _, _ -> lambdaError() }
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit> = simulateLongTask {
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean> = simulateLongTask {
toggleReactionLambda(
emoji,
eventOrTransactionId,

View file

@ -11,12 +11,18 @@ import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
class FakeCallWidgetSettingsProvider(
private val provideFn: (String, String, Boolean, Boolean) -> MatrixWidgetSettings = { _, _, _, _ -> MatrixWidgetSettings("id", true, "url") }
private val provideFn: (String, String, Boolean, Boolean, Boolean) -> MatrixWidgetSettings = { _, _, _, _, _ -> MatrixWidgetSettings("id", true, "url") }
) : CallWidgetSettingsProvider {
val providedBaseUrls = mutableListOf<String>()
override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean, direct: Boolean): MatrixWidgetSettings {
override suspend fun provide(
baseUrl: String,
widgetId: String,
encrypted: Boolean,
direct: Boolean,
hasActiveCall: Boolean
): MatrixWidgetSettings {
providedBaseUrls += baseUrl
return provideFn(baseUrl, widgetId, encrypted, direct)
return provideFn(baseUrl, widgetId, encrypted, direct, hasActiveCall)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -37,17 +38,8 @@ dependencies {
implementation(libs.coil.network.okhttp)
implementation(libs.jsoup)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.sessionStorage.test)
}

View file

@ -1,3 +1,5 @@
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
*
@ -18,9 +20,6 @@ android {
implementation(projects.libraries.di)
implementation(libs.inject)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -26,12 +27,7 @@ dependencies {
implementation(libs.coroutines.core)
testImplementation(projects.tests.testutils)
testCommonDependencies(libs)
testImplementation(projects.libraries.audio.test)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.mockk)
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -27,12 +28,8 @@ dependencies {
implementation(libs.inject)
implementation(libs.coroutines.core)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.robolectric)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -39,10 +40,6 @@ dependencies {
implementation(libs.coroutines.core)
implementation(libs.vanniktech.blurhash)
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testImplementation(projects.tests.testutils)
testCommonDependencies(libs)
testImplementation(projects.services.toolbox.test)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -52,21 +53,12 @@ dependencies {
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.audio.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.mockk)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -15,7 +15,7 @@ import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryRootNode
import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryFlowNode
@ContributesBinding(AppScope::class)
@Inject
@ -30,7 +30,7 @@ class DefaultMediaGalleryEntryPoint : MediaGalleryEntryPoint {
}
override fun build(): Node {
return parentNode.createNode<MediaGalleryRootNode>(buildContext, plugins)
return parentNode.createNode<MediaGalleryFlowNode>(buildContext, plugins)
}
}
}

View file

@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
interface FocusedTimelineMediaGalleryDataSourceFactory {
fun interface FocusedTimelineMediaGalleryDataSourceFactory {
fun createFor(
eventId: EventId,
mediaItem: MediaItem.Event,

View file

@ -41,11 +41,11 @@ import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@Inject
class MediaGalleryRootNode(
class MediaGalleryFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val mediaViewerEntryPoint: MediaViewerEntryPoint
) : BaseFlowNode<MediaGalleryRootNode.NavTarget>(
) : BaseFlowNode<MediaGalleryFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
@ -87,11 +87,11 @@ class MediaGalleryRootNode(
NavTarget.Root -> {
val callback = object : MediaGalleryNode.Callback {
override fun onBackClick() {
this@MediaGalleryRootNode.onBackClick()
this@MediaGalleryFlowNode.onBackClick()
}
override fun onViewInTimeline(eventId: EventId) {
this@MediaGalleryRootNode.onViewInTimeline(eventId)
this@MediaGalleryFlowNode.onViewInTimeline(eventId)
}
override fun onItemClick(item: MediaItem.Event) {
@ -122,7 +122,7 @@ class MediaGalleryRootNode(
}
override fun onViewInTimeline(eventId: EventId) {
this@MediaGalleryRootNode.onViewInTimeline(eventId)
this@MediaGalleryFlowNode.onViewInTimeline(eventId)
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)

View file

@ -10,12 +10,14 @@ package io.element.android.libraries.mediaviewer.impl.local.video
import android.annotation.SuppressLint
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -33,6 +35,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
@ -55,6 +58,7 @@ import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import kotlinx.coroutines.delay
import me.saket.telephoto.zoomable.zoomable
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@SuppressLint("UnsafeOptInUsageError")
@ -165,35 +169,6 @@ private fun ExoPlayerMediaVideoView(
}
}
LaunchedEffect(exoPlayer.isPlaying) {
if (exoPlayer.isPlaying) {
while (true) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
delay(200)
}
} else {
// Ensure we render the final state
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
}
}
var needsAutoPlay by remember { mutableStateOf(autoplay) }
LaunchedEffect(needsAutoPlay, isDisplayed, mediaPlayerControllerState.isReady) {
val isReadyAndNotPlaying = mediaPlayerControllerState.isReady && !mediaPlayerControllerState.isPlaying
if (needsAutoPlay && isDisplayed && isReadyAndNotPlaying) {
// When displayed, start autoplaying
exoPlayer.play()
needsAutoPlay = false
} else if (!isDisplayed && mediaPlayerControllerState.isPlaying) {
// If not displayed, make sure to pause the video
exoPlayer.pause()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
@ -263,16 +238,72 @@ private fun ExoPlayerMediaVideoView(
)
}
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener)
Lifecycle.Event.ON_RESUME -> exoPlayer.prepare()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener)
LaunchedEffect(exoPlayer.isPlaying) {
if (exoPlayer.isPlaying) {
while (true) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
delay(200)
}
else -> Unit
} else {
// Ensure we render the final state
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
}
}
ExoPlayerLifecycleHelper(
exoPlayer = exoPlayer,
autoplay = autoplay,
isDisplayed = isDisplayed,
playerListener = playerListener,
mediaPlayerControllerState = mediaPlayerControllerState,
)
}
@OptIn(UnstableApi::class)
@Composable
private fun ExoPlayerLifecycleHelper(
exoPlayer: ExoPlayer,
autoplay: Boolean,
isDisplayed: Boolean,
playerListener: Player.Listener,
mediaPlayerControllerState: MediaPlayerControllerState,
) {
// Prepare and release the exoPlayer with the composable lifecycle
DisposableEffect(Unit) {
Timber.d("ExoPlayerMediaVideoView DisposableEffect: initializing exoPlayer")
exoPlayer.addListener(playerListener)
exoPlayer.prepare()
onDispose {
Timber.d("Disposing exoplayer")
if (!exoPlayer.isReleased) {
exoPlayer.removeListener(playerListener)
exoPlayer.release()
}
}
}
var needsAutoPlay by remember { mutableStateOf(autoplay) }
LaunchedEffect(needsAutoPlay, isDisplayed, mediaPlayerControllerState.isReady) {
val isReadyAndNotPlaying = mediaPlayerControllerState.isReady && !mediaPlayerControllerState.isPlaying
if (needsAutoPlay && isDisplayed && isReadyAndNotPlaying) {
// When displayed, start autoplaying
exoPlayer.play()
needsAutoPlay = false
} else if (!isDisplayed && mediaPlayerControllerState.isPlaying) {
// If not displayed, make sure to pause the video
exoPlayer.pause()
}
}
// Pause playback when lifecycle is paused
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_PAUSE && exoPlayer.isPlaying) {
exoPlayer.pause()
}
}
}

View file

@ -58,7 +58,7 @@ class MediaViewerPresenter(
private val localMediaActions: LocalMediaActions,
) : Presenter<MediaViewerState> {
@AssistedFactory
interface Factory {
fun interface Factory {
fun create(
inputs: MediaViewerEntryPoint.Params,
navigator: MediaViewerNavigator,

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.libraries.mediaviewer.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryFlowNode
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultMediaGalleryEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultMediaGalleryEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
MediaGalleryFlowNode(
buildContext = buildContext,
plugins = plugins,
mediaViewerEntryPoint = object : MediaViewerEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext) = lambdaError()
}
)
}
val callback = object : MediaGalleryEntryPoint.Callback {
override fun onBackClick() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
assertThat(result).isInstanceOf(MediaGalleryFlowNode::class.java)
assertThat(result.plugins).contains(callback)
}
}

View file

@ -0,0 +1,147 @@
/*
* 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.libraries.mediaviewer.impl
import android.net.Uri
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
import io.element.android.libraries.mediaviewer.impl.viewer.PagerKeysHandler
import io.element.android.libraries.mediaviewer.impl.viewer.createMediaViewerEntryPointParams
import io.element.android.libraries.mediaviewer.impl.viewer.createMediaViewerPresenter
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultMediaViewerEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultMediaViewerEntryPoint()
val mockMediaUri: Uri = mockk("localMediaUri")
val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
val parentNode = TestParentNode.create { buildContext, plugins ->
MediaViewerNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { _, _, _ ->
createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
)
},
timelineMediaGalleryDataSource = createTimelineMediaGalleryDataSource(),
focusedTimelineMediaGalleryDataSourceFactory = { _, _, _ ->
lambdaError()
},
mediaLoader = FakeMatrixMediaLoader(),
localMediaFactory = FakeLocalMediaFactory(mockMediaUri),
coroutineDispatchers = testCoroutineDispatchers(),
systemClock = FakeSystemClock(),
pagerKeysHandler = PagerKeysHandler(),
textFileViewer = { _, _ -> lambdaError() },
audioFocus = FakeAudioFocus(),
)
}
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError()
}
val params = createMediaViewerEntryPointParams()
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.params(params)
.callback(callback)
.build()
assertThat(result).isInstanceOf(MediaViewerNode::class.java)
assertThat(result.plugins).contains(params)
assertThat(result.plugins).contains(callback)
}
@Test
fun `test node builder avatar`() = runTest {
val entryPoint = DefaultMediaViewerEntryPoint()
val mockMediaUri: Uri = mockk("localMediaUri")
val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
val parentNode = TestParentNode.create { buildContext, plugins ->
MediaViewerNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { _, _, _ ->
createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
)
},
timelineMediaGalleryDataSource = createTimelineMediaGalleryDataSource(),
focusedTimelineMediaGalleryDataSourceFactory = { _, _, _ ->
lambdaError()
},
mediaLoader = FakeMatrixMediaLoader(),
localMediaFactory = FakeLocalMediaFactory(mockMediaUri),
coroutineDispatchers = testCoroutineDispatchers(),
systemClock = FakeSystemClock(),
pagerKeysHandler = PagerKeysHandler(),
textFileViewer = { _, _ -> lambdaError() },
audioFocus = FakeAudioFocus(),
)
}
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.avatar(
filename = "fn",
avatarUrl = "avatarUrl",
)
.callback(callback)
.build()
assertThat(result).isInstanceOf(MediaViewerNode::class.java)
assertThat(result.plugins).contains(
MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
filename = "fn",
fileSize = null,
caption = null,
mimeType = MimeTypes.Images,
formattedFileSize = "",
fileExtension = "",
senderId = UserId("@dummy:server.org"),
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
mediaSource = MediaSource(url = "avatarUrl"),
thumbnailSource = null,
canShowInfo = false,
)
)
assertThat(result.plugins).contains(callback)
}
}

View file

@ -255,19 +255,19 @@ class TimelineMediaGalleryDataSourceTest {
)
}
}
}
private fun TestScope.createTimelineMediaGalleryDataSource(
room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
),
): TimelineMediaGalleryDataSource {
return TimelineMediaGalleryDataSource(
room = room,
mediaTimeline = LiveMediaTimeline(room),
timelineMediaItemsFactory = createTimelineMediaItemsFactory(),
mediaItemsPostProcessor = MediaItemsPostProcessor(),
)
}
internal fun TestScope.createTimelineMediaGalleryDataSource(
room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
),
): TimelineMediaGalleryDataSource {
return TimelineMediaGalleryDataSource(
room = room,
mediaTimeline = LiveMediaTimeline(room),
timelineMediaItemsFactory = createTimelineMediaItemsFactory(),
mediaItemsPostProcessor = MediaItemsPostProcessor(),
)
}
fun TestScope.createTimelineMediaItemsFactory() = TimelineMediaItemsFactory(

View file

@ -10,7 +10,9 @@ package io.element.android.libraries.mediaviewer.impl.gallery
import android.net.Uri
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
@ -29,6 +31,7 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@ -147,8 +150,8 @@ class MediaGalleryPresenterTest {
val presenter = createMediaGalleryPresenter(
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_USER_ID,
initialRoomInfo = aRoomInfo(name = A_ROOM_NAME),
sessionId = A_USER_ID,
initialRoomInfo = aRoomInfo(name = A_ROOM_NAME),
canRedactOtherResult = { Result.success(canDeleteOther) },
),
createTimelineResult = { Result.success(FakeTimeline()) }
@ -223,23 +226,122 @@ class MediaGalleryPresenterTest {
}
@Test
fun `present - share item`() = runTest {
fun `present - share item - item not found`() = runTest {
val presenter = createMediaGalleryPresenter()
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID))
}
// TODO Add more test on this part
}
@Test
fun `present - save on disk`() = runTest {
fun `present - share item - item found`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
aGroupedMediaItems(
imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)),
fileItems = emptyList(),
)
)
)
val presenter = createMediaGalleryPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.snackbarMessage).isNull()
}
}
@Test
fun `present - share item - item found - download error`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
aGroupedMediaItems(
imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)),
fileItems = emptyList(),
)
)
)
val presenter = createMediaGalleryPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true },
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.snackbarMessage).isInstanceOf(SnackbarMessage::class.java)
}
}
@Test
fun `present - save on disk - item not found`() = runTest {
val presenter = createMediaGalleryPresenter()
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID))
}
// TODO Add more test on this part
}
@Test
fun `present - save on disk - item found`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
aGroupedMediaItems(
imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)),
fileItems = emptyList(),
)
)
)
val presenter = createMediaGalleryPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_file_saved_on_disk_android)
}
}
@Test
fun `present - save on disk - item found - download error`() = runTest {
val mediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
)
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
aGroupedMediaItems(
imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)),
fileItems = emptyList(),
)
)
)
val presenter = createMediaGalleryPresenter(
mediaGalleryDataSource = mediaGalleryDataSource,
matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true },
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.snackbarMessage).isInstanceOf(SnackbarMessage::class.java)
}
}
@Test

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
@ -78,12 +79,13 @@ class MediaViewerPresenterTest {
@Test
fun `present - initial state null Event`() = runTest {
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOwnResult = { Result.success(true) },
)
)
)
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
@ -97,13 +99,14 @@ class MediaViewerPresenterTest {
@Test
fun `present - initial state cannot show info`() = runTest {
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
canShowInfo = false,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOwnResult = { Result.success(true) },
)
)
)
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
@ -117,13 +120,14 @@ class MediaViewerPresenterTest {
@Test
fun `present - initial state Event`() = runTest {
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
eventId = AN_EVENT_ID,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOwnResult = { Result.success(true) },
)
)
)
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
@ -137,14 +141,15 @@ class MediaViewerPresenterTest {
@Test
fun `present - initial state Event from other`() = runTest {
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
eventId = AN_EVENT_ID,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID_2,
canRedactOtherResult = { Result.success(false) },
sessionId = A_SESSION_ID_2,
canRedactOtherResult = { Result.success(false) },
)
)
)
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.listData).isEmpty()
@ -161,6 +166,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage()
@ -192,6 +198,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
@ -224,10 +231,13 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
room = FakeJoinedRoom(baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
))
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
)
)
)
val anImage = aMediaItemImage(
mediaSourceUrl = aUrl,
@ -266,6 +276,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
@ -298,6 +309,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
@ -330,6 +342,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
@ -362,6 +375,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
@ -394,6 +408,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
@ -441,6 +456,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }),
@ -498,6 +514,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
@ -549,6 +566,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mode = mode,
mediaGalleryDataSource = mediaGalleryDataSource,
)
@ -620,6 +638,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mode = mode,
mediaGalleryDataSource = mediaGalleryDataSource,
)
@ -674,6 +693,7 @@ class MediaViewerPresenterTest {
startLambda = { },
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
presenter.test {
@ -714,6 +734,7 @@ class MediaViewerPresenterTest {
loadMoreLambda = loadMoreLambda,
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaGalleryDataSource = mediaGalleryDataSource,
)
val anImage = aMediaItemImage(
@ -744,6 +765,7 @@ class MediaViewerPresenterTest {
onViewInTimelineClickLambda = onViewInTimelineClickLambda,
)
val presenter = createMediaViewerPresenter(
localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }),
@ -764,42 +786,53 @@ class MediaViewerPresenterTest {
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
private fun TestScope.createMediaViewerPresenter(
eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
),
canShowInfo: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
),
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerEntryPoint.Params(
mode = mode,
eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,
canShowInfo = canShowInfo,
),
navigator = mediaViewerNavigator,
dataSource = MediaViewerDataSource(
mode = mode,
dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader,
localMediaFactory = localMediaFactory,
systemClock = FakeSystemClock(),
pagerKeysHandler = PagerKeysHandler(),
),
room = room,
localMediaActions = localMediaActions,
)
}
}
internal fun TestScope.createMediaViewerPresenter(
localMediaFactory: LocalMediaFactory,
eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
startLambda = { },
),
canShowInfo: Boolean = true,
mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
),
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = createMediaViewerEntryPointParams(
eventId = eventId,
mode = mode,
canShowInfo = canShowInfo,
),
navigator = mediaViewerNavigator,
dataSource = MediaViewerDataSource(
mode = mode,
dispatcher = testCoroutineDispatchers().computation,
galleryDataSource = mediaGalleryDataSource,
mediaLoader = matrixMediaLoader,
localMediaFactory = localMediaFactory,
systemClock = FakeSystemClock(),
pagerKeysHandler = PagerKeysHandler(),
),
room = room,
localMediaActions = localMediaActions,
)
}
internal fun createMediaViewerEntryPointParams(
eventId: EventId? = null,
mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
canShowInfo: Boolean = true,
) = MediaViewerEntryPoint.Params(
mode = mode,
eventId = eventId,
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,
canShowInfo = canShowInfo,
)

View file

@ -168,7 +168,7 @@ class SingleMediaGalleryDataSourceTest {
assertThat(resultData.fileItems).isEmpty()
}
private fun aMediaViewerEntryPointParams(
internal fun aMediaViewerEntryPointParams(
mediaInfo: MediaInfo,
) = MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,

View file

@ -1,3 +1,5 @@
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
*
@ -19,6 +21,5 @@ dependencies {
implementation(projects.tests.testutils)
implementation(projects.libraries.matrix.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2024 New Vector Ltd.
@ -39,14 +40,7 @@ dependencies {
implementation(libs.serialization.json)
api(projects.libraries.oidc.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.tests.testutils)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -39,13 +40,8 @@ dependencies {
implementation(projects.services.toolbox.api)
api(projects.libraries.permissions.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
}

View file

@ -1,3 +1,5 @@
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
*
@ -17,10 +19,5 @@ dependencies {
implementation(projects.libraries.architecture)
api(projects.libraries.permissions.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.tests.testutils)
testCommonDependencies(libs)
}

View file

@ -1,3 +1,5 @@
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
*
@ -18,7 +20,6 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(libs.androidx.datastore.preferences)
testCommonDependencies(libs)
testImplementation(projects.libraries.preferences.test)
testImplementation(libs.test.truth)
testImplementation(libs.coroutines.test)
}

View file

@ -19,7 +19,6 @@ fun aRoomMember(
membership: RoomMembershipState = RoomMembershipState.JOIN,
isNameAmbiguous: Boolean = false,
powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null,
@ -30,7 +29,6 @@ fun aRoomMember(
membership = membership,
isNameAmbiguous = isNameAmbiguous,
powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored,
role = role,
membershipChangeReason = membershipChangeReason,

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -63,20 +64,14 @@ dependencies {
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
testImplementation(libs.coil.test)
testImplementation(libs.coroutines.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.call.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.services.appnavstate.test)

View file

@ -14,9 +14,9 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
@ -58,13 +58,13 @@ class DefaultCallNotificationEventResolver(
notificationData: NotificationData,
forceNotify: Boolean
): Result<NotifiableEvent> = runCatchingExceptions {
val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
val content = notificationData.content as? NotificationContent.MessageLike.RtcNotification
?: throw NotificationResolverException.UnknownError("content is not a call notify")
val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value
// We need the sync service working to get the updated room info
val isRoomCallActive = runCatchingExceptions {
if (content.type == CallNotifyType.RING) {
if (content.type == RtcNotificationType.RING) {
appForegroundStateService.updateHasRingingCall(true)
val client = clientProvider.getOrRestore(
@ -90,7 +90,7 @@ class DefaultCallNotificationEventResolver(
}.getOrDefault(false)
notificationData.run {
if (content.type == CallNotifyType.RING && isRoomCallActive && !forceNotify) {
if (content.type == RtcNotificationType.RING && isRoomCallActive && !forceNotify) {
NotifiableRingingCallEvent(
sessionId = sessionId,
roomId = roomId,
@ -104,9 +104,10 @@ class DefaultCallNotificationEventResolver(
description = stringProvider.getString(R.string.notification_incoming_call),
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
roomAvatarUrl = roomAvatarUrl,
callNotifyType = content.type,
rtcNotificationType = content.type,
senderId = content.senderId,
senderAvatarUrl = senderAvatarUrl,
expirationTimestamp = content.expirationTimestampMillis,
)
} else {
Timber.d("Event $eventId is call notify but should not ring: $isRoomCallActive, notify: ${content.type}")
@ -124,7 +125,7 @@ class DefaultCallNotificationEventResolver(
roomIsDm = isDm,
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
type = EventType.CALL_NOTIFY,
type = EventType.RTC_NOTIFICATION,
)
}
}

View file

@ -199,7 +199,7 @@ class DefaultNotifiableEventResolver(
)
ResolvedPushEvent.Event(notifiableMessageEvent)
}
is NotificationContent.MessageLike.CallNotify -> {
is NotificationContent.MessageLike.RtcNotification -> {
val notifiableEvent = callNotificationEventResolver.resolveEvent(userId, this).getOrThrow()
ResolvedPushEvent.Event(notifiableEvent)
}

View file

@ -123,7 +123,7 @@ class DefaultNotificationCreator(
val smallIcon = CommonDrawables.ic_notification
val containsMissedCall = events.any { it.type == EventType.CALL_NOTIFY }
val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION }
val channelId = if (containsMissedCall) {
notificationChannels.getChannelForIncomingCall(false)
} else {
@ -213,8 +213,8 @@ class DefaultNotificationCreator(
}
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
// If any of the events are of call notify type it means a missed call, set the category to the right value
if (events.any { it.type == EventType.CALL_NOTIFY }) {
// If any of the events are of rtc notification type it means a missed call, set the category to the right value
if (events.any { it.type == EventType.RTC_NOTIFICATION }) {
setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
}
}

View file

@ -11,7 +11,7 @@ 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.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
data class NotifiableRingingCallEvent(
override val sessionId: SessionId,
@ -27,6 +27,7 @@ data class NotifiableRingingCallEvent(
val senderDisambiguatedDisplayName: String?,
val senderAvatarUrl: String?,
val roomAvatarUrl: String? = null,
val callNotifyType: CallNotifyType,
val rtcNotificationType: RtcNotificationType,
val timestamp: Long,
val expirationTimestamp: Long,
) : NotifiableEvent

View file

@ -16,7 +16,6 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.history.onDiagnosticPush
@ -58,7 +57,6 @@ class DefaultPushHandler(
private val userPushStoreFactory: UserPushStoreFactory,
private val pushClientSecret: PushClientSecret,
private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService,
private val diagnosticPushHandler: DiagnosticPushHandler,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val notificationChannels: NotificationChannels,
@ -241,32 +239,15 @@ class DefaultPushHandler(
} else {
Timber.tag(loggerTag.value).d("## handleInternal()")
}
val clientSecret = pushData.clientSecret
// clientSecret should not be null. If this happens, restore default session
var reason = if (clientSecret == null) "No client secret" else ""
val userId = clientSecret?.let {
// Get userId from client secret
pushClientSecret.getUserIdFromSecret(clientSecret).also {
if (it == null) {
reason = "Unable to get userId from client secret"
}
}
}
?: run {
matrixAuthenticationService.getLatestSessionId().also {
if (it == null) {
if (reason.isNotEmpty()) reason += " - "
reason += "Unable to get latest sessionId"
}
}
}
// Get userId from client secret
val userId = pushClientSecret.getUserIdFromSecret(pushData.clientSecret)
if (userId == null) {
Timber.w("Unable to get a session")
Timber.w("Unable to get userId from client secret")
pushHistoryService.onUnableToRetrieveSession(
providerInfo = providerInfo,
eventId = pushData.eventId,
roomId = pushData.roomId,
reason = reason,
reason = "Unable to get userId from client secret",
)
return
}
@ -296,6 +277,7 @@ class DefaultPushHandler(
senderName = notifiableEvent.senderDisambiguatedDisplayName,
avatarUrl = notifiableEvent.roomAvatarUrl,
timestamp = notifiableEvent.timestamp,
expirationTimestamp = notifiableEvent.expirationTimestamp,
notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true),
textContent = notifiableEvent.description,
)

View file

@ -23,7 +23,7 @@
<item quantity="other">"%d convites"</item>
</plurals>
<string name="notification_invite_body">"Convidou-te para conversar"</string>
<string name="notification_invite_body_with_sender">"%1$s convidou-o para conversar"</string>
<string name="notification_invite_body_with_sender">"%1$s convidou-te para conversar"</string>
<string name="notification_mentioned_you_body">"Mencionou-te: %1$s"</string>
<string name="notification_new_messages">"Mensagens novas"</string>
<plurals name="notification_new_messages_for_room">
@ -34,7 +34,7 @@
<string name="notification_room_action_mark_as_read">"Marcar como lida"</string>
<string name="notification_room_action_quick_reply">"Resposta rápida"</string>
<string name="notification_room_invite_body">"Convidou-te a entrar na sala"</string>
<string name="notification_room_invite_body_with_sender">"%1$s convidou-o a juntar-se à sala"</string>
<string name="notification_room_invite_body_with_sender">"%1$s convidou-te a entrares na sala"</string>
<string name="notification_sender_me">"Eu"</string>
<string name="notification_sender_mention_reply">"%1$s mencionou ou respondeu"</string>
<string name="notification_test_push_notification_content">"Estás a ver a notificação! Clica em mim!"</string>

View file

@ -8,8 +8,8 @@
package io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
@ -61,11 +61,12 @@ class DefaultCallNotificationEventResolverTest {
isUpdated = false,
senderDisambiguatedDisplayName = A_USER_NAME_2,
senderAvatarUrl = null,
callNotifyType = CallNotifyType.RING,
expirationTimestamp = 1567L,
rtcNotificationType = RtcNotificationType.RING,
)
val notificationData = aNotificationData(
content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING)
content = NotificationContent.MessageLike.RtcNotification(A_USER_ID_2, RtcNotificationType.RING, 1567)
)
val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
@ -105,11 +106,11 @@ class DefaultCallNotificationEventResolverTest {
imageUriString = null,
imageMimeType = null,
threadId = null,
type = "m.call.notify",
type = "org.matrix.msc4075.rtc.notification",
)
val notificationData = aNotificationData(
content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.NOTIFY)
content = NotificationContent.MessageLike.RtcNotification(A_USER_ID_2, RtcNotificationType.NOTIFY, 0)
)
val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
@ -149,11 +150,11 @@ class DefaultCallNotificationEventResolverTest {
imageUriString = null,
imageMimeType = null,
threadId = null,
type = "m.call.notify",
type = "org.matrix.msc4075.rtc.notification",
)
val notificationData = aNotificationData(
content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING)
content = NotificationContent.MessageLike.RtcNotification(A_USER_ID_2, RtcNotificationType.RING, 0)
)
val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
assertThat(result.getOrNull()).isEqualTo(expectedResult)

View file

@ -12,9 +12,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
@ -693,9 +693,10 @@ class DefaultNotifiableEventResolverTest {
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.CallNotify(
content = NotificationContent.MessageLike.RtcNotification(
A_USER_ID_2,
CallNotifyType.NOTIFY
RtcNotificationType.NOTIFY,
0
),
))
)
@ -719,7 +720,7 @@ class DefaultNotifiableEventResolverTest {
isRedacted = false,
imageUriString = null,
imageMimeType = null,
type = EventType.CALL_NOTIFY,
type = EventType.RTC_NOTIFICATION,
)
)
callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) }

View file

@ -12,7 +12,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -119,8 +119,9 @@ fun aNotifiableCallEvent(
senderName: String? = null,
roomAvatarUrl: String? = AN_AVATAR_URL,
senderAvatarUrl: String? = AN_AVATAR_URL,
callNotifyType: CallNotifyType = CallNotifyType.NOTIFY,
rtcNotificationType: RtcNotificationType = RtcNotificationType.NOTIFY,
timestamp: Long = 0L,
expirationTimestamp: Long = 0L,
) = NotifiableRingingCallEvent(
sessionId = sessionId,
eventId = eventId,
@ -129,6 +130,7 @@ fun aNotifiableCallEvent(
editedEventId = null,
description = "description",
timestamp = timestamp,
expirationTimestamp = expirationTimestamp,
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
@ -136,5 +138,5 @@ fun aNotifiableCallEvent(
senderId = senderId,
roomAvatarUrl = roomAvatarUrl,
senderAvatarUrl = senderAvatarUrl,
callNotifyType = callNotifyType,
rtcNotificationType = rtcNotificationType,
)

View file

@ -14,13 +14,12 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.test.FakeElementCallEntryPoint
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
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.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
@ -28,7 +27,6 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SECRET
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.push.impl.history.FakePushHistoryService
import io.element.android.libraries.push.impl.history.PushHistoryService
@ -181,7 +179,7 @@ class DefaultPushHandlerTest {
}
@Test
fun `when PushData is received, but client secret is not known, fallback the latest session`() =
fun `when PushData is received, but client secret is not known, nothing happen`() =
runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
@ -207,58 +205,6 @@ class DefaultPushHandlerTest {
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { null }
),
matrixAuthenticationService = FakeMatrixAuthenticationService().apply {
getLatestSessionIdLambda = { A_USER_ID }
},
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
.isCalledOnce()
.with(value(A_USER_ID), any())
onNotifiableEventsReceived.assertions()
.isCalledOnce()
.with(value(listOf(aNotifiableMessageEvent)))
onPushReceivedResult.assertions()
.isCalledOnce()
}
@Test
fun `when PushData is received, but client secret is not known, and there is no latest session, nothing happen`() =
runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val aPushData = PushData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
unread = 0,
clientSecret = A_SECRET,
)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = notifiableEventResult,
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { null }
),
matrixAuthenticationService = FakeMatrixAuthenticationService().apply {
getLatestSessionIdLambda = { null }
},
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
@ -388,7 +334,7 @@ class DefaultPushHandlerTest {
mapOf(
request to Result.success(
ResolvedPushEvent.Event(
aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli())
aNotifiableCallEvent(rtcNotificationType = RtcNotificationType.RING, timestamp = Instant.now().toEpochMilli())
)
)
)
@ -440,7 +386,7 @@ class DefaultPushHandlerTest {
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY)))))
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.RTC_NOTIFICATION)))))
},
incrementPushCounterResult = {},
pushClientSecret = FakePushClientSecret(
@ -655,8 +601,8 @@ class DefaultPushHandlerTest {
var receivedFallbackEvent = false
val onPushReceivedResult =
lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, isResolved, _, comment ->
receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}"
}
receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}"
}
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@ -694,7 +640,6 @@ class DefaultPushHandlerTest {
userPushStore: UserPushStore = FakeUserPushStore(),
pushClientSecret: PushClientSecret = FakePushClientSecret(),
buildMeta: BuildMeta = aBuildMeta(),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(),
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
@ -712,7 +657,6 @@ class DefaultPushHandlerTest {
userPushStoreFactory = FakeUserPushStoreFactory { userPushStore },
pushClientSecret = pushClientSecret,
buildMeta = buildMeta,
matrixAuthenticationService = matrixAuthenticationService,
diagnosticPushHandler = diagnosticPushHandler,
elementCallEntryPoint = elementCallEntryPoint,
notificationChannels = notificationChannels,

View file

@ -22,5 +22,5 @@ data class PushData(
val eventId: EventId,
val roomId: RoomId,
val unread: Int?,
val clientSecret: String?,
val clientSecret: String,
)

View file

@ -9,6 +9,7 @@
import config.BuildTimeConfig
import extension.setupDependencyInjection
import extension.testCommonDependencies
plugins {
id("io.element.android-library")
@ -68,17 +69,12 @@ dependencies {
exclude(group = "com.google.firebase", module = "firebase-measurement-connector")
}
testImplementation(libs.coroutines.test)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs)
testImplementation(libs.kotlinx.collections.immutable)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.services.toolbox.test)
}

View file

@ -34,10 +34,11 @@ data class PushDataFirebase(
fun PushDataFirebase.toPushData(): PushData? {
val safeEventId = eventId?.let(::EventId) ?: return null
val safeRoomId = roomId?.let(::RoomId) ?: return null
val safeClientSecret = clientSecret ?: return null
return PushData(
eventId = safeEventId,
roomId = safeRoomId,
unread = unread,
clientSecret = clientSecret,
clientSecret = safeClientSecret,
)
}

View file

@ -59,6 +59,12 @@ class FirebasePushParserTest {
assertThrowsInDebug { pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", "")) }
}
@Test
fun `test empty client secret`() {
val pushParser = FirebasePushParser()
assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("cs", null))).isNull()
}
@Test
fun `test invalid eventId`() {
val pushParser = FirebasePushParser()

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -42,18 +43,14 @@ dependencies {
// UnifiedPush library
api(libs.unifiedpush)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
testImplementation(libs.kotlinx.collections.immutable)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.services.appnavstate.test)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -31,12 +32,7 @@ dependencies {
implementation(libs.androidx.corektx)
implementation(libs.androidx.datastore.preferences)
testImplementation(libs.test.junit)
testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.test)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.services.appnavstate.test)

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -28,11 +29,6 @@ dependencies {
implementation(projects.libraries.uiStrings)
api(projects.libraries.roomselect.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

Some files were not shown because too many files have changed in this diff Show more