Merge branch 'develop' into feature/fga/room_navigation

This commit is contained in:
ganfra 2024-04-10 16:55:55 +02:00
commit d07df2319b
447 changed files with 3318 additions and 1374 deletions

View file

@ -34,6 +34,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.modifiers.blurCompat
import io.element.android.libraries.designsystem.modifiers.blurredShapeShadow
@ -171,6 +172,7 @@ internal fun ElementLogoAtomLargeNoBlurShadowPreview() = ElementPreview {
ContentToPreview(ElementLogoAtomSize.Large, useBlurredShadow = false)
}
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize, useBlurredShadow: Boolean = true) {
Box(

View file

@ -59,8 +59,8 @@ fun InfoListItemMolecule(
color = backgroundColor,
shape = backgroundShape,
)
.padding(vertical = 12.dp, horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
.padding(vertical = 12.dp, horizontal = 18.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
icon()
message()

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
/**
* @param modifier Classical modifier.
* @param background optional background component.
* @param topBar optional topBar.
* @param header optional header.
* @param footer optional footer.
@ -42,6 +43,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun HeaderFooterPage(
modifier: Modifier = Modifier,
background: @Composable () -> Unit = {},
topBar: @Composable () -> Unit = {},
header: @Composable () -> Unit = {},
footer: @Composable () -> Unit = {},
@ -51,25 +53,28 @@ fun HeaderFooterPage(
modifier = modifier,
topBar = topBar,
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.padding(all = 20.dp),
) {
// Header
header()
// Content
Box {
background()
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
.padding(all = 20.dp)
.padding(padding)
.consumeWindowInsets(padding)
) {
content()
}
// Footer
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
footer()
// Header
header()
// Content
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
content()
}
// Footer
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
footer()
}
}
}
}

View file

@ -0,0 +1,86 @@
/*
* 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.designsystem.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Gradient background for FTUE (onboarding) screens.
*/
@Suppress("ModifierMissing")
@Composable
fun OnboardingBackground() {
Box(modifier = Modifier.fillMaxSize()) {
val isLightTheme = ElementTheme.isLightTheme
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.align(Alignment.BottomCenter)
) {
val gradientBrush = ShaderBrush(
LinearGradientShader(
from = Offset(0f, size.height / 2f),
to = Offset(size.width, size.height / 2f),
colors = listOf(
Color(0xFF0DBDA8),
if (isLightTheme) Color(0xC90D5CBD) else Color(0xFF0D5CBD),
)
)
)
val eraseBrush = ShaderBrush(
LinearGradientShader(
from = Offset(size.width / 2f, 0f),
to = Offset(size.width / 2f, size.height * 2f),
colors = listOf(
Color(0xFF000000),
Color(0x00000000),
)
)
)
drawWithLayer {
drawRect(brush = gradientBrush, size = size)
drawRect(brush = gradientBrush, size = size, blendMode = BlendMode.Overlay)
drawRect(brush = eraseBrush, size = size, blendMode = BlendMode.DstOut)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun OnboardingBackgroundPreview() {
ElementPreview {
OnboardingBackground()
}
}

View file

@ -23,16 +23,21 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
@ -71,16 +76,34 @@ private fun ImageAvatar(
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
AsyncImage(
model = avatarData,
onError = {
Timber.e(it.result.throwable, "Error loading avatar $it\n${it.result}")
},
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
placeholder = debugPlaceholderAvatar(),
modifier = modifier
)
if (LocalInspectionMode.current) {
// For compose previews, use debugPlaceholderAvatar()
// instead of falling back to initials avatar on load failure
AsyncImage(
model = avatarData,
contentDescription = contentDescription,
placeholder = debugPlaceholderAvatar(),
modifier = modifier
)
} else {
SubcomposeAsyncImage(
model = avatarData,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = modifier
) {
when (val state = painter.state) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
}
InitialsAvatar(avatarData = avatarData)
}
else -> InitialsAvatar(avatarData = avatarData)
}
}
}
}
@Composable

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -162,6 +163,7 @@ internal fun PreferenceTextWithEndBadgeDarkPreview() = ElementPreviewDark {
ContentToPreview(showEndBadge = true)
}
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(showEndBadge: Boolean) {
Column(

View file

@ -1,107 +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.designsystem.modifiers
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.platform.debugInspectorInfo
import kotlin.math.sqrt
// Note: these modifiers come from https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
/**
* A modifier that clips the composable content using an animated circle. The circle will
* expand/shrink with an animation whenever [visible] changes.
*
* For more fine-grained control over the transition, see this method's overload, which allows passing
* a [State] object to control the progress of the reveal animation.
*
* By default, the circle is centered in the content, but custom positions may be specified using
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).*/
fun Modifier.circularReveal(
visible: Boolean,
showScrim: Boolean = false,
revealFrom: Offset = Offset(0.5f, 0.5f),
): Modifier = composed(
factory = {
val factor = updateTransition(visible, label = "Visibility")
.animateFloat(label = "revealFactor") { if (it) 1f else 0f }
circularReveal(factor, showScrim, revealFrom)
},
inspectorInfo = debugInspectorInfo {
name = "circularReveal"
properties["visible"] = visible
properties["revealFrom"] = revealFrom
}
)
/**
* A modifier that clips the composable content using a circular shape. The radius of the circle
* will be determined by the [transitionProgress].
*
* The values of the progress should be between 0 and 1.
*
* By default, the circle is centered in the content, but custom positions may be specified using
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
* */
fun Modifier.circularReveal(
transitionProgress: State<Float>,
showScrim: Boolean = false,
revealFrom: Offset = Offset(0.5f, 0.5f)
): Modifier {
return drawWithCache {
val path = Path()
val center = revealFrom.mapTo(size)
val radius = calculateRadius(revealFrom, size)
val scrimColor = if (showScrim) {
Color.Gray
} else {
Color.Transparent
}
path.addOval(Rect(center, radius * transitionProgress.value))
onDrawWithContent {
if (showScrim) {
drawRect(scrimColor, alpha = transitionProgress.value * 0.75f)
}
clipPath(path) { this@onDrawWithContent.drawContent() }
}
}
}
private fun Offset.mapTo(size: Size): Offset {
return Offset(x * size.width, y * size.height)
}
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
val x = (if (x > 0.5f) x else 1 - x) * size.width
val y = (if (y > 0.5f) y else 1 - y) * size.height
sqrt(x * x + y * y)
}

View file

@ -0,0 +1,188 @@
/*
* 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.designsystem.modifiers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.LayoutModifier
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.InspectorValueInfo
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import kotlin.math.max
import kotlin.math.min
/**
* Makes the content square in size.
*
* This is achieved by cropping incoming max constraints to the largest possible square size
* and measuring the content using resulting constraints.
* Next the size of layout is decided based on largest dimension of the measured content.
* Finally the content is placed inside the square layout according to specified [position].
*
* If no square exists that falls within the size range of the incoming constraints,
* the content will be laid out as usual, as if the modifier was not applied.
*
* @param position The fraction of the content's position inside its square layout.
* It determines the point on the axis that was extended to make a square.
* Typically you'd want to use values between `0` and `1`, inclusive, where `0`
* will place the content at the "start" of the square, `0.5` in the middle, and `1` at the "end".
*/
@Stable
fun Modifier.squareSize(
position: Float = 0.5f,
): Modifier =
this.then(
when {
position == 0.5f -> SquareSizeCenter
else -> createSquareSizeModifier(position = position)
}
)
private val SquareSizeCenter = createSquareSizeModifier(position = 0.5f)
private class SquareSizeModifier(
private val position: Float,
inspectorInfo: InspectorInfo.() -> Unit,
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val maxSquare = min(constraints.maxWidth, constraints.maxHeight)
val minSquare = max(constraints.minWidth, constraints.minHeight)
val squareExists = minSquare <= maxSquare
val resolvedConstraints = constraints
.takeUnless { squareExists }
?: constraints.copy(maxWidth = maxSquare, maxHeight = maxSquare)
val placeable = measurable.measure(resolvedConstraints)
return if (squareExists) {
val size = max(placeable.width, placeable.height)
layout(size, size) {
val x = ((size - placeable.width) * position).toInt()
val y = ((size - placeable.height) * position).toInt()
placeable.placeRelative(x, y)
}
} else {
layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
}
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (other !is SquareSizeModifier) return false
if (position != other.position) return false
return true
}
override fun hashCode(): Int {
return position.hashCode()
}
}
@Suppress("ModifierFactoryExtensionFunction", "ModifierFactoryReturnType")
private fun createSquareSizeModifier(
position: Float,
) =
SquareSizeModifier(
position = position,
inspectorInfo = debugInspectorInfo {
name = "squareSize"
properties["position"] = position
},
)
@Preview
@Composable
internal fun SquareSizeModifierLargeWidthPreview() {
ElementPreview {
Box(
modifier = Modifier
.padding(32.dp)
.background(Color.Gray)
.squareSize(position = 0.25f)
) {
Box(
modifier = Modifier
.background(Color.Black)
.size(100.dp, 10.dp)
)
}
}
}
@Preview
@Composable
internal fun SquareSizeModifierLargeHeightPreview() {
ElementPreview {
Box(
modifier = Modifier
.padding(32.dp)
.background(Color.Gray)
.squareSize(position = 0.75f)
) {
Box(
modifier = Modifier
.background(Color.Black)
.size(10.dp, 100.dp)
)
}
}
}
@Preview
@Composable
internal fun SquareSizeModifierInsideSquarePreview() {
ElementPreview {
Box(
modifier = Modifier
.padding(32.dp)
.size(120.dp)
.background(Color.Gray),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.background(Color.Black)
.width(100.dp)
.squareSize(position = 0.75f)
)
}
}
}

View file

@ -43,6 +43,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@ -269,6 +270,7 @@ internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ExcludeFromCoverage
private fun ContentToPreview(
query: String = "",
active: Boolean = false,

View file

@ -82,13 +82,6 @@ enum class FeatureFlags(
defaultValue = true,
isFinished = false,
),
RoomModeration(
key = "feature.roomModeration",
title = "Room moderation",
description = "Add moderation features to the room for users with permissions",
defaultValue = true,
isFinished = false,
),
RoomDirectorySearch(
key = "feature.roomdirectorysearch",
title = "Room directory search",

View file

@ -41,7 +41,6 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.Mentions -> true
FeatureFlags.MarkAsUnread -> true
FeatureFlags.RoomListFilters -> true
FeatureFlags.RoomModeration -> false
FeatureFlags.RoomDirectorySearch -> false
}
} else {

View file

@ -96,4 +96,7 @@ interface MatrixClient : Closeable {
fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<MatrixRoomInfo>>
fun isMe(userId: UserId?) = userId == sessionId
suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit>
suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>>
}

View file

@ -35,13 +35,8 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import java.io.Closeable
import java.io.File
@ -87,6 +82,12 @@ interface MatrixRoom : Closeable {
*/
suspend fun updateMembers()
/**
* Get the members of the room. Note: generally this should not be used, please use
* [membersStateFlow] and [updateMembers] instead.
*/
suspend fun getMembers(limit: Int = 5): Result<List<RoomMember>>
/**
* Will return an updated member or an error.
*/
@ -183,18 +184,6 @@ interface MatrixRoom : Closeable {
suspend fun canUserJoinCall(userId: UserId): Result<Boolean> =
canUserSendState(userId, StateEventType.CALL_MEMBER)
fun usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
return roomInfoFlow
.map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
.distinctUntilChanged()
.combine(membersStateFlow) { powerLevels, membersState ->
membersState.roomMembers()
.orEmpty()
.filter { powerLevels.containsKey(it.userId) }
.toPersistentList()
}
}
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>

View file

@ -35,3 +35,7 @@ fun MatrixRoomMembersState.roomMembers(): List<RoomMember>? {
else -> null
}
}
fun MatrixRoomMembersState.joinedRoomMembers(): List<RoomMember> {
return roomMembers().orEmpty().filter { it.membership == RoomMembershipState.JOIN }
}

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
data class RoomMember(
val userId: UserId,
@ -78,3 +79,9 @@ enum class RoomMembershipState {
fun RoomMember.getBestName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
}
fun RoomMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
)

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.api.room.powerlevels
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Return a flow of the list of room members who are still in the room (with membership == RoomMembershipState.JOIN)
* and who have the given role.
*/
fun MatrixRoom.usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
return roomInfoFlow
.map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
.combine(membersStateFlow) { powerLevels, membersState ->
membersState.joinedRoomMembers()
.filter { powerLevels.containsKey(it.userId) }
.toPersistentList()
}
.distinctUntilChanged()
}

View file

@ -42,7 +42,7 @@ suspend fun MatrixRoom.canInvite(): Result<Boolean> = canUserInvite(sessionId)
suspend fun MatrixRoom.canKick(): Result<Boolean> = canUserKick(sessionId)
/**
* Shortcut for calling [MatrixRoom.canBanUser] with our own user.
* Shortcut for calling [MatrixRoom.canUserBan] with our own user.
*/
suspend fun MatrixRoom.canBan(): Result<Boolean> = canUserBan(sessionId)

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.matrix.api.room.recent
import io.element.android.libraries.matrix.api.MatrixClient
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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.first
private const val MAX_RECENT_DIRECT_ROOMS_TO_RETURN = 5
data class RecentDirectRoom(
val roomId: RoomId,
val matrixUser: MatrixUser,
)
suspend fun MatrixClient.getRecentDirectRooms(
maxNumberOfResults: Int = MAX_RECENT_DIRECT_ROOMS_TO_RETURN,
): List<RecentDirectRoom> {
val result = mutableListOf<RecentDirectRoom>()
val foundUserIds = mutableSetOf<UserId>()
getRecentlyVisitedRooms().getOrNull()?.let { roomIds ->
roomIds
.mapNotNull { roomId -> getRoom(roomId) }
.filter { it.isDm && it.isJoined() }
.map { room ->
val otherUser = room.getMembers().getOrNull()
?.firstOrNull { it.userId != sessionId }
?.takeIf { foundUserIds.add(it.userId) }
?.toMatrixUser()
if (otherUser != null) {
result.add(
RecentDirectRoom(room.roomId, otherUser)
)
// Return early to avoid useless computation
if (result.size >= maxNumberOfResults) {
return@map
}
}
}
}
return result
}
suspend fun MatrixRoom.isJoined(): Boolean {
return roomInfoFlow.first().currentUserMembership == CurrentUserMembership.JOINED
}

View file

@ -21,6 +21,17 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface SessionVerificationService {
/**
* This flow stores the local verification status of the current session.
*
* We should ideally base the verified status in the Rust SDK info, but there are several issues with that approach:
*
* - The SDK takes a while to report this value, resulting in a delay of 1-2s in displaying the UI.
* - We need to add a 'Skip' option for testing purposes, which would not be possible if we relied only on the SDK.
* - The SDK sometimes doesn't report the verification state if there is no network connection when the app boots.
*/
val needsVerificationFlow: StateFlow<Boolean>
/**
* State of the current verification flow ([VerificationFlowState.Initial] if not started).
*/
@ -72,6 +83,11 @@ interface SessionVerificationService {
* Returns the verification service state to the initial step.
*/
suspend fun reset()
/**
* Saves the current session state as [verified].
*/
suspend fun saveVerifiedState(verified: Boolean)
}
/** Verification status of the current session. */

View file

@ -156,6 +156,7 @@ class RustMatrixClient(
syncService = rustSyncService,
sessionCoroutineScope = sessionCoroutineScope,
dispatchers = dispatchers,
sessionStore = sessionStore,
)
private val roomDirectoryService = RustRoomDirectoryService(
@ -183,6 +184,7 @@ class RustMatrixClient(
isTokenValid = false,
loginType = existingData.loginType,
passphrase = existingData.passphrase,
needsVerification = existingData.needsVerification,
)
sessionStore.updateData(newData)
Timber.d("Removed session data with token: '...$anonymizedToken'.")
@ -210,6 +212,7 @@ class RustMatrixClient(
isTokenValid = true,
loginType = existingData.loginType,
passphrase = existingData.passphrase,
needsVerification = existingData.needsVerification,
)
sessionStore.updateData(newData)
Timber.d("Saved new session data with token: '...$anonymizedToken'.")
@ -235,6 +238,7 @@ class RustMatrixClient(
client = client,
isSyncServiceReady = rustSyncService.syncState.map { it == SyncState.Running },
sessionCoroutineScope = sessionCoroutineScope,
sessionStore = sessionStore,
)
private val eventFilters = TimelineConfig.excludedEvents
@ -444,6 +448,18 @@ class RustMatrixClient(
}
}
override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit> = withContext(sessionDispatcher) {
runCatching {
client.trackRecentlyVisitedRoom(roomId.value)
}
}
override suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> = withContext(sessionDispatcher) {
runCatching {
client.getRecentlyVisitedRooms().map(::RoomId)
}
}
override fun syncService(): SyncService = rustSyncService
override fun sessionVerificationService(): SessionVerificationService = verificationService

View file

@ -148,6 +148,7 @@ class RustMatrixAuthenticationService @Inject constructor(
isTokenValid = true,
loginType = LoginType.PASSWORD,
passphrase = pendingPassphrase,
needsVerification = true,
)
}
sessionStore.storeData(sessionData)
@ -195,7 +196,8 @@ class RustMatrixAuthenticationService @Inject constructor(
it.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,
passphrase = pendingPassphrase
passphrase = pendingPassphrase,
needsVerification = true,
)
}
pendingOidcAuthenticationData?.close()

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.currentCoroutineContext
@ -48,10 +49,11 @@ import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecover
import org.matrix.rustcomponents.sdk.SteadyStateException as RustSteadyStateException
internal class RustEncryptionService(
client: Client,
private val client: Client,
syncService: RustSyncService,
sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val sessionStore: SessionStore,
) : EncryptionService {
private val service: Encryption = client.encryption()
@ -186,6 +188,9 @@ internal class RustEncryptionService(
override suspend fun recover(recoveryKey: String): Result<Unit> = withContext(dispatchers.io) {
runCatching {
service.recover(recoveryKey)
val existingSession = sessionStore.getSession(client.userId())
?: error("Failed to save verification state. No session with id ${client.userId()}")
sessionStore.updateData(existingSession.copy(needsVerification = false))
}.mapFailure {
it.mapRecoveryException()
}

View file

@ -25,6 +25,7 @@ internal fun Session.toSessionData(
isTokenValid: Boolean,
loginType: LoginType,
passphrase: String?,
needsVerification: Boolean,
) = SessionData(
userId = userId,
deviceId = deviceId,
@ -37,4 +38,5 @@ internal fun Session.toSessionData(
isTokenValid = isTokenValid,
loginType = loginType,
passphrase = passphrase,
needsVerification = needsVerification,
)

View file

@ -71,8 +71,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EventTimelineItem
@ -164,7 +169,11 @@ class RustMatrixRoom(
override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow()
init {
timeline.membershipChangeEventReceived
val powerLevelChanges = roomInfoFlow.map { it.userPowerLevels }.distinctUntilChanged()
val membershipChanges = timeline.membershipChangeEventReceived.onStart { emit(Unit) }
combine(membershipChanges, powerLevelChanges) { _, _ -> }
// Skip initial one
.drop(1)
// The new events should already be in the SDK cache, no need to fetch them from the server
.onEach { roomMemberListFetcher.fetchRoomMembers(source = RoomMemberListFetcher.Source.CACHE) }
.launchIn(roomCoroutineScope)
@ -228,6 +237,16 @@ class RustMatrixRoom(
roomMemberListFetcher.fetchRoomMembers(source = source)
}
override suspend fun getMembers(limit: Int) = withContext(roomDispatcher) {
runCatching {
innerRoom.members().use {
it.nextChunk(limit.toUInt()).orEmpty().map { roomMember ->
RoomMemberMapper.map(roomMember)
}
}
}
}
override suspend fun getUpdatedMember(userId: UserId): Result<RoomMember> = withContext(roomDispatcher) {
runCatching {
RoomMemberMapper.map(innerRoom.member(userId.value))

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -23,108 +24,97 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.RecoveryState
import org.matrix.rustcomponents.sdk.RecoveryStateListener
import org.matrix.rustcomponents.sdk.SessionVerificationController
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.VerificationState
import org.matrix.rustcomponents.sdk.VerificationStateListener
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
class RustSessionVerificationService(
client: Client,
private val client: Client,
isSyncServiceReady: Flow<Boolean>,
sessionCoroutineScope: CoroutineScope,
private val sessionCoroutineScope: CoroutineScope,
private val sessionStore: SessionStore,
) : SessionVerificationService, SessionVerificationControllerDelegate {
private var verificationStateListenerTaskHandle: TaskHandle? = null
private var recoveryStateListenerTaskHandle: TaskHandle? = null
private val encryptionService: Encryption = client.encryption()
private lateinit var verificationController: SessionVerificationController
// Listen for changes in verification status and update accordingly
private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
override fun onUpdate(status: VerificationState) {
Timber.d("New verification state: $status")
updateVerificationStatus(status)
}
})
// In case we enter the recovery key instead we check changes in the recovery state, since the listener above won't be triggered
private val recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener {
override fun onUpdate(status: RecoveryState) {
Timber.d("New recovery state: $status")
// We could check the `RecoveryState`, but it's easier to just use the verification state directly
updateVerificationStatus(encryptionService.verificationState())
}
})
override val needsVerificationFlow: StateFlow<Boolean> = sessionStore.sessionsFlow()
.map { sessions -> sessions.firstOrNull { it.userId == client.userId() }?.needsVerification.orFalse() }
.distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
private val _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
override val verificationFlowState = _verificationFlowState.asStateFlow()
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
override val isReady = MutableStateFlow(false)
override val isReady = isSyncServiceReady.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
override val canVerifySessionFlow = combine(sessionVerifiedStatus, isReady) { verificationStatus, isReady ->
isReady && verificationStatus == SessionVerifiedStatus.NotVerified
}
init {
isSyncServiceReady
.onEach { syncServiceReady ->
if (syncServiceReady) {
isReady.value = true
runCatching {
// If the controller was failed to initialize before, we try to get it again
if (!this::verificationController.isInitialized) {
verificationController = client.getSessionVerificationController()
}
}
.onFailure {
isReady.value = false
Timber.e(it, "Failed to get verification controller. Trying again in next sync.")
}
} else {
isReady.value = false
}
}
.launchIn(sessionCoroutineScope)
isReady.onEach { isReady ->
if (isReady) {
Timber.d("Starting verification service")
// Setup delegate
verificationController.setDelegate(this)
// Immediate status update
updateVerificationStatus(encryptionService.verificationState())
// Listen for changes in verification status and update accordingly
verificationStateListenerTaskHandle?.cancelAndDestroy()
verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
override fun onUpdate(status: VerificationState) {
Timber.d("New verification state: $status")
updateVerificationStatus(status)
}
})
// In case we enter the recovery key instead we check changes in the recovery state, since the listener above won't be triggered
recoveryStateListenerTaskHandle?.cancelAndDestroy()
recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener {
override fun onUpdate(status: RecoveryState) {
Timber.d("New recovery state: $status")
// We could check the `RecoveryState`, but it's easier to just use the verification state directly
updateVerificationStatus(encryptionService.verificationState())
}
})
} else {
Timber.d("Stopping verification service")
if (this::verificationController.isInitialized) {
verificationController.setDelegate(null)
}
}
}
.launchIn(sessionCoroutineScope)
}
override suspend fun requestVerification() = tryOrFail {
if (!this::verificationController.isInitialized) {
verificationController = client.getSessionVerificationController()
verificationController.setDelegate(this)
}
verificationController.requestVerification()
}
@ -164,9 +154,26 @@ class RustSessionVerificationService(
}
override fun didFinish() {
_verificationFlowState.value = VerificationFlowState.Finished
// Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it always returns false
updateVerificationStatus(VerificationState.VERIFIED)
sessionCoroutineScope.launch {
// Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it returns false if run immediately
// It also sometimes unexpectedly fails to report the session as verified, so we have to handle that possibility and fail if needed
runCatching {
withTimeout(30.seconds) {
while (!verificationController.isVerified()) {
delay(100)
}
}
}
.onSuccess {
saveVerifiedState(true)
updateVerificationStatus(VerificationState.VERIFIED)
_verificationFlowState.value = VerificationFlowState.Finished
}
.onFailure {
Timber.e(it, "Verification finished, but the Rust SDK still reports the session as unverified.")
didFail()
}
}
}
override fun didReceiveVerificationData(data: RustSessionVerificationData) {
@ -188,12 +195,21 @@ class RustSessionVerificationService(
_verificationFlowState.value = VerificationFlowState.Initial
}
override suspend fun saveVerifiedState(verified: Boolean) = tryOrFail {
val existingSession = sessionStore.getSession(client.userId())
?: error("Failed to save verification state. No session with id ${client.userId()}")
sessionStore.updateData(existingSession.copy(needsVerification = !verified))
// Wait until the new state is saved
needsVerificationFlow.first { needsVerification -> !needsVerification }
}
fun destroy() {
Timber.d("Destroying RustSessionVerificationService")
recoveryStateListenerTaskHandle?.cancelAndDestroy()
verificationStateListenerTaskHandle.cancelAndDestroy()
recoveryStateListenerTaskHandle.cancelAndDestroy()
if (this::verificationController.isInitialized) {
verificationController.setDelegate(null)
(verificationController as? SessionVerificationController)?.destroy()
verificationController.destroy()
}
}

View file

@ -255,4 +255,16 @@ class FakeMatrixClient(
fun givenRemoveAvatarResult(result: Result<Unit>) {
removeAvatarResult = result
}
private val visitedRoomsId: MutableList<RoomId> = mutableListOf()
override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit> {
visitedRoomsId.removeAll { it == roomId }
visitedRoomsId.add(0, roomId)
return Result.success(Unit)
}
override suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> {
return Result.success(visitedRoomsId)
}
}

View file

@ -201,6 +201,10 @@ class FakeMatrixRoom(
return getRoomMemberResult
}
override suspend fun getMembers(limit: Int): Result<List<RoomMember>> {
return Result.success(emptyList())
}
override suspend fun updateRoomNotificationSettings(): Result<Unit> = simulateLongTask {
val notificationSettings = notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow()
roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(notificationSettings)

View file

@ -20,17 +20,22 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService : SessionVerificationService {
class FakeSessionVerificationService(
var saveVerifiedStateResult: LambdaOneParamRecorder<Boolean, Unit> = lambdaRecorder<Boolean, Unit> {}
) : SessionVerificationService {
private val _isReady = MutableStateFlow(false)
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
private var _canVerifySessionFlow = MutableStateFlow(true)
var shouldFail = false
override val needsVerificationFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val verificationFlowState: StateFlow<VerificationFlowState> = _verificationFlowState
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val canVerifySessionFlow: Flow<Boolean> = _canVerifySessionFlow
@ -89,7 +94,15 @@ class FakeSessionVerificationService : SessionVerificationService {
_isReady.value = value
}
fun givenNeedsVerification(value: Boolean) {
needsVerificationFlow.value = value
}
override suspend fun reset() {
_verificationFlowState.value = VerificationFlowState.Initial
}
override suspend fun saveVerifiedState(verified: Boolean) {
saveVerifiedStateResult(verified)
}
}

View file

@ -1,67 +0,0 @@
/*
* Copyright (c) 2022 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.push.impl.permission
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import io.element.android.libraries.di.ApplicationContext
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import javax.inject.Inject
// TODO EAx move
class NotificationPermissionManager @Inject constructor(
private val sdkIntProvider: BuildVersionSdkIntProvider,
@ApplicationContext private val context: Context,
) {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun isPermissionGranted(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
/*
fun eventuallyRequestPermission(
activity: Activity,
requestPermissionLauncher: ActivityResultLauncher<Array<String>>,
showRationale: Boolean = true,
ignorePreference: Boolean = false,
) {
if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
// if (!vectorPreferences.areNotificationEnabledForDevice() && !ignorePreference) return
checkPermissions(
listOf(Manifest.permission.POST_NOTIFICATIONS),
activity,
activityResultLauncher = requestPermissionLauncher,
if (showRationale) R.string.permissions_rationale_msg_notification else 0
)
}
*/
fun eventuallyRevokePermission(
activity: Activity,
) {
if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
activity.revokeSelfPermissionOnKill(Manifest.permission.POST_NOTIFICATIONS)
}
}

View file

@ -127,6 +127,7 @@ fun RoomSelectView(
.consumeWindowInsets(paddingValues)
) {
SearchBar(
modifier = Modifier.fillMaxWidth(),
placeHolderTitle = stringResource(CommonStrings.action_search),
query = state.query,
onQueryChange = { state.eventSink(RoomSelectEvents.UpdateQuery(it)) },

View file

@ -18,16 +18,32 @@ package io.element.android.libraries.sessionstorage.api
import java.util.Date
/**
* Data class representing the session data to store locally.
*/
data class SessionData(
/** The user ID of the logged in user. */
val userId: String,
/** The device ID of the session. */
val deviceId: String,
/** The current access token of the session. */
val accessToken: String,
/** The optional current refresh token of the session. */
val refreshToken: String?,
/** The homeserver URL of the session. */
val homeserverUrl: String,
/** The Open ID Connect info for this session, if any. */
val oidcData: String?,
/** The Sliding Sync Proxy URL for this session, if any. */
val slidingSyncProxy: String?,
/** The timestamp of the last login. May be `null` in very old sessions. */
val loginTimestamp: Date?,
/** Whether the [accessToken] is valid or not. */
val isTokenValid: Boolean,
/** The login type used to authenticate the session. */
val loginType: LoginType,
/** The optional passphrase used to encrypt data in the SDK local store. */
val passphrase: String?,
/** Whether the session needs verification. */
val needsVerification: Boolean,
)

View file

@ -34,6 +34,7 @@ internal fun SessionData.toDbModel(): DbSessionData {
isTokenValid = if (isTokenValid) 1L else 0L,
loginType = loginType.name,
passphrase = passphrase,
needsVerification = if (needsVerification) 1L else 0L,
)
}
@ -50,5 +51,6 @@ internal fun DbSessionData.toApiModel(): SessionData {
isTokenValid = isTokenValid == 1L,
loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name),
passphrase = passphrase,
needsVerification = needsVerification == 1L,
)
}

View file

@ -23,7 +23,9 @@ CREATE TABLE SessionData (
isTokenValid INTEGER NOT NULL DEFAULT 1,
loginType TEXT,
-- added in version 5
passphrase TEXT
passphrase TEXT,
-- added in version 6
needsVerification INTEGER NOT NULL DEFAULT 0
);

View file

@ -0,0 +1,4 @@
-- Migrate DB from version 5
-- For users migrating previously logged in sessions, we force them to verify them too
ALTER TABLE SessionData ADD COLUMN needsVerification INTEGER NOT NULL DEFAULT 1;

View file

@ -144,6 +144,7 @@ class DatabaseSessionStoreTests {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphrase",
needsVerification = 1L,
)
val secondSessionData = SessionData(
userId = "userId",
@ -157,6 +158,7 @@ class DatabaseSessionStoreTests {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphraseAltered",
needsVerification = 0L,
)
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
@ -177,6 +179,7 @@ class DatabaseSessionStoreTests {
assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData)
assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase)
assertThat(alteredSession.needsVerification).isEqualTo(secondSessionData.needsVerification)
}
@Test
@ -193,6 +196,7 @@ class DatabaseSessionStoreTests {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphrase",
needsVerification = 1L,
)
val secondSessionData = SessionData(
userId = "userIdUnknown",
@ -206,6 +210,7 @@ class DatabaseSessionStoreTests {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphraseAltered",
needsVerification = 0L,
)
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)
@ -224,5 +229,6 @@ class DatabaseSessionStoreTests {
assertThat(notAlteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
assertThat(notAlteredSession.oidcData).isEqualTo(firstSessionData.oidcData)
assertThat(notAlteredSession.passphrase).isEqualTo(firstSessionData.passphrase)
assertThat(notAlteredSession.needsVerification).isEqualTo(firstSessionData.needsVerification)
}
}

View file

@ -31,4 +31,5 @@ internal fun aSessionData() = SessionData(
isTokenValid = 1,
loginType = LoginType.UNKNOWN.name,
passphrase = null,
needsVerification = 0L,
)

View file

@ -202,7 +202,7 @@
<string name="common_server_not_supported">"Сервер не падтрымліваецца"</string>
<string name="common_server_url">"URL-адрас сервера"</string>
<string name="common_settings">"Налады"</string>
<string name="common_shared_location">"Абагуленыя геаданыя"</string>
<string name="common_shared_location">"Абагуленае месцазнаходжанне"</string>
<string name="common_signing_out">"Выхад"</string>
<string name="common_starting_chat">"Пачатак чата…"</string>
<string name="common_sticker">"Стыкер"</string>
@ -249,12 +249,6 @@
<string name="invite_friends_text">"Гэй, пагавары са мной у %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Паведаміць аб памылцы з дапамогай Rageshake"</string>
<string name="screen_identity_confirmation_subtitle">"Пацвердзіце гэтую прыладу, каб наладзіць бяспечны абмен паведамленнямі."</string>
<string name="screen_identity_confirmation_title">"Пацвердзіце, што гэта вы"</string>
<string name="screen_identity_confirmed_subtitle">"Цяпер вы можаце бяспечна чытаць і адпраўляць паведамленні, і ўсе, з кім вы маеце зносіны ў чаце, таксама могуць давяраць гэтай прыладзе."</string>
<string name="screen_identity_confirmed_title">"Прылада праверана"</string>
<string name="screen_identity_use_another_device">"Выкарыстоўвайце іншую прыладу"</string>
<string name="screen_identity_waiting_on_other_device">"Чаканне на іншай прыладзе…"</string>
<string name="screen_media_picker_error_failed_selection">"Не ўдалося выбраць носьбіт, паўтарыце спробу."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>
@ -263,6 +257,7 @@
<string name="screen_room_member_details_block_alert_action">"Заблакіраваць"</string>
<string name="screen_room_member_details_block_alert_description">"Заблакіраваныя карыстальнікі не змогуць адпраўляць вам паведамленні, і ўсе іх паведамленні будуць схаваны. Вы можаце разблакіраваць іх у любы час."</string>
<string name="screen_room_member_details_block_user">"Заблакіраваць карыстальніка"</string>
<string name="screen_room_member_details_title">"Профіль"</string>
<string name="screen_room_member_details_unblock_alert_action">"Разблакіраваць"</string>
<string name="screen_room_member_details_unblock_alert_description">"Вы зноў зможаце ўбачыць усе паведамленні."</string>
<string name="screen_room_member_details_unblock_user">"Разблакіраваць карыстальніка"</string>

View file

@ -179,7 +179,7 @@
<string name="common_privacy_policy">"Zásady ochrany osobních údajů"</string>
<string name="common_reaction">"Reakce"</string>
<string name="common_reactions">"Reakce"</string>
<string name="common_recovery_key">"Klíč pro obnovu"</string>
<string name="common_recovery_key">"Klíč pro obnovení"</string>
<string name="common_refreshing">"Obnovování…"</string>
<string name="common_replying_to">"Odpověď na %1$s"</string>
<string name="common_report_a_bug">"Nahlásit chybu"</string>
@ -249,12 +249,6 @@
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string>
<string name="screen_identity_confirmation_subtitle">"Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv."</string>
<string name="screen_identity_confirmation_title">"Potvrďte, že jste to vy"</string>
<string name="screen_identity_confirmed_subtitle">"Nyní můžete bezpečně číst nebo odesílat zprávy, a kdokoli, s kým chatujete, může tomuto zařízení důvěřovat."</string>
<string name="screen_identity_confirmed_title">"Zařízení ověřeno"</string>
<string name="screen_identity_use_another_device">"Použít jiné zařízení"</string>
<string name="screen_identity_waiting_on_other_device">"Čekání na jiném zařízení…"</string>
<string name="screen_media_picker_error_failed_selection">"Výběr média se nezdařil, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
@ -263,6 +257,7 @@
<string name="screen_room_member_details_block_alert_action">"Zablokovat"</string>
<string name="screen_room_member_details_block_alert_description">"Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat."</string>
<string name="screen_room_member_details_block_user">"Zablokovat uživatele"</string>
<string name="screen_room_member_details_title">"Profil"</string>
<string name="screen_room_member_details_unblock_alert_action">"Odblokovat"</string>
<string name="screen_room_member_details_unblock_alert_description">"Znovu uvidíte všechny zprávy od nich."</string>
<string name="screen_room_member_details_unblock_user">"Odblokovat uživatele"</string>

View file

@ -175,7 +175,9 @@
<string name="common_privacy_policy">"Datenschutz­erklärung"</string>
<string name="common_reaction">"Reaktion"</string>
<string name="common_reactions">"Reaktionen"</string>
<string name="common_recovery_key">"Wiederherstellungsschlüssel"</string>
<string name="common_recovery_key">
<b>"Wiederherstellungsschlüssel"</b>
</string>
<string name="common_refreshing">"Wird erneuert…"</string>
<string name="common_replying_to">"%1$s antworten"</string>
<string name="common_report_a_bug">"Einen Fehler melden"</string>
@ -245,12 +247,6 @@
<string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Schüttel heftig zum Melden von Fehlern"</string>
<string name="screen_identity_confirmation_subtitle">"Verifiziere dieses Gerät, um sicheres Messaging einzurichten."</string>
<string name="screen_identity_confirmation_title">"Bestätige, dass du es bist"</string>
<string name="screen_identity_confirmed_subtitle">"Du kannst nun verschlüsselte Nachrichten lesen oder versenden."</string>
<string name="screen_identity_confirmed_title">"Gerät verifiziert"</string>
<string name="screen_identity_use_another_device">"Ein anderes Gerät verwenden"</string>
<string name="screen_identity_waiting_on_other_device">"Bitte warten bis das andere Gerät bereit ist."</string>
<string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut."</string>
@ -259,6 +255,7 @@
<string name="screen_room_member_details_block_alert_action">"Blockieren"</string>
<string name="screen_room_member_details_block_alert_description">"Blockierte Benutzer können Dir keine Nachrichten senden und alle ihre alten Nachrichten werden ausgeblendet. Die Blockierung kann jederzeit aufgehoben werden."</string>
<string name="screen_room_member_details_block_user">"Benutzer blockieren"</string>
<string name="screen_room_member_details_title">"Profil"</string>
<string name="screen_room_member_details_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_room_member_details_unblock_alert_description">"Der Nutzer kann dir wieder Nachrichten senden &amp; alle Nachrichten des Nutzers werden wieder angezeigt."</string>
<string name="screen_room_member_details_unblock_user">"Blockierung aufheben"</string>

View file

@ -245,12 +245,6 @@
<string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake pour signaler un problème"</string>
<string name="screen_identity_confirmation_subtitle">"Vérifier cette session pour configurer votre messagerie sécurisée."</string>
<string name="screen_identity_confirmation_title">"Confirmez votre identité"</string>
<string name="screen_identity_confirmed_subtitle">"Vous pouvez désormais lire ou envoyer des messages en toute sécurité, et toute personne avec qui vous discutez peut également faire confiance à cette session."</string>
<string name="screen_identity_confirmed_title">"Session vérifiée"</string>
<string name="screen_identity_use_another_device">"Utiliser une autre session"</string>
<string name="screen_identity_waiting_on_other_device">"En attente dune autre session…"</string>
<string name="screen_media_picker_error_failed_selection">"Échec de la sélection du média, veuillez réessayer."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Échec du téléchargement du média, veuillez réessayer."</string>

View file

@ -245,12 +245,6 @@
<string name="invite_friends_text">"Beszélgessünk itt: %1$s, %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Az eszköz rázása a hibajelentéshez"</string>
<string name="screen_identity_confirmation_subtitle">"A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt."</string>
<string name="screen_identity_confirmation_title">"Erősítse meg, hogy Ön az"</string>
<string name="screen_identity_confirmed_subtitle">"Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bármelyik csevegőpartnere megbízhat ebben az eszközben."</string>
<string name="screen_identity_confirmed_title">"Eszköz ellenőrizve"</string>
<string name="screen_identity_use_another_device">"Másik eszköz használata"</string>
<string name="screen_identity_waiting_on_other_device">"Várakozás a másik eszközre…"</string>
<string name="screen_media_picker_error_failed_selection">"Nem sikerült kiválasztani a médiát, próbálja újra."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nem sikerült a média feltöltése, próbálja újra."</string>
@ -259,6 +253,7 @@
<string name="screen_room_member_details_block_alert_action">"Letiltás"</string>
<string name="screen_room_member_details_block_alert_description">"A letiltott felhasználók nem fognak tudni üzeneteket küldeni, és az összes üzenetük rejtve lesz. Bármikor feloldhatja a letiltásukat."</string>
<string name="screen_room_member_details_block_user">"Felhasználó letiltása"</string>
<string name="screen_room_member_details_title">"Profil"</string>
<string name="screen_room_member_details_unblock_alert_action">"Letiltás feloldása"</string>
<string name="screen_room_member_details_unblock_alert_description">"Újra láthatja az összes üzenetét."</string>
<string name="screen_room_member_details_unblock_user">"Felhasználó kitiltásának feloldása"</string>

View file

@ -194,7 +194,7 @@
<string name="common_server_not_supported">"Server tidak didukung"</string>
<string name="common_server_url">"URL Server"</string>
<string name="common_settings">"Pengaturan"</string>
<string name="common_shared_location">"Membagikan lokasi"</string>
<string name="common_shared_location">"Lokasi terbagi"</string>
<string name="common_signing_out">"Mengeluarkan dari akun"</string>
<string name="common_starting_chat">"Memulai obrolan…"</string>
<string name="common_sticker">"Stiker"</string>
@ -241,12 +241,6 @@
<string name="invite_friends_text">"Hai, bicaralah dengan saya di %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake untuk melaporkan kutu"</string>
<string name="screen_identity_confirmation_subtitle">"Verifikasi perangkat ini untuk menyiapkan perpesanan aman."</string>
<string name="screen_identity_confirmation_title">"Konfirmasi bahwa ini Anda"</string>
<string name="screen_identity_confirmed_subtitle">"Sekarang Anda dapat membaca atau mengirim pesan dengan aman, dan siapa pun yang mengobrol dengan Anda juga dapat mempercayai perangkat ini."</string>
<string name="screen_identity_confirmed_title">"Perangkat terverifikasi"</string>
<string name="screen_identity_use_another_device">"Gunakan perangkat lain"</string>
<string name="screen_identity_waiting_on_other_device">"Menunggu di perangkat lain…"</string>
<string name="screen_media_picker_error_failed_selection">"Gagal memilih media, silakan coba lagi."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Gagal memproses media untuk diunggah, silakan coba lagi."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Gagal mengunggah media, silakan coba lagi."</string>
@ -255,6 +249,7 @@
<string name="screen_room_member_details_block_alert_action">"Blokir"</string>
<string name="screen_room_member_details_block_alert_description">"Pengguna yang diblokir tidak akan dapat mengirim Anda pesan dan semua pesan mereka akan disembunyikan. Anda dapat membuka blokirnya kapan saja."</string>
<string name="screen_room_member_details_block_user">"Blokir pengguna"</string>
<string name="screen_room_member_details_title">"Profil"</string>
<string name="screen_room_member_details_unblock_alert_action">"Buka blokir"</string>
<string name="screen_room_member_details_unblock_alert_description">"Anda akan dapat melihat semua pesan dari mereka lagi."</string>
<string name="screen_room_member_details_unblock_user">"Buka blokir pengguna"</string>

View file

@ -179,7 +179,9 @@
<string name="common_privacy_policy">"Политика конфиденциальности"</string>
<string name="common_reaction">"Реакция"</string>
<string name="common_reactions">"Реакции"</string>
<string name="common_recovery_key">"Ключ восстановления"</string>
<string name="common_recovery_key">
<b>"Ключ восстановления"</b>
</string>
<string name="common_refreshing">"Обновление…"</string>
<string name="common_replying_to">"Отвечает на %1$s"</string>
<string name="common_report_a_bug">"Сообщить об ошибке"</string>
@ -249,12 +251,6 @@
<string name="invite_friends_text">"Привет, поговори со мной по %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Встряхните устройство, чтобы сообщить об ошибке"</string>
<string name="screen_identity_confirmation_subtitle">"Подтвердите это устройство, чтобы настроить безопасный обмен сообщениями."</string>
<string name="screen_identity_confirmation_title">"Подтвердите, что это вы"</string>
<string name="screen_identity_confirmed_subtitle">"Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству."</string>
<string name="screen_identity_confirmed_title">"Устройство проверено"</string>
<string name="screen_identity_use_another_device">"Используйте другое устройство"</string>
<string name="screen_identity_waiting_on_other_device">"Ожидание на другом устройстве…"</string>
<string name="screen_media_picker_error_failed_selection">"Не удалось выбрать носитель, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>
@ -263,6 +259,7 @@
<string name="screen_room_member_details_block_alert_action">"Заблокировать"</string>
<string name="screen_room_member_details_block_alert_description">"Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время."</string>
<string name="screen_room_member_details_block_user">"Заблокировать пользователя"</string>
<string name="screen_room_member_details_title">"Профиль"</string>
<string name="screen_room_member_details_unblock_alert_action">"Разблокировать"</string>
<string name="screen_room_member_details_unblock_alert_description">"Вы снова сможете увидеть все сообщения."</string>
<string name="screen_room_member_details_unblock_user">"Разблокировать пользователя"</string>

View file

@ -248,12 +248,6 @@
<string name="invite_friends_text">"Ahoj, porozprávajte sa so mnou na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Zúrivo potriasť pre nahlásenie chyby"</string>
<string name="screen_identity_confirmation_subtitle">"Ak chcete nastaviť zabezpečené správy, overte toto zariadenie."</string>
<string name="screen_identity_confirmation_title">"Potvrďte, že ste to vy"</string>
<string name="screen_identity_confirmed_subtitle">"Teraz môžete bezpečne čítať alebo odosielať správy a tomuto zariadeniu môže dôverovať aj ktokoľvek, s kým konverzujete."</string>
<string name="screen_identity_confirmed_title">"Zariadenie overené"</string>
<string name="screen_identity_use_another_device">"Použiť iné zariadenie"</string>
<string name="screen_identity_waiting_on_other_device">"Čaká sa na druhom zariadení…"</string>
<string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string>
@ -262,6 +256,7 @@
<string name="screen_room_member_details_block_alert_action">"Zablokovať"</string>
<string name="screen_room_member_details_block_alert_description">"Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."</string>
<string name="screen_room_member_details_block_user">"Zablokovať používateľa"</string>
<string name="screen_room_member_details_title">"Profil"</string>
<string name="screen_room_member_details_unblock_alert_action">"Odblokovať"</string>
<string name="screen_room_member_details_unblock_alert_description">"Všetky správy od nich budete môcť opäť vidieť."</string>
<string name="screen_room_member_details_unblock_user">"Odblokovať používateľa"</string>

View file

@ -96,6 +96,7 @@
<string name="common_editing">"Redigerar"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Kryptering aktiverad"</string>
<string name="common_enter_your_pin">"Ange din PIN-kod"</string>
<string name="common_error">"Fel"</string>
<string name="common_everyone">"Alla"</string>
<string name="common_file">"Fil"</string>
@ -143,6 +144,7 @@
<string name="common_rich_text_editor">"Riktextredigerare"</string>
<string name="common_room_name">"Rumsnamn"</string>
<string name="common_room_name_placeholder">"t.ex. ditt projektnamn"</string>
<string name="common_screen_lock">"Skärmlås"</string>
<string name="common_search_for_someone">"Sök efter någon"</string>
<string name="common_search_results">"Sökresultat"</string>
<string name="common_security">"Säkerhet"</string>
@ -164,6 +166,7 @@
<string name="common_unable_to_decrypt">"Kan inte avkryptera"</string>
<string name="common_unable_to_invite_message">"Inbjudan kunde inte skickas till en eller flera användare."</string>
<string name="common_unable_to_invite_title">"Kunde inte skicka inbjudningar"</string>
<string name="common_unlock">"Lås upp"</string>
<string name="common_unmute">"Avtysta"</string>
<string name="common_unsupported_event">"Händelse som inte stöds"</string>
<string name="common_username">"Användarnamn"</string>

View file

@ -249,10 +249,6 @@
<string name="invite_friends_text">"Привіт, пишіть мені за адресою %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Повідомити про ваду за допомогою Rageshake"</string>
<string name="screen_identity_confirmation_subtitle">"Перевірте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."</string>
<string name="screen_identity_confirmation_title">"Підтвердіть, що це ви"</string>
<string name="screen_identity_confirmed_subtitle">"Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."</string>
<string name="screen_identity_confirmed_title">"Пристрій перевірено"</string>
<string name="screen_media_picker_error_failed_selection">"Не вдалося вибрати медіафайл, спробуйте ще раз."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не вдалося завантажити медіафайл, спробуйте ще раз."</string>

View file

@ -227,9 +227,6 @@
<string name="error_some_messages_have_not_been_sent">"有些訊息尚未傳送"</string>
<string name="invite_friends_text">"嘿,來 %1$s 和我聊天:%2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="screen_identity_confirmed_title">"裝置已認證"</string>
<string name="screen_identity_use_another_device">"使用另一個裝置"</string>
<string name="screen_identity_waiting_on_other_device">"正在等待其他裝置……"</string>
<string name="screen_media_upload_preview_error_failed_sending">"無法上傳媒體檔案,請稍後再試。"</string>
<string name="screen_room_member_details_block_alert_action">"封鎖"</string>
<string name="screen_room_member_details_block_user">"封鎖使用者"</string>

View file

@ -245,22 +245,15 @@
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_identity_confirmation_subtitle">"Verify this device to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm that it\'s you"</string>
<string name="screen_identity_confirmed_subtitle">"Now you can read or send messages securely, and anyone you chat with can also trust this device."</string>
<string name="screen_identity_confirmed_title">"Device verified"</string>
<string name="screen_identity_use_another_device">"Use another device"</string>
<string name="screen_identity_waiting_on_other_device">"Waiting on other device…"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
<string name="screen_room_directory_search_loading_error">"Failed loading"</string>
<string name="screen_room_directory_search_title">"Room directory"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
<string name="screen_room_member_details_block_alert_action">"Block"</string>
<string name="screen_room_member_details_block_alert_description">"Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."</string>
<string name="screen_room_member_details_block_user">"Block user"</string>
<string name="screen_room_member_details_title">"Profile"</string>
<string name="screen_room_member_details_unblock_alert_action">"Unblock"</string>
<string name="screen_room_member_details_unblock_alert_description">"You\'ll be able to see all messages from them again."</string>
<string name="screen_room_member_details_unblock_user">"Unblock user"</string>