Merge branch 'develop' into feature/fga/better_timeline_scroll
This commit is contained in:
commit
8f01e8133f
156 changed files with 3806 additions and 539 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>
|
||||
|
|
@ -14,18 +14,21 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.core.data
|
||||
|
||||
/**
|
||||
* Wrapper for a CharSequence, which support mutation of the CharSequence.
|
||||
*/
|
||||
class StableCharSequence(val charSequence: CharSequence) {
|
||||
private val hash = charSequence.toString().hashCode()
|
||||
|
||||
override fun hashCode() = hash
|
||||
override fun equals(other: Any?) = other is StableCharSequence && other.hash == hash
|
||||
|
||||
override fun toString(): String = "StableCharSequence(\"$charSequence\")"
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
fun CharSequence.toStableCharSequence() = StableCharSequence(this)
|
||||
android {
|
||||
namespace = "io.element.android.libraries.maplibre.compose"
|
||||
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-Xexplicit-api=strict"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.maplibre)
|
||||
api(libs.maplibre.ktx)
|
||||
api(libs.maplibre.annotation)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.mapbox.mapboxsdk.location.modes.CameraMode as InternalCameraMode
|
||||
|
||||
@Immutable
|
||||
public enum class CameraMode {
|
||||
NONE,
|
||||
NONE_COMPASS,
|
||||
NONE_GPS,
|
||||
TRACKING,
|
||||
TRACKING_COMPASS,
|
||||
TRACKING_GPS,
|
||||
TRACKING_GPS_NORTH;
|
||||
|
||||
@InternalCameraMode.Mode
|
||||
internal fun toInternal(): Int = when (this) {
|
||||
NONE -> InternalCameraMode.NONE
|
||||
NONE_COMPASS -> InternalCameraMode.NONE_COMPASS
|
||||
NONE_GPS -> InternalCameraMode.NONE_GPS
|
||||
TRACKING -> InternalCameraMode.TRACKING
|
||||
TRACKING_COMPASS -> InternalCameraMode.TRACKING_COMPASS
|
||||
TRACKING_GPS -> InternalCameraMode.TRACKING_GPS
|
||||
TRACKING_GPS_NORTH -> InternalCameraMode.TRACKING_GPS_NORTH
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
fun fromInternal(@InternalCameraMode.Mode mode: Int): CameraMode = when (mode) {
|
||||
InternalCameraMode.NONE -> NONE
|
||||
InternalCameraMode.NONE_COMPASS -> NONE_COMPASS
|
||||
InternalCameraMode.NONE_GPS -> NONE_GPS
|
||||
InternalCameraMode.TRACKING -> TRACKING
|
||||
InternalCameraMode.TRACKING_COMPASS -> TRACKING_COMPASS
|
||||
InternalCameraMode.TRACKING_GPS -> TRACKING_GPS
|
||||
InternalCameraMode.TRACKING_GPS_NORTH -> TRACKING_GPS_NORTH
|
||||
else -> error("Unknown camera mode: $mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_ANIMATION
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_GESTURE
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION
|
||||
|
||||
/**
|
||||
* Enumerates the different reasons why the map camera started to move.
|
||||
*
|
||||
* Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener.
|
||||
*
|
||||
* [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed.
|
||||
*
|
||||
* [UNKNOWN] is used to represent when an unsupported integer value is provided to [fromInt] - this
|
||||
* may be a new constant value from the Maps SDK that isn't supported by maps-compose yet, in which
|
||||
* case this library should be updated to include a new enum value for that constant.
|
||||
*/
|
||||
@Immutable
|
||||
public enum class CameraMoveStartedReason(public val value: Int) {
|
||||
UNKNOWN(-2),
|
||||
NO_MOVEMENT_YET(-1),
|
||||
GESTURE(REASON_API_GESTURE),
|
||||
API_ANIMATION(REASON_API_ANIMATION),
|
||||
DEVELOPER_ANIMATION(REASON_DEVELOPER_ANIMATION);
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* Converts from the Maps SDK [com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener]
|
||||
* constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such
|
||||
* [CameraMoveStartedReason] for the given [value].
|
||||
*
|
||||
* See https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener.
|
||||
*/
|
||||
public fun fromInt(value: Int): CameraMoveStartedReason {
|
||||
return values().firstOrNull { it.value == value } ?: return UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
import android.location.Location
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.mapbox.mapboxsdk.camera.CameraPosition
|
||||
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap
|
||||
import com.mapbox.mapboxsdk.maps.Projection
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver].
|
||||
* [init] will be called when the [CameraPositionState] is first created to configure its
|
||||
* initial state.
|
||||
*/
|
||||
@Composable
|
||||
public inline fun rememberCameraPositionState(
|
||||
key: String? = null,
|
||||
crossinline init: CameraPositionState.() -> Unit = {}
|
||||
): CameraPositionState = rememberSaveable(key = key, saver = CameraPositionState.Saver) {
|
||||
CameraPositionState().apply(init)
|
||||
}
|
||||
|
||||
/**
|
||||
* A state object that can be hoisted to control and observe the map's camera state.
|
||||
* A [CameraPositionState] may only be used by a single [MapboxMap] composable at a time
|
||||
* as it reflects instance state for a single view of a map.
|
||||
*
|
||||
* @param position the initial camera position
|
||||
* @param cameraMode the initial camera mode
|
||||
*/
|
||||
public class CameraPositionState(
|
||||
position: CameraPosition = CameraPosition.Builder().build(),
|
||||
cameraMode: CameraMode = CameraMode.NONE,
|
||||
) {
|
||||
/**
|
||||
* Whether the camera is currently moving or not. This includes any kind of movement:
|
||||
* panning, zooming, or rotation.
|
||||
*/
|
||||
public var isMoving: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* The reason for the start of the most recent camera moment, or
|
||||
* [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or
|
||||
* [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK.
|
||||
*/
|
||||
public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf(
|
||||
CameraMoveStartedReason.NO_MOVEMENT_YET
|
||||
)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* Returns the current [Projection] to be used for converting between screen
|
||||
* coordinates and lat/lng.
|
||||
*/
|
||||
public val projection: Projection?
|
||||
get() = map?.projection
|
||||
|
||||
/**
|
||||
* Local source of truth for the current camera position.
|
||||
* While [map] is non-null this reflects the current position of [map] as it changes.
|
||||
* While [map] is null it reflects the last known map position, or the last value set by
|
||||
* explicitly setting [position].
|
||||
*/
|
||||
internal var rawPosition by mutableStateOf(position)
|
||||
|
||||
/**
|
||||
* Current position of the camera on the map.
|
||||
*/
|
||||
public var position: CameraPosition
|
||||
get() = rawPosition
|
||||
set(value) {
|
||||
synchronized(lock) {
|
||||
val map = map
|
||||
if (map == null) {
|
||||
rawPosition = value
|
||||
} else {
|
||||
map.moveCamera(CameraUpdateFactory.newCameraPosition(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local source of truth for the current camera mode.
|
||||
* While [map] is non-null this reflects the current camera mode as it changes.
|
||||
* While [map] is null it reflects the last known camera mode, or the last value set by
|
||||
* explicitly setting [cameraMode].
|
||||
*/
|
||||
internal var rawCameraMode by mutableStateOf(cameraMode)
|
||||
|
||||
/**
|
||||
* Current tracking mode of the camera.
|
||||
*/
|
||||
public var cameraMode: CameraMode
|
||||
get() = rawCameraMode
|
||||
set(value) {
|
||||
synchronized(lock) {
|
||||
val map = map
|
||||
if (map == null) {
|
||||
rawCameraMode = value
|
||||
} else {
|
||||
map.locationComponent.cameraMode = value.toInternal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's last available location.
|
||||
*/
|
||||
public var location: Location? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
// Used to perform side effects thread-safely.
|
||||
// Guards all mutable properties that are not `by mutableStateOf`.
|
||||
private val lock = Unit
|
||||
|
||||
// The map currently associated with this CameraPositionState.
|
||||
// Guarded by `lock`.
|
||||
private var map: MapboxMap? by mutableStateOf(null)
|
||||
|
||||
// The current map is set and cleared by side effect.
|
||||
// There can be only one associated at a time.
|
||||
internal fun setMap(map: MapboxMap?) {
|
||||
synchronized(lock) {
|
||||
if (this.map == null && map == null) return
|
||||
if (this.map != null && map != null) {
|
||||
error("CameraPositionState may only be associated with one MapboxMap at a time")
|
||||
}
|
||||
this.map = map
|
||||
if (map == null) {
|
||||
isMoving = false
|
||||
} else {
|
||||
map.moveCamera(CameraUpdateFactory.newCameraPosition(position))
|
||||
map.locationComponent.cameraMode = cameraMode.toInternal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* The default saver implementation for [CameraPositionState].
|
||||
*/
|
||||
public val Saver: Saver<CameraPositionState, SaveableCameraPositionState> = Saver(
|
||||
save = { SaveableCameraPositionState(it.position, it.cameraMode.toInternal()) },
|
||||
restore = { CameraPositionState(it.position, CameraMode.fromInternal(it.cameraMode)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Provides the [CameraPositionState] used by the map. */
|
||||
internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositionState() }
|
||||
|
||||
/** The current [CameraPositionState] used by the map. */
|
||||
public val currentCameraPositionState: CameraPositionState
|
||||
@[MapboxMapComposable ReadOnlyComposable Composable]
|
||||
get() = LocalCameraPositionState.current
|
||||
|
||||
@Parcelize
|
||||
public data class SaveableCameraPositionState(
|
||||
val position: CameraPosition,
|
||||
val cameraMode: Int
|
||||
) : Parcelable
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.mapbox.mapboxsdk.style.layers.Property
|
||||
|
||||
@Immutable
|
||||
public enum class IconAnchor {
|
||||
CENTER,
|
||||
LEFT,
|
||||
RIGHT,
|
||||
TOP,
|
||||
BOTTOM,
|
||||
TOP_LEFT,
|
||||
TOP_RIGHT,
|
||||
BOTTOM_LEFT,
|
||||
BOTTOM_RIGHT;
|
||||
|
||||
@Property.ICON_ANCHOR
|
||||
internal fun toInternal(): String = when (this) {
|
||||
CENTER -> Property.ICON_ANCHOR_CENTER
|
||||
LEFT -> Property.ICON_ANCHOR_LEFT
|
||||
RIGHT -> Property.ICON_ANCHOR_RIGHT
|
||||
TOP -> Property.ICON_ANCHOR_TOP
|
||||
BOTTOM -> Property.ICON_ANCHOR_BOTTOM
|
||||
TOP_LEFT -> Property.ICON_ANCHOR_TOP_LEFT
|
||||
TOP_RIGHT -> Property.ICON_ANCHOR_TOP_RIGHT
|
||||
BOTTOM_LEFT -> Property.ICON_ANCHOR_BOTTOM_LEFT
|
||||
BOTTOM_RIGHT -> Property.ICON_ANCHOR_BOTTOM_RIGHT
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.AbstractApplier
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap
|
||||
import com.mapbox.mapboxsdk.maps.Style
|
||||
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
|
||||
|
||||
internal interface MapNode {
|
||||
fun onAttached() {}
|
||||
fun onRemoved() {}
|
||||
fun onCleared() {}
|
||||
}
|
||||
|
||||
private object MapNodeRoot : MapNode
|
||||
|
||||
internal class MapApplier(
|
||||
val map: MapboxMap,
|
||||
val style: Style,
|
||||
val symbolManager: SymbolManager,
|
||||
) : AbstractApplier<MapNode>(MapNodeRoot) {
|
||||
|
||||
private val decorations = mutableListOf<MapNode>()
|
||||
|
||||
override fun onClear() {
|
||||
symbolManager.deleteAll()
|
||||
decorations.forEach { it.onCleared() }
|
||||
decorations.clear()
|
||||
}
|
||||
|
||||
override fun insertBottomUp(index: Int, instance: MapNode) {
|
||||
decorations.add(index, instance)
|
||||
instance.onAttached()
|
||||
}
|
||||
|
||||
override fun insertTopDown(index: Int, instance: MapNode) {
|
||||
// insertBottomUp is preferred
|
||||
}
|
||||
|
||||
override fun move(from: Int, to: Int, count: Int) {
|
||||
decorations.move(from, to, count)
|
||||
}
|
||||
|
||||
override fun remove(index: Int, count: Int) {
|
||||
repeat(count) {
|
||||
decorations[index + it].onRemoved()
|
||||
}
|
||||
decorations.remove(index, count)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
internal val DefaultMapLocationSettings = MapLocationSettings()
|
||||
|
||||
/**
|
||||
* Data class for UI-related settings on the map.
|
||||
*
|
||||
* Note: Should not be a data class if in need of maintaining binary compatibility
|
||||
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
|
||||
*/
|
||||
public data class MapLocationSettings(
|
||||
public val locationEnabled: Boolean = false,
|
||||
)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
internal val DefaultMapSymbolManagerSettings = MapSymbolManagerSettings()
|
||||
|
||||
/**
|
||||
* Data class for UI-related settings on the map.
|
||||
*
|
||||
* Note: Should not be a data class if in need of maintaining binary compatibility
|
||||
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
|
||||
*/
|
||||
public data class MapSymbolManagerSettings(
|
||||
public val iconAllowOverlap: Boolean = false,
|
||||
)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
import android.view.Gravity
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
internal val DefaultMapUiSettings = MapUiSettings()
|
||||
|
||||
/**
|
||||
* Data class for UI-related settings on the map.
|
||||
*
|
||||
* Note: Should not be a data class if in need of maintaining binary compatibility
|
||||
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
|
||||
*/
|
||||
public data class MapUiSettings(
|
||||
public val compassEnabled: Boolean = true,
|
||||
public val rotationGesturesEnabled: Boolean = true,
|
||||
public val scrollGesturesEnabled: Boolean = true,
|
||||
public val tiltGesturesEnabled: Boolean = true,
|
||||
public val zoomGesturesEnabled: Boolean = true,
|
||||
public val logoGravity: Int = Gravity.BOTTOM,
|
||||
public val attributionGravity: Int = Gravity.BOTTOM,
|
||||
public val attributionTintColor: Color = Color.Unspecified,
|
||||
)
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
package io.element.android.libraries.maplibre.compose
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ComposeNode
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions
|
||||
import com.mapbox.mapboxsdk.location.LocationComponentOptions
|
||||
import com.mapbox.mapboxsdk.location.OnCameraTrackingChangedListener
|
||||
import com.mapbox.mapboxsdk.location.engine.LocationEngineRequest
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap
|
||||
import com.mapbox.mapboxsdk.maps.Style
|
||||
|
||||
private const val LOCATION_REQUEST_INTERVAL = 750L
|
||||
|
||||
internal class MapPropertiesNode(
|
||||
val map: MapboxMap,
|
||||
style: Style,
|
||||
context: Context,
|
||||
cameraPositionState: CameraPositionState,
|
||||
) : MapNode {
|
||||
|
||||
init {
|
||||
map.locationComponent.activateLocationComponent(
|
||||
LocationComponentActivationOptions.Builder(context, style)
|
||||
.locationComponentOptions(
|
||||
LocationComponentOptions.builder(context)
|
||||
.pulseEnabled(true)
|
||||
.build()
|
||||
)
|
||||
.locationEngineRequest(
|
||||
LocationEngineRequest.Builder(LOCATION_REQUEST_INTERVAL)
|
||||
.setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY)
|
||||
.setFastestInterval(LOCATION_REQUEST_INTERVAL)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
cameraPositionState.setMap(map)
|
||||
}
|
||||
|
||||
var cameraPositionState = cameraPositionState
|
||||
set(value) {
|
||||
if (value == field) return
|
||||
field.setMap(null)
|
||||
field = value
|
||||
value.setMap(map)
|
||||
}
|
||||
|
||||
override fun onAttached() {
|
||||
map.addOnCameraIdleListener {
|
||||
cameraPositionState.isMoving = false
|
||||
// addOnCameraIdleListener is only invoked when the camera position
|
||||
// is changed via .animate(). To handle updating state when .move()
|
||||
// is used, it's necessary to set the camera's position here as well
|
||||
cameraPositionState.rawPosition = map.cameraPosition
|
||||
// Updating user location on every camera move due to lack of a better location updates API.
|
||||
cameraPositionState.location = map.locationComponent.lastKnownLocation
|
||||
}
|
||||
map.addOnCameraMoveCancelListener {
|
||||
cameraPositionState.isMoving = false
|
||||
}
|
||||
map.addOnCameraMoveStartedListener {
|
||||
cameraPositionState.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(it)
|
||||
cameraPositionState.isMoving = true
|
||||
}
|
||||
map.addOnCameraMoveListener {
|
||||
cameraPositionState.rawPosition = map.cameraPosition
|
||||
// Updating user location on every camera move due to lack of a better location updates API.
|
||||
cameraPositionState.location = map.locationComponent.lastKnownLocation
|
||||
}
|
||||
map.locationComponent.addOnCameraTrackingChangedListener(object : OnCameraTrackingChangedListener {
|
||||
override fun onCameraTrackingDismissed() {}
|
||||
|
||||
override fun onCameraTrackingChanged(currentMode: Int) {
|
||||
cameraPositionState.rawCameraMode = CameraMode.fromInternal(currentMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onRemoved() {
|
||||
cameraPositionState.setMap(null)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
cameraPositionState.setMap(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to keep the primary map properties up to date. This should never leave the map composition.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
@Composable
|
||||
internal inline fun MapUpdater(
|
||||
cameraPositionState: CameraPositionState,
|
||||
mapLocationSettings: MapLocationSettings,
|
||||
mapUiSettings: MapUiSettings,
|
||||
mapSymbolManagerSettings: MapSymbolManagerSettings,
|
||||
) {
|
||||
val mapApplier = currentComposer.applier as MapApplier
|
||||
val map = mapApplier.map
|
||||
val style = mapApplier.style
|
||||
val symbolManager = mapApplier.symbolManager
|
||||
val context = LocalContext.current
|
||||
ComposeNode<MapPropertiesNode, MapApplier>(
|
||||
factory = {
|
||||
MapPropertiesNode(
|
||||
map = map,
|
||||
style = style,
|
||||
context = context,
|
||||
cameraPositionState = cameraPositionState,
|
||||
)
|
||||
},
|
||||
update = {
|
||||
set(mapLocationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it }
|
||||
|
||||
set(mapUiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it }
|
||||
set(mapUiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it }
|
||||
set(mapUiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it }
|
||||
set(mapUiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it }
|
||||
set(mapUiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it }
|
||||
set(mapUiSettings.logoGravity) { map.uiSettings.logoGravity = it }
|
||||
set(mapUiSettings.attributionGravity) { map.uiSettings.attributionGravity = it }
|
||||
set(mapUiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) }
|
||||
|
||||
set(mapSymbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it }
|
||||
|
||||
update(cameraPositionState) { this.cameraPositionState = it }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
import android.content.ComponentCallbacks
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Composition
|
||||
import androidx.compose.runtime.CompositionContext
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCompositionContext
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.mapbox.mapboxsdk.Mapbox
|
||||
import com.mapbox.mapboxsdk.maps.MapView
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap
|
||||
import com.mapbox.mapboxsdk.maps.Style
|
||||
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* A compose container for a MapLibre [MapView].
|
||||
*
|
||||
* Heavily inspired by https://github.com/googlemaps/android-maps-compose
|
||||
*
|
||||
* @param styleUri a URI where to asynchronously fetch a style for the map
|
||||
* @param modifier Modifier to be applied to the MapboxMap
|
||||
* @param images images added to the map's style to be later used with [Symbol]
|
||||
* @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's
|
||||
* camera state
|
||||
* @param uiSettings the [MapUiSettings] to be used for UI-specific settings on the map
|
||||
* @param symbolManagerSettings the [MapSymbolManagerSettings] to be used for symbol manager settings
|
||||
* @param locationSettings the [MapLocationSettings] to be used for location settings
|
||||
* @param content the content of the map
|
||||
*/
|
||||
@Composable
|
||||
public fun MapboxMap(
|
||||
styleUri: String,
|
||||
modifier: Modifier = Modifier,
|
||||
images: ImmutableMap<String, Int> = persistentMapOf(),
|
||||
cameraPositionState: CameraPositionState = rememberCameraPositionState(),
|
||||
uiSettings: MapUiSettings = DefaultMapUiSettings,
|
||||
symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings,
|
||||
locationSettings: MapLocationSettings = DefaultMapLocationSettings,
|
||||
content: (@Composable @MapboxMapComposable () -> Unit)? = null,
|
||||
) {
|
||||
// When in preview, early return a Box with the received modifier preserving layout
|
||||
if (LocalInspectionMode.current) {
|
||||
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
|
||||
Box(
|
||||
modifier = modifier.background(Color.DarkGray)
|
||||
) {
|
||||
Text("[Map]", modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val mapView = remember {
|
||||
Mapbox.getInstance(context)
|
||||
MapView(context)
|
||||
}
|
||||
|
||||
@Suppress("ModifierReused")
|
||||
AndroidView(modifier = modifier, factory = { mapView })
|
||||
MapLifecycle(mapView)
|
||||
|
||||
// rememberUpdatedState and friends are used here to make these values observable to
|
||||
// the subcomposition without providing a new content function each recomposition
|
||||
val currentCameraPositionState by rememberUpdatedState(cameraPositionState)
|
||||
val currentUiSettings by rememberUpdatedState(uiSettings)
|
||||
val currentMapLocationSettings by rememberUpdatedState(locationSettings)
|
||||
val currentSymbolManagerSettings by rememberUpdatedState(symbolManagerSettings)
|
||||
|
||||
val parentComposition = rememberCompositionContext()
|
||||
val currentContent by rememberUpdatedState(content)
|
||||
|
||||
LaunchedEffect(styleUri, images) {
|
||||
disposingComposition {
|
||||
parentComposition.newComposition(
|
||||
context = context,
|
||||
mapView = mapView,
|
||||
styleUri = styleUri,
|
||||
images = images,
|
||||
) {
|
||||
MapUpdater(
|
||||
cameraPositionState = currentCameraPositionState,
|
||||
mapUiSettings = currentUiSettings,
|
||||
mapLocationSettings = currentMapLocationSettings,
|
||||
mapSymbolManagerSettings = currentSymbolManagerSettings,
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalCameraPositionState provides cameraPositionState,
|
||||
) {
|
||||
currentContent?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun disposingComposition(factory: () -> Composition) {
|
||||
val composition = factory()
|
||||
try {
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
composition.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun CompositionContext.newComposition(
|
||||
context: Context,
|
||||
mapView: MapView,
|
||||
styleUri: String,
|
||||
images: ImmutableMap<String, Int>,
|
||||
noinline content: @Composable () -> Unit
|
||||
): Composition {
|
||||
val map = mapView.awaitMap()
|
||||
val style = map.awaitStyle(context, styleUri, images)
|
||||
val symbolManager = SymbolManager(mapView, map, style)
|
||||
return Composition(
|
||||
MapApplier(map, style, symbolManager), this
|
||||
).apply {
|
||||
setContent(content)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun MapView.awaitMap(): MapboxMap = suspendCoroutine { continuation ->
|
||||
getMapAsync { map ->
|
||||
continuation.resume(map)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun MapboxMap.awaitStyle(
|
||||
context: Context,
|
||||
styleUri: String,
|
||||
images: ImmutableMap<String, Int>,
|
||||
): Style = suspendCoroutine { continuation ->
|
||||
setStyle(
|
||||
Style.Builder().apply {
|
||||
fromUri(styleUri)
|
||||
images.forEach { (id, drawableRes) ->
|
||||
withImage(id, checkNotNull(context.getDrawable(drawableRes)) {
|
||||
"Drawable resource $drawableRes with id $id not found"
|
||||
})
|
||||
}
|
||||
}
|
||||
) { style ->
|
||||
continuation.resume(style)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers lifecycle observers to the local [MapView].
|
||||
*/
|
||||
@Composable
|
||||
private fun MapLifecycle(mapView: MapView) {
|
||||
val context = LocalContext.current
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) }
|
||||
DisposableEffect(context, lifecycle, mapView) {
|
||||
val mapLifecycleObserver = mapView.lifecycleObserver(previousState)
|
||||
val callbacks = mapView.componentCallbacks()
|
||||
|
||||
lifecycle.addObserver(mapLifecycleObserver)
|
||||
context.registerComponentCallbacks(callbacks)
|
||||
|
||||
onDispose {
|
||||
lifecycle.removeObserver(mapLifecycleObserver)
|
||||
context.unregisterComponentCallbacks(callbacks)
|
||||
}
|
||||
}
|
||||
DisposableEffect(mapView) {
|
||||
onDispose {
|
||||
mapView.onDestroy()
|
||||
mapView.removeAllViews()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MapView.lifecycleObserver(previousState: MutableState<Lifecycle.Event>): LifecycleEventObserver =
|
||||
LifecycleEventObserver { _, event ->
|
||||
event.targetState
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_CREATE -> {
|
||||
// Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in
|
||||
// this case the MapboxMap composable also doesn't leave the composition. So,
|
||||
// recreating the map does not restore state properly which must be avoided.
|
||||
if (previousState.value != Lifecycle.Event.ON_STOP) {
|
||||
this.onCreate(Bundle())
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_START -> this.onStart()
|
||||
Lifecycle.Event.ON_RESUME -> this.onResume()
|
||||
Lifecycle.Event.ON_PAUSE -> this.onPause()
|
||||
Lifecycle.Event.ON_STOP -> this.onStop()
|
||||
Lifecycle.Event.ON_DESTROY -> {
|
||||
//handled in onDispose
|
||||
}
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
previousState.value = event
|
||||
}
|
||||
|
||||
private fun MapView.componentCallbacks(): ComponentCallbacks =
|
||||
object : ComponentCallbacks {
|
||||
override fun onConfigurationChanged(config: Configuration) {}
|
||||
|
||||
override fun onLowMemory() {
|
||||
this@componentCallbacks.onLowMemory()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.ComposableTargetMarker
|
||||
|
||||
/**
|
||||
* An annotation that can be used to mark a composable function as being expected to be use in a
|
||||
* composable function that is also marked or inferred to be marked as a [MapboxMapComposable].
|
||||
*
|
||||
* This will produce build warnings when [MapboxMapComposable] composable functions are used outside
|
||||
* of a [MapboxMapComposable] content lambda, and vice versa.
|
||||
*/
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
@ComposableTargetMarker(description = "MapLibre Map Composable")
|
||||
@Target(
|
||||
AnnotationTarget.FILE,
|
||||
AnnotationTarget.FUNCTION,
|
||||
AnnotationTarget.PROPERTY_GETTER,
|
||||
AnnotationTarget.TYPE,
|
||||
AnnotationTarget.TYPE_PARAMETER,
|
||||
)
|
||||
public annotation class MapboxMapComposable
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright 2021 Google LLC
|
||||
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
|
||||
*
|
||||
* 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.maplibre.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ComposeNode
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.mapbox.mapboxsdk.geometry.LatLng
|
||||
import com.mapbox.mapboxsdk.plugins.annotation.Symbol
|
||||
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
|
||||
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
|
||||
|
||||
internal class SymbolNode(
|
||||
val symbolManager: SymbolManager,
|
||||
val symbol: Symbol,
|
||||
) : MapNode {
|
||||
override fun onRemoved() {
|
||||
symbolManager.delete(symbol)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
symbolManager.delete(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A state object that can be hoisted to control and observe the symbol state.
|
||||
*
|
||||
* @param position the initial symbol position
|
||||
*/
|
||||
public class SymbolState(
|
||||
position: LatLng = LatLng(0.0, 0.0)
|
||||
) {
|
||||
/**
|
||||
* Current position of the symbol.
|
||||
*/
|
||||
public var position: LatLng by mutableStateOf(position)
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* The default saver implementation for [SymbolState].
|
||||
*/
|
||||
public val Saver: Saver<SymbolState, LatLng> = Saver(
|
||||
save = { it.position },
|
||||
restore = { SymbolState(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun rememberSymbolState(
|
||||
key: String? = null,
|
||||
position: LatLng = LatLng(0.0, 0.0)
|
||||
): SymbolState = rememberSaveable(key = key, saver = SymbolState.Saver) {
|
||||
SymbolState(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable for a symbol on the map.
|
||||
*
|
||||
* @param iconId an id of an image from the current [Style]
|
||||
* @param state the [SymbolState] to be used to control or observe the symbol
|
||||
* state such as its position and info window
|
||||
* @param iconAnchor the anchor for the symbol image
|
||||
*/
|
||||
@Composable
|
||||
@MapboxMapComposable
|
||||
public fun Symbol(
|
||||
iconId: String,
|
||||
state: SymbolState = rememberSymbolState(),
|
||||
iconAnchor: IconAnchor? = null,
|
||||
) {
|
||||
val mapApplier = currentComposer.applier as MapApplier
|
||||
val symbolManager = mapApplier.symbolManager
|
||||
ComposeNode<SymbolNode, MapApplier>(
|
||||
factory = {
|
||||
SymbolNode(
|
||||
symbolManager = symbolManager,
|
||||
symbol = symbolManager.create(
|
||||
SymbolOptions().apply {
|
||||
withLatLng(state.position)
|
||||
withIconImage(iconId)
|
||||
iconAnchor?.let { withIconAnchor(it.toInternal()) }
|
||||
}
|
||||
),
|
||||
)
|
||||
},
|
||||
update = {
|
||||
update(state.position) {
|
||||
this.symbol.latLng = it
|
||||
symbolManager.update(this.symbol)
|
||||
}
|
||||
update(iconId) {
|
||||
this.symbol.iconImage = it
|
||||
symbolManager.update(this.symbol)
|
||||
}
|
||||
update(iconAnchor) {
|
||||
this.symbol.iconAnchor = it?.toInternal()
|
||||
symbolManager.update(this.symbol)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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.matrix.api.core
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
@JvmInline
|
||||
value class TransactionId(val value: String) : Serializable {
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
|
|
@ -70,7 +71,7 @@ interface MatrixRoom : Closeable {
|
|||
|
||||
suspend fun sendMessage(message: String): Result<Unit>
|
||||
|
||||
suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit>
|
||||
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit>
|
||||
|
||||
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit>
|
||||
|
||||
|
|
@ -88,9 +89,9 @@ interface MatrixRoom : Closeable {
|
|||
|
||||
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
|
||||
|
||||
suspend fun retrySendMessage(transactionId: String): Result<Unit>
|
||||
suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit>
|
||||
|
||||
suspend fun cancelSend(transactionId: String): Result<Unit>
|
||||
suspend fun cancelSend(transactionId: TransactionId): Result<Unit>
|
||||
|
||||
suspend fun leave(): Result<Unit>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@
|
|||
package io.element.android.libraries.matrix.api.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
|
||||
sealed interface MatrixTimelineItem {
|
||||
data class Event(val uniqueId: Long, val event: EventTimelineItem) : MatrixTimelineItem {
|
||||
val eventId: EventId? = event.eventId
|
||||
val transactionId: String? = event.transactionId
|
||||
val transactionId: TransactionId? = event.transactionId
|
||||
}
|
||||
|
||||
data class Virtual(val uniqueId: Long, val virtual: VirtualTimelineItem) : MatrixTimelineItem
|
||||
|
|
|
|||
|
|
@ -17,12 +17,13 @@
|
|||
package io.element.android.libraries.matrix.api.timeline.item.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
|
||||
data class EventTimelineItem(
|
||||
val eventId: EventId?,
|
||||
val transactionId: String?,
|
||||
val transactionId: TransactionId?,
|
||||
val isEditable: Boolean,
|
||||
val isLocal: Boolean,
|
||||
val isOwn: Boolean,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
|
|
@ -41,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
|
||||
|
|
@ -72,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())
|
||||
|
|
@ -90,7 +94,8 @@ class RustMatrixRoom(
|
|||
matrixRoom = this,
|
||||
innerRoom = innerRoom,
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
dispatcher = roomDispatcher
|
||||
dispatcher = roomDispatcher,
|
||||
lastLoginTimestamp = sessionData.loginTimestamp,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -218,10 +223,10 @@ class RustMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit> = withContext(roomDispatcher) {
|
||||
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> = withContext(roomDispatcher) {
|
||||
if (originalEventId != null) {
|
||||
runCatching {
|
||||
innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId)
|
||||
innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId?.value)
|
||||
}
|
||||
} else {
|
||||
runCatching {
|
||||
|
|
@ -326,17 +331,17 @@ class RustMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun retrySendMessage(transactionId: String): Result<Unit> =
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> =
|
||||
withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerRoom.retrySend(transactionId)
|
||||
innerRoom.retrySend(transactionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelSend(transactionId: String): Result<Unit> =
|
||||
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> =
|
||||
withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerRoom.cancelSend(transactionId)
|
||||
innerRoom.cancelSend(transactionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.libraries.matrix.impl.timeline.item.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
|
|
@ -36,7 +37,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
|
|||
fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use {
|
||||
EventTimelineItem(
|
||||
eventId = it.eventId()?.let(::EventId),
|
||||
transactionId = it.transactionId(),
|
||||
transactionId = it.transactionId()?.let(::TransactionId),
|
||||
isEditable = it.isEditable(),
|
||||
isLocal = it.isLocal(),
|
||||
isOwn = it.isOwn(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
|||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.SpaceId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
|
|||
val A_THREAD_ID = ThreadId("\$aThreadId")
|
||||
val AN_EVENT_ID = EventId("\$anEventId")
|
||||
val AN_EVENT_ID_2 = EventId("\$anEventId2")
|
||||
const val A_TRANSACTION_ID = "aTransactionId"
|
||||
val A_TRANSACTION_ID = TransactionId("aTransactionId")
|
||||
const val A_UNIQUE_ID = "aUniqueId"
|
||||
|
||||
const val A_ROOM_NAME = "A room name"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
|
|
@ -164,17 +165,17 @@ class FakeMatrixRoom(
|
|||
return toggleReactionResult
|
||||
}
|
||||
|
||||
override suspend fun retrySendMessage(transactionId: String): Result<Unit> {
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> {
|
||||
retrySendMessageCount++
|
||||
return retrySendMessageResult
|
||||
}
|
||||
|
||||
override suspend fun cancelSend(transactionId: String): Result<Unit> {
|
||||
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> {
|
||||
cancelSendCount++
|
||||
return cancelSendResult
|
||||
}
|
||||
|
||||
override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit> {
|
||||
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> {
|
||||
editMessageCalls += message
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.test.room
|
|||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||
|
|
@ -91,7 +92,7 @@ fun aRoomMessage(
|
|||
|
||||
fun anEventTimelineItem(
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
transactionId: String? = null,
|
||||
transactionId: TransactionId? = null,
|
||||
isEditable: Boolean = false,
|
||||
isLocal: Boolean = false,
|
||||
isOwn: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ dependencies {
|
|||
implementation(libs.dagger)
|
||||
implementation(libs.androidx.corektx)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.serialization.json)
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
|
|||
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
||||
import io.element.android.libraries.push.api.store.PushDataStore
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
|
||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
||||
import io.element.android.services.appnavstate.api.NavigationState
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -65,7 +63,6 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
|||
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
|
||||
*/
|
||||
private val notificationState by lazy { createInitialNotificationState() }
|
||||
private var currentAppNavigationState: AppNavigationState? = null
|
||||
private val firstThrottler = FirstThrottler(200)
|
||||
|
||||
// TODO EAx add a setting per user for this
|
||||
|
|
@ -74,26 +71,25 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
|||
init {
|
||||
// Observe application state
|
||||
coroutineScope.launch {
|
||||
appNavigationStateService.appNavigationStateFlow
|
||||
.collect { onAppNavigationStateChange(it) }
|
||||
appNavigationStateService.appNavigationState
|
||||
.collect { onAppNavigationStateChange(it.navigationState) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAppNavigationStateChange(appNavigationState: AppNavigationState) {
|
||||
currentAppNavigationState = appNavigationState
|
||||
when (appNavigationState) {
|
||||
AppNavigationState.Root -> {}
|
||||
is AppNavigationState.Session -> {}
|
||||
is AppNavigationState.Space -> {}
|
||||
is AppNavigationState.Room -> {
|
||||
private fun onAppNavigationStateChange(navigationState: NavigationState) {
|
||||
when (navigationState) {
|
||||
NavigationState.Root -> {}
|
||||
is NavigationState.Session -> {}
|
||||
is NavigationState.Space -> {}
|
||||
is NavigationState.Room -> {
|
||||
// Cleanup notification for current room
|
||||
clearMessagesForRoom(appNavigationState.parentSpace.parentSession.sessionId, appNavigationState.roomId)
|
||||
clearMessagesForRoom(navigationState.parentSpace.parentSession.sessionId, navigationState.roomId)
|
||||
}
|
||||
is AppNavigationState.Thread -> {
|
||||
is NavigationState.Thread -> {
|
||||
onEnteringThread(
|
||||
appNavigationState.parentRoom.parentSpace.parentSession.sessionId,
|
||||
appNavigationState.parentRoom.roomId,
|
||||
appNavigationState.threadId
|
||||
navigationState.parentRoom.parentSpace.parentSession.sessionId,
|
||||
navigationState.parentRoom.roomId,
|
||||
navigationState.threadId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -225,7 +221,7 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
|||
private suspend fun refreshNotificationDrawerBg() {
|
||||
Timber.v("refreshNotificationDrawerBg()")
|
||||
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
|
||||
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also {
|
||||
notifiableEventProcessor.process(queuedEvents.rawEvents(), renderedEvents).also {
|
||||
queuedEvents.clearAndAdd(it.onlyKeptEvents())
|
||||
}
|
||||
}
|
||||
|
|
@ -275,8 +271,4 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
|||
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents)
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
|
||||
return resolvedEvent.shouldIgnoreEventInRoom(currentAppNavigationState)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven
|
|||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
|
||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -31,18 +31,19 @@ private typealias ProcessedEvents = List<ProcessedEvent<NotifiableEvent>>
|
|||
|
||||
class NotifiableEventProcessor @Inject constructor(
|
||||
private val outdatedDetector: OutdatedEventDetector,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
) {
|
||||
|
||||
fun process(
|
||||
queuedEvents: List<NotifiableEvent>,
|
||||
appNavigationState: AppNavigationState?,
|
||||
renderedEvents: ProcessedEvents,
|
||||
): ProcessedEvents {
|
||||
val appState = appNavigationStateService.appNavigationState.value
|
||||
val processedEvents = queuedEvents.map {
|
||||
val type = when (it) {
|
||||
is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
|
||||
is NotifiableMessageEvent -> when {
|
||||
it.shouldIgnoreEventInRoom(appNavigationState) -> {
|
||||
it.shouldIgnoreEventInRoom(appState) -> {
|
||||
ProcessedEvent.Type.REMOVE
|
||||
.also { Timber.d("notification message removed due to currently viewing the same room or thread") }
|
||||
}
|
||||
|
|
@ -55,7 +56,7 @@ class NotifiableEventProcessor @Inject constructor(
|
|||
else -> ProcessedEvent.Type.KEEP
|
||||
}
|
||||
is FallbackNotifiableEvent -> when {
|
||||
it.shouldIgnoreEventInRoom(appNavigationState) -> {
|
||||
it.shouldIgnoreEventInRoom(appState) -> {
|
||||
ProcessedEvent.Type.REMOVE
|
||||
.also { Timber.d("notification fallback removed due to currently viewing the same room or thread") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@
|
|||
package io.element.android.libraries.push.impl.notifications.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
|
@ -69,18 +67,13 @@ data class NotifiableMessageEvent(
|
|||
/**
|
||||
* Used to check if a notification should be ignored based on the current app and navigation state.
|
||||
*/
|
||||
fun NotifiableEvent.shouldIgnoreEventInRoom(
|
||||
appNavigationState: AppNavigationState?
|
||||
): Boolean {
|
||||
val currentSessionId = appNavigationState?.currentSessionId() ?: return false
|
||||
return when (val currentRoomId = appNavigationState.currentRoomId()) {
|
||||
fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationState): Boolean {
|
||||
val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false
|
||||
return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) {
|
||||
null -> false
|
||||
else -> isAppInForeground
|
||||
else -> appNavigationState.isInForeground
|
||||
&& sessionId == currentSessionId
|
||||
&& roomId == currentRoomId
|
||||
&& (this as? NotifiableMessageEvent)?.threadId == appNavigationState.currentThreadId()
|
||||
&& (this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId()
|
||||
}
|
||||
}
|
||||
|
||||
private val isAppInForeground: Boolean
|
||||
get() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
<string name="notification_channel_listening_for_events">"Listening for events"</string>
|
||||
<string name="notification_channel_noisy">"Noisy notifications"</string>
|
||||
<string name="notification_channel_silent">"Silent notifications"</string>
|
||||
<string name="notification_fallback_content">"Notification"</string>
|
||||
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
|
||||
<string name="notification_invitation_action_join">"Join"</string>
|
||||
<string name="notification_invitation_action_reject">"Reject"</string>
|
||||
|
|
@ -48,5 +47,6 @@
|
|||
<string name="push_distributor_background_sync_android">"Background synchronization"</string>
|
||||
<string name="push_distributor_firebase_android">"Google Services"</string>
|
||||
<string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string>
|
||||
<string name="notification_fallback_content">"Notification"</string>
|
||||
<string name="notification_room_action_quick_reply">"Quick reply"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -30,17 +30,20 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable
|
|||
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.services.appnavstate.test.anAppNavigationState
|
||||
import io.element.android.services.appnavstate.api.NavigationState
|
||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
||||
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
|
||||
import io.element.android.services.appnavstate.test.aNavigationState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Test
|
||||
|
||||
private val NOT_VIEWING_A_ROOM = anAppNavigationState()
|
||||
private val VIEWING_A_ROOM = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID)
|
||||
private val VIEWING_A_THREAD = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID)
|
||||
private val NOT_VIEWING_A_ROOM = aNavigationState()
|
||||
private val VIEWING_A_ROOM = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID)
|
||||
private val VIEWING_A_THREAD = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID)
|
||||
|
||||
class NotifiableEventProcessorTest {
|
||||
|
||||
private val outdatedDetector = FakeOutdatedEventDetector()
|
||||
private val eventProcessor = NotifiableEventProcessor(outdatedDetector.instance)
|
||||
|
||||
@Test
|
||||
fun `given simple events when processing then keep simple events`() {
|
||||
|
|
@ -48,8 +51,9 @@ class NotifiableEventProcessorTest {
|
|||
aSimpleNotifiableEvent(eventId = AN_EVENT_ID),
|
||||
aSimpleNotifiableEvent(eventId = AN_EVENT_ID_2)
|
||||
)
|
||||
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
|
||||
|
||||
val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||
val result = eventProcessor.process(events, renderedEvents = emptyList())
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -62,8 +66,9 @@ class NotifiableEventProcessorTest {
|
|||
@Test
|
||||
fun `given redacted simple event when processing then remove redaction event`() {
|
||||
val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = EventType.REDACTION))
|
||||
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
|
||||
|
||||
val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||
val result = eventProcessor.process(events, renderedEvents = emptyList())
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -78,8 +83,9 @@ class NotifiableEventProcessorTest {
|
|||
anInviteNotifiableEvent(roomId = A_ROOM_ID),
|
||||
anInviteNotifiableEvent(roomId = A_ROOM_ID_2)
|
||||
)
|
||||
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
|
||||
|
||||
val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||
val result = eventProcessor.process(events, renderedEvents = emptyList())
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -94,7 +100,9 @@ class NotifiableEventProcessorTest {
|
|||
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
|
||||
outdatedDetector.givenEventIsOutOfDate(events[0])
|
||||
|
||||
val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
|
||||
|
||||
val result = eventProcessor.process(events, renderedEvents = emptyList())
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -107,8 +115,9 @@ class NotifiableEventProcessorTest {
|
|||
fun `given in date message event when processing then keep message event`() {
|
||||
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
|
||||
outdatedDetector.givenEventIsInDate(events[0])
|
||||
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
|
||||
|
||||
val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||
val result = eventProcessor.process(events, renderedEvents = emptyList())
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -121,8 +130,9 @@ class NotifiableEventProcessorTest {
|
|||
fun `given viewing the same room main timeline when processing main timeline message event then removes message`() {
|
||||
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null))
|
||||
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
|
||||
val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM)
|
||||
|
||||
val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||
val result = eventProcessor.process(events, renderedEvents = emptyList())
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -135,8 +145,9 @@ class NotifiableEventProcessorTest {
|
|||
fun `given viewing the same thread timeline when processing thread message event then removes message`() {
|
||||
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
|
||||
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
|
||||
val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD)
|
||||
|
||||
val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())
|
||||
val result = eventProcessor.process(events, renderedEvents = emptyList())
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -149,8 +160,9 @@ class NotifiableEventProcessorTest {
|
|||
fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() {
|
||||
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
|
||||
outdatedDetector.givenEventIsInDate(events[0])
|
||||
val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM)
|
||||
|
||||
val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||
val result = eventProcessor.process(events, renderedEvents = emptyList())
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -163,8 +175,9 @@ class NotifiableEventProcessorTest {
|
|||
fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() {
|
||||
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
|
||||
outdatedDetector.givenEventIsInDate(events[0])
|
||||
val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD)
|
||||
|
||||
val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())
|
||||
val result = eventProcessor.process(events, renderedEvents = emptyList())
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -180,8 +193,9 @@ class NotifiableEventProcessorTest {
|
|||
ProcessedEvent(ProcessedEvent.Type.KEEP, events[0]),
|
||||
ProcessedEvent(ProcessedEvent.Type.KEEP, anInviteNotifiableEvent(eventId = AN_EVENT_ID_2))
|
||||
)
|
||||
val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
|
||||
|
||||
val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = renderedEvents)
|
||||
val result = eventProcessor.process(events, renderedEvents = renderedEvents)
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
listOfProcessedEvents(
|
||||
|
|
@ -194,4 +208,14 @@ class NotifiableEventProcessorTest {
|
|||
private fun listOfProcessedEvents(vararg event: Pair<ProcessedEvent.Type, NotifiableEvent>) = event.map {
|
||||
ProcessedEvent(it.first, it.second)
|
||||
}
|
||||
|
||||
private fun createProcessor(
|
||||
isInForeground: Boolean = false,
|
||||
navigationState: NavigationState
|
||||
): NotifiableEventProcessor {
|
||||
return NotifiableEventProcessor(
|
||||
outdatedDetector.instance,
|
||||
FakeAppNavigationStateService(MutableStateFlow(AppNavigationState(navigationState, isInForeground))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer
|
|||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
|
|
@ -25,15 +26,15 @@ sealed interface MessageComposerMode : Parcelable {
|
|||
@Parcelize
|
||||
data class Normal(val content: CharSequence?) : MessageComposerMode
|
||||
|
||||
sealed class Special(open val eventId: EventId?, open val defaultContent: CharSequence) :
|
||||
sealed class Special(open val eventId: EventId?, open val defaultContent: String) :
|
||||
MessageComposerMode
|
||||
|
||||
@Parcelize
|
||||
data class Edit(override val eventId: EventId?, override val defaultContent: CharSequence, val transactionId: String?) :
|
||||
data class Edit(override val eventId: EventId?, override val defaultContent: String, val transactionId: TransactionId?) :
|
||||
Special(eventId, defaultContent)
|
||||
|
||||
@Parcelize
|
||||
class Quote(override val eventId: EventId, override val defaultContent: CharSequence) :
|
||||
class Quote(override val eventId: EventId, override val defaultContent: String) :
|
||||
Special(eventId, defaultContent)
|
||||
|
||||
@Parcelize
|
||||
|
|
@ -41,7 +42,7 @@ sealed interface MessageComposerMode : Parcelable {
|
|||
val senderName: String,
|
||||
val attachmentThumbnailInfo: AttachmentThumbnailInfo?,
|
||||
override val eventId: EventId,
|
||||
override val defaultContent: CharSequence
|
||||
override val defaultContent: String
|
||||
) : Special(eventId, defaultContent)
|
||||
|
||||
val relatedEventId: EventId?
|
||||
|
|
@ -51,4 +52,13 @@ sealed interface MessageComposerMode : Parcelable {
|
|||
is Quote -> eventId
|
||||
is Reply -> eventId
|
||||
}
|
||||
|
||||
val isEditing: Boolean
|
||||
get() = this is Edit
|
||||
|
||||
val isReply: Boolean
|
||||
get() = this is Reply
|
||||
|
||||
val inThread: Boolean
|
||||
get() = false // TODO
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
|||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
|
|
@ -95,7 +96,7 @@ fun TextComposer(
|
|||
focusRequester: FocusRequester = FocusRequester(),
|
||||
onSendMessage: (String) -> Unit = {},
|
||||
onResetComposerMode: () -> Unit = {},
|
||||
onComposerTextChange: (CharSequence) -> Unit = {},
|
||||
onComposerTextChange: (String) -> Unit = {},
|
||||
onAddAttachment: () -> Unit = {},
|
||||
onFocusChanged: (Boolean) -> Unit = {},
|
||||
) {
|
||||
|
|
@ -449,7 +450,7 @@ fun TextComposerEditPreview() = ElementPreview {
|
|||
TextComposer(
|
||||
onSendMessage = {},
|
||||
onComposerTextChange = {},
|
||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", "1234"),
|
||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
||||
onResetComposerMode = {},
|
||||
composerCanSendMessage = true,
|
||||
composerText = "A message",
|
||||
|
|
|
|||
|
|
@ -140,10 +140,9 @@
|
|||
<string name="emoji_picker_category_places">"Reisen & Orte"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbole"</string>
|
||||
<string name="error_failed_creating_the_permalink">"Fehler beim Erstellen des Permalinks"</string>
|
||||
<string name="error_failed_loading_map">"Element konnte die Karte nicht laden. Bitte versuche es später erneut."</string>
|
||||
<string name="error_failed_loading_map">"%1$s konnte die Karte nicht laden. Bitte versuche es später erneut."</string>
|
||||
<string name="error_failed_loading_messages">"Fehler beim Laden der Nachrichten"</string>
|
||||
<string name="error_failed_locating_user">"Element konnte nicht auf deinen Standort zugreifen. Bitte versuche es später erneut."</string>
|
||||
<string name="error_missing_location_auth">"Element hat keine Berechtigung, auf deinen Standort zuzugreifen. Du kannst den Zugriff unter Einstellungen > Standort aktivieren."</string>
|
||||
<string name="error_failed_locating_user">"%1$s konnte nicht auf deinen Standort zugreifen. Bitte versuche es später erneut."</string>
|
||||
<string name="error_some_messages_have_not_been_sent">"Einige Nachrichten wurden nicht gesendet"</string>
|
||||
<string name="error_unknown">"Entschuldigung, ein Fehler ist aufgetreten."</string>
|
||||
<string name="invite_friends_rich_title">"🔐️ Besuchen Sie mich auf %1$s"</string>
|
||||
|
|
@ -177,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>
|
||||
|
|
|
|||
|
|
@ -65,12 +65,14 @@
|
|||
<string name="common_analytics">"Statistiques d\'utilisation"</string>
|
||||
<string name="common_audio">"Audio"</string>
|
||||
<string name="common_bubbles">"Bulles"</string>
|
||||
<string name="common_copyright">"Copyright"</string>
|
||||
<string name="common_creating_room">"Création du salon…"</string>
|
||||
<string name="common_current_user_left_room">"Le salon a été quitté"</string>
|
||||
<string name="common_decryption_error">"Erreur de déchiffrement"</string>
|
||||
<string name="common_developer_options">"Options de développement"</string>
|
||||
<string name="common_edited_suffix">"(modifié)"</string>
|
||||
<string name="common_editing">"Modification en cours"</string>
|
||||
<string name="common_emote">"* %1$s %2$s"</string>
|
||||
<string name="common_encryption_enabled">"Chiffrement activé"</string>
|
||||
<string name="common_error">"Erreur"</string>
|
||||
<string name="common_file">"Fichier"</string>
|
||||
|
|
@ -79,12 +81,14 @@
|
|||
<string name="common_gif">"GIF"</string>
|
||||
<string name="common_image">"Image"</string>
|
||||
<string name="common_invite_unknown_profile">"Nous ne pouvons pas vérifier le Matrix ID de cet utilisateur. Cette invitation pourrait être envoyée dans le vide."</string>
|
||||
<string name="common_leaving_room">"Quitter le salon"</string>
|
||||
<string name="common_link_copied_to_clipboard">"Lien copié dans le presse-papiers"</string>
|
||||
<string name="common_loading">"Chargement…"</string>
|
||||
<string name="common_message">"Message"</string>
|
||||
<string name="common_message_layout">"Mode d\'affichage des messages"</string>
|
||||
<string name="common_message_removed">"Message supprimé"</string>
|
||||
<string name="common_modern">"Moderne"</string>
|
||||
<string name="common_mute">"Sourdine"</string>
|
||||
<string name="common_no_results">"Aucun résultat"</string>
|
||||
<string name="common_offline">"Hors ligne"</string>
|
||||
<string name="common_password">"Mot de passe"</string>
|
||||
|
|
@ -106,15 +110,19 @@
|
|||
<string name="common_server_not_supported">"Serveur non pris en charge"</string>
|
||||
<string name="common_server_url">"URL du serveur"</string>
|
||||
<string name="common_settings">"Paramètres"</string>
|
||||
<string name="common_shared_location">"Position partagée"</string>
|
||||
<string name="common_starting_chat">"Démarrage du chat…"</string>
|
||||
<string name="common_sticker">"Autocollant"</string>
|
||||
<string name="common_success">"Succès"</string>
|
||||
<string name="common_suggestions">"Suggestions"</string>
|
||||
<string name="common_syncing">"Synchronisation"</string>
|
||||
<string name="common_third_party_notices">"Mentions tierces"</string>
|
||||
<string name="common_topic">"Sujet"</string>
|
||||
<string name="common_topic_placeholder">"De quoi parle ce salon ?"</string>
|
||||
<string name="common_unable_to_decrypt">"Échec de déchiffrement"</string>
|
||||
<string name="common_unable_to_invite_message">"Nous n\'avons pas réussi à envoyer des invitations à un ou plusieurs utilisateurs."</string>
|
||||
<string name="common_unable_to_invite_title">"Impossible d\'envoyer une ou plusieurs invitations"</string>
|
||||
<string name="common_unmute">"Réactiver"</string>
|
||||
<string name="common_unsupported_event">"Événement non pris en charge"</string>
|
||||
<string name="common_username">"Nom d\'utilisateur"</string>
|
||||
<string name="common_verification_cancelled">"Vérification annulée"</string>
|
||||
|
|
@ -132,9 +140,11 @@
|
|||
<string name="emoji_picker_category_places">"Voyages & lieux"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboles"</string>
|
||||
<string name="error_failed_creating_the_permalink">"Échec de la création du permalien"</string>
|
||||
<string name="error_failed_loading_map">"%1$s n’a pas pu charger la carte. Veuillez réessayer plus tard."</string>
|
||||
<string name="error_failed_loading_messages">"Échec du chargement des messages"</string>
|
||||
<string name="error_some_messages_have_not_been_sent">"Certains messages n\'ont pas été envoyés"</string>
|
||||
<string name="error_unknown">"Désolé, une erreur est survenue."</string>
|
||||
<string name="invite_friends_rich_title">"🔐️ Rejoignez-moi sur %1$s"</string>
|
||||
<string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$s"</string>
|
||||
<string name="leave_room_alert_empty_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra plus rejoindre ce salon, y compris vous."</string>
|
||||
<string name="leave_room_alert_private_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Ce salon n\'est pas public et vous ne pourrez pas le rejoindre sans invitation."</string>
|
||||
|
|
@ -152,7 +162,22 @@
|
|||
<string name="room_timeline_beginning_of_room_no_name">"Ceci est le début de cette conversation."</string>
|
||||
<string name="room_timeline_read_marker_title">"Nouveau"</string>
|
||||
<string name="screen_analytics_settings_share_data">"Partager les statistiques d\'utilisation"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Impossible de sélectionner un média, veuillez réessayer."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement du média avant son envoi, veuillez réessayer."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Impossible d’envoyer le média, veuillez réessayer."</string>
|
||||
<string name="screen_migration_message">"Ce processus n’a besoin d’être fait qu’une seule fois, merci de patienter."</string>
|
||||
<string name="screen_migration_title">"Configuration de votre compte."</string>
|
||||
<string name="screen_notification_settings_enable_notifications">"Activer les notifications sur cet appareil"</string>
|
||||
<string name="screen_notification_settings_system_notifications_action_required_content_link">"paramètres système"</string>
|
||||
<string name="screen_notification_settings_system_notifications_turned_off">"Notifications système désactivées"</string>
|
||||
<string name="screen_notification_settings_title">"Notifications"</string>
|
||||
<string name="screen_report_content_block_user_hint">"Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur."</string>
|
||||
<string name="screen_share_location_title">"Partage de position"</string>
|
||||
<string name="screen_share_my_location_action">"Partager ma position"</string>
|
||||
<string name="screen_share_open_apple_maps">"Ouvrir dans Apple Maps"</string>
|
||||
<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="settings_rageshake">"Rageshake"</string>
|
||||
<string name="settings_rageshake_detection_threshold">"Seuil de détection"</string>
|
||||
<string name="settings_title_general">"Général"</string>
|
||||
|
|
|
|||
|
|
@ -140,10 +140,10 @@
|
|||
<string name="emoji_picker_category_places">"Cestovanie a miesta"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboly"</string>
|
||||
<string name="error_failed_creating_the_permalink">"Nepodarilo sa vytvoriť trvalý odkaz"</string>
|
||||
<string name="error_failed_loading_map">"Element nedokázal načítať mapu. Skúste to prosím neskôr."</string>
|
||||
<string name="error_failed_loading_map">"%1$s nedokázal načítať mapu. Skúste to prosím neskôr."</string>
|
||||
<string name="error_failed_loading_messages">"Načítanie správ zlyhalo"</string>
|
||||
<string name="error_failed_locating_user">"Element nemohol získať prístup k vašej polohe. Skúste to prosím neskôr."</string>
|
||||
<string name="error_missing_location_auth">"Element nemá povolenie na prístup k vašej polohe. Prístup môžete povoliť v Nastavenia > Poloha"</string>
|
||||
<string name="error_failed_locating_user">"%1$s nemohol získať prístup k vašej polohe. Skúste to prosím neskôr."</string>
|
||||
<string name="error_missing_location_auth">"%1$s nemá povolenie na prístup k vašej polohe. Prístup môžete povoliť v Nastavenia > Poloha"</string>
|
||||
<string name="error_some_messages_have_not_been_sent">"Niektoré správy neboli odoslané"</string>
|
||||
<string name="error_unknown">"Prepáčte, vyskytla sa chyba"</string>
|
||||
<string name="invite_friends_rich_title">"🔐️ Pripojte sa ku mne na %1$s"</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>
|
||||
|
|
|
|||
|
|
@ -140,10 +140,10 @@
|
|||
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbols"</string>
|
||||
<string name="error_failed_creating_the_permalink">"Failed creating the permalink"</string>
|
||||
<string name="error_failed_loading_map">"Element could not load the map. Please try again later."</string>
|
||||
<string name="error_failed_loading_map">"%1$s could not load the map. Please try again later."</string>
|
||||
<string name="error_failed_loading_messages">"Failed loading messages"</string>
|
||||
<string name="error_failed_locating_user">"Element could not access your location. Please try again later."</string>
|
||||
<string name="error_missing_location_auth">"Element does not have permission to access your location. You can enable access in Settings > Location"</string>
|
||||
<string name="error_failed_locating_user">"%1$s could not access your location. Please try again later."</string>
|
||||
<string name="error_missing_location_auth">"%1$s does not have permission to access your location. You can enable access in Settings > Location"</string>
|
||||
<string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
|
||||
<string name="error_unknown">"Sorry, an error occurred"</string>
|
||||
<string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</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