Merge pull request #6342 from element-hq/feature/fga/live_location_sharing_setup

Setup live location sharing feature
This commit is contained in:
ganfra
2026-03-24 15:46:45 +01:00
committed by GitHub
197 changed files with 3767 additions and 2803 deletions

View File

@@ -11,6 +11,19 @@ package io.element.android.libraries.dateformatter.api
import java.util.Locale
import kotlin.time.Duration
/**
* Formats a duration in a localized, human-readable way.
* Uses the largest appropriate unit (hours, minutes, or seconds).
*
* Examples (in English):
* - 2 hours 30 minutes → "3 hours" (rounded)
* - 45 minutes → "45 minutes"
* - 30 seconds → "30 seconds"
*/
interface DurationFormatter {
fun format(duration: Duration): String
}
/**
* Convert milliseconds to human readable duration.
* Hours in 1 digit or more.

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import android.icu.text.MeasureFormat
import android.icu.text.MeasureFormat.FormatWidth
import android.icu.util.Measure
import android.icu.util.MeasureUnit
import android.text.format.DateUtils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.libraries.dateformatter.api.DurationFormatter
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* Formats durations in a localized, human-readable way using Android's MeasureFormat.
*
* Uses WIDE format for readability (e.g., "5 hours", "3 minutes", "10 seconds").
* Rounds to the nearest unit for cleaner display.
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, binding = binding<DurationFormatter>())
class DefaultDurationFormatter(
localeChangeObserver: LocaleChangeObserver,
locale: Locale,
) : DurationFormatter, LocaleChangeListener {
init {
localeChangeObserver.addListener(this)
}
// Cache formatter, recreate only on locale change
private var formatter: MeasureFormat = MeasureFormat.getInstance(locale, FormatWidth.WIDE)
override fun onLocaleChange() {
formatter = MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE)
}
override fun format(duration: Duration): String {
val millis = duration.inWholeMilliseconds
return when {
duration >= 1.hours -> {
// Round to nearest hour (add 30 minutes before dividing)
val hours = ((millis + 30 * DateUtils.MINUTE_IN_MILLIS) / DateUtils.HOUR_IN_MILLIS).toInt()
formatter.format(Measure(hours, MeasureUnit.HOUR))
}
duration >= 1.minutes -> {
// Round to nearest minute (add 30 seconds before dividing)
val minutes = ((millis + 30 * DateUtils.SECOND_IN_MILLIS) / DateUtils.MINUTE_IN_MILLIS).toInt()
formatter.format(Measure(minutes, MeasureUnit.MINUTE))
}
else -> {
// Round to nearest second (add 500ms before dividing)
val seconds = ((millis + 500) / DateUtils.SECOND_IN_MILLIS).toInt()
formatter.format(Measure(seconds, MeasureUnit.SECOND))
}
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import java.util.Locale
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@RunWith(AndroidJUnit4::class)
@Config(qualifiers = "en", sdk = [Build.VERSION_CODES.TIRAMISU])
class DefaultDurationFormatterTest {
private fun createDurationFormatter(): DefaultDurationFormatter {
return DefaultDurationFormatter(
localeChangeObserver = {},
locale = Locale.US,
)
}
@Test
fun `test zero duration`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(0.seconds)).isEqualTo("0 seconds")
}
@Test
fun `test 1 second`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.seconds)).isEqualTo("1 second")
}
@Test
fun `test 30 seconds`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(30.seconds)).isEqualTo("30 seconds")
}
@Test
fun `test 59 seconds`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(59.seconds)).isEqualTo("59 seconds")
}
@Test
fun `test 1 minute`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.minutes)).isEqualTo("1 minute")
}
@Test
fun `test 1 minute 29 seconds rounds to 1 minute`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.minutes + 29.seconds)).isEqualTo("1 minute")
}
@Test
fun `test 1 minute 30 seconds rounds to 2 minutes`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.minutes + 30.seconds)).isEqualTo("2 minutes")
}
@Test
fun `test 45 minutes`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(45.minutes)).isEqualTo("45 minutes")
}
@Test
fun `test 59 minutes`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(59.minutes)).isEqualTo("59 minutes")
}
@Test
fun `test 1 hour`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.hours)).isEqualTo("1 hour")
}
@Test
fun `test 1 hour 29 minutes rounds to 1 hour`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.hours + 29.minutes)).isEqualTo("1 hour")
}
@Test
fun `test 1 hour 30 minutes rounds to 2 hours`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(1.hours + 30.minutes)).isEqualTo("2 hours")
}
@Test
fun `test 2 hours 30 minutes rounds to 3 hours`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(2.hours + 30.minutes)).isEqualTo("3 hours")
}
@Test
fun `test 5 hours`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(5.hours)).isEqualTo("5 hours")
}
@Test
fun `test 24 hours`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(24.hours)).isEqualTo("24 hours")
}
@Test
fun `test rounding at seconds threshold - 499ms rounds to 0 seconds`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(499.milliseconds)).isEqualTo("0 seconds")
}
@Test
fun `test rounding at seconds threshold - 500ms rounds to 1 second`() {
val formatter = createDurationFormatter()
assertThat(formatter.format(500.milliseconds)).isEqualTo("1 second")
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.test
import io.element.android.libraries.dateformatter.api.DurationFormatter
import kotlin.time.Duration
class FakeDurationFormatter(
private val formatLambda: (Duration) -> String = { it.toString() },
) : DurationFormatter {
override fun format(duration: Duration): String {
return formatLambda(duration)
}
}

View File

@@ -0,0 +1,419 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withSave
import coil3.Image
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.asImage
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowHardware
import coil3.toBitmap
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
private val PIN_WIDTH = 42.dp
private val PIN_HEIGHT = PIN_WIDTH * 1.2f
private val AVATAR_SIZE = PIN_WIDTH - 10.dp
private val CONTENT_OFFSET = 5.dp
private val DOT_RADIUS = 6.dp
private val STROKE_WIDTH = 1.dp
/**
* Variants of location pin markers.
*/
@Immutable
sealed interface PinVariant {
data class UserLocation(
val avatarData: AvatarData,
val isLive: Boolean,
) : PinVariant
data object PinnedLocation : PinVariant
data object StaleLocation : PinVariant
}
/**
* A location pin composable that supports multiple variants.
*
* Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev
*/
@Composable
fun LocationPin(
variant: PinVariant,
modifier: Modifier = Modifier,
) {
val image = rememberLocationPinBitmap(variant)
Canvas(modifier = modifier.size(PIN_WIDTH, PIN_HEIGHT)) {
if (image != null) {
drawImage(image)
}
}
}
/**
* Renders a location pin to an [ImageBitmap] using Canvas operations.
* @param variant The pin variant to render
* @return The rendered [ImageBitmap], or null if still loading
*/
@Composable
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
val context = LocalContext.current
val density = LocalDensity.current
val imageLoader = SingletonImageLoader.get(context)
val colors = pinColors(variant)
val cacheKey = rememberCacheKey(variant)
return produceState<ImageBitmap?>(initialValue = null, cacheKey) {
val memoryCacheKey = MemoryCache.Key(cacheKey)
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
if (cached != null) {
value = cached.image.toBitmap().asImageBitmap()
} else {
val dimensions = PinDimensions(density)
val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader)
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
value = bitmap.asImageBitmap()
}
}.value
}
@Composable
private fun pinColors(variant: PinVariant): PinColors {
return when (variant) {
is PinVariant.UserLocation -> {
val avatarColors = AvatarColorsProvider.provide(variant.avatarData.id)
if (variant.isLive) {
PinColors(
fill = ElementTheme.colors.iconAccentPrimary,
stroke = Color.Transparent,
dot = Color.Transparent,
avatarStroke = ElementTheme.colors.bgCanvasDefault,
avatarBackground = avatarColors.background,
avatarForeground = avatarColors.foreground,
)
} else {
PinColors(
fill = ElementTheme.colors.bgCanvasDefault,
stroke = ElementTheme.colors.iconQuaternaryAlpha,
dot = Color.Transparent,
avatarStroke = ElementTheme.colors.iconQuaternaryAlpha,
avatarBackground = avatarColors.background,
avatarForeground = avatarColors.foreground,
)
}
}
PinVariant.PinnedLocation -> PinColors(
fill = ElementTheme.colors.bgCanvasDefault,
stroke = ElementTheme.colors.iconSecondaryAlpha,
avatarStroke = Color.Transparent,
avatarBackground = Color.Transparent,
avatarForeground = Color.Transparent,
dot = ElementTheme.colors.iconPrimary,
)
PinVariant.StaleLocation -> PinColors(
fill = ElementTheme.colors.bgSubtleSecondary,
stroke = ElementTheme.colors.iconDisabled,
avatarStroke = Color.Transparent,
avatarBackground = Color.Transparent,
avatarForeground = Color.Transparent,
dot = ElementTheme.colors.iconDisabled,
)
}
}
/**
* Color configuration for rendering a location pin.
*/
private data class PinColors(
val fill: Color,
val stroke: Color,
val dot: Color,
val avatarStroke: Color,
val avatarBackground: Color,
val avatarForeground: Color,
)
/**
* Pre-calculated pixel dimensions for rendering a location pin.
*/
private class PinDimensions(density: Density) {
val pinWidth = with(density) { PIN_WIDTH.toPx() }
val pinHeight = with(density) { PIN_HEIGHT.toPx() }
val avatarSize: Float = with(density) { AVATAR_SIZE.toPx() }
val avatarOffset: Float = with(density) { CONTENT_OFFSET.toPx() }
val dotRadius: Float = with(density) { DOT_RADIUS.toPx() }
val strokeWidth: Float = with(density) { STROKE_WIDTH.toPx() }
}
/**
* Renders location pins to bitmaps using Canvas operations.
* Uses Coil for avatar loading.
* Paint objects are shared across all renders.
*/
private object LocationPinRenderer {
// Shared Paint objects to avoid allocations
private val fillPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
private val strokePaint = Paint().apply {
style = Paint.Style.STROKE
isAntiAlias = true
}
private val textPaint = Paint().apply {
textAlign = Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
/**
* Renders a pin variant to bitmap. Suspending for async avatar loading.
*/
suspend fun renderPin(
variant: PinVariant,
colors: PinColors,
dimensions: PinDimensions,
context: Context,
imageLoader: ImageLoader,
): Bitmap {
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
val canvas = Canvas(bitmap)
canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
when (variant) {
is PinVariant.UserLocation -> {
val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
canvas.drawAvatar(
avatarImage = avatarImage,
avatarData = variant.avatarData,
borderColor = colors.avatarStroke,
backgroundColor = colors.avatarBackground,
foregroundColor = colors.avatarForeground,
dimensions = dimensions,
)
}
PinVariant.PinnedLocation,
PinVariant.StaleLocation -> canvas.drawDot(colors.dot, dimensions)
}
return bitmap
}
private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color, dimensions: PinDimensions) {
val path = createPinPath(dimensions)
fillPaint.color = fillColor.toArgb()
drawPath(path, fillPaint)
strokePaint.color = strokeColor.toArgb()
strokePaint.strokeWidth = dimensions.strokeWidth
drawPath(path, strokePaint)
}
/**
* Updates the teardrop-shaped pin path to match dimensions.
* Based on SVG path with dimensions 40x48 (ratio 1:1.2).
*/
private fun createPinPath(dimensions: PinDimensions): Path {
val svgWidth = 40f
val svgHeight = 48f
val inset = dimensions.strokeWidth / 2
val scaleX = (dimensions.pinWidth - dimensions.strokeWidth) / svgWidth
val scaleY = (dimensions.pinHeight - dimensions.strokeWidth) / svgHeight
val path = Path().apply {
moveTo(20f, 48f)
cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f)
cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f)
cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f)
cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f)
cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f)
cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f)
cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f)
cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f)
cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f)
cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f)
cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f)
cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f)
cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f)
cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f)
cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f)
cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f)
close()
}
val matrix = Matrix().apply {
setScale(scaleX, scaleY)
postTranslate(inset, inset)
}
path.transform(matrix)
return path
}
private suspend fun loadAvatarImage(
avatarData: AvatarData,
context: Context,
imageLoader: ImageLoader,
): Image? {
val request = ImageRequest.Builder(context)
.data(avatarData)
// Disable hardware rendering for Canvas
.allowHardware(false)
.build()
return imageLoader.execute(request).image
}
private fun Canvas.drawAvatar(
avatarImage: Image?,
avatarData: AvatarData,
borderColor: Color,
backgroundColor: Color,
foregroundColor: Color,
dimensions: PinDimensions,
) {
val centerX = dimensions.pinWidth / 2
val avatarY = dimensions.avatarOffset
val avatarRadius = dimensions.avatarSize / 2
withSave {
if (avatarImage != null) {
val bitmap = avatarImage.toBitmap()
// Calculate centered square crop (ContentScale.Crop behavior)
val srcSize = minOf(bitmap.width, bitmap.height)
val srcX = (bitmap.width - srcSize) / 2
val srcY = (bitmap.height - srcSize) / 2
val srcRect = Rect(srcX, srcY, srcX + srcSize, srcY + srcSize)
val destRect = RectF(
centerX - avatarRadius,
avatarY,
centerX + avatarRadius,
avatarY + dimensions.avatarSize
)
val clipPath = Path().apply {
addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW)
}
clipPath(clipPath)
drawBitmap(bitmap, srcRect, destRect, null)
} else {
drawInitialLetterAvatar(
initialLetter = avatarData.initialLetter,
centerX = centerX,
centerY = avatarY + avatarRadius,
radius = avatarRadius,
foreground = foregroundColor.toArgb(),
background = backgroundColor.toArgb()
)
}
}
strokePaint.color = borderColor.toArgb()
strokePaint.strokeWidth = dimensions.strokeWidth
drawCircle(centerX, avatarY + avatarRadius, avatarRadius, strokePaint)
}
private fun Canvas.drawInitialLetterAvatar(
initialLetter: String,
centerX: Float,
centerY: Float,
radius: Float,
foreground: Int,
background: Int,
) {
fillPaint.color = background
drawCircle(centerX, centerY, radius, fillPaint)
textPaint.color = foreground
textPaint.textSize = radius * 1.2f
val textBounds = Rect()
textPaint.getTextBounds(initialLetter, 0, 1, textBounds)
val textY = centerY + textBounds.height() / 2f
drawText(initialLetter, centerX, textY, textPaint)
}
private fun Canvas.drawDot(dotColor: Color, dimensions: PinDimensions) {
if (dotColor == Color.Transparent) return
val centerX = dimensions.pinWidth / 2
val centerY = dimensions.avatarOffset + dimensions.avatarSize / 2
fillPaint.color = dotColor.toArgb()
drawCircle(centerX, centerY, dimensions.dotRadius, fillPaint)
}
}
@PreviewsDayNight
@Composable
internal fun LocationPinPreview() = ElementPreview {
val sampleAvatarData = AvatarData(
id = "@alice:matrix.org",
name = "Alice",
url = null,
size = AvatarSize.SelectedUser
)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
LocationPin(
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false),
)
LocationPin(
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
LocationPin(
variant = PinVariant.PinnedLocation,
)
LocationPin(
variant = PinVariant.StaleLocation,
)
}
}
}
@Composable
private fun rememberCacheKey(variant: PinVariant): String {
val isLightTheme = ElementTheme.isLightTheme
val density = LocalDensity.current.density
return remember(isLightTheme, density, variant) {
val pinVariant = when (variant) {
PinVariant.PinnedLocation -> "pin_pinned"
PinVariant.StaleLocation -> "pin_stale"
is PinVariant.UserLocation -> "pin_user_${variant.avatarData.id}_${variant.isLive}"
}
"${pinVariant}_{$isLightTheme}_{$density}"
}
}

View File

@@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@Composable
fun PinIcon(
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.background(ElementTheme.colors.bgSubtlePrimary)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.width(22.dp),
resourceId = R.drawable.pin,
contentDescription = null,
tint = Color.Unspecified,
)
}
}
@PreviewsDayNight
@Composable
internal fun PinIconPreview() = ElementPreview {
PinIcon()
}

View File

@@ -75,6 +75,6 @@ enum class AvatarSize(val dp: Dp) {
SpaceMember(24.dp),
LeaveSpaceRoom(32.dp),
SelectParentSpace(32.dp),
AccountItem(32.dp),
LocationPin(32.dp)
}

View File

@@ -42,6 +42,7 @@ fun ListDialog(
enabled: Boolean = true,
applyPaddingToContents: Boolean = true,
destructiveSubmit: Boolean = false,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp),
listItems: LazyListScope.() -> Unit,
) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@@ -67,6 +68,7 @@ fun ListDialog(
listItems = listItems,
applyPaddingToContents = applyPaddingToContents,
destructiveSubmit = destructiveSubmit,
verticalArrangement = verticalArrangement,
)
}
}
@@ -82,6 +84,7 @@ private fun ListDialogContent(
enabled: Boolean,
applyPaddingToContents: Boolean,
destructiveSubmit: Boolean,
verticalArrangement: Arrangement.Vertical,
subtitle: @Composable (() -> Unit)? = null,
) {
SimpleAlertDialogContent(
@@ -99,7 +102,7 @@ private fun ListDialogContent(
val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp
LazyColumn(
modifier = Modifier.padding(horizontal = horizontalPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = verticalArrangement,
) { listItems() }
}
}
@@ -126,6 +129,7 @@ internal fun ListDialogContentPreview() {
enabled = true,
destructiveSubmit = false,
applyPaddingToContents = true,
verticalArrangement = Arrangement.spacedBy(16.dp),
)
}
}

View File

@@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
@@ -115,6 +116,10 @@ class DefaultRoomLatestEventFormatter(
val message = sp.getString(CommonStrings.common_unsupported_event)
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is LiveLocationContent -> {
val message = sp.getString(CommonStrings.common_shared_location)
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call)
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
}?.take(DEFAULT_SAFE_LENGTH)

View File

@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
@@ -69,6 +70,7 @@ class DefaultTimelineEventFormatter(
is MessageContent,
is FailedToParseMessageLikeContent,
is FailedToParseStateContent,
is LiveLocationContent,
is UnknownContent -> {
if (buildMeta.isDebuggable) {
error("You should not use this formatter for this event content: $content")

View File

@@ -145,7 +145,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
ImageMessageType(body, null, null, MediaSource("url"), null),
StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null),
LocationMessageType(body, "geo:1,2", null, null),
NoticeMessageType(body, null),
EmoteMessageType(body, null),
OtherMessageType(msgType = "a_type", body = body),

View File

@@ -190,7 +190,7 @@ class DefaultRoomLatestEventFormatterTest {
ImageMessageType(body, null, null, MediaSource("url"), null),
StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null),
LocationMessageType(body, "geo:1,2", null, null),
NoticeMessageType(body, null),
EmoteMessageType(body, null),
OtherMessageType(msgType = "a_type", body = body),

View File

@@ -147,6 +147,13 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
LiveLocationSharing(
key = "feature.liveLocationSharing",
title = "Live location sharing",
description = "Allow sharing live location in rooms.",
defaultValue = { false },
isFinished = false,
),
ValidateNetworkWhenSchedulingNotificationFetching(
key = "feature.validate_network_when_scheduling_notification_fetching",
title = "validate internet connectivity when scheduling notification fetching",

View File

@@ -1,28 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.libraries.maplibre.compose"
kotlin {
compilerOptions {
explicitApi()
}
}
}
dependencies {
api(libs.maplibre)
api(libs.maplibre.ktx)
api(libs.maplibre.annotation)
}

View File

@@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.Immutable
import org.maplibre.android.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

@@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.Immutable
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_ANIMATION
import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE
import org.maplibre.android.maps.MapLibreMap.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/org.maplibre.android.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 [org.maplibre.android.maps.MapLibreMap.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/org.maplibre.android.maps/#oncameramovestartedlistener.
*/
public fun fromInt(value: Int): CameraMoveStartedReason {
return values().firstOrNull { it.value == value } ?: return UNKNOWN
}
}
}

View File

@@ -1,179 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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 kotlinx.parcelize.Parcelize
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Projection
/**
* 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(
crossinline init: CameraPositionState.() -> Unit = {}
): CameraPositionState = rememberSaveable(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 [MapLibreMap] 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: MapLibreMap? 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: MapLibreMap?) {
synchronized(lock) {
if (this.map == null && map == null) return
if (this.map != null && map != null) {
error("CameraPositionState may only be associated with one MapLibreMap 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, SaveableCameraPositionData> = Saver(
save = { SaveableCameraPositionData(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
@[MapLibreMapComposable ReadOnlyComposable Composable]
get() = LocalCameraPositionState.current
@Parcelize
public data class SaveableCameraPositionData(
val position: CameraPosition,
val cameraMode: Int
) : Parcelable

View File

@@ -1,39 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.Immutable
import org.maplibre.android.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

@@ -1,57 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.runtime.AbstractApplier
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
import org.maplibre.android.plugins.annotation.SymbolManager
internal interface MapNode {
fun onAttached() {}
fun onRemoved() {}
fun onCleared() {}
}
private object MapNodeRoot : MapNode
internal class MapApplier(
val map: MapLibreMap,
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

@@ -1,247 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import android.content.ComponentCallbacks2
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.material3.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.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.awaitCancellation
import org.maplibre.android.MapLibre
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
import org.maplibre.android.plugins.annotation.SymbolManager
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 MapLibreMap
* @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 MapLibreMap(
styleUri: String,
modifier: Modifier = Modifier,
images: ImmutableMap<String, Int> = persistentMapOf(),
cameraPositionState: CameraPositionState = rememberCameraPositionState(),
uiSettings: MapUiSettings = DefaultMapUiSettings,
symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings,
locationSettings: MapLocationSettings = DefaultMapLocationSettings,
content: (@Composable @MapLibreMapComposable () -> 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 {
MapLibre.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,
uiSettings = currentUiSettings,
locationSettings = currentMapLocationSettings,
symbolManagerSettings = 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(): MapLibreMap = suspendCoroutine { continuation ->
getMapAsync { map ->
continuation.resume(map)
}
}
private suspend inline fun MapLibreMap.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 MapLibreMap 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
}
Lifecycle.Event.ON_ANY -> error("ON_ANY should never be used")
}
previousState.value = event
}
private fun MapView.componentCallbacks(): ComponentCallbacks2 =
object : ComponentCallbacks2 {
override fun onConfigurationChanged(config: Configuration) = Unit
@Suppress("OVERRIDE_DEPRECATION")
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
// We call the `MapView.onLowMemory` method for any memory trim level
this@componentCallbacks.onLowMemory()
}
}

View File

@@ -1,30 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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 [MapLibreMapComposable].
*
* This will produce build warnings when [MapLibreMapComposable] composable functions are used outside
* of a [MapLibreMapComposable] 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 MapLibreMapComposable

View File

@@ -1,31 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.maplibre.compose
import androidx.compose.ui.graphics.Color
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,
public val backgroundTintColor: Color = Color.Unspecified,
public val foregroundTintColor: Color = Color.Unspecified,
public val backgroundStaleTintColor: Color = Color.Unspecified,
public val foregroundStaleTintColor: Color = Color.Unspecified,
public val accuracyColor: Color = Color.Unspecified,
public val pulseEnabled: Boolean = false,
public val pulseColor: Color = Color.Unspecified
)

View File

@@ -1,22 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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

@@ -1,32 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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

@@ -1,153 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@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 org.maplibre.android.location.LocationComponentActivationOptions
import org.maplibre.android.location.LocationComponentOptions
import org.maplibre.android.location.OnCameraTrackingChangedListener
import org.maplibre.android.location.engine.LocationEngineRequest
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
private const val LOCATION_REQUEST_INTERVAL = 750L
internal class MapPropertiesNode(
val map: MapLibreMap,
style: Style,
context: Context,
cameraPositionState: CameraPositionState,
locationSettings: MapLocationSettings,
) : MapNode {
init {
map.locationComponent.activateLocationComponent(
LocationComponentActivationOptions.Builder(context, style)
.locationComponentOptions(
LocationComponentOptions.builder(context)
.backgroundTintColor(locationSettings.backgroundTintColor.toArgb())
.foregroundTintColor(locationSettings.foregroundTintColor.toArgb())
.backgroundStaleTintColor(locationSettings.backgroundStaleTintColor.toArgb())
.foregroundStaleTintColor(locationSettings.foregroundStaleTintColor.toArgb())
.accuracyColor(locationSettings.accuracyColor.toArgb())
.pulseEnabled(locationSettings.pulseEnabled)
.pulseColor(locationSettings.pulseColor.toArgb())
.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,
locationSettings: MapLocationSettings,
uiSettings: MapUiSettings,
symbolManagerSettings: 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,
locationSettings = locationSettings,
)
},
update = {
set(locationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it }
set(uiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it }
set(uiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it }
set(uiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it }
set(uiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it }
set(uiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it }
set(uiSettings.logoGravity) { map.uiSettings.logoGravity = it }
set(uiSettings.attributionGravity) { map.uiSettings.attributionGravity = it }
set(uiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) }
set(symbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it }
update(cameraPositionState) { this.cameraPositionState = it }
}
)
}

View File

@@ -1,114 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2021 Google LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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 org.maplibre.android.geometry.LatLng
import org.maplibre.android.plugins.annotation.Symbol
import org.maplibre.android.plugins.annotation.SymbolManager
import org.maplibre.android.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
) {
/**
* 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(
position: LatLng = LatLng(0.0, 0.0)
): SymbolState = rememberSaveable(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
@MapLibreMapComposable
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

@@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
@@ -182,4 +183,30 @@ interface JoinedRoom : BaseRoom {
* Subscribe to a [Flow] of [SendQueueUpdate] related to this room.
*/
fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate>
/**
* Subscribe to live location shares in this room.
* @return Flow of list of active live location shares.
*/
fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>>
/**
* Start sharing live location in this room.
* @param durationMillis How long to share location (in milliseconds).
* @return Result indicating success or failure.
*/
suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit>
/**
* Stop sharing live location in this room.
* @return Result indicating success or failure.
*/
suspend fun stopLiveLocationShare(): Result<Unit>
/**
* Send a live location update while a live location share is active.
* @param geoUri The geo URI (e.g., "geo:51.5074,-0.1278").
* @return Result indicating success or failure.
*/
suspend fun sendLiveLocation(geoUri: String): Result<Unit>
}

View File

@@ -10,5 +10,6 @@ package io.element.android.libraries.matrix.api.room.location
enum class AssetType {
SENDER,
PIN
PIN,
UNKNOWN
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room.location
data class LiveLocationInfo(
val description: String?,
val geoUri: String,
val timestamp: Long,
)

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room.location
import io.element.android.libraries.matrix.api.core.UserId
/**
* Represents a live location share from a user in a room.
*/
data class LiveLocationShare(
/** The user who is sharing their location. */
val userId: UserId,
/** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */
val lastGeoUri: String,
/** The timestamp of the last location update. */
val lastTimestamp: Long,
/** Whether the live location share is still active. */
val isLive: Boolean,
)

View File

@@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.location.LiveLocationInfo
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -102,6 +104,15 @@ data class FailedToParseStateContent(
val error: String
) : EventContent
data class LiveLocationContent(
val body: String,
val isLive: Boolean,
val description: String?,
val timeout: Long,
val assetType: AssetType?,
val locations: List<LiveLocationInfo>,
) : EventContent
data object LegacyCallInviteContent : EventContent
data object CallNotifyContent : EventContent

View File

@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.location.AssetType
@Immutable
sealed interface MessageType
@@ -55,6 +56,7 @@ data class LocationMessageType(
val body: String,
val geoUri: String,
val description: String?,
val assetType: AssetType?,
) : MessageType
data class AudioMessageType(

View File

@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
@@ -42,6 +43,7 @@ import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
import io.element.android.libraries.matrix.impl.room.location.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
@@ -66,6 +68,7 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.KnockRequestsListener
import org.matrix.rustcomponents.sdk.LiveLocationShareListener
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
import org.matrix.rustcomponents.sdk.SendQueueListener
@@ -500,6 +503,34 @@ class JoinedRustRoom(
}
}
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
return mxCallbackFlow {
innerRoom.subscribeToLiveLocationShares(object : LiveLocationShareListener {
override fun call(liveLocationShares: List<org.matrix.rustcomponents.sdk.LiveLocationShare>) {
trySend(liveLocationShares.map { it.map() })
}
})
}
}
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.startLiveLocationShare(durationMillis.toULong())
}
}
override suspend fun stopLiveLocationShare(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.stopLiveLocationShare()
}
}
override suspend fun sendLiveLocation(geoUri: String): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.sendLiveLocation(geoUri)
}
}
override fun close() = destroy()
override fun destroy() {

View File

@@ -9,8 +9,16 @@
package io.element.android.libraries.matrix.impl.room.location
import io.element.android.libraries.matrix.api.room.location.AssetType
import org.matrix.rustcomponents.sdk.AssetType as RustAssetType
fun AssetType.toInner(): org.matrix.rustcomponents.sdk.AssetType = when (this) {
AssetType.SENDER -> org.matrix.rustcomponents.sdk.AssetType.SENDER
AssetType.PIN -> org.matrix.rustcomponents.sdk.AssetType.PIN
fun AssetType.into(): RustAssetType = when (this) {
AssetType.SENDER -> RustAssetType.SENDER
AssetType.PIN -> RustAssetType.PIN
AssetType.UNKNOWN -> RustAssetType.UNKNOWN
}
fun RustAssetType.into(): AssetType = when (this) {
RustAssetType.SENDER -> AssetType.SENDER
RustAssetType.PIN -> AssetType.PIN
RustAssetType.UNKNOWN -> AssetType.UNKNOWN
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.room.location
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare
fun RustLiveLocationShare.map(): LiveLocationShare {
return LiveLocationShare(
userId = UserId(userId),
lastGeoUri = lastLocation.location.geoUri,
lastTimestamp = lastLocation.ts.toLong(),
isLive = isLive,
)
}

View File

@@ -32,7 +32,7 @@ import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.room.location.into
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
@@ -478,7 +478,7 @@ class RustTimeline(
geoUri = geoUri,
description = description,
zoomLevel = zoomLevel?.toUByte(),
assetType = assetType?.toInner(),
assetType = assetType?.into(),
repliedToEventId = inReplyToEventId?.value,
)
}

View File

@@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.room.location.into
import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper
import org.matrix.rustcomponents.sdk.InReplyToDetails
import org.matrix.rustcomponents.sdk.MessageType
@@ -112,7 +113,12 @@ class EventMessageMapper {
)
}
is RustMessageType.Location -> {
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
LocationMessageType(
body = type.content.body,
geoUri = type.content.geoUri,
description = type.content.description,
assetType = type.content.asset.into()
)
}
is MessageType.Other -> {
OtherMessageType(type.msgtype, type.body)

View File

@@ -15,7 +15,7 @@ import org.junit.Test
class AssetTypeKtTest {
@Test
fun toInner() {
assertThat(AssetType.SENDER.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.SENDER)
assertThat(AssetType.PIN.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.PIN)
assertThat(AssetType.SENDER.into()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.SENDER)
assertThat(AssetType.PIN.into()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.PIN)
}
}

View File

@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
@@ -84,6 +85,10 @@ class FakeJoinedRoom(
private val enableEncryptionResult: () -> Result<Unit> = { lambdaError() },
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
private val liveLocationSharesFlow: Flow<List<LiveLocationShare>> = MutableStateFlow(emptyList()),
private val startLiveLocationShareResult: (Long) -> Result<Unit> = { lambdaError() },
private val stopLiveLocationShareResult: () -> Result<Unit> = { lambdaError() },
private val sendLiveLocationResult: (String) -> Result<Unit> = { lambdaError() },
) : JoinedRoom, BaseRoom by baseRoom {
private val sendQueueUpdates = MutableSharedFlow<SendQueueUpdate>(extraBufferCapacity = 10)
@@ -227,6 +232,22 @@ class FakeJoinedRoom(
return sendQueueUpdates
}
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
return liveLocationSharesFlow
}
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = simulateLongTask {
startLiveLocationShareResult(durationMillis)
}
override suspend fun stopLiveLocationShare(): Result<Unit> = simulateLongTask {
stopLiveLocationShareResult()
}
override suspend fun sendLiveLocation(geoUri: String): Result<Unit> = simulateLongTask {
sendLiveLocationResult(geoUri)
}
private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) {
progressCallbackValues.forEach { (current, total) ->
progressCallback?.onProgress(current, total)

View File

@@ -11,7 +11,6 @@ package io.element.android.libraries.matrix.ui.components
import android.os.Parcelable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -24,7 +23,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.PinIcon
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -97,16 +95,10 @@ fun AttachmentThumbnail(
)
}
AttachmentThumbnailType.Location -> {
PinIcon(
modifier = Modifier.fillMaxSize()
)
/*
// For coherency across the app, we should us this instead. Waiting for design decision.
Icon(
resourceId = R.drawable.ic_september_location,
imageVector = CompoundIcons.LocationPin(),
contentDescription = info.textContent,
)
*/
}
AttachmentThumbnailType.Poll -> {
Icon(

View File

@@ -71,7 +71,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
),
aMessageContent(
body = "Location",
type = LocationMessageType("Location", "geo:1,2", null),
type = LocationMessageType("Location", "geo:1,2", null, assetType = null),
),
aMessageContent(
body = "Notice",
@@ -152,13 +152,13 @@ private fun aInReplyToDetails(
eventId = EventId("\$event"),
eventContent = eventContent,
senderId = UserId("@Sender:domain"),
senderProfile = aProfileTimelineDetailsReady(
senderProfile = aProfileDetailsReady(
displayNameAmbiguous = displayNameAmbiguous,
),
textContent = (eventContent as? MessageContent)?.body.orEmpty(),
)
fun aProfileTimelineDetailsReady(
fun aProfileDetailsReady(
displayName: String? = "Sender",
displayNameAmbiguous: Boolean = false,
avatarUrl: String? = null,

View File

@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
@@ -32,6 +33,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToMetadata.Text
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToMetadata.Thumbnail
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
@@ -60,7 +63,7 @@ internal sealed interface InReplyToMetadata {
@Composable
internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetadata? = when (eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> InReplyToMetadata.Thumbnail(
is ImageMessageType -> Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = (type.info?.thumbnailSource ?: type.source).takeUnless { hideImage },
textContent = eventContent.body,
@@ -68,7 +71,7 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad
blurHash = type.info?.blurhash,
)
)
is VideoMessageType -> InReplyToMetadata.Thumbnail(
is VideoMessageType -> Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage },
textContent = eventContent.body,
@@ -76,34 +79,34 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad
blurHash = type.info?.blurhash,
)
)
is FileMessageType -> InReplyToMetadata.Thumbnail(
is FileMessageType -> Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage },
textContent = eventContent.body,
type = AttachmentThumbnailType.File,
)
)
is LocationMessageType -> InReplyToMetadata.Thumbnail(
is LocationMessageType -> Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_shared_location),
type = AttachmentThumbnailType.Location,
)
)
is AudioMessageType -> InReplyToMetadata.Thumbnail(
is AudioMessageType -> Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.body,
type = AttachmentThumbnailType.Audio,
)
)
is VoiceMessageType -> InReplyToMetadata.Thumbnail(
is VoiceMessageType -> Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_voice_message),
type = AttachmentThumbnailType.Voice,
)
)
else -> InReplyToMetadata.Text(textContent ?: eventContent.body)
else -> Text(textContent ?: eventContent.body)
}
is StickerContent -> InReplyToMetadata.Thumbnail(
is StickerContent -> Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = eventContent.source.takeUnless { hideImage },
textContent = eventContent.body,
@@ -111,7 +114,7 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad
blurHash = eventContent.info.blurhash,
)
)
is PollContent -> InReplyToMetadata.Thumbnail(
is PollContent -> Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.question,
type = AttachmentThumbnailType.Poll,
@@ -127,5 +130,6 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad
UnknownContent,
is LegacyCallInviteContent,
is CallNotifyContent,
is LiveLocationContent,
null -> null
}

View File

@@ -355,6 +355,7 @@ class InReplyToMetadataKtTest {
body = "body",
geoUri = "geo:3.0,4.0;u=5.0",
description = null,
assetType = null
)
)
).metadata(hideImage = false)

View File

@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
@@ -75,6 +76,7 @@ class EventItemFactory(
is StateContent,
is StickerContent,
is UnableToDecryptContent,
is LiveLocationContent,
UnknownContent -> {
Timber.w("Should not happen: ${content.javaClass.simpleName}")
null

View File

@@ -107,7 +107,7 @@ class DefaultEventItemFactoryTest {
EmoteMessageType("", null),
NoticeMessageType("", null),
OtherMessageType("", ""),
LocationMessageType("", "", null),
LocationMessageType("", "", null, null),
TextMessageType("", null)
)
messageTypes.forEach {

View File

@@ -339,7 +339,7 @@ class DefaultNotifiableEventResolverTest {
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = LocationMessageType("Location", "geo:1,2", null),
messageType = LocationMessageType("Location", "geo:1,2", null, null),
),
))
)

View File

@@ -496,7 +496,6 @@ Haluatko varmasti jatkaa?"</string>
<string name="screen_room_pinned_banner_loading_description">"Viestiä ladataan…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Näytä kaikki"</string>
<string name="screen_room_title">"Keskustelu"</string>
<string name="screen_share_location_live_location_duration_picker_title">"Valitse, kuinka kauan haluat jakaa reaaliaikaisen sijaintisi."</string>
<string name="screen_share_location_title">"Jaa sijainti"</string>
<string name="screen_share_my_location_action">"Jaa sijaintini"</string>
<string name="screen_share_open_apple_maps">"Avaa Apple Mapsissa"</string>

View File

@@ -496,7 +496,6 @@ Raison : %1$s."</string>
<string name="screen_room_pinned_banner_loading_description">"Chargement du message…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Voir tout"</string>
<string name="screen_room_title">"Discussion"</string>
<string name="screen_share_location_live_location_duration_picker_title">"Choisissez la durée pendant laquelle vous partagerez votre position en direct."</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>

View File

@@ -496,7 +496,6 @@ Are you sure you want to continue?"</string>
<string name="screen_room_pinned_banner_loading_description">"Loading message…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"View All"</string>
<string name="screen_room_title">"Chat"</string>
<string name="screen_share_location_live_location_duration_picker_title">"Choose how long to share your live location."</string>
<string name="screen_share_location_title">"Share location"</string>
<string name="screen_share_my_location_action">"Share my location"</string>
<string name="screen_share_open_apple_maps">"Open in Apple Maps"</string>