Merge branch 'develop' of github.com:element-hq/element-x-android into align-cta-button-on-login-flow

# Conflicts:
#	features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Day-0_1_null_0,NEXUS_5,1.0,en].png
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Day-0_1_null_1,NEXUS_5,1.0,en].png
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Day-0_1_null_2,NEXUS_5,1.0,en].png
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Day-0_1_null_3,NEXUS_5,1.0,en].png
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Day-0_1_null_4,NEXUS_5,1.0,en].png
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Night-0_2_null_0,NEXUS_5,1.0,en].png
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Night-0_2_null_1,NEXUS_5,1.0,en].png
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Night-0_2_null_2,NEXUS_5,1.0,en].png
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Night-0_2_null_3,NEXUS_5,1.0,en].png
#	tests/uitests/src/test/snapshots/images/ui_S_t[f.onboarding.impl_OnBoardingScreen_null_OnBoardingScreen-Night-0_2_null_4,NEXUS_5,1.0,en].png
This commit is contained in:
Marco Antonio Alvarez 2024-02-01 17:54:11 +01:00
commit f98cd5b99b
694 changed files with 6806 additions and 1630 deletions

View file

@ -25,7 +25,6 @@ data class BuildMeta(
val versionName: String,
val versionCode: Int,
val gitRevision: String,
val gitRevisionDate: String,
val gitBranchName: String,
val flavorDescription: String,
val flavorShortDescription: String,

View file

@ -18,6 +18,8 @@ package io.element.android.libraries.designsystem.components.async
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
@ -67,8 +69,9 @@ fun <T> AsyncActionView(
}
}
is AsyncAction.Success -> {
val latestOnSuccess by rememberUpdatedState(onSuccess)
LaunchedEffect(async) {
onSuccess(async.data)
latestOnSuccess(async.data)
}
}
}

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.designsystem.components.tooltip
import androidx.compose.material3.CaretScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipDefaults
import androidx.compose.runtime.Composable
@ -27,7 +28,7 @@ import androidx.compose.material3.PlainTooltip as M3PlainTooltip
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlainTooltip(
fun CaretScope.PlainTooltip(
modifier: Modifier = Modifier,
contentColor: Color = ElementTheme.colors.textOnSolidPrimary,
containerColor: Color = ElementTheme.colors.bgActionPrimaryRest,

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.designsystem.components.tooltip
import androidx.compose.material3.CaretScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
import androidx.compose.runtime.Composable
@ -27,7 +28,7 @@ import androidx.compose.material3.TooltipBox as M3TooltipBox
@Composable
fun TooltipBox(
positionProvider: PopupPositionProvider,
tooltip: @Composable () -> Unit,
tooltip: @Composable CaretScope.() -> Unit,
state: TooltipState,
modifier: Modifier = Modifier,
focusable: Boolean = true,

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.CheckboxColors
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -57,7 +58,7 @@ fun Checkbox(
onCheckedChange(!checked)
}
},
modifier = modifier,
modifier = modifier.minimumInteractiveComponentSize(),
enabled = enabled,
colors = colors,
interactionSource = interactionSource,

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.RadioButtonColors
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -48,7 +49,7 @@ fun RadioButton(
androidx.compose.material3.RadioButton(
selected = selected,
onClick = onClick,
modifier = modifier,
modifier = modifier.minimumInteractiveComponentSize(),
enabled = enabled,
colors = colors,
interactionSource = interactionSource,

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SwitchColors
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -52,7 +53,7 @@ fun Switch(
Material3Switch(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier,
modifier = modifier.minimumInteractiveComponentSize(),
enabled = enabled,
colors = colors,
interactionSource = interactionSource,

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.di.annotations
import javax.inject.Qualifier
/**
* Qualifies a [CoroutineScope] object which represents the base coroutine scope to use for an active session.
*/
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Qualifier
annotation class SessionCoroutineScope

View file

@ -69,6 +69,8 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? {
val isOutgoing = event.isOwn
// Note: we do not use disambiguated display name here, see
// https://github.com/element-hq/element-x-ios/issues/1845#issuecomment-1888707428
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
return when (val content = event.content) {
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)

View file

@ -27,13 +27,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ -48,7 +48,7 @@ class DefaultTimelineEventFormatter @Inject constructor(
) : TimelineEventFormatter {
override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = event.isOwn
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
val senderDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
return when (val content = event.content) {
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)

View file

@ -39,6 +39,8 @@
<string name="state_event_room_name_changed_by_you">"Hai cambiato il nome della stanza in: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s ha rimosso il nome della stanza"</string>
<string name="state_event_room_name_removed_by_you">"Hai rimosso il nome della stanza"</string>
<string name="state_event_room_none">"%1$s non ha apportato modifiche"</string>
<string name="state_event_room_none_by_you">"Non hai apportato modifiche"</string>
<string name="state_event_room_reject">"%1$s ha rifiutato l\'invito"</string>
<string name="state_event_room_reject_by_you">"Hai rifiutato l\'invito"</string>
<string name="state_event_room_remove">"%1$s ha rimosso %2$s"</string>

View file

@ -55,5 +55,5 @@
<string name="state_event_room_topic_removed_by_you">"Вы удалили тему комнаты"</string>
<string name="state_event_room_unban">"%1$s разблокирован %2$s"</string>
<string name="state_event_room_unban_by_you">"Вы разблокировали %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s внес неизвестное изменение в составе"</string>
<string name="state_event_room_unknown_membership_change">"%1$s внес неизвестное изменение для своих участников"</string>
</resources>

View file

@ -46,4 +46,5 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -34,12 +34,15 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope
import java.io.Closeable
interface MatrixClient : Closeable {
val sessionId: SessionId
val deviceId: String
val roomListService: RoomListService
val mediaLoader: MatrixMediaLoader
val sessionCoroutineScope: CoroutineScope
suspend fun getRoom(roomId: RoomId): MatrixRoom?
suspend fun findDM(userId: UserId): RoomId?
suspend fun ignoreUser(userId: UserId): Result<Unit>
@ -72,7 +75,7 @@ interface MatrixClient : Closeable {
*/
suspend fun logout(ignoreSdkError: Boolean): String?
suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String?>
suspend fun loadUserAvatarUrl(): Result<String?>
suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?>
suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String>
fun roomMembershipObserver(): RoomMembershipObserver

View file

@ -27,7 +27,9 @@ data class NotificationData(
val roomId: RoomId,
// mxc url
val senderAvatarUrl: String?,
val senderDisplayName: String?,
// private, must use `getSenderName`
private val senderDisplayName: String?,
private val senderIsNameAmbiguous: Boolean,
val roomAvatarUrl: String?,
val roomDisplayName: String?,
val isDirect: Boolean,
@ -36,7 +38,13 @@ data class NotificationData(
val timestamp: Long,
val content: NotificationContent,
val hasMention: Boolean,
)
) {
fun getSenderName(userId: UserId): String = when {
senderDisplayName.isNullOrBlank() -> userId.value
senderIsNameAmbiguous -> "$senderDisplayName ($userId)"
else -> senderDisplayName
}
}
sealed interface NotificationContent {
sealed interface MessageLike : NotificationContent {
@ -54,11 +62,13 @@ sealed interface NotificationContent {
data class ReactionContent(
val relatedEventId: String
) : MessageLike
data object RoomEncrypted : MessageLike
data class RoomMessage(
val senderId: UserId,
val messageType: MessageType
) : MessageLike
data object RoomRedaction : MessageLike
data object Sticker : MessageLike
data class Poll(
@ -83,6 +93,7 @@ sealed interface NotificationContent {
val userId: String,
val membershipState: RoomMembershipState
) : StateEvent
data object RoomName : StateEvent
data object RoomPinnedEvents : StateEvent
data object RoomPowerLevels : StateEvent

View file

@ -52,6 +52,9 @@ interface MatrixRoom : Closeable {
val activeMemberCount: Long
val joinedMemberCount: Long
/** Whether the room is a direct message. */
val isDm: Boolean get() = isDirect && isOneToOne
val roomInfoFlow: Flow<MatrixRoomInfo>
/**
@ -72,7 +75,7 @@ interface MatrixRoom : Closeable {
/**
* Try to load the room members and update the membersFlow.
*/
suspend fun updateMembers(): Result<Unit>
suspend fun updateMembers()
suspend fun updateRoomNotificationSettings(): Result<Unit>
@ -124,7 +127,9 @@ interface MatrixRoom : Closeable {
suspend fun canUserInvite(userId: UserId): Result<Boolean>
suspend fun canUserRedact(userId: UserId): Result<Boolean>
suspend fun canUserRedactOwn(userId: UserId): Result<Boolean>
suspend fun canUserRedactOther(userId: UserId): Result<Boolean>
suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean>
@ -219,6 +224,12 @@ interface MatrixRoom : Closeable {
progressCallback: ProgressCallback?
): Result<MediaUploadHandler>
/**
* Send a typing notification.
* @param isTyping True if the user is typing, false otherwise.
*/
suspend fun typingNotice(isTyping: Boolean): Result<Unit>
/**
* Generates a Widget url to display in a [android.webkit.WebView] given the provided parameters.
* @param widgetSettings The widget settings to use.
@ -241,7 +252,5 @@ interface MatrixRoom : Closeable {
*/
fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver>
fun pollHistory(): MatrixTimeline
override fun close() = destroy()
}

View file

@ -36,6 +36,11 @@ suspend fun MatrixRoom.canSendState(type: StateEventType): Result<Boolean> = can
suspend fun MatrixRoom.canSendMessage(type: MessageEventType): Result<Boolean> = canUserSendMessage(sessionId, type)
/**
* Shortcut for calling [MatrixRoom.canUserRedact] with our own user.
* Shortcut for calling [MatrixRoom.canUserRedactOwn] with our own user.
*/
suspend fun MatrixRoom.canRedact(): Result<Boolean> = canUserRedact(sessionId)
suspend fun MatrixRoom.canRedactOwn(): Result<Boolean> = canUserRedactOwn(sessionId)
/**
* Shortcut for calling [MatrixRoom.canRedactOther] with our own user.
*/
suspend fun MatrixRoom.canRedactOther(): Result<Boolean> = canUserRedactOther(sessionId)

View file

@ -36,13 +36,17 @@ sealed interface RoomSummary {
data class RoomSummaryDetails(
val roomId: RoomId,
val name: String,
val canonicalAlias: String? = null,
val canonicalAlias: String?,
val isDirect: Boolean,
val avatarURLString: String?,
val avatarUrl: String?,
val lastMessage: RoomMessage?,
val lastMessageTimestamp: Long?,
val unreadNotificationCount: Int,
val inviter: RoomMember? = null,
val notificationMode: RoomNotificationMode? = null,
val hasOngoingCall: Boolean = false,
)
val numUnreadMessages: Int,
val numUnreadMentions: Int,
val numUnreadNotifications: Int,
val inviter: RoomMember?,
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,
val isDm: Boolean,
) {
val lastMessageTimestamp = lastMessage?.originServerTs
}

View file

@ -85,7 +85,11 @@ data class ProfileChangeContent(
data class StateContent(
val stateKey: String,
val content: OtherState
) : EventContent
) : EventContent {
fun isVisibleInTimeline(): Boolean {
return content.isVisibleInTimeline()
}
}
data class FailedToParseMessageLikeContent(
val eventType: String,

View file

@ -41,4 +41,30 @@ sealed interface OtherState {
data object SpaceChild : OtherState
data object SpaceParent : OtherState
data class Custom(val eventType: String) : OtherState
fun isVisibleInTimeline() = when (this) {
// Visible
is RoomAvatar,
is RoomName,
is RoomTopic,
is RoomThirdPartyInvite,
is RoomCreate,
is RoomEncryption,
is Custom -> true
// Hidden
is RoomAliases,
is RoomCanonicalAlias,
is RoomGuestAccess,
is RoomHistoryVisibility,
is RoomJoinRules,
is RoomPinnedEvents,
is RoomPowerLevels,
is RoomServerAcl,
is RoomTombstone,
is SpaceChild,
is SpaceParent,
is PolicyRuleRoom,
is PolicyRuleServer,
is PolicyRuleUser -> false
}
}

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
@Immutable
sealed interface ProfileTimelineDetails {
@ -34,3 +35,20 @@ sealed interface ProfileTimelineDetails {
val message: String
) : ProfileTimelineDetails
}
/**
* Returns a disambiguated display name for the user.
* If the display name is null, or profile is not Ready, the user ID is returned.
* If the display name is ambiguous, the user ID is appended in parentheses.
* Otherwise, the display name is returned.
*/
fun ProfileTimelineDetails.getDisambiguatedDisplayName(userId: UserId): String {
return when (this) {
is ProfileTimelineDetails.Ready -> when {
displayName == null -> userId.value
displayNameAmbiguous -> "$displayName ($userId)"
else -> displayName
}
else -> userId.value
}
}

View file

@ -29,6 +29,7 @@ data class TracingFilterConfiguration(
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_UI_TIMELINE to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_READ_RECEIPTS to LogLevel.TRACE,
)
fun getLogLevel(target: Target): LogLevel {
@ -37,7 +38,7 @@ data class TracingFilterConfiguration(
val filter: String
get() {
val fullMap = Target.values().associateWith {
val fullMap = Target.entries.associateWith {
overrides[it] ?: targetsToLogLevel[it] ?: defaultLogLevel
}
return fullMap.map {
@ -64,6 +65,7 @@ enum class Target(open val filter: String) {
MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"),
MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"),
MATRIX_SDK_UI_TIMELINE("matrix_sdk_ui::timeline"),
MATRIX_SDK_BASE_READ_RECEIPTS("matrix_sdk_base::read_receipts"),
}
enum class LogLevel(open val filter: String) {
@ -80,6 +82,12 @@ object TracingFilterConfigurations {
Target.ELEMENT to LogLevel.DEBUG
),
)
val nightly = TracingFilterConfiguration(
overrides = mapOf(
Target.ELEMENT to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_READ_RECEIPTS to LogLevel.TRACE,
),
)
val debug = TracingFilterConfiguration(
overrides = mapOf(
Target.ELEMENT to LogLevel.TRACE
@ -89,7 +97,7 @@ object TracingFilterConfigurations {
/**
* Use this method to create a custom configuration where all targets will have the same log level.
*/
fun custom(logLevel: LogLevel) = TracingFilterConfiguration(overrides = Target.values().associateWith { logLevel })
fun custom(logLevel: LogLevel) = TracingFilterConfiguration(overrides = Target.entries.associateWith { logLevel })
/**
* Use this method to override the log level of specific targets.

View file

@ -19,11 +19,11 @@ package io.element.android.libraries.matrix.api.user
import io.element.android.libraries.matrix.api.MatrixClient
/**
* Get the current user, as [MatrixUser], using [MatrixClient.loadUserAvatarURLString]
* Get the current user, as [MatrixUser], using [MatrixClient.loadUserAvatarUrl]
* and [MatrixClient.loadUserDisplayName].
*/
suspend fun MatrixClient.getCurrentUser(): MatrixUser {
val userAvatarUrl = loadUserAvatarURLString().getOrNull()
val userAvatarUrl = loadUserAvatarUrl().getOrNull()
val userDisplayName = loadUserDisplayName().getOrNull()
return MatrixUser(
userId = sessionId,

View file

@ -21,6 +21,7 @@ import java.util.UUID
interface CallWidgetSettingsProvider {
fun provide(
baseUrl: String,
widgetId: String = UUID.randomUUID().toString()
widgetId: String = UUID.randomUUID().toString(),
encrypted: Boolean,
): MatrixWidgetSettings
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.notification
import com.google.common.truth.Truth.assertThat
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_USER_ID
import org.junit.Test
class NotificationDataTest {
@Test
fun `getSenderName should return user id if there is no sender name`() {
val sut = aNotificationData(
senderDisplayName = null,
senderIsNameAmbiguous = false,
)
assertThat(sut.getSenderName(A_USER_ID)).isEqualTo("@alice:server.org")
}
@Test
fun `getSenderName should return sender name if defined`() {
val sut = aNotificationData(
senderDisplayName = "Alice",
senderIsNameAmbiguous = false,
)
assertThat(sut.getSenderName(A_USER_ID)).isEqualTo("Alice")
}
@Test
fun `getSenderName should return sender name and user id in case of ambiguous display name`() {
val sut = aNotificationData(
senderDisplayName = "Alice",
senderIsNameAmbiguous = true,
)
assertThat(sut.getSenderName(A_USER_ID)).isEqualTo("Alice (@alice:server.org)")
}
private fun aNotificationData(
senderDisplayName: String?,
senderIsNameAmbiguous: Boolean,
): NotificationData {
return NotificationData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
senderAvatarUrl = null,
senderDisplayName = senderDisplayName,
senderIsNameAmbiguous = senderIsNameAmbiguous,
roomAvatarUrl = null,
roomDisplayName = null,
isDirect = false,
isEncrypted = false,
isNoisy = false,
timestamp = 0L,
content = NotificationContent.MessageLike.RoomEncrypted,
hasMention = false,
)
}
}

View file

@ -0,0 +1,74 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.timeline.item.event
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import org.junit.Test
private const val A_USER_ID = "@foo:example.org"
private val aUserId = UserId(A_USER_ID)
class ProfileTimelineDetailsTest {
@Test
fun `getDisambiguatedDisplayName of Unavailable should be equal to userId`() {
assertThat(ProfileTimelineDetails.Unavailable.getDisambiguatedDisplayName(aUserId)).isEqualTo(A_USER_ID)
}
@Test
fun `getDisambiguatedDisplayName of Error should be equal to userId`() {
assertThat(ProfileTimelineDetails.Error("An error").getDisambiguatedDisplayName(aUserId)).isEqualTo(A_USER_ID)
}
@Test
fun `getDisambiguatedDisplayName of Pending should be equal to userId`() {
assertThat(ProfileTimelineDetails.Pending.getDisambiguatedDisplayName(aUserId)).isEqualTo(A_USER_ID)
}
@Test
fun `getDisambiguatedDisplayName of Ready without display name should be equal to userId`() {
assertThat(
ProfileTimelineDetails.Ready(
displayName = null,
displayNameAmbiguous = false,
avatarUrl = null,
).getDisambiguatedDisplayName(aUserId)
).isEqualTo(A_USER_ID)
}
@Test
fun `getDisambiguatedDisplayName of Ready with display name should be equal to display name`() {
assertThat(
ProfileTimelineDetails.Ready(
displayName = "Alice",
displayNameAmbiguous = false,
avatarUrl = null,
).getDisambiguatedDisplayName(aUserId)
).isEqualTo("Alice")
}
@Test
fun `getDisambiguatedDisplayName of Ready with display name and ambiguous should be equal to display name with user id`() {
assertThat(
ProfileTimelineDetails.Ready(
displayName = "Alice",
displayNameAmbiguous = true,
avatarUrl = null,
).getDisambiguatedDisplayName(aUserId)
).isEqualTo("Alice ($A_USER_ID)")
}
}

View file

@ -53,4 +53,5 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.turbine)
}

View file

@ -76,11 +76,14 @@ import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.BackupState
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.FilterStateEventType
import org.matrix.rustcomponents.sdk.FilterTimelineEventType
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
@ -102,9 +105,11 @@ class RustMatrixClient(
private val clock: SystemClock,
) : MatrixClient {
override val sessionId: UserId = UserId(client.userId())
override val deviceId: String = client.deviceId()
override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
private val innerRoomListService = syncService.roomListService()
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
private val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope)
private val verificationService = RustSessionVerificationService(rustSyncService, sessionCoroutineScope)
private val pushersService = RustPushersService(
@ -139,15 +144,25 @@ class RustMatrixClient(
// TODO handle isSoftLogout parameter.
appCoroutineScope.launch {
val existingData = sessionStore.getSession(client.userId())
val anonymizedToken = existingData?.accessToken?.takeLast(4)
Timber.d("Removing session data with token: '...$anonymizedToken'.")
if (existingData != null) {
// Set isTokenValid to false
val newData = client.session().toSessionData(
isTokenValid = false,
loginType = existingData.loginType,
passphrase = existingData.passphrase,
)
sessionStore.updateData(newData)
Timber.d("Removed session data with token: '...$anonymizedToken'.")
} else {
Timber.d("No session data found.")
}
doLogout(doRequest = false, removeSession = false, ignoreSdkError = false)
}.invokeOnCompletion {
if (it != null) {
Timber.e(it, "Failed to remove session data.")
}
}
} else {
Timber.v("didReceiveAuthError -> already cleaning up")
@ -158,11 +173,19 @@ class RustMatrixClient(
Timber.w("didRefreshTokens()")
appCoroutineScope.launch {
val existingData = sessionStore.getSession(client.userId()) ?: return@launch
val anonymizedToken = client.session().accessToken.takeLast(4)
Timber.d("Saving new session data with token: '...$anonymizedToken'. Was token valid: ${existingData.isTokenValid}")
val newData = client.session().toSessionData(
isTokenValid = existingData.isTokenValid,
isTokenValid = true,
loginType = existingData.loginType,
passphrase = existingData.passphrase,
)
sessionStore.updateData(newData)
Timber.d("Saved new session data with token: '...$anonymizedToken'.")
}.invokeOnCompletion {
if (it != null) {
Timber.e(it, "Failed to save new session data.")
}
}
}
}
@ -178,6 +201,25 @@ class RustMatrixClient(
),
)
private val eventFilters = TimelineEventTypeFilter.exclude(
listOf(
FilterStateEventType.ROOM_ALIASES,
FilterStateEventType.ROOM_CANONICAL_ALIAS,
FilterStateEventType.ROOM_GUEST_ACCESS,
FilterStateEventType.ROOM_HISTORY_VISIBILITY,
FilterStateEventType.ROOM_JOIN_RULES,
FilterStateEventType.ROOM_PINNED_EVENTS,
FilterStateEventType.ROOM_POWER_LEVELS,
FilterStateEventType.ROOM_SERVER_ACL,
FilterStateEventType.ROOM_TOMBSTONE,
FilterStateEventType.SPACE_CHILD,
FilterStateEventType.SPACE_PARENT,
FilterStateEventType.POLICY_RULE_ROOM,
FilterStateEventType.POLICY_RULE_SERVER,
FilterStateEventType.POLICY_RULE_USER,
).map(FilterTimelineEventType::State)
)
override val roomListService: RoomListService
get() = rustRoomListService
@ -226,10 +268,14 @@ class RustMatrixClient(
}
}
private fun pairOfRoom(roomId: RoomId): Pair<RoomListItem, Room>? {
private suspend fun pairOfRoom(roomId: RoomId): Pair<RoomListItem, Room>? {
val cachedRoomListItem = innerRoomListService.roomOrNull(roomId.value)
// Keep using fullRoomBlocking for now as it's faster.
val fullRoom = cachedRoomListItem?.fullRoomBlocking()
val fullRoom = cachedRoomListItem?.let { roomListItem ->
if (!roomListItem.isTimelineInitialized()) {
roomListItem.initTimeline(eventFilters)
}
roomListItem.fullRoom()
}
return if (cachedRoomListItem == null || fullRoom == null) {
Timber.d("No room cached for $roomId")
null
@ -411,7 +457,7 @@ class RustMatrixClient(
}
}
override suspend fun loadUserAvatarURLString(): Result<String?> = withContext(sessionDispatcher) {
override suspend fun loadUserAvatarUrl(): Result<String?> = withContext(sessionDispatcher) {
runCatching {
client.avatarUrl()
}

View file

@ -44,6 +44,7 @@ class RustMatrixClientFactory @Inject constructor(
.basePath(baseDirectory.absolutePath)
.homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId)
.passphrase(sessionData.passphrase)
.userAgent(userAgentProvider.provide())
// FIXME Quick and dirty fix for stopping version requests on startup https://github.com/matrix-org/matrix-rust-sdk/pull/1376
.serverVersions(listOf("v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5"))

View file

@ -19,6 +19,8 @@ package io.element.android.libraries.matrix.impl.auth
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
@ -28,6 +30,7 @@ import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator
import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.LoggedInState
@ -39,6 +42,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.OidcAuthenticationData
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService
@ -51,10 +55,15 @@ class RustMatrixAuthenticationService @Inject constructor(
private val sessionStore: SessionStore,
userAgentProvider: UserAgentProvider,
private val rustMatrixClientFactory: RustMatrixClientFactory,
private val passphraseGenerator: PassphraseGenerator,
private val buildMeta: BuildMeta,
) : MatrixAuthenticationService {
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData.
private val pendingPassphrase = getDatabasePassphrase()
private val authService: RustAuthenticationService = RustAuthenticationService(
basePath = baseDirectory.absolutePath,
passphrase = null,
passphrase = pendingPassphrase,
userAgent = userAgentProvider.provide(),
oidcConfiguration = oidcConfiguration,
customSlidingSyncProxy = null,
@ -76,6 +85,12 @@ class RustMatrixAuthenticationService @Inject constructor(
val sessionData = sessionStore.getSession(sessionId.value)
if (sessionData != null) {
if (sessionData.isTokenValid) {
// Use the sessionData.passphrase, which can be null for a previously created session
if (sessionData.passphrase == null) {
Timber.w("Restoring a session without a passphrase")
} else {
Timber.w("Restoring a session with a passphrase")
}
rustMatrixClientFactory.create(sessionData)
} else {
error("Token is not valid")
@ -88,6 +103,21 @@ class RustMatrixAuthenticationService @Inject constructor(
}
}
private fun getDatabasePassphrase(): String? {
// TODO Remove this if block at some point
// Return a passphrase only for debug and nightly build for now
if (buildMeta.buildType == BuildType.RELEASE) {
Timber.w("New sessions will not be encrypted with a passphrase (release build)")
return null
}
val passphrase = passphraseGenerator.generatePassphrase()
if (passphrase != null) {
Timber.w("New sessions will be encrypted with a passphrase")
}
return passphrase
}
override fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> = currentHomeserver
override suspend fun setHomeserver(homeserver: String): Result<Unit> =
@ -111,6 +141,7 @@ class RustMatrixAuthenticationService @Inject constructor(
it.session().toSessionData(
isTokenValid = true,
loginType = LoginType.PASSWORD,
passphrase = pendingPassphrase,
)
}
sessionStore.storeData(sessionData)
@ -158,6 +189,7 @@ class RustMatrixAuthenticationService @Inject constructor(
it.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,
passphrase = pendingPassphrase
)
}
pendingOidcAuthenticationData?.close()

View file

@ -20,6 +20,7 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
@ -27,6 +28,7 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope
@Module
@ContributesTo(SessionScope::class)
@ -60,4 +62,10 @@ object SessionMatrixModule {
fun provideMediaLoader(matrixClient: MatrixClient): MatrixMediaLoader {
return matrixClient.mediaLoader
}
@SessionCoroutineScope
@Provides
fun provideSessionCoroutineScope(matrixClient: MatrixClient): CoroutineScope {
return matrixClient.sessionCoroutineScope
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.keys
import android.util.Base64
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import java.security.SecureRandom
import javax.inject.Inject
private const val SECRET_SIZE = 256
@ContributesBinding(AppScope::class)
class DefaultPassphraseGenerator @Inject constructor() : PassphraseGenerator {
override fun generatePassphrase(): String? {
val key = ByteArray(size = SECRET_SIZE)
SecureRandom().nextBytes(key)
return Base64.encodeToString(key, Base64.NO_PADDING or Base64.NO_WRAP)
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.keys
interface PassphraseGenerator {
/**
* Generate a passphrase to encrypt the databases of a session.
* Return null to not encrypt the databases.
*/
fun generatePassphrase(): String?
}

View file

@ -24,6 +24,7 @@ import java.util.Date
internal fun Session.toSessionData(
isTokenValid: Boolean,
loginType: LoginType,
passphrase: String?,
) = SessionData(
userId = userId,
deviceId = deviceId,
@ -35,4 +36,5 @@ internal fun Session.toSessionData(
loginTimestamp = Date(),
isTokenValid = isTokenValid,
loginType = loginType,
passphrase = passphrase,
)

View file

@ -45,6 +45,7 @@ class NotificationMapper(
roomId = roomId,
senderAvatarUrl = item.senderInfo.avatarUrl,
senderDisplayName = item.senderInfo.displayName,
senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous,
roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { item.roomInfo.isDirect },
roomDisplayName = item.roomInfo.displayName,
isDirect = item.roomInfo.isDirect,

View file

@ -18,7 +18,7 @@ package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.impl.room.RoomMemberMapper
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.StateEventContent

View file

@ -143,6 +143,6 @@ class RustNotificationSettingsService(
override suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean> =
runCatching {
notificationSettings.canHomeserverPushEncryptedEventToDevice()
notificationSettings.canPushEncryptedEventToDevice()
}
}

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.use

View file

@ -18,7 +18,6 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
@ -39,7 +38,6 @@ import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -51,20 +49,16 @@ import io.element.android.libraries.matrix.impl.media.toMSC3246range
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.timeline.AsyncMatrixTimeline
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.util.destroyAll
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -75,7 +69,6 @@ import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.WidgetCapabilities
@ -125,8 +118,8 @@ class RustMatrixRoom(
private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8)
private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId")
private val _membersStateFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown)
private val _syncUpdateFlow = MutableStateFlow(0L)
private val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher)
private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown)
override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow
@ -135,7 +128,7 @@ class RustMatrixRoom(
_syncUpdateFlow.value = systemClock.epochMillis()
}
override val membersStateFlow: StateFlow<MatrixRoomMembersState> = _membersStateFlow.asStateFlow()
override val membersStateFlow: StateFlow<MatrixRoomMembersState> = roomMemberListFetcher.membersFlow
override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow()
@ -192,35 +185,7 @@ class RustMatrixRoom(
override val activeMemberCount: Long
get() = innerRoom.activeMembersCount().toLong()
override suspend fun updateMembers(): Result<Unit> = withContext(roomMembersDispatcher) {
val currentState = _membersStateFlow.value
val currentMembers = currentState.roomMembers()?.toImmutableList()
_membersStateFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = currentMembers)
var rustMembers: List<RoomMember>? = null
try {
rustMembers = innerRoom.members().use { membersIterator ->
buildList {
while (true) {
// Loading the whole membersIterator as a stop-gap measure.
// We should probably implement some sort of paging in the future.
ensureActive()
addAll(membersIterator.nextChunk(1000u) ?: break)
}
}
}
val mappedMembers = rustMembers.parallelMap(RoomMemberMapper::map)
_membersStateFlow.value = MatrixRoomMembersState.Ready(mappedMembers.toImmutableList())
Result.success(Unit)
} catch (exception: CancellationException) {
_membersStateFlow.value = MatrixRoomMembersState.Error(prevRoomMembers = currentMembers, failure = exception)
throw exception
} catch (exception: Exception) {
_membersStateFlow.value = MatrixRoomMembersState.Error(prevRoomMembers = currentMembers, failure = exception)
Result.failure(exception)
} finally {
rustMembers?.destroyAll()
}
}
override suspend fun updateMembers() = roomMemberListFetcher.fetchRoomMembers()
override suspend fun userDisplayName(userId: UserId): Result<String?> = withContext(roomDispatcher) {
runCatching {
@ -335,9 +300,15 @@ class RustMatrixRoom(
}
}
override suspend fun canUserRedact(userId: UserId): Result<Boolean> {
override suspend fun canUserRedactOwn(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserRedact(userId.value)
innerRoom.canUserRedactOwn(userId.value)
}
}
override suspend fun canUserRedactOther(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserRedactOther(userId.value)
}
}
@ -548,6 +519,10 @@ class RustMatrixRoom(
)
}
override suspend fun typingNotice(isTyping: Boolean) = runCatching {
innerRoom.typingNotice(isTyping)
}
override suspend fun generateWidgetWebViewUrl(
widgetSettings: MatrixWidgetSettings,
clientId: String,
@ -569,14 +544,6 @@ class RustMatrixRoom(
)
}
override fun pollHistory() = AsyncMatrixTimeline(
coroutineScope = roomCoroutineScope,
dispatcher = roomDispatcher
) {
val innerTimeline = innerRoom.pollHistory()
createMatrixTimeline(innerTimeline)
}
private fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
return runCatching {
MediaUploadHandlerImpl(files, handle())

View file

@ -0,0 +1,129 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.room.member
import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.impl.util.destroyAll
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.RoomInterface
import org.matrix.rustcomponents.sdk.RoomMembersIterator
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
/**
* This class fetches the room members for a given room in a 'paginated' way, and taking into account previous cached values.
*/
internal class RoomMemberListFetcher(
private val room: RoomInterface,
private val dispatcher: CoroutineDispatcher,
private val pageSize: Int = 1000,
) {
private val updatedRoomMemberMutex = Mutex()
private val roomId = room.id()
private val _membersFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown)
val membersFlow: StateFlow<MatrixRoomMembersState> = _membersFlow
/**
* Fetches the room members for the given room.
* It will emit the cached members first, and then the updated members in batches of [pageSize] items, through [membersFlow].
* @param withCache Whether to load the cached members first. Defaults to true.
*/
suspend fun fetchRoomMembers(withCache: Boolean = true) {
if (updatedRoomMemberMutex.isLocked) {
Timber.i("Room members are already being updated for room $roomId")
return
}
updatedRoomMemberMutex.withLock {
withContext(dispatcher) {
// Load cached members as fallback and to get faster results
if (withCache) {
if (_membersFlow.value !is MatrixRoomMembersState.Ready) {
fetchCachedRoomMembers()
} else {
Timber.i("No need to load cached members found for room $roomId")
}
}
val prevRoomMembers = (_membersFlow.value as? MatrixRoomMembersState.Ready)?.roomMembers?.toImmutableList()
_membersFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = prevRoomMembers)
try {
// Start loading new members
parseAndEmitMembers(room.members())
} catch (exception: CancellationException) {
Timber.d("Cancelled loading updated members for room $roomId")
throw exception
} catch (exception: Exception) {
Timber.e(exception, "Failed to load updated members for room $roomId")
_membersFlow.value = MatrixRoomMembersState.Error(exception, prevRoomMembers)
}
}
}
}
internal suspend fun fetchCachedRoomMembers() = withContext(dispatcher) {
Timber.i("Loading cached members for room $roomId")
try {
val iterator = room.membersNoSync()
parseAndEmitMembers(iterator)
} catch (exception: CancellationException) {
Timber.d("Cancelled loading cached members for room $roomId")
throw exception
} catch (exception: Exception) {
Timber.e(exception, "Failed to load cached members for room $roomId")
_membersFlow.value = MatrixRoomMembersState.Error(exception, _membersFlow.value.roomMembers()?.toImmutableList())
}
}
private suspend fun parseAndEmitMembers(roomMembersIterator: RoomMembersIterator) {
roomMembersIterator.use { iterator ->
val results = buildList {
while (true) {
// Loading the whole membersIterator as a stop-gap measure.
// We should probably implement some sort of paging in the future.
coroutineContext.ensureActive()
val chunk = iterator.nextChunk(pageSize.toUInt())
val members = try {
// Load next chunk. If null (no more items), exit the loop
chunk?.parallelMap(RoomMemberMapper::map) ?: break
} finally {
// Make sure we clear all member references
chunk?.destroyAll()
}
addAll(members)
Timber.i("Emitting first $size members for room $roomId")
_membersFlow.value = MatrixRoomMembersState.Ready(toImmutableList())
}
}
if (results.isEmpty()) {
_membersFlow.value = MatrixRoomMembersState.Ready(results.toImmutableList())
}
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.room
package io.element.android.libraries.matrix.impl.room.member
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember

View file

@ -19,7 +19,7 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.impl.notificationsettings.RoomNotificationSettingsMapper
import io.element.android.libraries.matrix.impl.room.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.use
@ -34,13 +34,15 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
name = roomInfo.name ?: roomInfo.id,
canonicalAlias = roomInfo.canonicalAlias,
isDirect = roomInfo.isDirect,
avatarURLString = roomInfo.avatarUrl,
unreadNotificationCount = roomInfo.notificationCount.toInt(),
avatarUrl = roomInfo.avatarUrl,
numUnreadMentions = roomInfo.numUnreadMentions.toInt(),
numUnreadMessages = roomInfo.numUnreadMessages.toInt(),
numUnreadNotifications = roomInfo.numUnreadNotifications.toInt(),
lastMessage = latestRoomMessage,
lastMessageTimestamp = latestRoomMessage?.originServerTs,
inviter = roomInfo.inviter?.let(RoomMemberMapper::map),
notificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
hasOngoingCall = roomInfo.hasRoomCall,
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
hasRoomCall = roomInfo.hasRoomCall,
isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L,
)
}
}

View file

@ -38,6 +38,7 @@ import timber.log.Timber
/**
* This class is a wrapper around a [MatrixTimeline] that will be created asynchronously.
*/
@Suppress("unused")
class AsyncMatrixTimeline(
coroutineScope: CoroutineScope,
dispatcher: CoroutineDispatcher,

View file

@ -27,13 +27,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import org.matrix.rustcomponents.sdk.BackPaginationStatus
import org.matrix.rustcomponents.sdk.BackPaginationStatusListener
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem
import org.matrix.rustcomponents.sdk.TimelineListener
import timber.log.Timber
import uniffi.matrix_sdk_ui.BackPaginationStatus
internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -> Unit): Flow<List<TimelineDiff>> =
callbackFlow {

View file

@ -27,6 +27,8 @@ import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessage
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.postprocessor.DmBeginningTimelineProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.FilterHiddenStateEventsProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
@ -44,13 +46,13 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BackPaginationStatus
import org.matrix.rustcomponents.sdk.EventItemOrigin
import org.matrix.rustcomponents.sdk.PaginationOptions
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem
import timber.log.Timber
import uniffi.matrix_sdk_ui.BackPaginationStatus
import uniffi.matrix_sdk_ui.EventItemOrigin
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
@ -82,6 +84,10 @@ class RustMatrixTimeline(
dispatcher = dispatcher,
)
private val filterHiddenStateEventsProcessor = FilterHiddenStateEventsProcessor()
private val dmBeginningTimelineProcessor = DmBeginningTimelineProcessor()
private val timelineItemFactory = MatrixTimelineItemMapper(
fetchDetailsForEvent = this::fetchDetailsForEvent,
roomCoroutineScope = roomCoroutineScope,
@ -101,9 +107,16 @@ class RustMatrixTimeline(
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems.mapLatest { items ->
encryptedHistoryPostProcessor.process(items)
}
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems
.mapLatest { items -> encryptedHistoryPostProcessor.process(items) }
.mapLatest { items -> filterHiddenStateEventsProcessor.process(items) }
.mapLatest { items ->
dmBeginningTimelineProcessor.process(
items = items,
isDm = matrixRoom.isDirect && matrixRoom.isOneToOne,
isAtStartOfTimeline = paginationState.value.beginningOfRoomReached
)
}
init {
Timber.d("Initialize timeline for room ${matrixRoom.roomId}")

View file

@ -16,10 +16,10 @@
package io.element.android.libraries.matrix.impl.timeline
import org.matrix.rustcomponents.sdk.EventItemOrigin
import org.matrix.rustcomponents.sdk.TimelineChange
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem
import uniffi.matrix_sdk_ui.EventItemOrigin
/**
* Tries to get an event origin from the TimelineDiff.

View file

@ -31,12 +31,12 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
import org.matrix.rustcomponents.sdk.ProfileDetails as RustProfileDetails
import org.matrix.rustcomponents.sdk.Receipt as RustReceipt
import uniffi.matrix_sdk_ui.EventItemOrigin as RustEventItemOrigin
class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper()) {
fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use {

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.timeline.postprocessor
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
/**
* This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs.
*/
class DmBeginningTimelineProcessor {
fun process(
items: List<MatrixTimelineItem>,
isDm: Boolean,
isAtStartOfTimeline: Boolean
): List<MatrixTimelineItem> {
if (!isDm || !isAtStartOfTimeline) return items
// Find room creation event. This is usually index 0
val roomCreationEventIndex = items.indexOfFirst {
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? StateContent
stateEventContent?.content is OtherState.RoomCreate
}
// Find self-join event for room creator. This is usually index 1
val roomCreatorUserId = (items.getOrNull(roomCreationEventIndex) as? MatrixTimelineItem.Event)?.event?.sender
val selfUserJoinedEventIndex = roomCreatorUserId?.let { creatorUserId ->
items.indexOfFirst {
val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? RoomMembershipContent
stateEventContent?.change == MembershipChange.JOINED && stateEventContent.userId == creatorUserId
}
} ?: -1
// Remove items at the indices we found
val newItems = items.toMutableList()
if (selfUserJoinedEventIndex in newItems.indices) {
newItems.removeAt(selfUserJoinedEventIndex)
}
if (roomCreationEventIndex in newItems.indices) {
newItems.removeAt(roomCreationEventIndex)
}
return newItems
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.timeline.postprocessor
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
/**
* This class is used to filter out 'hidden' state events from the timeline.
*/
class FilterHiddenStateEventsProcessor {
fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
return items.filter { item ->
when (item) {
is MatrixTimelineItem.Event -> {
when (val content = item.event.content) {
// If it's a state event, make sure it's visible
is StateContent -> content.isVisibleInTimeline()
// We can display any other event
else -> true
}
}
is MatrixTimelineItem.Virtual -> true
is MatrixTimelineItem.Other -> true
}
}
}
}

View file

@ -27,7 +27,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCallWidgetSettingsProvider @Inject constructor() : CallWidgetSettingsProvider {
override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings {
override fun provide(baseUrl: String, widgetId: String, encrypted: Boolean): MatrixWidgetSettings {
val options = VirtualElementCallWidgetOptions(
elementCallUrl = baseUrl,
widgetId = widgetId,
@ -40,7 +40,7 @@ class DefaultCallWidgetSettingsProvider @Inject constructor() : CallWidgetSettin
confineToRoom = true,
font = null,
analyticsId = null,
encryption = EncryptionSystem.PerParticipantKeys,
encryption = if (encrypted) EncryptionSystem.PerParticipantKeys else EncryptionSystem.Unencrypted,
)
val rustWidgetSettings = newVirtualElementCallWidget(options)
return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings)

View file

@ -0,0 +1,303 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.room.member
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.MembershipState
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomMembersIterator
class RoomMemberListFetcherTest {
@Test
fun `fetchCachedRoomMembers - emits cached members, if any`() = runTest {
val room = FakeRustRoom(getMembersNoSync = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
)
)
})
val fetcher = RoomMemberListFetcher(room, Dispatchers.Default)
fetcher.membersFlow.test {
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java)
fetcher.fetchCachedRoomMembers()
val readyItem = awaitItem()
assertThat(readyItem).isInstanceOf(MatrixRoomMembersState.Ready::class.java)
assertThat((readyItem as? MatrixRoomMembersState.Ready)?.roomMembers?.size).isEqualTo(3)
}
}
@Test
fun `fetchCachedRoomMembers - emits empty list, if no members exist`() = runTest {
val room = FakeRustRoom(getMembersNoSync = {
FakeRoomMembersIterator(emptyList())
})
val fetcher = RoomMemberListFetcher(room, Dispatchers.Default)
fetcher.membersFlow.test {
fetcher.fetchCachedRoomMembers()
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java)
assertThat(awaitItem().roomMembers()).isEmpty()
}
}
@Test
fun `fetchCachedRoomMembers - emits Error on error found`() = runTest {
val room = FakeRustRoom(getMembersNoSync = {
error("Some unexpected issue")
})
val fetcher = RoomMemberListFetcher(room, Dispatchers.Default)
fetcher.membersFlow.test {
fetcher.fetchCachedRoomMembers()
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java)
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Error::class.java)
}
}
@Test
fun `fetchCachedRoomMembers - emits items using page size`() = runTest {
val room = FakeRustRoom(getMembersNoSync = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
)
)
})
val fetcher = RoomMemberListFetcher(room, Dispatchers.Default, pageSize = 2)
fetcher.membersFlow.test {
fetcher.fetchCachedRoomMembers()
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java)
assertThat((awaitItem() as? MatrixRoomMembersState.Ready)?.roomMembers?.size).isEqualTo(2)
assertThat((awaitItem() as? MatrixRoomMembersState.Ready)?.roomMembers?.size).isEqualTo(3)
ensureAllEventsConsumed()
}
}
@Test
fun `fetchRoomMembers - with 'withCache' set to false emits only new members, if any`() = runTest {
val room = FakeRustRoom(getMembers = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
)
)
})
val fetcher = RoomMemberListFetcher(room, Dispatchers.Default)
fetcher.membersFlow.test {
fetcher.fetchRoomMembers(withCache = false)
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java)
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java)
assertThat((awaitItem() as? MatrixRoomMembersState.Ready)?.roomMembers?.size).isEqualTo(3)
}
}
@Test
fun `fetchRoomMembers - on error it emits an Error item`() = runTest {
val room = FakeRustRoom(getMembers = { error("An unexpected error") })
val fetcher = RoomMemberListFetcher(room, Dispatchers.Default)
fetcher.membersFlow.test {
fetcher.fetchRoomMembers(withCache = false)
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java)
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java)
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Error::class.java)
}
}
@Test
fun `fetchRoomMembers - with 'withCache' returns cached items first, then new ones`() = runTest {
val room = FakeRustRoom(
getMembersNoSync = {
FakeRoomMembersIterator(listOf(FakeRustRoomMember(A_USER_ID_4)))
},
getMembers = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
)
)
}
)
val fetcher = RoomMemberListFetcher(room, Dispatchers.Default)
fetcher.membersFlow.test {
fetcher.fetchRoomMembers(withCache = true)
// Initial
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Unknown::class.java)
// Loaded cached
awaitItem().let { cached ->
assertThat(cached).isInstanceOf(MatrixRoomMembersState.Ready::class.java)
assertThat(cached.roomMembers()).hasSize(1)
}
// Start loading new
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java)
awaitItem().let { ready ->
assertThat(ready).isInstanceOf(MatrixRoomMembersState.Ready::class.java)
assertThat(ready.roomMembers()).hasSize(3)
}
}
}
@Test
fun `fetchRoomMembers - with 'withCache' skips cache if there is already a ready state`() = runTest {
val room = FakeRustRoom(
getMembersNoSync = {
FakeRoomMembersIterator(listOf(FakeRustRoomMember(A_USER_ID_4)))
},
getMembers = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
)
)
}
)
val fetcher = RoomMemberListFetcher(room, Dispatchers.Default)
// Set a ready state
fetcher.fetchRoomMembers(withCache = false)
fetcher.membersFlow.test {
// Start loading new members
fetcher.fetchRoomMembers(withCache = true)
// Previous ready state
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Ready::class.java)
// New pending state
assertThat(awaitItem()).isInstanceOf(MatrixRoomMembersState.Pending::class.java)
// New ready state
awaitItem().let { ready ->
assertThat(ready).isInstanceOf(MatrixRoomMembersState.Ready::class.java)
assertThat(ready.roomMembers()).hasSize(3)
}
}
}
}
class FakeRustRoom(
private val getMembers: () -> RoomMembersIterator = { FakeRoomMembersIterator() },
private val getMembersNoSync: () -> RoomMembersIterator = { FakeRoomMembersIterator() },
) : Room(NoPointer) {
override fun id(): String {
return A_ROOM_ID.value
}
override suspend fun members(): RoomMembersIterator {
return getMembers()
}
override suspend fun membersNoSync(): RoomMembersIterator {
return getMembersNoSync()
}
override fun close() {
// No-op
}
}
class FakeRoomMembersIterator(
private var members: List<RoomMember>? = null
) : RoomMembersIterator(NoPointer) {
override fun len(): UInt {
return members?.size?.toUInt() ?: 0u
}
override fun nextChunk(chunkSize: UInt): List<RoomMember>? {
if (members?.isEmpty() == true) {
return null
}
return members?.let {
val result = it.take(chunkSize.toInt())
members = it.subList(result.size, it.size)
result
}
}
}
class FakeRustRoomMember(
private val userId: UserId,
private val displayName: String? = null,
private val avatarUrl: String? = null,
private val membership: MembershipState = MembershipState.JOIN,
private val isNameAmbiguous: Boolean = false,
private val powerLevel: Long = 0L,
) : RoomMember(NoPointer) {
override fun userId(): String {
return userId.value
}
override fun displayName(): String? {
return displayName
}
override fun avatarUrl(): String? {
return avatarUrl
}
override fun membership(): MembershipState {
return membership
}
override fun isNameAmbiguous(): Boolean {
return isNameAmbiguous
}
override fun powerLevel(): Long {
return powerLevel
}
override fun normalizedPowerLevel(): Long {
return powerLevel
}
override fun isIgnored(): Boolean {
return false
}
}

View file

@ -0,0 +1,102 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.timeline.postprocessor
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import org.junit.Test
class DmBeginningTimelineProcessorTest {
@Test
fun `processor removes room creation event and self-join event from DM timeline`() {
val timelineItems = listOf(
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
)
val processor = DmBeginningTimelineProcessor()
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = true)
assertThat(processedItems).isEmpty()
}
@Test
fun `processor removes room creation event and self-join event from DM timeline even if they're not the first items`() {
val timelineItems = listOf(
MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))),
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
)
val expected = listOf(
MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))),
)
val processor = DmBeginningTimelineProcessor()
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = true)
assertThat(processedItems).isEqualTo(expected)
}
@Test
fun `processor won't remove items if it's not a DM`() {
val timelineItems = listOf(
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
)
val processor = DmBeginningTimelineProcessor()
val processedItems = processor.process(timelineItems, isDm = false, isAtStartOfTimeline = true)
assertThat(processedItems).isEqualTo(timelineItems)
}
@Test
fun `processor won't remove items if it's not at the start of the timeline`() {
val timelineItems = listOf(
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
)
val processor = DmBeginningTimelineProcessor()
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
assertThat(processedItems).isEqualTo(timelineItems)
}
@Test
fun `processor won't remove the first member join event if it can't find the room creation event`() {
val timelineItems = listOf(
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))),
)
val processor = DmBeginningTimelineProcessor()
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
assertThat(processedItems).isEqualTo(timelineItems)
}
@Test
fun `processor won't remove the first member join event if it's not from the room creator`() {
val timelineItems = listOf(
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))),
)
val processor = DmBeginningTimelineProcessor()
val processedItems = processor.process(timelineItems, isDm = true, isAtStartOfTimeline = false)
assertThat(processedItems).isEqualTo(timelineItems)
}
}

View file

@ -0,0 +1,77 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.timeline.postprocessor
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import org.junit.Test
class FilterHiddenStateEventsProcessorTest {
@Test
fun test() {
val items = listOf(
// These are visible because they're not state events
MatrixTimelineItem.Other,
MatrixTimelineItem.Virtual("virtual", VirtualTimelineItem.ReadMarker),
MatrixTimelineItem.Event("event", anEventTimelineItem()),
// These are visible state events
MatrixTimelineItem.Event("m.room.avatar", anEventTimelineItem(content = StateContent("", OtherState.RoomAvatar("")))),
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event("m.room.encrypted", anEventTimelineItem(content = StateContent("", OtherState.RoomEncryption))),
MatrixTimelineItem.Event("m.room.name", anEventTimelineItem(content = StateContent("", OtherState.RoomName("")))),
MatrixTimelineItem.Event("m.room.third_party_invite", anEventTimelineItem(content = StateContent("", OtherState.RoomThirdPartyInvite("")))),
MatrixTimelineItem.Event("m.room.topic", anEventTimelineItem(content = StateContent("", OtherState.RoomTopic("")))),
MatrixTimelineItem.Event("m.room.custom", anEventTimelineItem(content = StateContent("", OtherState.Custom("")))),
// These ones are hidden
MatrixTimelineItem.Event("m.room.aliases", anEventTimelineItem(content = StateContent("", OtherState.RoomAliases))),
MatrixTimelineItem.Event("m.room.canonical_alias", anEventTimelineItem(content = StateContent("", OtherState.RoomCanonicalAlias))),
MatrixTimelineItem.Event("m.room.guest_access", anEventTimelineItem(content = StateContent("", OtherState.RoomGuestAccess))),
MatrixTimelineItem.Event("m.room.history_visibility", anEventTimelineItem(content = StateContent("", OtherState.RoomHistoryVisibility))),
MatrixTimelineItem.Event("m.room.join_rules", anEventTimelineItem(content = StateContent("", OtherState.RoomJoinRules))),
MatrixTimelineItem.Event("m.room.pinned_events", anEventTimelineItem(content = StateContent("", OtherState.RoomPinnedEvents))),
MatrixTimelineItem.Event("m.room.power_levels", anEventTimelineItem(content = StateContent("", OtherState.RoomPowerLevels))),
MatrixTimelineItem.Event("m.room.server_acl", anEventTimelineItem(content = StateContent("", OtherState.RoomServerAcl))),
MatrixTimelineItem.Event("m.room.tombstone", anEventTimelineItem(content = StateContent("", OtherState.RoomTombstone))),
MatrixTimelineItem.Event("m.space.child", anEventTimelineItem(content = StateContent("", OtherState.SpaceChild))),
MatrixTimelineItem.Event("m.space.parent", anEventTimelineItem(content = StateContent("", OtherState.SpaceParent))),
MatrixTimelineItem.Event("m.room.policy.rule.room", anEventTimelineItem(content = StateContent("", OtherState.PolicyRuleRoom))),
MatrixTimelineItem.Event("m.room.policy.rule.server", anEventTimelineItem(content = StateContent("", OtherState.PolicyRuleServer))),
MatrixTimelineItem.Event("m.room.policy.rule.user", anEventTimelineItem(content = StateContent("", OtherState.PolicyRuleUser))),
)
val expected = listOf(
MatrixTimelineItem.Other,
MatrixTimelineItem.Virtual("virtual", VirtualTimelineItem.ReadMarker),
MatrixTimelineItem.Event("event", anEventTimelineItem()),
MatrixTimelineItem.Event("m.room.avatar", anEventTimelineItem(content = StateContent("", OtherState.RoomAvatar("")))),
MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event("m.room.encrypted", anEventTimelineItem(content = StateContent("", OtherState.RoomEncryption))),
MatrixTimelineItem.Event("m.room.name", anEventTimelineItem(content = StateContent("", OtherState.RoomName("")))),
MatrixTimelineItem.Event("m.room.third_party_invite", anEventTimelineItem(content = StateContent("", OtherState.RoomThirdPartyInvite("")))),
MatrixTimelineItem.Event("m.room.topic", anEventTimelineItem(content = StateContent("", OtherState.RoomTopic("")))),
MatrixTimelineItem.Event("m.room.custom", anEventTimelineItem(content = StateContent("", OtherState.Custom("")))),
)
val processor = FilterHiddenStateEventsProcessor()
assertThat(processor.process(items)).isEqualTo(expected)
}
}

View file

@ -43,12 +43,16 @@ import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.TestScope
class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
override val deviceId: String = "A_DEVICE_ID",
override val sessionCoroutineScope: CoroutineScope = TestScope(),
private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
private val userAvatarUrl: Result<String> = Result.success(AN_AVATAR_URL),
override val roomListService: RoomListService = FakeRoomListService(),
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(),
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
@ -135,8 +139,8 @@ class FakeMatrixClient(
return userDisplayName
}
override suspend fun loadUserAvatarURLString(): Result<String?> {
return userAvatarURLString
override suspend fun loadUserAvatarUrl(): Result<String?> {
return userAvatarUrl
}
override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> {

View file

@ -28,7 +28,6 @@ fun aBuildMeta(
versionName: String = "",
versionCode: Int = 0,
gitRevision: String = "",
gitRevisionDate: String = "",
gitBranchName: String = "",
flavorDescription: String = "",
flavorShortDescription: String = "",
@ -41,7 +40,6 @@ fun aBuildMeta(
versionName,
versionCode,
gitRevision,
gitRevisionDate,
gitBranchName,
flavorDescription,
flavorShortDescription

View file

@ -78,7 +78,8 @@ class FakeMatrixRoom(
override val activeMemberCount: Long = 234L,
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
canRedact: Boolean = false,
canRedactOwn: Boolean = false,
canRedactOther: Boolean = false,
) : MatrixRoom {
private var ignoreResult: Result<Unit> = Result.success(Unit)
private var unignoreResult: Result<Unit> = Result.success(Unit)
@ -88,7 +89,8 @@ class FakeMatrixRoom(
private var joinRoomResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true)
private var canRedactResult = Result.success(canRedact)
private var canRedactOwnResult = Result.success(canRedactOwn)
private var canRedactOtherResult = Result.success(canRedactOther)
private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>()
private val canSendEventResults = mutableMapOf<MessageEventType, Result<Boolean>>()
private var sendMediaResult = Result.success(FakeMediaUploadHandler())
@ -113,6 +115,9 @@ class FakeMatrixRoom(
private var canUserJoinCallResult: Result<Boolean> = Result.success(true)
var sendMessageMentions = emptyList<Mention>()
val editMessageCalls = mutableListOf<Pair<String, String?>>()
private val _typingRecord = mutableListOf<Boolean>()
val typingRecord: List<Boolean>
get() = _typingRecord
var sendMediaCount = 0
private set
@ -161,7 +166,7 @@ class FakeMatrixRoom(
private var leaveRoomError: Throwable? = null
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableStateFlow(aRoomInfo())
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
@ -169,9 +174,7 @@ class FakeMatrixRoom(
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =
MutableStateFlow(MatrixRoomNotificationSettingsState.Unknown)
override suspend fun updateMembers(): Result<Unit> = simulateLongTask {
updateMembersResult
}
override suspend fun updateMembers() = Unit
override suspend fun updateRoomNotificationSettings(): Result<Unit> = simulateLongTask {
val notificationSettings = notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow()
@ -276,8 +279,12 @@ class FakeMatrixRoom(
return canInviteResult
}
override suspend fun canUserRedact(userId: UserId): Result<Boolean> {
return canRedactResult
override suspend fun canUserRedactOwn(userId: UserId): Result<Boolean> {
return canRedactOwnResult
}
override suspend fun canUserRedactOther(userId: UserId): Result<Boolean> {
return canRedactOtherResult
}
override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean> {
@ -422,6 +429,11 @@ class FakeMatrixRoom(
progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = fakeSendMedia(progressCallback)
override suspend fun typingNotice(isTyping: Boolean): Result<Unit> {
_typingRecord += isTyping
return Result.success(Unit)
}
override suspend fun generateWidgetWebViewUrl(
widgetSettings: MatrixWidgetSettings,
clientId: String,
@ -431,10 +443,6 @@ class FakeMatrixRoom(
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
override fun pollHistory(): MatrixTimeline {
return FakeMatrixTimeline()
}
fun givenLeaveRoomError(throwable: Throwable?) {
this.leaveRoomError = throwable
}

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.test.room
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.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@ -34,42 +35,52 @@ fun aRoomSummaryFilled(
roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME,
isDirect: Boolean = false,
avatarURLString: String? = null,
avatarUrl: String? = null,
lastMessage: RoomMessage? = aRoomMessage(),
lastMessageTimestamp: Long? = null,
unreadNotificationCount: Int = 2,
numUnreadMentions: Int = 1,
numUnreadMessages: Int = 2,
notificationMode: RoomNotificationMode? = null,
) = RoomSummary.Filled(
aRoomSummaryDetail(
aRoomSummaryDetails(
roomId = roomId,
name = name,
isDirect = isDirect,
avatarURLString = avatarURLString,
avatarUrl = avatarUrl,
lastMessage = lastMessage,
lastMessageTimestamp = lastMessageTimestamp,
unreadNotificationCount = unreadNotificationCount,
numUnreadMentions = numUnreadMentions,
numUnreadMessages = numUnreadMessages,
notificationMode = notificationMode,
)
)
fun aRoomSummaryDetail(
fun aRoomSummaryDetails(
roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME,
isDirect: Boolean = false,
avatarURLString: String? = null,
avatarUrl: String? = null,
lastMessage: RoomMessage? = aRoomMessage(),
lastMessageTimestamp: Long? = null,
unreadNotificationCount: Int = 2,
numUnreadMentions: Int = 0,
numUnreadMessages: Int = 0,
numUnreadNotifications: Int = 0,
notificationMode: RoomNotificationMode? = null,
inviter: RoomMember? = null,
canonicalAlias: String? = null,
hasRoomCall: Boolean = false,
isDm: Boolean = false,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
isDirect = isDirect,
avatarURLString = avatarURLString,
avatarUrl = avatarUrl,
lastMessage = lastMessage,
lastMessageTimestamp = lastMessageTimestamp,
unreadNotificationCount = unreadNotificationCount,
notificationMode = notificationMode
numUnreadMentions = numUnreadMentions,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
userDefinedNotificationMode = notificationMode,
inviter = inviter,
canonicalAlias = canonicalAlias,
hasRoomCall = hasRoomCall,
isDm = isDm,
)
fun aRoomMessage(

View file

@ -39,7 +39,7 @@ class FakeMatrixTimeline(
private val _paginationState: MutableStateFlow<MatrixTimeline.PaginationState> = MutableStateFlow(initialPaginationState)
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> = MutableStateFlow(initialTimelineItems)
var sendReadReceiptCount = 0
var sentReadReceipts = mutableListOf<Pair<EventId, ReceiptType>>()
private set
var sendReadReceiptLatch: CompletableDeferred<Unit>? = null
@ -81,7 +81,7 @@ class FakeMatrixTimeline(
eventId: EventId,
receiptType: ReceiptType,
): Result<Unit> = simulateLongTask {
sendReadReceiptCount++
sentReadReceipts.add(eventId to receiptType)
sendReadReceiptLatch?.complete(Unit)
Result.success(Unit)
}

View file

@ -24,7 +24,7 @@ class FakeCallWidgetSettingsProvider(
) : CallWidgetSettingsProvider {
val providedBaseUrls = mutableListOf<String>()
override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings {
override fun provide(baseUrl: String, widgetId: String, encrypted: Boolean): MatrixWidgetSettings {
providedBaseUrls += baseUrl
return provideFn(baseUrl, widgetId)
}

View file

@ -1,75 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@Composable
fun CheckableMatrixUserRow(
checked: Boolean,
matrixUser: MatrixUser,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.UserListItem,
enabled: Boolean = true,
) = CheckableUserRow(
checked = checked,
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
@PreviewsDayNight
@Composable
internal fun CheckableMatrixUserRowPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
Column {
CheckableMatrixUserRow(
checked = true,
onCheckedChange = { },
matrixUser = matrixUser,
)
CheckableMatrixUserRow(
checked = false,
onCheckedChange = { },
matrixUser = matrixUser,
)
CheckableMatrixUserRow(
checked = true,
onCheckedChange = { },
matrixUser = matrixUser,
enabled = false,
)
CheckableMatrixUserRow(
checked = false,
onCheckedChange = { },
matrixUser = matrixUser,
enabled = false,
)
}
}

View file

@ -1,104 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.matrix.ui.model.getAvatarData
@Composable
fun CheckableUnresolvedUserRow(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
avatarData: AvatarData,
id: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(role = Role.Checkbox, enabled = enabled) {
onCheckedChange(!checked)
},
verticalAlignment = Alignment.CenterVertically,
) {
UnresolvedUserRow(
modifier = Modifier.weight(1f),
avatarData = avatarData,
id = id,
)
Checkbox(
modifier = Modifier.padding(end = 16.dp),
checked = checked,
onCheckedChange = null,
enabled = enabled,
)
}
}
@Preview
@Composable
internal fun CheckableUnresolvedUserRowPreview() = ElementThemedPreview {
val matrixUser = aMatrixUser()
Column {
CheckableUnresolvedUserRow(
checked = false,
onCheckedChange = { },
avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem),
id = matrixUser.userId.value,
)
HorizontalDivider()
CheckableUnresolvedUserRow(
checked = true,
onCheckedChange = { },
avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem),
id = matrixUser.userId.value,
)
HorizontalDivider()
CheckableUnresolvedUserRow(
checked = false,
onCheckedChange = { },
avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem),
id = matrixUser.userId.value,
enabled = false,
)
HorizontalDivider()
CheckableUnresolvedUserRow(
checked = true,
onCheckedChange = { },
avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem),
id = matrixUser.userId.value,
enabled = false,
)
}
}

View file

@ -17,24 +17,29 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.matrix.ui.model.getAvatarData
@Composable
fun CheckableUserRow(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
avatarData: AvatarData,
name: String,
subtext: String?,
data: CheckableUserRowData,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
@ -46,19 +51,119 @@ fun CheckableUserRow(
},
verticalAlignment = Alignment.CenterVertically,
) {
UserRow(
modifier = Modifier.weight(1f),
avatarData = avatarData,
name = name,
subtext = subtext,
)
val rowModifier = Modifier.weight(1f)
when (data) {
is CheckableUserRowData.Resolved -> {
UserRow(
modifier = rowModifier,
avatarData = data.avatarData,
name = data.name,
subtext = data.subtext,
)
}
is CheckableUserRowData.Unresolved -> {
UnresolvedUserRow(
modifier = rowModifier,
avatarData = data.avatarData,
id = data.id,
)
}
}
Checkbox(
modifier = Modifier
.padding(end = 16.dp),
modifier = Modifier.padding(end = 4.dp),
checked = checked,
onCheckedChange = null,
enabled = enabled,
)
}
}
@Immutable
sealed interface CheckableUserRowData {
data class Resolved(
val avatarData: AvatarData,
val name: String,
val subtext: String?,
) : CheckableUserRowData
data class Unresolved(
val avatarData: AvatarData,
val id: String,
) : CheckableUserRowData
}
@Preview
@Composable
internal fun CheckableResolvedUserRowPreview() = ElementThemedPreview {
val matrixUser = aMatrixUser()
val data = CheckableUserRowData.Resolved(
avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem),
name = matrixUser.displayName.orEmpty(),
subtext = matrixUser.userId.value,
)
Column {
CheckableUserRow(
checked = false,
onCheckedChange = { },
data = data,
)
HorizontalDivider()
CheckableUserRow(
checked = true,
onCheckedChange = { },
data = data,
)
HorizontalDivider()
CheckableUserRow(
checked = false,
onCheckedChange = { },
data = data,
enabled = false,
)
HorizontalDivider()
CheckableUserRow(
checked = true,
onCheckedChange = { },
data = data,
enabled = false,
)
}
}
@Preview
@Composable
internal fun CheckableUnresolvedUserRowPreview() = ElementThemedPreview {
val matrixUser = aMatrixUser()
val data = CheckableUserRowData.Unresolved(
avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem),
id = matrixUser.userId.value,
)
Column {
CheckableUserRow(
checked = false,
onCheckedChange = { },
data = data,
)
HorizontalDivider()
CheckableUserRow(
checked = true,
onCheckedChange = { },
data = data,
)
HorizontalDivider()
CheckableUserRow(
checked = false,
onCheckedChange = { },
data = data,
enabled = false,
)
HorizontalDivider()
CheckableUserRow(
checked = true,
onCheckedChange = { },
data = data,
enabled = false,
)
}
}

View file

@ -44,6 +44,9 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.ui.strings.CommonStrings
@ -60,7 +63,7 @@ fun SelectedRoom(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarURLString, AvatarSize.SelectedRoom))
Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarUrl, AvatarSize.SelectedRoom))
Text(
text = roomSummary.name,
overflow = TextOverflow.Ellipsis,
@ -94,17 +97,37 @@ fun SelectedRoom(
@Composable
internal fun SelectedRoomPreview() = ElementPreview {
SelectedRoom(
roomSummary = RoomSummaryDetails(
roomId = RoomId("!room:domain"),
name = "roomName",
canonicalAlias = null,
isDirect = true,
avatarURLString = null,
lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
inviter = null,
),
roomSummary = aRoomSummaryDetails(),
onRoomRemoved = {},
)
}
fun aRoomSummaryDetails(
roomId: RoomId = RoomId("!room:domain"),
name: String = "roomName",
canonicalAlias: String? = null,
isDirect: Boolean = true,
avatarUrl: String? = null,
lastMessage: RoomMessage? = null,
inviter: RoomMember? = null,
notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
isDm: Boolean = false,
numUnreadMentions: Int = 0,
numUnreadMessages: Int = 0,
numUnreadNotifications: Int = 0,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
isDirect = isDirect,
avatarUrl = avatarUrl,
lastMessage = lastMessage,
inviter = inviter,
userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall,
isDm = isDm,
numUnreadMentions = numUnreadMentions,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
)

View file

@ -21,7 +21,8 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canRedact
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
@Composable
@ -32,8 +33,15 @@ fun MatrixRoom.canSendMessageAsState(type: MessageEventType, updateKey: Long): S
}
@Composable
fun MatrixRoom.canRedactAsState(updateKey: Long): State<Boolean> {
fun MatrixRoom.canRedactOwnAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canRedact().getOrElse { false }
value = canRedactOwn().getOrElse { false }
}
}
@Composable
fun MatrixRoom.canRedactOtherAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canRedactOther().getOrElse { false }
}
}

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.room
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@Composable
fun MatrixRoom.rememberPollHistory(): MatrixTimeline {
val pollHistory = remember {
pollHistory()
}
DisposableEffect(pollHistory) {
onDispose {
pollHistory.close()
}
}
return pollHistory
}

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="dialog_permission_camera">"Hogy az alkalmazás használhassa a kamerát, adja meg az engedélyt a rendszerbeállításokban."</string>
<string name="dialog_permission_generic">"Adja meg az engedélyt a rendszerbeállításokban."</string>
<string name="dialog_permission_generic">"Add meg az engedélyt a rendszerbeállításokban."</string>
<string name="dialog_permission_microphone">"Hogy az alkalmazás használhassa a mikrofont, adja meg az engedélyt a rendszerbeállításokban."</string>
<string name="dialog_permission_notification">"Hogy az alkalmazás megjeleníthesse az értesítéseket, adja meg az engedélyt a rendszerbeállításokban."</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="dialog_permission_camera">"Per permettere all\'applicazione di usare la fotocamera, concedi l\'autorizzazione nelle impostazioni di sistema."</string>
<string name="dialog_permission_generic">"Concedi l\'autorizzazione nelle impostazioni di sistema."</string>
<string name="dialog_permission_microphone">"Per permettere all\'applicazione di usare il microfono, concedi l\'autorizzazione nelle impostazioni di sistema."</string>
<string name="dialog_permission_notification">"Per permettere all\'applicazione di mostrare notifiche, concedi l\'autorizzazione nelle impostazioni di sistema."</string>
</resources>

View file

@ -18,7 +18,7 @@ package io.element.android.features.preferences.api.store
import kotlinx.coroutines.flow.Flow
interface PreferencesStore {
interface AppPreferencesStore {
suspend fun setRichTextEditorEnabled(enabled: Boolean)
fun isRichTextEditorEnabledFlow(): Flow<Boolean>

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.api.store
import kotlinx.coroutines.flow.Flow
interface SessionPreferencesStore {
suspend fun setSendPublicReadReceipts(enabled: Boolean)
fun isSendPublicReadReceiptsEnabled(): Flow<Boolean>
suspend fun clear()
}

View file

@ -31,6 +31,8 @@ dependencies {
api(projects.libraries.preferences.api)
implementation(libs.dagger)
implementation(libs.androidx.datastore.preferences)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.di)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
}

View file

@ -24,7 +24,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
@ -42,10 +42,10 @@ private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseU
private val themeKey = stringPreferencesKey("theme")
@ContributesBinding(AppScope::class)
class DefaultPreferencesStore @Inject constructor(
class DefaultAppPreferencesStore @Inject constructor(
@ApplicationContext context: Context,
private val buildMeta: BuildMeta,
) : PreferencesStore {
) : AppPreferencesStore {
private val store = context.dataStore
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {

View file

@ -0,0 +1,65 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.preferences.impl.store
import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStoreFile
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.io.File
class DefaultSessionPreferencesStore(
context: Context,
sessionId: SessionId,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
) : SessionPreferencesStore {
companion object {
fun storeFile(context: Context, sessionId: SessionId): File {
val hashedUserId = sessionId.value.hash().take(16)
return context.preferencesDataStoreFile("session_${hashedUserId}_preferences")
}
}
private val sendPublicReadReceiptsKey = booleanPreferencesKey("sendPublicReadReceipts")
private val dataStoreFile = storeFile(context, sessionId)
private val store = PreferenceDataStoreFactory.create(scope = sessionCoroutineScope) { dataStoreFile }
override suspend fun setSendPublicReadReceipts(enabled: Boolean) = update(sendPublicReadReceiptsKey, enabled)
override fun isSendPublicReadReceiptsEnabled(): Flow<Boolean> = get(sendPublicReadReceiptsKey, true)
override suspend fun clear() {
dataStoreFile.safeDelete()
}
private suspend fun <T> update(key: Preferences.Key<T>, value: T) {
store.edit { prefs -> prefs[key] = value }
}
private fun <T> get(key: Preferences.Key<T>, default: T): Flow<T> {
return store.data.map { prefs -> prefs[key] ?: default }
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.preferences.impl.store
import android.content.Context
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@SingleIn(AppScope::class)
class DefaultSessionPreferencesStoreFactory @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val cache = ConcurrentHashMap<SessionId, DefaultSessionPreferencesStore>()
fun get(sessionId: SessionId, sessionCoroutineScope: CoroutineScope): DefaultSessionPreferencesStore = cache.getOrPut(sessionId) {
DefaultSessionPreferencesStore(context, sessionId, sessionCoroutineScope)
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.preferences.impl.store
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import kotlinx.coroutines.CoroutineScope
@Module
@ContributesTo(SessionScope::class)
object SessionPreferencesModule {
@Provides
fun providesSessionPreferencesStore(
defaultSessionPreferencesStoreFactory: DefaultSessionPreferencesStoreFactory,
currentSessionIdHolder: CurrentSessionIdHolder,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
): SessionPreferencesStore {
return defaultSessionPreferencesStoreFactory
.get(currentSessionIdHolder.current, sessionCoroutineScope)
}
}

View file

@ -16,16 +16,16 @@
package io.element.android.libraries.featureflag.test
import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.features.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class InMemoryPreferencesStore(
class InMemoryAppPreferencesStore(
isRichTextEditorEnabled: Boolean = false,
isDeveloperModeEnabled: Boolean = false,
customElementCallBaseUrl: String? = null,
theme: String? = null,
) : PreferencesStore {
) : AppPreferencesStore {
private val isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled)
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.featureflag.test
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class InMemorySessionPreferencesStore(
isSendPublicReadReceiptsEnabled: Boolean = true,
) : SessionPreferencesStore {
private val isSendPublicReadReceiptsEnabled = MutableStateFlow(isSendPublicReadReceiptsEnabled)
var clearCallCount = 0
private set
override suspend fun setSendPublicReadReceipts(enabled: Boolean) {
isSendPublicReadReceiptsEnabled.tryEmit(enabled)
}
override fun isSendPublicReadReceiptsEnabled(): Flow<Boolean> {
return isSendPublicReadReceiptsEnabled
}
override suspend fun clear() {
clearCallCount++
isSendPublicReadReceiptsEnabled.tryEmit(true)
}
}

View file

@ -24,6 +24,10 @@ interface PushService {
// TODO Move away
fun notificationStyleChanged()
/**
* Return the list of push providers, available at compile time, and
* available at runtime, sorted by index.
*/
fun getAvailablePushProviders(): List<PushProvider>
/**

View file

@ -38,7 +38,9 @@ class DefaultPushService @Inject constructor(
}
override fun getAvailablePushProviders(): List<PushProvider> {
return pushProviders.sortedBy { it.index }
return pushProviders
.filter { it.isAvailable() }
.sortedBy { it.index }
}
/**

View file

@ -297,7 +297,7 @@ class DefaultNotificationDrawerManager @Inject constructor(
operation = {
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
val myUserDisplayName = client.loadUserDisplayName().getOrNull() ?: sessionId.value
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
val userAvatarUrl = client.loadUserAvatarUrl().getOrNull()
MatrixUser(
userId = sessionId,
displayName = myUserDisplayName,

View file

@ -91,7 +91,8 @@ class NotifiableEventResolver @Inject constructor(
): NotifiableEvent? {
return when (val content = this.content) {
is NotificationContent.MessageLike.RoomMessage -> {
val messageBody = descriptionFromMessageContent(content, senderDisplayName ?: content.senderId.value)
val senderName = getSenderName(content.senderId)
val messageBody = descriptionFromMessageContent(content, senderName)
val notificationBody = if (hasMention) {
stringProvider.getString(R.string.notification_mentioned_you_body, messageBody)
} else {
@ -104,7 +105,7 @@ class NotifiableEventResolver @Inject constructor(
eventId = eventId,
noisy = isNoisy,
timestamp = this.timestamp,
senderName = senderDisplayName,
senderName = senderName,
body = notificationBody,
imageUriString = fetchImageIfPresent(client)?.toString(),
roomName = roomDisplayName,
@ -161,7 +162,7 @@ class NotifiableEventResolver @Inject constructor(
eventId = eventId,
noisy = isNoisy,
timestamp = this.timestamp,
senderName = senderDisplayName,
senderName = getSenderName(content.senderId),
body = stringProvider.getString(CommonStrings.common_poll_summary, content.question),
imageUriString = null,
roomName = roomDisplayName,

View file

@ -4,7 +4,7 @@
<string name="notification_channel_listening_for_events">"Események figyelése"</string>
<string name="notification_channel_noisy">"Zajos értesítések"</string>
<string name="notification_channel_silent">"Csendes értesítések"</string>
<string name="notification_inline_reply_failed">"** Nem sikerült elküldeni nyissa meg a szobát"</string>
<string name="notification_inline_reply_failed">"** Nem sikerült elküldeni kérlek nyisd meg a szobát"</string>
<string name="notification_invitation_action_join">"Csatlakozás"</string>
<string name="notification_invitation_action_reject">"Elutasítás"</string>
<string name="notification_invite_body">"Meghívta, hogy csevegjen"</string>
@ -14,7 +14,7 @@
<string name="notification_room_action_mark_as_read">"Megjelölés olvasottként"</string>
<string name="notification_room_invite_body">"Meghívta, hogy csatlakozzon a szobához"</string>
<string name="notification_sender_me">"Én"</string>
<string name="notification_test_push_notification_content">"Az értesítést nézi! Kattintson ide!"</string>
<string name="notification_test_push_notification_content">"Az értesítést nézed! Kattints ide!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<string name="notification_unread_notified_messages_and_invitation">"%1$s és %2$s"</string>
@ -44,7 +44,7 @@
<item quantity="one">"%d szoba"</item>
<item quantity="other">"%d szoba"</item>
</plurals>
<string name="push_choose_distributor_dialog_title_android">"Válassza ki az értesítések fogadásának módját"</string>
<string name="push_choose_distributor_dialog_title_android">"Válaszd ki az értesítések fogadásának módját"</string>
<string name="push_distributor_background_sync_android">"Háttérszinkronizálás"</string>
<string name="push_distributor_firebase_android">"Google szolgáltatások"</string>
<string name="push_no_valid_google_play_services_apk_android">"A Google Play szolgáltatások nem találhatók. Előfordulhat, hogy az értesítések nem működnek megfelelően."</string>

View file

@ -1,5 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Chiamata"</string>
<string name="notification_channel_silent">"Notifiche silenziose"</string>
<string name="notification_inline_reply_failed">"** Invio fallito - si prega di aprire la stanza"</string>
<string name="notification_invitation_action_join">"Entra"</string>
<string name="notification_invitation_action_reject">"Rifiuta"</string>
<string name="notification_invite_body">"Ti ha invitato a chattare"</string>
<string name="notification_mentioned_you_body">"Ti ha menzionato: %1$s"</string>
<string name="notification_new_messages">"Nuovi messaggi"</string>
<string name="notification_reaction_body">"Ha reagito con %1$s"</string>
<string name="notification_room_action_mark_as_read">"Segna come letto"</string>
<string name="notification_room_invite_body">"Ti ha invitato ad entrare nella stanza"</string>
<string name="notification_sender_me">"Io"</string>
<string name="notification_test_push_notification_content">"Stai visualizzando la notifica! Cliccami!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<string name="notification_unread_notified_messages_and_invitation">"%1$s e %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s in %2$s e %3$s"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d messaggio"</item>
<item quantity="other">"%1$s: %2$d messaggi"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d notifica"</item>
<item quantity="other">"%d notifiche"</item>
</plurals>
<plurals name="notification_invitations">
<item quantity="one">"%d invito"</item>
<item quantity="other">"%d inviti"</item>
</plurals>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d nuovo messaggio"</item>
<item quantity="other">"%d nuovi messaggi"</item>
</plurals>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d messaggio notificato non letto"</item>
<item quantity="other">"%d messaggi notificati non letti"</item>
</plurals>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d stanza"</item>
<item quantity="other">"%d stanze"</item>
</plurals>
<string name="push_choose_distributor_dialog_title_android">"Scegli come ricevere le notifiche"</string>
<string name="push_distributor_background_sync_android">"Sincronizzazione in background"</string>
<string name="push_distributor_firebase_android">"Servizi Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Google Play Services non trovato. Le notifiche non funzioneranno bene."</string>
<string name="notification_fallback_content">"Notifica"</string>
<string name="notification_room_action_quick_reply">"Risposta rapida"</string>
</resources>

View file

@ -527,6 +527,7 @@ class NotifiableEventResolverTest {
roomId = A_ROOM_ID,
senderAvatarUrl = null,
senderDisplayName = "Bob",
senderIsNameAmbiguous = false,
roomAvatarUrl = null,
roomDisplayName = null,
isDirect = isDirect,

View file

@ -32,6 +32,11 @@ interface PushProvider {
*/
val name: String
/**
* Return true if the push provider is available on this device.
*/
fun isAvailable(): Boolean
fun getDistributors(): List<Distributor>
/**

View file

@ -16,9 +16,13 @@
package io.element.android.libraries.pushproviders.firebase
import android.content.Context
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.squareup.anvil.annotations.ContributesMultibinding
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
@ -30,6 +34,7 @@ private val loggerTag = LoggerTag("FirebasePushProvider", LoggerTag.PushLoggerTa
@ContributesMultibinding(AppScope::class)
class FirebasePushProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val firebaseStore: FirebaseStore,
private val firebaseTroubleshooter: FirebaseTroubleshooter,
private val pusherSubscriber: PusherSubscriber,
@ -37,6 +42,19 @@ class FirebasePushProvider @Inject constructor(
override val index = FirebaseConfig.INDEX
override val name = FirebaseConfig.NAME
override fun isAvailable(): Boolean {
// The PlayServices has to be available
val apiAvailability = GoogleApiAvailability.getInstance()
val resultCode = apiAvailability.isGooglePlayServicesAvailable(context)
return if (resultCode == ConnectionResult.SUCCESS) {
Timber.tag(loggerTag.value).d("Google Play Services is available")
true
} else {
Timber.tag(loggerTag.value).w("Google Play Services is not available")
false
}
}
override fun getDistributors(): List<Distributor> {
return listOf(Distributor("Firebase", "Firebase"))
}

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.pushproviders.unifiedpush
import android.content.Context
import com.squareup.anvil.annotations.ContributesMultibinding
import io.element.android.libraries.androidutils.system.getApplicationLabel
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
@ -26,8 +27,11 @@ import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import org.unifiedpush.android.connector.UnifiedPush
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("UnifiedPushProvider", LoggerTag.PushLoggerTag)
@ContributesMultibinding(AppScope::class)
class UnifiedPushProvider @Inject constructor(
@ApplicationContext private val context: Context,
@ -38,6 +42,17 @@ class UnifiedPushProvider @Inject constructor(
override val index = UnifiedPushConfig.INDEX
override val name = UnifiedPushConfig.NAME
override fun isAvailable(): Boolean {
val isAvailable = getDistributors().isNotEmpty()
return if (isAvailable) {
Timber.tag(loggerTag.value).d("UnifiedPush is available")
true
} else {
Timber.tag(loggerTag.value).w("UnifiedPush is not available")
false
}
}
override fun getDistributors(): List<Distributor> {
val distributors = UnifiedPush.getDistributors(context)
return distributors.mapNotNull {

View file

@ -105,7 +105,6 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
*/
override fun onUnregistered(context: Context, instance: String) {
Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered")
TODO()
/*
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
pushDataStore.setFdroidSyncBackgroundMode(mode)

View file

@ -29,9 +29,4 @@ interface PushClientSecret {
* Return null if not found.
*/
suspend fun getUserIdFromSecret(clientSecret: String): SessionId?
/**
* To call when the user signs out.
*/
suspend fun resetSecretForUser(userId: SessionId)
}

View file

@ -34,6 +34,7 @@ anvil {
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.pushstore.api)
@ -48,6 +49,7 @@ dependencies {
testImplementation(libs.coroutines.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.libraries.sessionStorage.test)
androidTestImplementation(libs.coroutines.test)
androidTestImplementation(libs.test.core)

View file

@ -23,12 +23,15 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.datastore.preferences.preferencesDataStoreFile
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.UserPushStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import timber.log.Timber
/**
* Store data related to push about a user.
@ -37,7 +40,24 @@ class UserPushStoreDataStore(
private val context: Context,
userId: SessionId,
) : UserPushStore {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_store_$userId")
// Hash the sessionId to get rid of exotic chars and take only the first 16 chars.
// The risk of collision is not high.
private val preferenceName = "push_store_${userId.value.hash().take(16)}"
init {
// Migrate legacy data. Previous file can be too long if the userId is too long. The userId can be up to 255 chars.
// Example of long file path, with `averylonguserid` replacing a very longer name
// /data/user/0/io.element.android.x.debug/files/datastore/push_store_@averylonguserid:example.org.preferences_pb
val legacyFile = context.preferencesDataStoreFile("push_store_$userId")
if (legacyFile.exists()) {
Timber.d("Migrating legacy push data store for $userId")
if (!legacyFile.renameTo(context.preferencesDataStoreFile(preferenceName))) {
Timber.w("Failed to migrate legacy push data store for $userId")
}
}
}
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = preferenceName)
private val pushProviderName = stringPreferencesKey("pushProviderName")
private val currentPushKey = stringPreferencesKey("currentPushKey")
private val notificationEnabled = booleanPreferencesKey("notificationEnabled")
@ -80,5 +100,7 @@ class UserPushStoreDataStore(
context.dataStore.edit {
it.clear()
}
// Also delete the file
context.preferencesDataStoreFile(preferenceName).delete()
}
}

View file

@ -18,17 +18,26 @@ package io.element.android.libraries.pushstore.impl.clientsecret
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, boundType = PushClientSecret::class)
class PushClientSecretImpl @Inject constructor(
private val pushClientSecretFactory: PushClientSecretFactory,
private val pushClientSecretStore: PushClientSecretStore,
) : PushClientSecret {
private val sessionObserver: SessionObserver,
) : PushClientSecret, SessionListener {
init {
observeSessions()
}
override suspend fun getSecretForUser(userId: SessionId): String {
val existingSecret = pushClientSecretStore.getSecret(userId)
if (existingSecret != null) {
@ -43,7 +52,16 @@ class PushClientSecretImpl @Inject constructor(
return pushClientSecretStore.getUserIdFromSecret(clientSecret)
}
override suspend fun resetSecretForUser(userId: SessionId) {
pushClientSecretStore.resetSecret(userId)
private fun observeSessions() {
sessionObserver.addListener(this)
}
override suspend fun onSessionCreated(userId: String) {
// Nothing to do
}
override suspend fun onSessionDeleted(userId: String) {
// Delete the secret
pushClientSecretStore.resetSecret(SessionId(userId))
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.pushstore.impl.clientsecret
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -31,7 +32,7 @@ internal class PushClientSecretImplTest {
fun test() = runTest {
val factory = FakePushClientSecretFactory()
val store = InMemoryPushClientSecretStore()
val sut = PushClientSecretImpl(factory, store)
val sut = PushClientSecretImpl(factory, store, NoOpSessionObserver())
val secret0 = factory.getSecretForUser(0)
val secret1 = factory.getSecretForUser(1)
@ -56,7 +57,7 @@ internal class PushClientSecretImplTest {
assertThat(sut.getUserIdFromSecret(A_UNKNOWN_SECRET)).isNull()
// User signs out
sut.resetSecretForUser(A_USER_ID_0)
sut.onSessionDeleted(A_USER_ID_0.value)
assertThat(store.getSecrets()).hasSize(1)
// Create a new secret after reset
assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret2)

View file

@ -19,9 +19,8 @@ package io.element.android.libraries.roomselect.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.ui.components.aRoomSummaryDetails
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -41,7 +40,7 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
query = "Test",
isSearchActive = true,
selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain")))
selectedRooms = persistentListOf(aRoomSummaryDetails(roomId = RoomId("!room2:domain")))
),
// Add other states here
)
@ -62,32 +61,10 @@ private fun aRoomSelectState(
)
private fun aForwardMessagesRoomList() = persistentListOf(
aRoomDetailsState(),
aRoomDetailsState(
aRoomSummaryDetails(),
aRoomSummaryDetails(
roomId = RoomId("!room2:domain"),
name = "Room with alias",
canonicalAlias = "#alias:example.org",
),
)
private fun aRoomDetailsState(
roomId: RoomId = RoomId("!room:domain"),
name: String = "roomName",
canonicalAlias: String? = null,
isDirect: Boolean = true,
avatarURLString: String? = null,
lastMessage: RoomMessage? = null,
lastMessageTimestamp: Long? = null,
unreadNotificationCount: Int = 0,
inviter: RoomMember? = null,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
isDirect = isDirect,
avatarURLString = avatarURLString,
lastMessage = lastMessage,
lastMessageTimestamp = lastMessageTimestamp,
unreadNotificationCount = unreadNotificationCount,
inviter = inviter,
)

View file

@ -223,7 +223,7 @@ private fun RoomSummaryView(
avatarData = AvatarData(
id = summary.roomId.value,
name = summary.name,
url = summary.avatarURLString,
url = summary.avatarUrl,
size = AvatarSize.RoomSelectRoomListItem,
),
)

View file

@ -23,7 +23,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.WarmUpRule
@ -72,7 +72,7 @@ class RoomSelectPresenterTests {
@Test
fun `present - update query`() = runTest {
val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail())))
postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetails())))
}
val client = FakeMatrixClient(roomListService = roomListService)
val presenter = aPresenter(client = client)
@ -80,7 +80,7 @@ class RoomSelectPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail())))
assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetails())))
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained"))
assertThat(awaitItem().query).isEqualTo("string not contained")
@ -96,7 +96,7 @@ class RoomSelectPresenterTests {
}.test {
val initialState = awaitItem()
skipItems(1)
val summary = aRoomSummaryDetail()
val summary = aRoomSummaryDetails()
initialState.eventSink(RoomSelectEvents.SetSelectedRoom(summary))
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary))

View file

@ -29,4 +29,5 @@ data class SessionData(
val loginTimestamp: Date?,
val isTokenValid: Boolean,
val loginType: LoginType,
val passphrase: String?,
)

View file

@ -28,6 +28,8 @@ import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
@ -37,6 +39,8 @@ class DatabaseSessionStore @Inject constructor(
private val database: SessionDatabase,
private val dispatchers: CoroutineDispatchers,
) : SessionStore {
private val sessionDataMutex = Mutex()
override fun isLoggedIn(): Flow<LoggedInState> {
return database.sessionDataQueries.selectFirst()
.asFlow()
@ -53,11 +57,11 @@ class DatabaseSessionStore @Inject constructor(
}
}
override suspend fun storeData(sessionData: SessionData) {
override suspend fun storeData(sessionData: SessionData) = sessionDataMutex.withLock {
database.sessionDataQueries.insertSessionData(sessionData.toDbModel())
}
override suspend fun updateData(sessionData: SessionData) {
override suspend fun updateData(sessionData: SessionData) = sessionDataMutex.withLock {
val result = database.sessionDataQueries.selectByUserId(sessionData.userId)
.executeAsOneOrNull()
?.toApiModel()
@ -66,8 +70,7 @@ class DatabaseSessionStore @Inject constructor(
Timber.e("User ${sessionData.userId} not found in session database")
return
}
// Copy new data from SDK, but keep login timestamp
// Copy new data from SDK, but keep login timestamp
database.sessionDataQueries.updateSession(
sessionData.copy(
loginTimestamp = result.loginTimestamp,
@ -76,21 +79,27 @@ class DatabaseSessionStore @Inject constructor(
}
override suspend fun getLatestSession(): SessionData? {
return database.sessionDataQueries.selectFirst()
.executeAsOneOrNull()
?.toApiModel()
return sessionDataMutex.withLock {
database.sessionDataQueries.selectFirst()
.executeAsOneOrNull()
?.toApiModel()
}
}
override suspend fun getSession(sessionId: String): SessionData? {
return database.sessionDataQueries.selectByUserId(sessionId)
.executeAsOneOrNull()
?.toApiModel()
return sessionDataMutex.withLock {
database.sessionDataQueries.selectByUserId(sessionId)
.executeAsOneOrNull()
?.toApiModel()
}
}
override suspend fun getAllSessions(): List<SessionData> {
return database.sessionDataQueries.selectAll()
.executeAsList()
.map { it.toApiModel() }
return sessionDataMutex.withLock {
database.sessionDataQueries.selectAll()
.executeAsList()
.map { it.toApiModel() }
}
}
override fun sessionsFlow(): Flow<List<SessionData>> {
@ -102,6 +111,8 @@ class DatabaseSessionStore @Inject constructor(
}
override suspend fun removeSession(sessionId: String) {
database.sessionDataQueries.removeSession(sessionId)
sessionDataMutex.withLock {
database.sessionDataQueries.removeSession(sessionId)
}
}
}

View file

@ -33,6 +33,7 @@ internal fun SessionData.toDbModel(): DbSessionData {
loginTimestamp = loginTimestamp?.time,
isTokenValid = if (isTokenValid) 1L else 0L,
loginType = loginType.name,
passphrase = passphrase,
)
}
@ -48,5 +49,6 @@ internal fun DbSessionData.toApiModel(): SessionData {
loginTimestamp = loginTimestamp?.let { Date(it) },
isTokenValid = isTokenValid == 1L,
loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name),
passphrase = passphrase,
)
}

View file

@ -21,7 +21,9 @@ CREATE TABLE SessionData (
oidcData TEXT,
-- added in version 4
isTokenValid INTEGER NOT NULL DEFAULT 1,
loginType TEXT
loginType TEXT,
-- added in version 5
passphrase TEXT
);

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