Hide encryption history + FTUE flow (#839)
* First attempt at implementing encrypted history banner and removing old UTDs * Get the right behavior in the timeline * Implement the designs * Extract post-processing logic, add tests * Add encryption banner to timeline screenshots * Create FTUE feature to handle welcome screen and analytics * Move classes to their own packages, add tests for `DefaultFtueState`. * Remove unnecessary private MutableStateFlow * Move some FTUE related methods and classes back to the `impl` module * Handle back press at each FTUE step * Remove unneeded `TestScope` receiver for `createState` in tests. * Use light & dark previews for the banner view. * Move color customization from `TextStyle` to `Text` component. * Rename `InfoList` design components, use them in `AnalyticsOptInView` too. * Cleanup MatrixClient. * Fix copy&paste error Co-authored-by: Benoit Marty <benoit@matrix.org> * Fix typo * Fix Maestro tests --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io> Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
parent
9f6b68f204
commit
280b7e32e0
62 changed files with 1714 additions and 123 deletions
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* 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.atomic.atoms
|
||||
|
||||
import android.graphics.BlurMaskFilter
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.graphics.ClipOp
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.R
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun ElementLogoAtom(
|
||||
size: ElementLogoAtomSize,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val outerSize = when (size) {
|
||||
ElementLogoAtomSize.Large -> 158.dp
|
||||
ElementLogoAtomSize.Medium -> 120.dp
|
||||
}
|
||||
val logoSize = when (size) {
|
||||
ElementLogoAtomSize.Large -> 110.dp
|
||||
ElementLogoAtomSize.Medium -> 83.5.dp
|
||||
}
|
||||
val cornerRadius = when(size) {
|
||||
ElementLogoAtomSize.Large -> 44.dp
|
||||
ElementLogoAtomSize.Medium -> 33.dp
|
||||
}
|
||||
val borderWidth = when (size) {
|
||||
ElementLogoAtomSize.Large -> 1.dp
|
||||
ElementLogoAtomSize.Medium -> 0.38.dp
|
||||
}
|
||||
val blur = if (isSystemInDarkTheme()) {
|
||||
160.dp
|
||||
} else {
|
||||
24.dp
|
||||
}
|
||||
//box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280;
|
||||
val shadowColor = if (isSystemInDarkTheme()) {
|
||||
Color.Black.copy(alpha = 0.4f)
|
||||
} else {
|
||||
Color(0x401B1D22)
|
||||
}
|
||||
val backgroundColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f)
|
||||
val borderColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(outerSize)
|
||||
.border(borderWidth, borderColor, RoundedCornerShape(cornerRadius)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(outerSize)
|
||||
.shapeShadow(
|
||||
color = shadowColor,
|
||||
cornerRadius = cornerRadius,
|
||||
blurRadius = 32.dp,
|
||||
offsetY = 8.dp,
|
||||
)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(cornerRadius))
|
||||
.size(outerSize)
|
||||
.background(backgroundColor)
|
||||
.blur(blur)
|
||||
)
|
||||
Image(
|
||||
modifier = Modifier.size(logoSize),
|
||||
painter = painterResource(id = R.drawable.element_logo),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ElementLogoAtomSize {
|
||||
Medium,
|
||||
Large
|
||||
}
|
||||
|
||||
@Composable
|
||||
@DayNightPreviews
|
||||
internal fun ElementLogoAtomPreview() {
|
||||
ElementPreview {
|
||||
Box(
|
||||
Modifier
|
||||
.size(170.dp)
|
||||
.background(ElementTheme.colors.bgSubtlePrimary))
|
||||
ElementLogoAtom(ElementLogoAtomSize.Large)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.shapeShadow(
|
||||
color: Color = Color.Black,
|
||||
cornerRadius: Dp = 0.dp,
|
||||
offsetX: Dp = 0.dp,
|
||||
offsetY: Dp = 0.dp,
|
||||
blurRadius: Dp = 0.dp,
|
||||
) = then(
|
||||
drawBehind {
|
||||
drawIntoCanvas { canvas ->
|
||||
val path = Path().apply {
|
||||
addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx())))
|
||||
}
|
||||
|
||||
clipPath(path, ClipOp.Difference) {
|
||||
val paint = Paint()
|
||||
val frameworkPaint = paint.asFrameworkPaint()
|
||||
if (blurRadius != 0.dp) {
|
||||
frameworkPaint.maskFilter = (BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL))
|
||||
}
|
||||
frameworkPaint.color = color.toArgb()
|
||||
|
||||
val leftPixel = offsetX.toPx()
|
||||
val topPixel = offsetY.toPx()
|
||||
val rightPixel = size.width + topPixel
|
||||
val bottomPixel = size.height + leftPixel
|
||||
|
||||
canvas.drawRect(
|
||||
left = leftPixel,
|
||||
top = topPixel,
|
||||
right = rightPixel,
|
||||
bottom = bottomPixel,
|
||||
paint = paint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* 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.atomic.atoms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
@Composable
|
||||
fun InfoListItemMolecule(
|
||||
message: @Composable () -> Unit,
|
||||
position: InfoListItemPosition,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: @Composable () -> Unit = {},
|
||||
) {
|
||||
val radius = 14.dp
|
||||
val backgroundShape = remember(position) {
|
||||
when (position) {
|
||||
InfoListItemPosition.Single -> RoundedCornerShape(radius)
|
||||
InfoListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
|
||||
InfoListItemPosition.Middle -> RoundedCornerShape(0.dp)
|
||||
InfoListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = backgroundColor,
|
||||
shape = backgroundShape,
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
icon()
|
||||
message()
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun InfoListItemMoleculePreview() {
|
||||
ElementPreview {
|
||||
val color = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
InfoListItemMolecule(
|
||||
message = { Text("A single item") },
|
||||
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
|
||||
position = InfoListItemPosition.Single,
|
||||
backgroundColor = color,
|
||||
)
|
||||
InfoListItemMolecule(
|
||||
message = { Text("A top item") },
|
||||
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
|
||||
position = InfoListItemPosition.Top,
|
||||
backgroundColor = color,
|
||||
)
|
||||
InfoListItemMolecule(
|
||||
message = { Text("A middle item") },
|
||||
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
|
||||
position = InfoListItemPosition.Middle,
|
||||
backgroundColor = color,
|
||||
)
|
||||
InfoListItemMolecule(
|
||||
message = { Text("A bottom item") },
|
||||
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
|
||||
position = InfoListItemPosition.Bottom,
|
||||
backgroundColor = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class InfoListItemPosition {
|
||||
Top,
|
||||
Middle,
|
||||
Bottom,
|
||||
Single,
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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.atomic.molecules
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemPosition
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
fun InfoListOrganism(
|
||||
items: ImmutableList<InfoListItem>,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
iconTint: Color = LocalContentColor.current,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = verticalArrangement,
|
||||
) {
|
||||
for ((index, item) in items.withIndex()) {
|
||||
val position = when {
|
||||
items.size == 1 -> InfoListItemPosition.Single
|
||||
index == 0 -> InfoListItemPosition.Top
|
||||
index == items.size - 1 -> InfoListItemPosition.Bottom
|
||||
else -> InfoListItemPosition.Middle
|
||||
}
|
||||
InfoListItemMolecule(
|
||||
message = { Text(item.message, style = textStyle) },
|
||||
icon = {
|
||||
if (item.iconId != null) {
|
||||
Icon(resourceId = item.iconId, contentDescription = null, tint = iconTint)
|
||||
} else if (item.iconVector != null) {
|
||||
Icon(imageVector = item.iconVector, contentDescription = null, tint = iconTint)
|
||||
} else {
|
||||
item.iconComposable()
|
||||
}
|
||||
},
|
||||
position = position,
|
||||
backgroundColor = backgroundColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class InfoListItem(
|
||||
val message: String,
|
||||
@DrawableRes val iconId: Int? = null,
|
||||
val iconVector: ImageVector? = null,
|
||||
val iconComposable: @Composable () -> Unit = {},
|
||||
)
|
||||
|
|
@ -41,12 +41,14 @@ import io.element.android.libraries.theme.ElementTheme
|
|||
*
|
||||
* Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
|
||||
* @param modifier Classical modifier.
|
||||
* @param contentAlignment horizontal alignment of the contents.
|
||||
* @param footer optional footer.
|
||||
* @param content main content.
|
||||
*/
|
||||
@Composable
|
||||
fun OnBoardingPage(
|
||||
modifier: Modifier = Modifier,
|
||||
contentAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
|
||||
footer: @Composable () -> Unit = {},
|
||||
content: @Composable () -> Unit = {},
|
||||
) {
|
||||
|
|
@ -78,6 +80,7 @@ fun OnBoardingPage(
|
|||
.weight(1f)
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = contentAlignment,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="110dp"
|
||||
android:height="110dp"
|
||||
android:viewportWidth="110"
|
||||
android:viewportHeight="110">
|
||||
<path
|
||||
android:pathData="M55,110C85.38,110 110,85.38 110,55C110,24.62 85.38,0 55,0C24.62,0 0,24.62 0,55C0,85.38 24.62,110 55,110Z"
|
||||
android:fillColor="#0DBD8B"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M44.94,25.63C44.94,23.41 46.75,21.61 48.97,21.61C64.05,21.61 76.27,33.81 76.27,48.85C76.27,51.07 74.47,52.87 72.25,52.87C70.02,52.87 68.22,51.07 68.22,48.85C68.22,38.25 59.6,29.65 48.97,29.65C46.75,29.65 44.94,27.85 44.94,25.63Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M84.36,44.83C86.59,44.83 88.39,46.63 88.39,48.85C88.39,63.9 76.17,76.1 61.09,76.1C58.87,76.1 57.06,74.3 57.06,72.08C57.06,69.86 58.87,68.06 61.09,68.06C71.72,68.06 80.34,59.46 80.34,48.85C80.34,46.63 82.14,44.83 84.36,44.83Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M65.12,84.37C65.12,86.59 63.32,88.39 61.09,88.39C46.01,88.39 33.79,76.19 33.79,61.15C33.79,58.93 35.59,57.13 37.82,57.13C40.04,57.13 41.85,58.93 41.85,61.15C41.85,71.75 50.46,80.35 61.09,80.35C63.32,80.35 65.12,82.15 65.12,84.37Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M25.63,65.17C23.41,65.17 21.61,63.37 21.61,61.15C21.61,46.1 33.83,33.9 48.91,33.9C51.13,33.9 52.94,35.7 52.94,37.92C52.94,40.14 51.13,41.94 48.91,41.94C38.28,41.94 29.66,50.54 29.66,61.15C29.66,63.37 27.86,65.17 25.63,65.17Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
@ -31,9 +31,7 @@ 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.TimeoutCancellationException
|
||||
import java.io.Closeable
|
||||
import kotlin.time.Duration
|
||||
|
||||
interface MatrixClient : Closeable {
|
||||
val sessionId: SessionId
|
||||
|
|
|
|||
|
|
@ -24,4 +24,5 @@ sealed interface VirtualTimelineItem {
|
|||
|
||||
object ReadMarker : VirtualTimelineItem
|
||||
|
||||
object EncryptedHistoryBanner : VirtualTimelineItem
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,4 +41,8 @@ dependencies {
|
|||
implementation("net.java.dev.jna:jna:5.13.0@aar")
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.serialization.json)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ class RustMatrixClient constructor(
|
|||
coroutineDispatchers = dispatchers,
|
||||
systemClock = clock,
|
||||
roomContentForwarder = roomContentForwarder,
|
||||
sessionData = sessionStore.getSession(sessionId.value)!!,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import org.matrix.rustcomponents.sdk.ClientBuilder
|
|||
import org.matrix.rustcomponents.sdk.Session
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService
|
||||
|
||||
|
|
@ -208,4 +209,5 @@ private fun Session.toSessionData() = SessionData(
|
|||
refreshToken = refreshToken,
|
||||
homeserverUrl = homeserverUrl,
|
||||
slidingSyncProxy = slidingSyncProxy,
|
||||
loginTimestamp = Date(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ import io.element.android.libraries.matrix.impl.room.location.toInner
|
|||
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||
import io.element.android.libraries.matrix.impl.timeline.backPaginationStatusFlow
|
||||
import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -73,6 +75,7 @@ class RustMatrixRoom(
|
|||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val systemClock: SystemClock,
|
||||
private val roomContentForwarder: RoomContentForwarder,
|
||||
private val sessionData: SessionData,
|
||||
) : MatrixRoom {
|
||||
|
||||
override val roomId = RoomId(innerRoom.id())
|
||||
|
|
@ -91,7 +94,8 @@ class RustMatrixRoom(
|
|||
matrixRoom = this,
|
||||
innerRoom = innerRoom,
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
dispatcher = roomDispatcher
|
||||
dispatcher = roomDispatcher,
|
||||
lastLoginTimestamp = sessionData.loginTimestamp,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,19 +21,23 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
|||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
|
||||
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 kotlinx.coroutines.CompletableDeferred
|
||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.BackPaginationStatus
|
||||
|
|
@ -43,6 +47,7 @@ import org.matrix.rustcomponents.sdk.TimelineDiff
|
|||
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.Date
|
||||
|
||||
private const val INITIAL_MAX_SIZE = 50
|
||||
|
||||
|
|
@ -51,6 +56,7 @@ class RustMatrixTimeline(
|
|||
private val matrixRoom: MatrixRoom,
|
||||
private val innerRoom: Room,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
private val lastLoginTimestamp: Date?,
|
||||
) : MatrixTimeline {
|
||||
|
||||
private val initLatch = CompletableDeferred<Unit>()
|
||||
|
|
@ -63,6 +69,12 @@ class RustMatrixTimeline(
|
|||
MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)
|
||||
)
|
||||
|
||||
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
|
||||
lastLoginTimestamp = lastLoginTimestamp,
|
||||
isRoomEncrypted = matrixRoom.isEncrypted,
|
||||
paginationStateFlow = _paginationState,
|
||||
)
|
||||
|
||||
private val timelineItemFactory = MatrixTimelineItemMapper(
|
||||
fetchDetailsForEvent = this::fetchDetailsForEvent,
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
|
|
@ -81,8 +93,11 @@ class RustMatrixTimeline(
|
|||
|
||||
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState.asStateFlow()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems.sample(50)
|
||||
.mapLatest { items ->
|
||||
encryptedHistoryPostProcessor.process(items)
|
||||
}
|
||||
|
||||
internal suspend fun postItems(items: List<TimelineItem>) {
|
||||
// Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
|
||||
|
|
@ -100,6 +115,12 @@ class RustMatrixTimeline(
|
|||
|
||||
internal fun postPaginationStatus(status: BackPaginationStatus) {
|
||||
_paginationState.getAndUpdate { currentPaginationState ->
|
||||
if (hasEncryptionHistoryBanner()) {
|
||||
return@getAndUpdate currentPaginationState.copy(
|
||||
isBackPaginating = false,
|
||||
hasMoreToLoadBackwards = false,
|
||||
)
|
||||
}
|
||||
when (status) {
|
||||
BackPaginationStatus.IDLE -> {
|
||||
currentPaginationState.copy(
|
||||
|
|
@ -159,4 +180,10 @@ class RustMatrixTimeline(
|
|||
fun getItemById(eventId: EventId): MatrixTimelineItem.Event? {
|
||||
return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event
|
||||
}
|
||||
|
||||
private fun hasEncryptionHistoryBanner(): Boolean {
|
||||
val firstItem = _timelineItems.value.firstOrNull()
|
||||
return firstItem is MatrixTimelineItem.Virtual
|
||||
&& firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* 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.impl.timeline.postprocessor
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
class TimelineEncryptedHistoryPostProcessor(
|
||||
private val lastLoginTimestamp: Date?,
|
||||
private val isRoomEncrypted: Boolean,
|
||||
private val paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState>,
|
||||
) {
|
||||
|
||||
fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
if (!isRoomEncrypted || lastLoginTimestamp == null) return items
|
||||
|
||||
val filteredItems = replaceWithEncryptionHistoryBannerIfNeeded(items)
|
||||
// Disable back pagination
|
||||
val wasFiltered = filteredItems !== items
|
||||
if (wasFiltered) {
|
||||
paginationStateFlow.getAndUpdate {
|
||||
it.copy(
|
||||
isBackPaginating = false,
|
||||
hasMoreToLoadBackwards = false
|
||||
)
|
||||
}
|
||||
}
|
||||
return filteredItems
|
||||
}
|
||||
|
||||
private fun replaceWithEncryptionHistoryBannerIfNeeded(list: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
var lastEncryptedHistoryBannerIndex = -1
|
||||
for ((i, item) in list.withIndex()) {
|
||||
if (isItemEncryptionHistory(item)) {
|
||||
lastEncryptedHistoryBannerIndex = i
|
||||
}
|
||||
}
|
||||
return if (lastEncryptedHistoryBannerIndex >= 0) {
|
||||
val sublist = list.drop(lastEncryptedHistoryBannerIndex + 1).toMutableList()
|
||||
sublist.add(0, MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner))
|
||||
sublist
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
private fun isItemEncryptionHistory(item: MatrixTimelineItem): Boolean {
|
||||
if ((item as? MatrixTimelineItem.Virtual)?.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
|
||||
return true
|
||||
}
|
||||
val timestamp = (item as? MatrixTimelineItem.Event)?.event?.timestamp ?: return false
|
||||
return timestamp <= lastLoginTimestamp!!.time
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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.impl.timeline.postprocessor
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class TimelineEncryptedHistoryPostProcessorTest {
|
||||
|
||||
private val defaultLastLoginTimestamp = Date(1689061264L)
|
||||
|
||||
@Test
|
||||
fun `given an unencrypted room, nothing is done`() {
|
||||
val processor = createPostProcessor(isRoomEncrypted = false)
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(0L, anEventTimelineItem())
|
||||
)
|
||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a null lastLoginTimestamp, nothing is done`() {
|
||||
val processor = createPostProcessor(lastLoginTimestamp = null)
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(0L, anEventTimelineItem())
|
||||
)
|
||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given an empty list, nothing is done`() {
|
||||
val processor = createPostProcessor()
|
||||
val items = emptyList<MatrixTimelineItem>()
|
||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a list with no items before lastLoginTimestamp, nothing is done`() {
|
||||
val processor = createPostProcessor()
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
|
||||
)
|
||||
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a list with an item with equal timestamp as lastLoginTimestamp, it's replaced`() {
|
||||
val processor = createPostProcessor()
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time))
|
||||
)
|
||||
assertThat(processor.process(items))
|
||||
.isEqualTo(listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a list with an item with a lower timestamp than lastLoginTimestamp, it's replaced`() {
|
||||
val processor = createPostProcessor()
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1))
|
||||
)
|
||||
assertThat(processor.process(items)).isEqualTo(
|
||||
listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a list with several with lower or equal timestamps than lastLoginTimestamp, they're replaced and the user can't back paginate`() {
|
||||
val paginationStateFlow = MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false))
|
||||
val processor = createPostProcessor(paginationStateFlow = paginationStateFlow)
|
||||
val items = listOf(
|
||||
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)),
|
||||
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)),
|
||||
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)),
|
||||
)
|
||||
assertThat(processor.process(items)).isEqualTo(
|
||||
listOf(
|
||||
MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner),
|
||||
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
|
||||
)
|
||||
)
|
||||
assertThat(paginationStateFlow.value).isEqualTo(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = false, isBackPaginating = false))
|
||||
}
|
||||
|
||||
private fun createPostProcessor(
|
||||
lastLoginTimestamp: Date? = defaultLastLoginTimestamp,
|
||||
isRoomEncrypted: Boolean = true,
|
||||
paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState> =
|
||||
MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false))
|
||||
) = TimelineEncryptedHistoryPostProcessor(
|
||||
lastLoginTimestamp = lastLoginTimestamp,
|
||||
isRoomEncrypted = isRoomEncrypted,
|
||||
paginationStateFlow = paginationStateFlow,
|
||||
)
|
||||
}
|
||||
|
|
@ -16,11 +16,14 @@
|
|||
|
||||
package io.element.android.libraries.sessionstorage.api
|
||||
|
||||
import java.util.Date
|
||||
|
||||
data class SessionData(
|
||||
val userId: String,
|
||||
val deviceId: String,
|
||||
val accessToken: String,
|
||||
val refreshToken: String?,
|
||||
val homeserverUrl: String,
|
||||
val slidingSyncProxy: String?
|
||||
val slidingSyncProxy: String?,
|
||||
val loginTimestamp: Date?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,5 +48,7 @@ dependencies {
|
|||
}
|
||||
|
||||
sqldelight {
|
||||
database("SessionDatabase") {}
|
||||
database("SessionDatabase") {
|
||||
verifyMigrations = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,19 +17,22 @@
|
|||
package io.element.android.libraries.sessionstorage.impl
|
||||
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import java.util.Date
|
||||
import io.element.android.libraries.matrix.session.SessionData as DbSessionData
|
||||
|
||||
internal fun SessionData.toDbModel(): io.element.android.libraries.matrix.session.SessionData {
|
||||
return io.element.android.libraries.matrix.session.SessionData(
|
||||
internal fun SessionData.toDbModel(): DbSessionData {
|
||||
return DbSessionData(
|
||||
userId = userId,
|
||||
deviceId = deviceId,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
homeserverUrl = homeserverUrl,
|
||||
slidingSyncProxy = slidingSyncProxy,
|
||||
loginTimestamp = loginTimestamp?.time,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun io.element.android.libraries.matrix.session.SessionData.toApiModel(): SessionData {
|
||||
internal fun DbSessionData.toApiModel(): SessionData {
|
||||
return SessionData(
|
||||
userId = userId,
|
||||
deviceId = deviceId,
|
||||
|
|
@ -37,5 +40,6 @@ internal fun io.element.android.libraries.matrix.session.SessionData.toApiModel(
|
|||
refreshToken = refreshToken,
|
||||
homeserverUrl = homeserverUrl,
|
||||
slidingSyncProxy = slidingSyncProxy,
|
||||
loginTimestamp = loginTimestamp?.let { Date(it) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ CREATE TABLE SessionData (
|
|||
accessToken TEXT NOT NULL,
|
||||
refreshToken TEXT,
|
||||
homeserverUrl TEXT NOT NULL,
|
||||
slidingSyncProxy TEXT
|
||||
slidingSyncProxy TEXT,
|
||||
loginTimestamp INTEGER
|
||||
);
|
||||
|
||||
|
||||
selectFirst:
|
||||
SELECT * FROM SessionData LIMIT 1;
|
||||
|
||||
|
|
@ -17,7 +19,7 @@ selectByUserId:
|
|||
SELECT * FROM SessionData WHERE userId = ?;
|
||||
|
||||
insertSessionData:
|
||||
INSERT INTO SessionData(userId, deviceId, accessToken, refreshToken, homeserverUrl, slidingSyncProxy) VALUES ?;
|
||||
INSERT INTO SessionData VALUES ?;
|
||||
|
||||
removeSession:
|
||||
DELETE FROM SessionData WHERE userId = ?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE SessionData (
|
||||
userId TEXT NOT NULL PRIMARY KEY,
|
||||
deviceId TEXT NOT NULL,
|
||||
accessToken TEXT NOT NULL,
|
||||
refreshToken TEXT,
|
||||
homeserverUrl TEXT NOT NULL,
|
||||
slidingSyncProxy TEXT
|
||||
);
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE SessionData ADD COLUMN loginTimestamp INTEGER;
|
||||
|
|
@ -35,7 +35,8 @@ class DatabaseSessionStoreTests {
|
|||
accessToken = "accessToken",
|
||||
refreshToken = "refreshToken",
|
||||
homeserverUrl = "homeserverUrl",
|
||||
slidingSyncProxy = null
|
||||
slidingSyncProxy = null,
|
||||
loginTimestamp = null,
|
||||
)
|
||||
|
||||
@Before
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ object TestTags {
|
|||
* Room list / Home screen.
|
||||
*/
|
||||
val homeScreenSettings = TestTag("home_screen-settings")
|
||||
|
||||
/**
|
||||
* Welcome screen.
|
||||
*/
|
||||
val welcomeScreenTitle = TestTag("welcome_screen-title")
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -176,12 +176,6 @@
|
|||
<string name="screen_share_open_osm_maps">"In OpenStreetMap öffnen"</string>
|
||||
<string name="screen_share_this_location_action">"Diesen Ort teilen"</string>
|
||||
<string name="screen_view_location_title">"Standort"</string>
|
||||
<string name="screen_welcome_bullet_1">"Anrufe, Standortfreigabe, Suche und mehr werden später in diesem Jahr hinzugefügt."</string>
|
||||
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
|
||||
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."</string>
|
||||
<string name="screen_welcome_button">"Los geht\'s!"</string>
|
||||
<string name="screen_welcome_subtitle">"Folgendes musst du wissen:"</string>
|
||||
<string name="screen_welcome_title">"Willkommen bei %1$s!"</string>
|
||||
<string name="settings_rageshake">"Rageshake"</string>
|
||||
<string name="settings_rageshake_detection_threshold">"Erkennungsschwelle"</string>
|
||||
<string name="settings_title_general">"Allgemein"</string>
|
||||
|
|
|
|||
|
|
@ -178,11 +178,6 @@
|
|||
<string name="screen_share_open_google_maps">"Ouvrir dans Google Maps"</string>
|
||||
<string name="screen_share_open_osm_maps">"Ouvrir dans OpenStreetMap"</string>
|
||||
<string name="screen_share_this_location_action">"Partager cette position"</string>
|
||||
<string name="screen_welcome_bullet_2">"L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string>
|
||||
<string name="screen_welcome_bullet_3">"Nous serions ravis d’avoir votre avis, n’hésitez pas à nous le partager via la page des paramètres."</string>
|
||||
<string name="screen_welcome_button">"C’est parti !"</string>
|
||||
<string name="screen_welcome_subtitle">"Voici ce qu’il faut savoir :"</string>
|
||||
<string name="screen_welcome_title">"Bienvenue sur %1$s !"</string>
|
||||
<string name="settings_rageshake">"Rageshake"</string>
|
||||
<string name="settings_rageshake_detection_threshold">"Seuil de détection"</string>
|
||||
<string name="settings_title_general">"Général"</string>
|
||||
|
|
|
|||
|
|
@ -183,12 +183,6 @@
|
|||
<string name="screen_share_open_osm_maps">"Otvoriť v OpenStreetMap"</string>
|
||||
<string name="screen_share_this_location_action">"Zdieľajte túto polohu"</string>
|
||||
<string name="screen_view_location_title">"Poloha"</string>
|
||||
<string name="screen_welcome_bullet_1">"Hovory, zdieľanie polohy, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string>
|
||||
<string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string>
|
||||
<string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string>
|
||||
<string name="screen_welcome_button">"Poďme na to!"</string>
|
||||
<string name="screen_welcome_subtitle">"Tu je to, čo potrebujete vedieť:"</string>
|
||||
<string name="screen_welcome_title">"Vitajte v %1$s!"</string>
|
||||
<string name="settings_rageshake">"Zúrivé potrasenie"</string>
|
||||
<string name="settings_rageshake_detection_threshold">"Prahová hodnota detekcie"</string>
|
||||
<string name="settings_title_general">"Všeobecné"</string>
|
||||
|
|
|
|||
|
|
@ -182,12 +182,6 @@
|
|||
<string name="screen_share_open_osm_maps">"Open in OpenStreetMap"</string>
|
||||
<string name="screen_share_this_location_action">"Share this location"</string>
|
||||
<string name="screen_view_location_title">"Location"</string>
|
||||
<string name="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string>
|
||||
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms won’t be available in this update."</string>
|
||||
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
||||
<string name="screen_welcome_button">"Let\'s go!"</string>
|
||||
<string name="screen_welcome_subtitle">"Here’s what you need to know:"</string>
|
||||
<string name="screen_welcome_title">"Welcome to %1$s!"</string>
|
||||
<string name="settings_rageshake">"Rageshake"</string>
|
||||
<string name="settings_rageshake_detection_threshold">"Detection threshold"</string>
|
||||
<string name="settings_title_general">"General"</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue