Merge branch 'develop' into feature/fga/better_timeline_scroll

This commit is contained in:
ganfra 2023-07-17 23:35:41 +02:00
commit 8f01e8133f
156 changed files with 3806 additions and 539 deletions

View file

@ -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,
)
}
}
}
)

View file

@ -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,
}

View file

@ -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 = {},
)

View file

@ -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()
}

View file

@ -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>

View file

@ -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)
}

View file

@ -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")
}
}
}

View file

@ -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
}
}
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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 }
}
)
}

View file

@ -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()
}
}

View file

@ -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

View file

@ -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)
}
}
)
}

View file

@ -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

View file

@ -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
}

View file

@ -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>

View file

@ -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

View file

@ -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,

View file

@ -24,4 +24,5 @@ sealed interface VirtualTimelineItem {
object ReadMarker : VirtualTimelineItem
object EncryptedHistoryBanner : VirtualTimelineItem
}

View file

@ -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)
}

View file

@ -157,6 +157,7 @@ class RustMatrixClient constructor(
coroutineDispatchers = dispatchers,
systemClock = clock,
roomContentForwarder = roomContentForwarder,
sessionData = sessionStore.getSession(sessionId.value)!!,
)
}

View file

@ -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(),
)

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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(),

View file

@ -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
}
}

View file

@ -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,
)
}

View file

@ -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"

View file

@ -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)
}

View file

@ -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,

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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") }
}

View file

@ -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)

View file

@ -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>

View file

@ -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))),
)
}
}

View file

@ -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?,
)

View file

@ -48,5 +48,7 @@ dependencies {
}
sqldelight {
database("SessionDatabase") {}
database("SessionDatabase") {
verifyMigrations = true
}
}

View file

@ -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) }
)
}

View file

@ -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 = ?;

View file

@ -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
);

View file

@ -0,0 +1 @@
ALTER TABLE SessionData ADD COLUMN loginTimestamp INTEGER;

View file

@ -35,7 +35,8 @@ class DatabaseSessionStoreTests {
accessToken = "accessToken",
refreshToken = "refreshToken",
homeserverUrl = "homeserverUrl",
slidingSyncProxy = null
slidingSyncProxy = null,
loginTimestamp = null,
)
@Before

View file

@ -42,6 +42,11 @@ object TestTags {
* Room list / Home screen.
*/
val homeScreenSettings = TestTag("home_screen-settings")
/**
* Welcome screen.
*/
val welcomeScreenTitle = TestTag("welcome_screen-title")
}

View file

@ -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
}

View file

@ -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",

View file

@ -140,10 +140,9 @@
<string name="emoji_picker_category_places">"Reisen &amp; 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>

View file

@ -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 &amp; 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 na 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 denvoyer le média, veuillez réessayer."</string>
<string name="screen_migration_message">"Ce processus na besoin dêtre fait quune 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>

View file

@ -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>

View file

@ -140,10 +140,10 @@
<string name="emoji_picker_category_places">"Travel &amp; 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 wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed 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">"Heres 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>