mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 22:36:34 +00:00
Merge branch 'develop' into feature/fga/local_network_permission
# Conflicts: # libraries/androidutils/src/main/AndroidManifest.xml
This commit is contained in:
@@ -69,18 +69,18 @@ body:
|
||||
description: |
|
||||
Trigger the defect, then click on the menu from the room list then "Report a bug". Describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers.
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
- 'Yes'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: pr
|
||||
attributes:
|
||||
label: Are you willing to provide a PR?
|
||||
label: Will you submit a Pull Request?
|
||||
description: |
|
||||
Providing a PR can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :).
|
||||
Submitting a Pull Request can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :).
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
- 'Yes'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
name: Enhancement request
|
||||
description: Do you have a suggestion or feature request?
|
||||
description: Do you have a suggestion or feature request? Please open issue on the repo element-hq/element-meta if the request is global to all Element clients.
|
||||
labels: [T-Enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please [start a discussion here](https://github.com/element-hq/element-meta/discussions/new?category=ideas).
|
||||
Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please open an issue on the [element-meta repository](https://github.com/element-hq/element-meta/issues/new?template=enhancement.yml) instead.
|
||||
- type: textarea
|
||||
id: usecase
|
||||
attributes:
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- To be able to install APK from the application -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".ElementXApplication"
|
||||
android:allowBackup="false"
|
||||
|
||||
+1
-1
Submodule enterprise updated: efdf9d9a15...bc38a11f95
@@ -14,6 +14,7 @@
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.contentscanner.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import extension.setupDependencyInjection
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.contentscanner.impl"
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.features.contentscanner.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
+49
-18
@@ -15,15 +15,23 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
@@ -32,6 +40,8 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.pinDigitBg
|
||||
import io.element.android.libraries.ui.strings.CommonPlurals
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun PinEntryTextField(
|
||||
@@ -39,16 +49,25 @@ fun PinEntryTextField(
|
||||
isSecured: Boolean,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialIsFocus: Boolean = false,
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(initialIsFocus) }
|
||||
val filledCount = pinEntry.digits.count { it is PinDigit.Filled }
|
||||
val pinFieldLabel = stringResource(CommonStrings.a11y_pin_field)
|
||||
val digitsEnteredLabel = pluralStringResource(CommonPlurals.a11y_digits_entered, filledCount, filledCount)
|
||||
BasicTextField(
|
||||
modifier = modifier,
|
||||
modifier = modifier
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = "$pinFieldLabel, $digitsEnteredLabel"
|
||||
},
|
||||
value = pinEntry.toText(),
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
decorationBox = {
|
||||
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured)
|
||||
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured, isFocused = isFocused)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -58,13 +77,19 @@ fun PinEntryTextField(
|
||||
private fun PinEntryRow(
|
||||
pinEntry: PinEntry,
|
||||
isSecured: Boolean,
|
||||
isFocused: Boolean,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (digit in pinEntry.digits) {
|
||||
PinDigitView(digit = digit, isSecured = isSecured)
|
||||
val focusedIndex = pinEntry.digits.indexOfFirst { it is PinDigit.Empty }
|
||||
pinEntry.digits.forEachIndexed { index, digit ->
|
||||
PinDigitView(
|
||||
digit = digit,
|
||||
isSecured = isSecured,
|
||||
isFocused = isFocused && index == focusedIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,11 +98,16 @@ private fun PinEntryRow(
|
||||
private fun PinDigitView(
|
||||
digit: PinDigit,
|
||||
isSecured: Boolean,
|
||||
isFocused: Boolean,
|
||||
) {
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
val appearanceModifier = when (digit) {
|
||||
PinDigit.Empty -> {
|
||||
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
|
||||
if (isFocused) {
|
||||
Modifier.border(2.dp, ElementTheme.colors.borderFocused, shape)
|
||||
} else {
|
||||
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
|
||||
}
|
||||
}
|
||||
is PinDigit.Filled -> {
|
||||
Modifier.background(ElementTheme.colors.pinDigitBg, shape)
|
||||
@@ -108,18 +138,19 @@ private fun PinDigitView(
|
||||
internal fun PinEntryTextFieldPreview() {
|
||||
ElementPreview {
|
||||
val pinEntry = PinEntry.createEmpty(4).fillWith("12")
|
||||
Column {
|
||||
PinEntryTextField(
|
||||
pinEntry = pinEntry,
|
||||
isSecured = true,
|
||||
onValueChange = {},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
PinEntryTextField(
|
||||
pinEntry = pinEntry,
|
||||
isSecured = false,
|
||||
onValueChange = {},
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
listOf(true, false).forEach { isSecured ->
|
||||
listOf(true, false).forEach { initialIsFocus ->
|
||||
PinEntryTextField(
|
||||
pinEntry = pinEntry,
|
||||
isSecured = isSecured,
|
||||
initialIsFocus = initialIsFocus,
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -288,8 +288,7 @@ class MessageComposerPresenter(
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
|
||||
// Reset composer since the attachment has been sent
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
is MessageComposerEvent.SetMode -> {
|
||||
localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
|
||||
@@ -638,8 +637,7 @@ class MessageComposerPresenter(
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
|
||||
|
||||
// Reset composer since the attachment will be sent in a separate flow
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
|
||||
private fun handlePickedMediaList(
|
||||
@@ -663,7 +661,15 @@ class MessageComposerPresenter(
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
|
||||
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
|
||||
private fun resetComposerModeAfterAttaching() {
|
||||
// An attachment is sent as its own message, so a Reply/Normal mode is consumed and reset.
|
||||
// An in-progress edit must survive: the typed text still edits the original on the next send.
|
||||
if (!messageComposerContext.composerMode.isEditing) {
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendMedia(
|
||||
|
||||
+245
-24
@@ -8,61 +8,206 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePadding
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePicker
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSize
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSpacing
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.aSkinList
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@Composable
|
||||
fun EmojiItem(
|
||||
item: Emoji,
|
||||
isSelected: Boolean,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
onLongPress: (Emoji) -> Unit,
|
||||
onDismissSkinPicker: () -> Unit,
|
||||
skinPickerEmoji: Emoji?,
|
||||
selectedSkinUnicodes: ImmutableSet<String>,
|
||||
hasSelectedSkin: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
emojiSize: TextUnit = 20.sp,
|
||||
) {
|
||||
val backgroundColor = if (isSelected) {
|
||||
ElementTheme.colors.bgActionPrimaryRest
|
||||
} else {
|
||||
Color.Transparent
|
||||
val backgroundColor = when {
|
||||
isSelected -> ElementTheme.colors.bgActionPrimaryRest
|
||||
hasSelectedSkin -> ElementTheme.colors.bgActionTertiarySelected
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
var itemSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var itemOffsetPercent by remember { mutableFloatStateOf(0f) }
|
||||
var hoveredIndex by remember { mutableIntStateOf(-1) }
|
||||
var dismissed by remember { mutableStateOf(false) }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val description = a11yReactionAction(
|
||||
emoji = item.unicode,
|
||||
userAlreadyReacted = isSelected,
|
||||
)
|
||||
val hasSkinTones = !item.skins.isNullOrEmpty()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onPlaced { coordinates ->
|
||||
val position = coordinates.positionInParent()
|
||||
val parentBounds = coordinates.parentLayoutCoordinates?.size ?: return@onPlaced
|
||||
// Calculate the offset of the item inside its parent as a percentage, to be able to set the initial offset
|
||||
// for the selection inside the skin tone picker relative to its size.
|
||||
itemOffsetPercent = if (parentBounds.width > 0) {
|
||||
position.x / parentBounds.width
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
|
||||
.onSizeChanged { itemSize = it }
|
||||
.background(backgroundColor, CircleShape)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = { onSelectEmoji(item) },
|
||||
indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
.indication(interactionSource, ripple())
|
||||
.pointerInput(item) {
|
||||
// Always detect long press and tap gestures
|
||||
detectTapGestures(
|
||||
onPress = { pressOffset ->
|
||||
val press = PressInteraction.Press(pressOffset)
|
||||
interactionSource.emit(press)
|
||||
if (tryAwaitRelease()) {
|
||||
interactionSource.emit(PressInteraction.Release(press))
|
||||
if (hasSkinTones) {
|
||||
onSelectEmoji(item)
|
||||
}
|
||||
} else {
|
||||
interactionSource.emit(PressInteraction.Cancel(press))
|
||||
}
|
||||
},
|
||||
onTap = { onSelectEmoji(item) },
|
||||
)
|
||||
}
|
||||
.then(
|
||||
// Only detect drag after long press for those items which have a skin tone picker
|
||||
if (hasSkinTones) {
|
||||
Modifier.pointerInput(item) {
|
||||
val slotWidthPx = with(density) { SkinToneSlotSize.toPx() }
|
||||
val spacingPx = with(density) { SkinToneSlotSpacing.toPx() }
|
||||
val paddingPx = with(density) { SkinTonePadding.toPx() }
|
||||
var startOffset = Offset.Zero
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { position ->
|
||||
startOffset = position
|
||||
dismissed = false
|
||||
onLongPress(item)
|
||||
hoveredIndex = -1
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
if (!dismissed) {
|
||||
change.consume()
|
||||
// It's a valid drag event if it's within ~2 items height above, so, on top of the current one or the skin tone selector,
|
||||
// or 1 item below, which should be far enough to not be triggered by mistake
|
||||
val isValidDrag = if (change.position.y < startOffset.y) {
|
||||
startOffset.y - change.position.y <= itemSize.height * 2f
|
||||
} else {
|
||||
change.position.y - startOffset.y <= itemSize.height
|
||||
}
|
||||
if (!isValidDrag) {
|
||||
dismissed = true
|
||||
hoveredIndex = -1
|
||||
onDismissSkinPicker()
|
||||
} else {
|
||||
val skinItemsCount = item.skins!!.size
|
||||
// Original + variants
|
||||
val totalSlots = 1 + skinItemsCount
|
||||
// Calculate the whole size of the skin tone picker
|
||||
val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx
|
||||
// Calculate the initial offset inside the picker, given the relative position of the item inside its parent
|
||||
val initialOffset = pickerWidthPx * itemOffsetPercent
|
||||
val xInPicker = initialOffset + change.position.x
|
||||
|
||||
// If it's a valid offset, calculate the hovered index, otherwise, set it to -1 to indicate that no item is hovered
|
||||
val index = if (xInPicker in 0f..pickerWidthPx) {
|
||||
(xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
hoveredIndex = index
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
if (!dismissed) {
|
||||
val idx = hoveredIndex
|
||||
val skinCount = item.skins?.size ?: 0
|
||||
if (idx in 0..skinCount) {
|
||||
if (idx == 0) {
|
||||
onSelectEmoji(item)
|
||||
} else {
|
||||
val skin = item.skins?.getOrNull(idx - 1)
|
||||
if (skin != null) {
|
||||
onSelectEmoji(item.copy(unicode = skin.unicode))
|
||||
}
|
||||
}
|
||||
} else if (idx < 0) {
|
||||
onSelectEmoji(item)
|
||||
}
|
||||
}
|
||||
dismissed = false
|
||||
hoveredIndex = -1
|
||||
onDismissSkinPicker()
|
||||
},
|
||||
onDragCancel = {
|
||||
dismissed = false
|
||||
hoveredIndex = -1
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = description
|
||||
@@ -73,6 +218,42 @@ fun EmojiItem(
|
||||
text = item.unicode,
|
||||
style = LocalTextStyle.current.copy(fontSize = emojiSize),
|
||||
)
|
||||
if (hasSkinTones) {
|
||||
val pickerIndicatorColor = ElementTheme.colors.borderInteractivePrimary
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(8.dp),
|
||||
) {
|
||||
val path = Path().apply {
|
||||
moveTo(size.width, size.height)
|
||||
lineTo(size.width, 0f)
|
||||
lineTo(0f, size.height)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color = pickerIndicatorColor, style = Fill)
|
||||
}
|
||||
}
|
||||
|
||||
if (skinPickerEmoji == item) {
|
||||
val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize
|
||||
val popupOffsetPx = with(density) { -pickerHeight.roundToPx() }
|
||||
Popup(
|
||||
onDismissRequest = { onDismissSkinPicker() },
|
||||
alignment = Alignment.BottomCenter,
|
||||
offset = IntOffset(0, popupOffsetPx),
|
||||
) {
|
||||
SkinTonePicker(
|
||||
emoji = item,
|
||||
onSelect = { selectedEmoji ->
|
||||
onSelectEmoji(selectedEmoji)
|
||||
onDismissSkinPicker()
|
||||
},
|
||||
hoveredIndex = hoveredIndex,
|
||||
selectedUnicodes = selectedSkinUnicodes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,21 +261,61 @@ fun EmojiItem(
|
||||
@Composable
|
||||
internal fun EmojiItemPreview() = ElementPreview {
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (isSelected in listOf(true, false)) {
|
||||
EmojiItem(
|
||||
item = Emoji(
|
||||
hexcode = "",
|
||||
label = "",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf(),
|
||||
unicode = "👍",
|
||||
skins = null
|
||||
),
|
||||
isSelected = isSelected,
|
||||
onSelectEmoji = {},
|
||||
)
|
||||
for (isSelected in listOf(false, true)) {
|
||||
for (skinList in listOf(persistentListOf(), aSkinList())) {
|
||||
val hasSelectedSkinValues = if (skinList.isNotEmpty()) listOf(false, true) else listOf(false)
|
||||
for (hasSelectedSkin in hasSelectedSkinValues) {
|
||||
EmojiItem(
|
||||
item = Emoji(
|
||||
hexcode = "",
|
||||
label = "",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf(),
|
||||
unicode = "👍",
|
||||
skins = skinList,
|
||||
),
|
||||
isSelected = isSelected,
|
||||
onSelectEmoji = {},
|
||||
hasSelectedSkin = hasSelectedSkin,
|
||||
onLongPress = {},
|
||||
onDismissSkinPicker = {},
|
||||
skinPickerEmoji = null,
|
||||
selectedSkinUnicodes = persistentSetOf(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmojiItemWithPopupPreview() = ElementPreview {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 100.dp, bottom = 4.dp, start = 200.dp, end = 200.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val item = Emoji(
|
||||
hexcode = "",
|
||||
label = "",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf(),
|
||||
unicode = "👍",
|
||||
skins = aSkinList(),
|
||||
)
|
||||
EmojiItem(
|
||||
item = item,
|
||||
isSelected = false,
|
||||
onSelectEmoji = {},
|
||||
hasSelectedSkin = false,
|
||||
onLongPress = {},
|
||||
onDismissSkinPicker = {},
|
||||
skinPickerEmoji = item,
|
||||
selectedSkinUnicodes = persistentSetOf("👍🏾"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -25,7 +25,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -54,6 +58,8 @@ fun EmojiPicker(
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(pageCount = { state.categories.size })
|
||||
var skinPickerEmoji by remember { mutableStateOf<Emoji?>(null) }
|
||||
|
||||
Column(modifier) {
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 10.dp),
|
||||
@@ -68,6 +74,10 @@ fun EmojiPicker(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = { skinPickerEmoji = it },
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||
selectedEmojis = selectedEmojis,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,6 +116,10 @@ fun EmojiPicker(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = { skinPickerEmoji = it },
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||
selectedEmojis = selectedEmojis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +131,10 @@ private fun EmojiResults(
|
||||
emojis: ImmutableList<Emoji>,
|
||||
isEmojiSelected: (Emoji) -> Boolean,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
onLongPress: (Emoji) -> Unit,
|
||||
skinPickerEmoji: Emoji?,
|
||||
onDismissSkinPicker: () -> Unit,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -131,7 +149,12 @@ private fun EmojiResults(
|
||||
item = item,
|
||||
isSelected = isEmojiSelected(item),
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = onLongPress,
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = onDismissSkinPicker,
|
||||
emojiSize = 32.dp.toSp(),
|
||||
selectedSkinUnicodes = selectedEmojis,
|
||||
hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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.features.messages.impl.timeline.components.customreaction.picker
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojiSkin
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
val SkinToneSlotSize = 48.dp
|
||||
val SkinToneSlotSpacing = 2.dp
|
||||
val SkinTonePadding = 4.dp
|
||||
|
||||
@Composable
|
||||
fun SkinTonePicker(
|
||||
emoji: Emoji,
|
||||
hoveredIndex: Int,
|
||||
selectedUnicodes: ImmutableSet<String>,
|
||||
onSelect: (Emoji) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val skins = emoji.skins.orEmpty()
|
||||
val emojiSize = 32.dp.toSp()
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = ElementTheme.colors.bgCanvasDefault,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(SkinTonePadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(SkinToneSlotSpacing),
|
||||
) {
|
||||
SkinToneSlot(
|
||||
unicode = emoji.unicode,
|
||||
emojiSize = emojiSize,
|
||||
isHovered = hoveredIndex == 0,
|
||||
isSelected = emoji.unicode in selectedUnicodes,
|
||||
onClick = { onSelect(emoji) },
|
||||
)
|
||||
skins.forEachIndexed { index, skin ->
|
||||
SkinToneSlot(
|
||||
unicode = skin.unicode,
|
||||
emojiSize = emojiSize,
|
||||
isHovered = hoveredIndex == index + 1,
|
||||
isSelected = skin.unicode in selectedUnicodes,
|
||||
onClick = { onSelect(emoji.copy(unicode = skin.unicode)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkinToneSlot(
|
||||
unicode: String,
|
||||
emojiSize: TextUnit,
|
||||
isHovered: Boolean,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val background = when {
|
||||
isSelected && isHovered -> ElementTheme.colors.bgActionPrimaryPressed
|
||||
isSelected -> ElementTheme.colors.bgActionPrimaryRest
|
||||
isHovered -> ElementTheme.colors.bgActionPrimaryHovered
|
||||
else -> Color.Transparent
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(SkinToneSlotSize)
|
||||
.clip(CircleShape)
|
||||
.background(background, CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = unicode,
|
||||
style = LocalTextStyle.current.copy(fontSize = emojiSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SkinTonePickerPreview() = ElementPreview {
|
||||
SkinTonePicker(
|
||||
emoji = Emoji(
|
||||
hexcode = "1F44D",
|
||||
label = "thumbs up",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf("+1", "thumbsup"),
|
||||
unicode = "👍",
|
||||
skins = aSkinList(),
|
||||
),
|
||||
hoveredIndex = -1,
|
||||
selectedUnicodes = persistentSetOf(),
|
||||
onSelect = {},
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aSkinList() = persistentListOf(
|
||||
EmojiSkin("1F44D-1F3FB", "thumbs up: light skin tone", "👍🏻"),
|
||||
EmojiSkin("1F44D-1F3FC", "thumbs up: medium-light skin tone", "👍🏼"),
|
||||
EmojiSkin("1F44D-1F3FD", "thumbs up: medium skin tone", "👍🏽"),
|
||||
EmojiSkin("1F44D-1F3FE", "thumbs up: medium-dark skin tone", "👍🏾"),
|
||||
EmojiSkin("1F44D-1F3FF", "thumbs up: dark skin tone", "👍🏿"),
|
||||
)
|
||||
+74
@@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -758,6 +759,79 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick media from gallery while editing keeps the edit pending`() = runTest {
|
||||
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment>, _: EventId? -> }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onPreviewAttachmentLambda = onPreviewAttachmentLambda
|
||||
)
|
||||
val presenter = createPresenter(navigator = navigator)
|
||||
pickerProvider.givenMimeType(MimeTypes.Images)
|
||||
presenter.test {
|
||||
var state = awaitFirstItem()
|
||||
val editMode = anEditMode()
|
||||
state.eventSink(MessageComposerEvent.SetMode(editMode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(editMode)
|
||||
// The media is sent as a new message while the edit stays active. If the mode reset,
|
||||
// a Normal-mode state would be emitted and expectNoEvents() below would fail.
|
||||
state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery)
|
||||
onPreviewAttachmentLambda.assertions().isCalledOnce()
|
||||
expectNoEvents()
|
||||
assertThat(state.mode).isEqualTo(editMode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick media from gallery while replying clears the reply mode`() = runTest {
|
||||
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment>, _: EventId? -> }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onPreviewAttachmentLambda = onPreviewAttachmentLambda
|
||||
)
|
||||
val presenter = createPresenter(navigator = navigator)
|
||||
pickerProvider.givenMimeType(MimeTypes.Images)
|
||||
presenter.test {
|
||||
var state = awaitFirstItem()
|
||||
state.eventSink(MessageComposerEvent.SetMode(aReplyMode()))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
// The media becomes the reply, so the reply intent is consumed and the composer resets.
|
||||
state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery)
|
||||
onPreviewAttachmentLambda.assertions().isCalledOnce()
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(MessageComposerMode.Normal)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick multiple media from gallery while editing keeps the edit pending`() = runTest {
|
||||
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment>, _: EventId? -> }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onPreviewAttachmentLambda = onPreviewAttachmentLambda
|
||||
)
|
||||
val presenter = createPresenter(
|
||||
navigator = navigator,
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.SendGalleryMessages.key to true)
|
||||
),
|
||||
)
|
||||
pickerProvider.givenMimeType(MimeTypes.Images)
|
||||
// Two Uris take the multi-item branch (handlePickedMediaList) instead of delegating to the
|
||||
// single-item path, so the edit is preserved for gallery messages too.
|
||||
pickerProvider.givenMultipleResults(listOf(mockk(), mockk()))
|
||||
presenter.test {
|
||||
var state = awaitFirstItem()
|
||||
val editMode = anEditMode()
|
||||
state.eventSink(MessageComposerEvent.SetMode(editMode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(editMode)
|
||||
state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery)
|
||||
onPreviewAttachmentLambda.assertions().isCalledOnce()
|
||||
expectNoEvents()
|
||||
assertThat(state.mode).isEqualTo(editMode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick media from gallery & cancel does nothing`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
|
||||
+5
-1
@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.enterprise.api.SessionEnterpriseService
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
|
||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -57,6 +58,7 @@ class PreferencesRootPresenter(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val sessionStore: SessionStore,
|
||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||
private val userStatusPresenter: Presenter<UserStatusState>,
|
||||
) : Presenter<PreferencesRootState> {
|
||||
@Composable
|
||||
override fun present(): PreferencesRootState {
|
||||
@@ -66,13 +68,14 @@ class PreferencesRootPresenter(
|
||||
// Force a refresh of the profile
|
||||
matrixClient.getUserProfile()
|
||||
}
|
||||
|
||||
val isMultiAccountEnabled by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
|
||||
}.collectAsState(initial = false)
|
||||
val showLinkNewDevice by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
|
||||
}.collectAsState(initial = false)
|
||||
val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false)
|
||||
val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null
|
||||
|
||||
val otherSessions by remember {
|
||||
sessionStore.sessionsFlow().map { list ->
|
||||
@@ -137,6 +140,7 @@ class PreferencesRootPresenter(
|
||||
|
||||
return PreferencesRootState(
|
||||
myUser = matrixUser.value,
|
||||
userStatusState = userStatusState,
|
||||
version = remember { versionFormatter.get() },
|
||||
deviceId = matrixClient.deviceId,
|
||||
isMultiAccountEnabled = isMultiAccountEnabled,
|
||||
|
||||
+2
@@ -9,6 +9,7 @@
|
||||
package io.element.android.features.preferences.impl.root
|
||||
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -16,6 +17,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class PreferencesRootState(
|
||||
val myUser: MatrixUser,
|
||||
val userStatusState: UserStatusState?,
|
||||
val version: String,
|
||||
val deviceId: DeviceId?,
|
||||
val isMultiAccountEnabled: Boolean,
|
||||
|
||||
+7
-1
@@ -11,6 +11,9 @@ package io.element.android.features.preferences.impl.root
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -64,8 +67,9 @@ open class PreferencesRootStateProvider : PreviewParameterProvider<PreferencesRo
|
||||
showDeveloperSettings = true,
|
||||
canDeactivateAccount = true,
|
||||
),
|
||||
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
|
||||
// Minimal state
|
||||
aPreferencesRootState(),
|
||||
aPreferencesRootState(userStatusState = null),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,6 +90,7 @@ fun aPreferencesRootState(
|
||||
nbOfBlockedUsers: Int = 0,
|
||||
showLabsItem: Boolean = false,
|
||||
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
||||
userStatusState: UserStatusState? = aUserStatusState(),
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
eventSink: (PreferencesRootEvent) -> Unit = {},
|
||||
) = PreferencesRootState(
|
||||
@@ -105,6 +110,7 @@ fun aPreferencesRootState(
|
||||
nbOfBlockedUsers = nbOfBlockedUsers,
|
||||
showLabsItem = showLabsItem,
|
||||
directLogoutState = directLogoutState,
|
||||
userStatusState = userStatusState,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
+27
-3
@@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.root
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -23,6 +24,8 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.user.UserPreferences
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusView
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
@@ -85,10 +88,10 @@ fun PreferencesRootView(
|
||||
state = state,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
)
|
||||
} else {
|
||||
HorizontalDivider()
|
||||
}
|
||||
// User status will be added here
|
||||
if (state.userStatusState != null) {
|
||||
UserStatusSection(state.userStatusState, showTopDivider = !state.isMultiAccountEnabled)
|
||||
}
|
||||
// 'Account' section
|
||||
ManageAccountSection(
|
||||
state = state,
|
||||
@@ -129,6 +132,27 @@ fun PreferencesRootView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.UserStatusSection(
|
||||
userStatusState: UserStatusState,
|
||||
showTopDivider: Boolean,
|
||||
) {
|
||||
if (showTopDivider) {
|
||||
HorizontalDivider(
|
||||
thickness = 8.dp,
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
)
|
||||
}
|
||||
UserStatusView(
|
||||
state = userStatusState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.MultiAccountSection(
|
||||
state: PreferencesRootState,
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.features.preferences.impl.userstatus
|
||||
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.Binds
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesTo(SessionScope::class)
|
||||
@BindingContainer
|
||||
interface UserStatusBindingContainer {
|
||||
@Binds
|
||||
fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter<UserStatusState>
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.features.preferences.impl.userstatus
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
|
||||
sealed interface UserStatusEvent {
|
||||
data object OpenPicker : UserStatusEvent
|
||||
data object DismissPicker : UserStatusEvent
|
||||
data class SetStatus(val status: UserStatus) : UserStatusEvent
|
||||
data object OpenCustomInput : UserStatusEvent
|
||||
data object CancelCustomInput : UserStatusEvent
|
||||
data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent
|
||||
data object ClearStatus : UserStatusEvent
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.features.preferences.impl.userstatus
|
||||
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class UserStatusPresenter(
|
||||
private val matrixClient: MatrixClient,
|
||||
) : Presenter<UserStatusState> {
|
||||
@Composable
|
||||
override fun present(): UserStatusState {
|
||||
val userProfile by matrixClient.userProfile.collectAsState()
|
||||
var pickerState by remember { mutableStateOf<UserStatusPickerState>(UserStatusPickerState.Hidden) }
|
||||
val customTextFieldState = rememberTextFieldState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleEvent(event: UserStatusEvent) {
|
||||
when (event) {
|
||||
UserStatusEvent.OpenPicker -> pickerState = UserStatusPickerState.ShowingPicker
|
||||
UserStatusEvent.DismissPicker -> pickerState = UserStatusPickerState.Hidden
|
||||
UserStatusEvent.OpenCustomInput -> {
|
||||
val raw = userProfile.rawStatus
|
||||
if (raw != null) {
|
||||
customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text)
|
||||
} else {
|
||||
customTextFieldState.clearText()
|
||||
}
|
||||
pickerState = UserStatusPickerState.CustomInput(
|
||||
emoji = raw?.emoji ?: "😀",
|
||||
textFieldState = customTextFieldState,
|
||||
)
|
||||
}
|
||||
is UserStatusEvent.SetStatus -> {
|
||||
pickerState = UserStatusPickerState.Hidden
|
||||
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
|
||||
}
|
||||
UserStatusEvent.ClearStatus -> {
|
||||
pickerState = UserStatusPickerState.Hidden
|
||||
coroutineScope.launch { matrixClient.clearUserStatus() }
|
||||
}
|
||||
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
|
||||
is UserStatusEvent.UpdateCustomEmoji -> {
|
||||
val current = pickerState as? UserStatusPickerState.CustomInput ?: return
|
||||
pickerState = current.copy(emoji = event.emoji)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return UserStatusState(
|
||||
displayedStatus = userProfile.displayedStatus,
|
||||
rawStatus = userProfile.rawStatus,
|
||||
pickerState = pickerState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.features.preferences.impl.userstatus
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
|
||||
data class UserStatusState(
|
||||
val displayedStatus: DisplayedStatus?,
|
||||
val rawStatus: UserStatus?,
|
||||
val pickerState: UserStatusPickerState,
|
||||
val eventSink: (UserStatusEvent) -> Unit,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
sealed interface UserStatusPickerState {
|
||||
data object Hidden : UserStatusPickerState
|
||||
data object ShowingPicker : UserStatusPickerState
|
||||
data class CustomInput(
|
||||
val emoji: String,
|
||||
val textFieldState: TextFieldState,
|
||||
) : UserStatusPickerState
|
||||
}
|
||||
|
||||
enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) {
|
||||
IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting),
|
||||
FOCUS_TIME("💡", R.string.common_user_status_focus_time),
|
||||
ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road),
|
||||
BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back),
|
||||
AWAY("🌴", R.string.common_user_status_away),
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.features.preferences.impl.userstatus
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
|
||||
internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusState> {
|
||||
override val values get() = sequenceOf(
|
||||
aUserStatusState(),
|
||||
aUserStatusState(displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away"))),
|
||||
aUserStatusState(displayedStatus = DisplayedStatus.InCall(callJoinedTs = 0L)),
|
||||
aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker),
|
||||
aUserStatusState(
|
||||
displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")),
|
||||
rawStatus = UserStatus("🌴", "Away"),
|
||||
pickerState = UserStatusPickerState.ShowingPicker,
|
||||
),
|
||||
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState())),
|
||||
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))),
|
||||
)
|
||||
}
|
||||
|
||||
fun aUserStatusState(
|
||||
displayedStatus: DisplayedStatus? = null,
|
||||
rawStatus: UserStatus? = null,
|
||||
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
|
||||
eventSink: (UserStatusEvent) -> Unit = {},
|
||||
) = UserStatusState(
|
||||
displayedStatus = displayedStatus,
|
||||
rawStatus = rawStatus,
|
||||
pickerState = pickerState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* 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.features.preferences.impl.userstatus
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.maxLength
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
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
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.hide
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun UserStatusView(
|
||||
state: UserStatusState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (val pickerState = state.pickerState) {
|
||||
UserStatusPickerState.Hidden, UserStatusPickerState.ShowingPicker -> {
|
||||
val displayedStatus = state.displayedStatus
|
||||
if (displayedStatus != null) {
|
||||
CurrentStatusRow(
|
||||
displayedStatus = displayedStatus,
|
||||
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
|
||||
onClear = { state.eventSink(UserStatusEvent.ClearStatus) },
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
EmptyStatusRow(
|
||||
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
if (pickerState == UserStatusPickerState.ShowingPicker) {
|
||||
UserStatusPickerBottomSheet(
|
||||
currentRawStatus = state.rawStatus,
|
||||
onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) },
|
||||
onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) },
|
||||
onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) },
|
||||
)
|
||||
}
|
||||
}
|
||||
is UserStatusPickerState.CustomInput -> {
|
||||
CustomStatusInputRow(
|
||||
emoji = pickerState.emoji,
|
||||
textFieldState = pickerState.textFieldState,
|
||||
rawStatus = state.rawStatus,
|
||||
onConfirm = {
|
||||
state.eventSink(
|
||||
UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString()))
|
||||
)
|
||||
},
|
||||
onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyStatusRow(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_preferences_user_status_placeholder),
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CurrentStatusRow(
|
||||
displayedStatus: DisplayedStatus,
|
||||
onClick: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (emoji, text) = when (displayedStatus) {
|
||||
is DisplayedStatus.UserSet -> displayedStatus.status.emoji to displayedStatus.status.text
|
||||
is DisplayedStatus.InCall -> "🎧" to stringResource(R.string.common_user_status_on_a_call)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) },
|
||||
leadingContent = ListItemContent.Custom { EmojiText(emoji) },
|
||||
trailingContent = ListItemContent.Custom({
|
||||
IconButton(
|
||||
onClick = onClear,
|
||||
// Remove the extra padding caused by IconButton
|
||||
modifier = Modifier.offset(x = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_clear),
|
||||
)
|
||||
}
|
||||
}),
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiText(emoji: String) {
|
||||
// Ensure the emoji doesn't scale with fontSize to match icons size
|
||||
val fontSize = with(LocalDensity.current) { 16.dp.toSp() }
|
||||
Text(
|
||||
modifier = Modifier.sizeIn(minWidth = 24.dp, minHeight = 24.dp),
|
||||
text = emoji,
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSize),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun UserStatusPickerBottomSheet(
|
||||
currentRawStatus: UserStatus?,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectPredefinedStatus: (UserStatus) -> Unit,
|
||||
onSelectCustomStatus: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.Hidden,
|
||||
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded)
|
||||
)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
scrollable = true,
|
||||
modifier = modifier,
|
||||
) {
|
||||
PredefinedUserStatus.entries.forEach { predefined ->
|
||||
val label = stringResource(predefined.labelRes)
|
||||
val predefinedUserStatus = UserStatus(emoji = predefined.emoji, text = label)
|
||||
val isSelected = currentRawStatus == predefinedUserStatus
|
||||
ListItem(
|
||||
headlineContent = { Text(text = label) },
|
||||
leadingContent = ListItemContent.Custom { EmojiText(predefined.emoji) },
|
||||
trailingContent = if (isSelected) {
|
||||
ListItemContent.Icon(IconSource.Vector(CompoundIcons.Check()), tintColor = ElementTheme.colors.iconAccentPrimary)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
sheetState.hide(coroutineScope) {
|
||||
onSelectPredefinedStatus(predefinedUserStatus)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(R.string.common_user_status_custom))
|
||||
},
|
||||
leadingContent = ListItemContent.Custom { EmojiText("✏️") },
|
||||
onClick = {
|
||||
sheetState.hide(coroutineScope) {
|
||||
onSelectCustomStatus()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CUSTOM_STATUS_TEXT_MAX_LENGTH = 30
|
||||
|
||||
@Composable
|
||||
private fun CustomStatusInputRow(
|
||||
emoji: String,
|
||||
textFieldState: TextFieldState,
|
||||
rawStatus: UserStatus?,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } }
|
||||
val hasChanges by remember(emoji, rawStatus) {
|
||||
derivedStateOf {
|
||||
val text = textFieldState.text.toString()
|
||||
text.isNotBlank() && UserStatus(emoji, text) != rawStatus
|
||||
}
|
||||
}
|
||||
val saveLabel = stringResource(CommonStrings.action_save)
|
||||
val cancelLabel = stringResource(CommonStrings.action_cancel)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
TextField(
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
state = textFieldState,
|
||||
placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint),
|
||||
inputTransformation = InputTransformation.maxLength(CUSTOM_STATUS_TEXT_MAX_LENGTH),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
onKeyboardAction = { if (hasChanges) onConfirm() },
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
trailingIcon = if (hasText) {
|
||||
{
|
||||
Box(modifier = Modifier.clickable { textFieldState.clearText() }) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_cancel),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingContent = ListItemContent.Custom({
|
||||
// Layout measures both buttons to determine the max natural width,
|
||||
// then places only the active one.
|
||||
Layout(
|
||||
content = {
|
||||
TextButton(onClick = onConfirm, text = saveLabel)
|
||||
TextButton(onClick = onCancel, text = cancelLabel)
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
val placeables = measurables.map { it.measure(constraints) }
|
||||
val maxWidth = placeables.maxOf { it.width }
|
||||
val maxHeight = placeables.maxOf { it.height }
|
||||
layout(maxWidth, maxHeight) {
|
||||
val active = if (hasChanges) placeables[0] else placeables[1]
|
||||
active.placeRelative(
|
||||
x = (maxWidth - active.width) / 2,
|
||||
y = (maxHeight - active.height) / 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
leadingContent = ListItemContent.Custom({
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary),
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { },
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
EmojiText(emoji)
|
||||
}
|
||||
}
|
||||
}),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun UserStatusViewPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview {
|
||||
UserStatusView(state = state)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
<resources>
|
||||
<string name="common_user_status_in_a_meeting">In a meeting</string>
|
||||
<string name="common_user_status_focus_time">Focus time</string>
|
||||
<string name="common_user_status_on_the_road">On the road</string>
|
||||
<string name="common_user_status_be_right_back">Be right back</string>
|
||||
<string name="common_user_status_away">Away</string>
|
||||
<string name="common_user_status_on_a_call">On a call</string>
|
||||
<string name="common_user_status_custom">Custom…</string>
|
||||
<string name="screen_preferences_user_status_placeholder">What\'s your status?</string>
|
||||
<string name="screen_preferences_user_status_custom_hint">Custom status…</string>
|
||||
</resources>
|
||||
+2
@@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.api.SessionEnterpriseService
|
||||
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
|
||||
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
|
||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
@@ -346,5 +347,6 @@ class PreferencesRootPresenterTest {
|
||||
featureFlagService = featureFlagService,
|
||||
sessionStore = sessionStore,
|
||||
sessionEnterpriseService = sessionEnterpriseService,
|
||||
userStatusPresenter = { aUserStatusState() },
|
||||
)
|
||||
}
|
||||
|
||||
+14
-6
@@ -12,8 +12,11 @@ package io.element.android.features.preferences.impl.root
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -199,7 +202,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onOpenAnalytics = callback,
|
||||
)
|
||||
clickOn(CommonStrings.common_analytics)
|
||||
val text = activity!!.getString(CommonStrings.common_analytics)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +230,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onOpenRageShake = callback,
|
||||
)
|
||||
clickOn(CommonStrings.common_report_a_problem)
|
||||
val text = activity!!.getString(CommonStrings.common_report_a_problem)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +286,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onOpenDeveloperSettings = callback,
|
||||
)
|
||||
clickOn(CommonStrings.common_developer_options)
|
||||
val text = activity!!.getString(CommonStrings.common_developer_options)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +395,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onSignOutClick = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_signout)
|
||||
val text = activity!!.getString(CommonStrings.action_signout)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +411,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onDeactivateClick = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_delete_account)
|
||||
val text = activity!!.getString(CommonStrings.action_delete_account)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +438,7 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
onNodeWithText(version).performClick()
|
||||
onNodeWithText(version).performScrollTo().performClick()
|
||||
eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick)
|
||||
}
|
||||
}
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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.features.preferences.impl.userstatus
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class UserStatusPresenterTest {
|
||||
private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) =
|
||||
UserStatusPresenter(matrixClient = client)
|
||||
|
||||
@Test
|
||||
fun `initial state - no status set`() = runTest {
|
||||
createPresenter().test {
|
||||
val state = awaitItem()
|
||||
assertThat(state.displayedStatus).isNull()
|
||||
assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Open event transitions to ShowingPicker`() = runTest {
|
||||
createPresenter().test {
|
||||
val state = awaitItem()
|
||||
state.eventSink(UserStatusEvent.OpenPicker)
|
||||
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest {
|
||||
createPresenter().test {
|
||||
val state = awaitItem()
|
||||
state.eventSink(UserStatusEvent.OpenPicker)
|
||||
awaitItem() // ShowingPicker
|
||||
state.eventSink(UserStatusEvent.DismissPicker)
|
||||
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest {
|
||||
val client = FakeMatrixClient()
|
||||
createPresenter(client).test {
|
||||
val status = UserStatus(emoji = "💬", text = "In a meeting")
|
||||
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||
val newState = awaitItem()
|
||||
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
|
||||
assertThat(client.setUserStatusCalled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OpenCustomInput with no existing status uses defaults`() = runTest {
|
||||
createPresenter().test {
|
||||
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
|
||||
val state = awaitItem()
|
||||
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
|
||||
assertThat(pickerState.emoji).isEqualTo("😀")
|
||||
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest {
|
||||
val client = FakeMatrixClient()
|
||||
createPresenter(client).test {
|
||||
val status = UserStatus(emoji = "🌴", text = "Away")
|
||||
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
|
||||
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
|
||||
val state = awaitItem()
|
||||
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
|
||||
assertThat(pickerState.emoji).isEqualTo("🌴")
|
||||
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("Away")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest {
|
||||
createPresenter().test {
|
||||
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
|
||||
awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("🚀"))
|
||||
val state = awaitItem()
|
||||
assertThat((state.pickerState as UserStatusPickerState.CustomInput).emoji).isEqualTo("🚀")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelCustomInput goes back to Hidden`() = runTest {
|
||||
createPresenter().test {
|
||||
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
|
||||
awaitItem().eventSink(UserStatusEvent.CancelCustomInput) // CustomInput state, fire Cancel
|
||||
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Clear event calls clearUserStatus and clears displayed status`() = runTest {
|
||||
val client = FakeMatrixClient()
|
||||
createPresenter(client).test {
|
||||
val status = UserStatus(emoji = "☕", text = "Be right back")
|
||||
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
|
||||
awaitItem().eventSink(UserStatusEvent.ClearStatus)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
assertThat(clearedState.displayedStatus).isNull()
|
||||
assertThat(client.clearUserStatusCalled).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ haze = "1.7.2"
|
||||
dependencyAnalysis = "3.16.0"
|
||||
|
||||
# DI
|
||||
metro = "1.2.1"
|
||||
metro = "1.3.0"
|
||||
|
||||
# Auto service
|
||||
autoservice = "1.1.1"
|
||||
@@ -192,6 +192,7 @@ coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
|
||||
coil_network_okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
||||
coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
|
||||
coil_svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
|
||||
coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" }
|
||||
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
||||
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
|
||||
|
||||
+54
@@ -24,6 +24,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.KeyboardActionHandler
|
||||
import androidx.compose.foundation.text.input.TextFieldDecorator
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -161,6 +166,55 @@ fun TextField(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextField(
|
||||
state: TextFieldState,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String? = null,
|
||||
supportingText: String? = null,
|
||||
placeholder: String? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
validity: TextFieldValidity = TextFieldValidity.None,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
inputTransformation: InputTransformation? = null,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
onKeyboardAction: KeyboardActionHandler? = null,
|
||||
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
BasicTextField(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
textStyle = textFieldStyle(enabled),
|
||||
interactionSource = interactionSource,
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
inputTransformation = inputTransformation,
|
||||
cursorBrush = SolidColor(ElementTheme.colors.textPrimary),
|
||||
keyboardOptions = keyboardOptions,
|
||||
onKeyboardAction = onKeyboardAction,
|
||||
lineLimits = lineLimits,
|
||||
decorator = TextFieldDecorator { innerTextField ->
|
||||
DecorationBox(
|
||||
label = label,
|
||||
readOnly = readOnly,
|
||||
enabled = enabled,
|
||||
isFocused = isFocused,
|
||||
validity = validity,
|
||||
leadingIcon = leadingIcon,
|
||||
placeholder = placeholder,
|
||||
isTextEmpty = state.text.isEmpty(),
|
||||
innerTextField = innerTextField,
|
||||
trailingIcon = trailingIcon,
|
||||
supportingText = supportingText,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DecorationBox(
|
||||
label: String?,
|
||||
|
||||
+7
@@ -152,4 +152,11 @@ enum class FeatureFlags(
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
UserStatus(
|
||||
key = "feature.user_status",
|
||||
title = "User status",
|
||||
description = "Allow users to set a status (e.g. In a meeting, Away) visible to their contacts.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
}
|
||||
|
||||
+7
@@ -38,12 +38,14 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
|
||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
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.user.UserStatus
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -74,6 +76,7 @@ interface MatrixClient {
|
||||
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
|
||||
val roomMembershipObserver: RoomMembershipObserver
|
||||
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
|
||||
val contentScanner: ContentScanner?
|
||||
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
|
||||
suspend fun getRoom(roomId: RoomId): BaseRoom?
|
||||
suspend fun findDM(userId: UserId): Result<RoomId?>
|
||||
@@ -87,6 +90,10 @@ interface MatrixClient {
|
||||
suspend fun setDisplayName(displayName: String): Result<Unit>
|
||||
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit>
|
||||
suspend fun removeAvatar(): Result<Unit>
|
||||
suspend fun setUserStatus(status: UserStatus): Result<Unit>
|
||||
|
||||
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
|
||||
suspend fun clearUserStatus(): Result<Unit>
|
||||
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
|
||||
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
|
||||
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
|
||||
|
||||
+3
@@ -22,3 +22,6 @@ sealed class ClientException(message: String, val details: String?, cause: Throw
|
||||
fun ClientException.isNetworkError(): Boolean {
|
||||
return this is ClientException.Generic && message?.contains("error sending request for url", ignoreCase = true) == true
|
||||
}
|
||||
|
||||
fun Throwable.isNetworkError(): Boolean =
|
||||
(this as? ClientException)?.isNetworkError() == true
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.scanner
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
|
||||
/**
|
||||
* Component used to manually scan media content for potential security risks.
|
||||
*
|
||||
* While the Matrix SDK automatically scans media content we load if a content scanner instance is provided, this interface allows for scanning
|
||||
* some media sources in advance without actually loading their contents, which can be useful for pre-fetching scenarios.
|
||||
*/
|
||||
interface ContentScanner {
|
||||
/**
|
||||
* Manually scans the given [mediaSource] for potential security risks.
|
||||
*
|
||||
* @param mediaSource The media source to scan.
|
||||
* @return A [Result] containing a [Boolean] indicating whether the content is safe (true) or potentially unsafe (false).
|
||||
* If the scan fails, the [Result] will contain an exception.
|
||||
*/
|
||||
suspend fun scan(mediaSource: MediaSource): Result<Boolean>
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.scanner
|
||||
|
||||
/**
|
||||
* Provides the URL of the content scanner service for a given homeserver, if any is set up.
|
||||
*/
|
||||
fun interface ContentScannerUrlProvider {
|
||||
/**
|
||||
* Returns the URL of the content scanner service for the given [homeserver], or `null` if no content scanner is set up.
|
||||
*/
|
||||
suspend fun getContentScannerUrl(homeserver: String): Result<String?>
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.user
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Immutable
|
||||
sealed interface DisplayedStatus : Parcelable {
|
||||
/** Status set manually by the user via m.status. */
|
||||
@Parcelize
|
||||
data class UserSet(val status: UserStatus) : DisplayedStatus
|
||||
|
||||
/** Status set automatically when the user is in a call via m.call. */
|
||||
@Parcelize
|
||||
data class InCall(
|
||||
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
|
||||
val callJoinedTs: Long,
|
||||
) : DisplayedStatus
|
||||
}
|
||||
+2
@@ -17,4 +17,6 @@ data class MatrixUser(
|
||||
val userId: UserId,
|
||||
val displayName: String? = null,
|
||||
val avatarUrl: String? = null,
|
||||
val displayedStatus: DisplayedStatus? = null,
|
||||
val rawStatus: UserStatus? = null,
|
||||
) : Parcelable
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.user
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class UserStatus(
|
||||
val emoji: String,
|
||||
val text: String,
|
||||
) : Parcelable
|
||||
+35
-1
@@ -45,11 +45,14 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
|
||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
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.user.UserStatus
|
||||
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
|
||||
import io.element.android.libraries.matrix.impl.exception.mapClientException
|
||||
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
|
||||
@@ -136,6 +139,7 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam
|
||||
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
|
||||
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class RustMatrixClient(
|
||||
override val sessionPaths: SessionPaths,
|
||||
private val innerClient: Client,
|
||||
@@ -150,6 +154,7 @@ class RustMatrixClient(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
override val contentScanner: ContentScanner?,
|
||||
) : MatrixClient {
|
||||
override val sessionId: UserId = UserId(innerClient.userId())
|
||||
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
|
||||
@@ -259,6 +264,8 @@ class RustMatrixClient(
|
||||
|
||||
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
||||
|
||||
@Volatile private var localUserStatus: UserStatus? = null
|
||||
|
||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
||||
MatrixUser(
|
||||
userId = sessionId,
|
||||
@@ -444,7 +451,12 @@ class RustMatrixClient(
|
||||
|
||||
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
|
||||
.onSuccess { matrixUser ->
|
||||
_userProfile.emit(matrixUser)
|
||||
_userProfile.emit(
|
||||
matrixUser.copy(
|
||||
rawStatus = localUserStatus,
|
||||
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
|
||||
)
|
||||
)
|
||||
// Also update our session storage
|
||||
sessionStore.updateUserProfile(
|
||||
sessionId = sessionId.value,
|
||||
@@ -475,6 +487,28 @@ class RustMatrixClient(
|
||||
runCatchingExceptions { innerClient.removeAvatar() }
|
||||
}
|
||||
|
||||
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) {
|
||||
localUserStatus = status
|
||||
_userProfile.emit(
|
||||
_userProfile.value.copy(
|
||||
rawStatus = status,
|
||||
displayedStatus = DisplayedStatus.UserSet(status),
|
||||
)
|
||||
)
|
||||
Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
|
||||
localUserStatus = null
|
||||
_userProfile.emit(
|
||||
_userProfile.value.copy(
|
||||
rawStatus = null,
|
||||
displayedStatus = null,
|
||||
)
|
||||
)
|
||||
Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerClient.joinRoomById(roomId.value).destroy()
|
||||
|
||||
+23
@@ -17,11 +17,14 @@ import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.paths.SessionPaths
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
|
||||
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
|
||||
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
|
||||
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
|
||||
import io.element.android.libraries.matrix.impl.scanner.RustContentScanner
|
||||
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
|
||||
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
|
||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||
@@ -34,6 +37,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||
import org.matrix.rustcomponents.sdk.ContentScanner
|
||||
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
|
||||
import org.matrix.rustcomponents.sdk.RequestConfig
|
||||
import org.matrix.rustcomponents.sdk.Session
|
||||
@@ -66,6 +70,7 @@ class RustMatrixClientFactory(
|
||||
private val clientBuilderProvider: ClientBuilderProvider,
|
||||
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val contentScannerUrlProvider: ContentScannerUrlProvider,
|
||||
) {
|
||||
private val sessionDelegate = RustClientSessionDelegate(
|
||||
sessionStore = sessionStore,
|
||||
@@ -113,6 +118,23 @@ class RustMatrixClientFactory(
|
||||
|
||||
client.setUtdDelegate(UtdTracker(analyticsService))
|
||||
|
||||
val domainName = UserId(client.userId()).domainName
|
||||
// If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client.
|
||||
// This allows the SDK to use the content scanner for automatic media scanning.
|
||||
// If no content scanner URL is available, the contentScanner will be null.
|
||||
val contentScanner = domainName?.let {
|
||||
contentScannerUrlProvider.getContentScannerUrl(domainName)
|
||||
.getOrNull()
|
||||
?.let { contentScannerUrl ->
|
||||
val contentScanner = ContentScanner(contentScannerUrl)
|
||||
client.setContentScanner(contentScanner)
|
||||
RustContentScanner(
|
||||
client = client,
|
||||
rustScanner = contentScanner,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val syncService = client.syncService()
|
||||
.withSharePos(true)
|
||||
.withOfflineMode()
|
||||
@@ -132,6 +154,7 @@ class RustMatrixClientFactory(
|
||||
featureFlagService = featureFlagService,
|
||||
analyticsService = analyticsService,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
contentScanner = contentScanner,
|
||||
).also {
|
||||
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
|
||||
}
|
||||
|
||||
+6
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
@@ -96,4 +97,9 @@ object SessionMatrixModule {
|
||||
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
|
||||
return matrixClient.homeserverCapabilities()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun providesContentScanner(matrixClient: MatrixClient): ContentScanner? {
|
||||
return matrixClient.contentScanner
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -9,11 +9,13 @@
|
||||
package io.element.android.libraries.matrix.impl.media
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.impl.exception.mapClientException
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
@@ -37,7 +39,7 @@ class RustMediaLoader(
|
||||
source.toRustMediaSource().use { source ->
|
||||
innerClient.getMediaContent(source)
|
||||
}
|
||||
}
|
||||
}.mapFailure { it.mapClientException() }
|
||||
}
|
||||
|
||||
override suspend fun loadMediaThumbnail(
|
||||
@@ -54,7 +56,7 @@ class RustMediaLoader(
|
||||
height = height.toULong()
|
||||
)
|
||||
}
|
||||
}
|
||||
}.mapFailure { it.mapClientException() }
|
||||
}
|
||||
|
||||
override suspend fun downloadMediaFile(
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.impl.scanner
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
|
||||
/**
|
||||
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
|
||||
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.impl.scanner
|
||||
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
import org.matrix.rustcomponents.sdk.ContentScanner as RustScanner
|
||||
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
|
||||
|
||||
class RustContentScanner(
|
||||
private val client: Client,
|
||||
private val rustScanner: RustScanner,
|
||||
) : ContentScanner {
|
||||
override suspend fun scan(mediaSource: MediaSource): Result<Boolean> {
|
||||
return runCatchingExceptions {
|
||||
rustScanner.scan(client, mediaSource.toRustMediaSource()).clean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaSource.toRustMediaSource(): RustMediaSource {
|
||||
val json = this.json
|
||||
return try {
|
||||
if (json != null) {
|
||||
RustMediaSource.fromJson(json)
|
||||
} else {
|
||||
RustMediaSource.fromUrl(safeUrl)
|
||||
}
|
||||
} catch (e: LinkageError) {
|
||||
// Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded
|
||||
val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess
|
||||
if (isTest) RustMediaSource(NoHandle) else throw e
|
||||
}
|
||||
}
|
||||
+3
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
|
||||
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
|
||||
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
|
||||
@@ -51,6 +52,7 @@ fun TestScope.createRustMatrixClientFactory(
|
||||
),
|
||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||
contentScannerUrlProvider: ContentScannerUrlProvider = { Result.success(null) },
|
||||
) = RustMatrixClientFactory(
|
||||
cacheDirectory = cacheDirectory,
|
||||
appCoroutineScope = backgroundScope,
|
||||
@@ -65,4 +67,5 @@ fun TestScope.createRustMatrixClientFactory(
|
||||
clientBuilderProvider = clientBuilderProvider,
|
||||
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
contentScannerUrlProvider = contentScannerUrlProvider,
|
||||
)
|
||||
|
||||
+2
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
@@ -156,5 +157,6 @@ class RustMatrixClientTest {
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
|
||||
contentScanner = FakeContentScanner(),
|
||||
)
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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.impl.scanner
|
||||
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ContentScanner
|
||||
import org.matrix.rustcomponents.sdk.MediaSource
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
import uniffi.matrix_sdk_contentscanner.MediaScanResponse
|
||||
|
||||
class FakeFfiContentScanner(
|
||||
private val scan: suspend (client: Client, mediaSource: MediaSource) -> MediaScanResponse = { _, _ ->
|
||||
MediaScanResponse(clean = true, info = "Just peachy")
|
||||
},
|
||||
) : ContentScanner(NoHandle) {
|
||||
override suspend fun scan(client: Client, mediaSource: MediaSource): MediaScanResponse {
|
||||
return scan.invoke(client, mediaSource)
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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.impl.scanner
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
|
||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import uniffi.matrix_sdk_contentscanner.MediaScanResponse
|
||||
|
||||
class RustContentScannerTest {
|
||||
@Test
|
||||
fun `test successful valid scan`() = runTest {
|
||||
val scanner = RustContentScanner(
|
||||
client = FakeFfiClient(),
|
||||
rustScanner = FakeFfiContentScanner(),
|
||||
)
|
||||
|
||||
scanner.scan(aMediaSource()).run {
|
||||
assertThat(isSuccess).isTrue()
|
||||
assertThat(getOrNull()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test successful invalid scan`() = runTest {
|
||||
val scanner = RustContentScanner(
|
||||
client = FakeFfiClient(),
|
||||
rustScanner = FakeFfiContentScanner(scan = { _, _ -> MediaScanResponse(clean = false, info = "Not clean") })
|
||||
)
|
||||
|
||||
scanner.scan(aMediaSource()).run {
|
||||
assertThat(isSuccess).isTrue()
|
||||
assertThat(getOrNull()).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test failed scan`() = runTest {
|
||||
val scanner = RustContentScanner(
|
||||
client = FakeFfiClient(),
|
||||
rustScanner = FakeFfiContentScanner(scan = { _, _ -> throw IllegalStateException("BOOM") })
|
||||
)
|
||||
|
||||
scanner.scan(aMediaSource()).run {
|
||||
assertThat(isFailure).isTrue()
|
||||
assertThat(getOrNull()).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -38,11 +38,14 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
|
||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
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.user.UserStatus
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||
@@ -123,6 +126,7 @@ class FakeMatrixClient(
|
||||
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
|
||||
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
|
||||
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
|
||||
override val contentScanner: ContentScanner? = null
|
||||
) : MatrixClient {
|
||||
var setDisplayNameCalled: Boolean = false
|
||||
private set
|
||||
@@ -130,6 +134,12 @@ class FakeMatrixClient(
|
||||
private set
|
||||
var removeAvatarCalled: Boolean = false
|
||||
private set
|
||||
var setUserStatusCalled: Boolean = false
|
||||
private set
|
||||
var clearUserStatusCalled: Boolean = false
|
||||
private set
|
||||
var setUserStatusResult: Result<Unit> = Result.success(Unit)
|
||||
var clearUserStatusResult: Result<Unit> = Result.success(Unit)
|
||||
|
||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
|
||||
override val userProfile: StateFlow<MatrixUser> = _userProfile
|
||||
@@ -252,6 +262,28 @@ class FakeMatrixClient(
|
||||
return removeAvatarResult
|
||||
}
|
||||
|
||||
override suspend fun setUserStatus(status: UserStatus): Result<Unit> {
|
||||
setUserStatusCalled = true
|
||||
if (setUserStatusResult.isSuccess) {
|
||||
_userProfile.emit(_userProfile.value.copy(
|
||||
rawStatus = status,
|
||||
displayedStatus = DisplayedStatus.UserSet(status),
|
||||
))
|
||||
}
|
||||
return setUserStatusResult
|
||||
}
|
||||
|
||||
override suspend fun clearUserStatus(): Result<Unit> {
|
||||
clearUserStatusCalled = true
|
||||
if (clearUserStatusResult.isSuccess) {
|
||||
_userProfile.emit(_userProfile.value.copy(
|
||||
rawStatus = null,
|
||||
displayedStatus = null,
|
||||
))
|
||||
}
|
||||
return clearUserStatusResult
|
||||
}
|
||||
|
||||
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
|
||||
|
||||
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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.test.scanner
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
|
||||
class FakeContentScanner(
|
||||
private val scan: suspend (mediaSource: MediaSource) -> Result<Boolean> = { Result.success(true) },
|
||||
) : ContentScanner {
|
||||
override suspend fun scan(mediaSource: MediaSource): Result<Boolean> {
|
||||
return scan.invoke(mediaSource)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.gif)
|
||||
implementation(libs.coil.svg)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
|
||||
+15
-3
@@ -13,6 +13,7 @@ import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import io.element.android.libraries.matrix.api.exception.isNetworkError
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.media.toFile
|
||||
@@ -34,7 +35,18 @@ internal class CoilMediaFetcher(
|
||||
}
|
||||
return when (val kind = mediaData.kind) {
|
||||
is MediaRequestData.Kind.Content -> fetchContent(mediaSource)
|
||||
is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaSource, kind)
|
||||
is MediaRequestData.Kind.Thumbnail -> {
|
||||
fetchThumbnail(mediaSource, kind).fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { error ->
|
||||
if (error.isNetworkError()) {
|
||||
null
|
||||
} else {
|
||||
fetchContent(mediaSource)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +86,7 @@ internal class CoilMediaFetcher(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): FetchResult? {
|
||||
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result<FetchResult> {
|
||||
return mediaLoader.loadMediaThumbnail(
|
||||
source = mediaSource,
|
||||
width = kind.width,
|
||||
@@ -83,7 +95,7 @@ internal class CoilMediaFetcher(
|
||||
byteArray.asSourceResult()
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.asSourceResult(): SourceFetchResult {
|
||||
|
||||
+3
@@ -14,6 +14,7 @@ import coil3.ImageLoader
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.svg.SvgDecoder
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
@@ -49,6 +50,8 @@ class DefaultImageLoaderFactory(
|
||||
return ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(okHttpNetworkFetcherFactory)
|
||||
// Add svg support
|
||||
add(SvgDecoder.Factory())
|
||||
// Add gif support
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(AnimatedImageDecoder.Factory())
|
||||
|
||||
+7
-2
@@ -19,6 +19,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
class FakePickerProvider : PickerProvider {
|
||||
private var mimeType = MimeTypes.Any
|
||||
private var result: Uri? = null
|
||||
private var multipleResults: List<Uri>? = null
|
||||
|
||||
@Composable
|
||||
override fun registerGalleryPicker(onResult: (uri: Uri?, mimeType: String?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
|
||||
@@ -32,7 +33,7 @@ class FakePickerProvider : PickerProvider {
|
||||
|
||||
@Composable
|
||||
override fun registerGalleryMultiPicker(onResult: (uris: List<Uri>) -> Unit): PickerLauncher<PickVisualMediaRequest, List<Uri>> {
|
||||
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
|
||||
return NoOpPickerLauncher { onResult(multipleResults ?: result?.let { listOf(it) } ?: emptyList()) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -42,7 +43,7 @@ class FakePickerProvider : PickerProvider {
|
||||
|
||||
@Composable
|
||||
override fun registerFileMultiPicker(mimeType: String, onResult: (uris: List<Uri>) -> Unit): PickerLauncher<Array<String>, List<Uri>> {
|
||||
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
|
||||
return NoOpPickerLauncher { onResult(multipleResults ?: result?.let { listOf(it) } ?: emptyList()) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -59,6 +60,10 @@ class FakePickerProvider : PickerProvider {
|
||||
this.result = value
|
||||
}
|
||||
|
||||
fun givenMultipleResults(uris: List<Uri>) {
|
||||
this.multipleResults = uris
|
||||
}
|
||||
|
||||
fun givenMimeType(mimeType: String) {
|
||||
this.mimeType = mimeType
|
||||
}
|
||||
|
||||
+25
-5
@@ -67,7 +67,7 @@ class AndroidMediaPreProcessor(
|
||||
*/
|
||||
private const val IMAGE_SCALE_REF_SIZE = 640
|
||||
|
||||
private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg)
|
||||
private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP)
|
||||
}
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
@@ -83,10 +83,7 @@ class AndroidMediaPreProcessor(
|
||||
): Result<MediaUploadInfo> = withContext(coroutineDispatchers.computation) {
|
||||
runCatchingExceptions {
|
||||
val result = when {
|
||||
// Special case for SVG, since Android can't read its metadata or create a thumbnail, it must be sent as a file
|
||||
mimeType == MimeTypes.Svg -> {
|
||||
processFile(uri, mimeType)
|
||||
}
|
||||
mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType)
|
||||
mimeType.isMimeTypeImage() -> {
|
||||
val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes
|
||||
processImage(uri, mimeType, shouldBeCompressed)
|
||||
@@ -160,6 +157,7 @@ class AndroidMediaPreProcessor(
|
||||
|
||||
private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
|
||||
Timber.d("Processing image ${uri.path.orEmpty().hash()}")
|
||||
|
||||
suspend fun processImageWithCompression(): MediaUploadInfo {
|
||||
// Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail.
|
||||
val orientation = contentResolver.openInputStream(uri).use { input ->
|
||||
@@ -220,6 +218,28 @@ class AndroidMediaPreProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private val svgDimensionExtractor = SvgDimensionExtractor()
|
||||
|
||||
private suspend fun processSvgImage(uri: Uri, mimeType: String): MediaUploadInfo {
|
||||
Timber.d("Processing SVG image ${uri.path.orEmpty().hash()}")
|
||||
val file = copyToTmpFile(uri)
|
||||
val size = svgDimensionExtractor.extractDimensions(file)
|
||||
val imageInfo = ImageInfo(
|
||||
width = size.width.toLong(),
|
||||
height = size.height.toLong(),
|
||||
mimetype = mimeType,
|
||||
size = file.length(),
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = null,
|
||||
blurhash = null,
|
||||
)
|
||||
return MediaUploadInfo.Image(
|
||||
file = file,
|
||||
imageInfo = imageInfo,
|
||||
thumbnailFile = null,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo {
|
||||
Timber.d("Processing video ${uri.path.orEmpty().hash()}")
|
||||
val resultFile = runCatchingExceptions {
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.mediaupload.impl
|
||||
|
||||
import android.util.Xml
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.File
|
||||
|
||||
class SvgDimensionExtractor(
|
||||
private val defaultWidth: Long = 640L,
|
||||
private val defaultHeight: Long = 480L,
|
||||
) {
|
||||
fun extractDimensions(file: File): android.util.Size {
|
||||
return file.inputStream().use { inputStream ->
|
||||
try {
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setInput(inputStream, null)
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.name.equals("svg", ignoreCase = true)) {
|
||||
val width = parser.getAttributeValue(null, "width")
|
||||
val height = parser.getAttributeValue(null, "height")
|
||||
val viewBox = parser.getAttributeValue(null, "viewBox")
|
||||
|
||||
val parsedWidth = width?.let { parseLength(it) }
|
||||
val parsedHeight = height?.let { parseLength(it) }
|
||||
|
||||
if (parsedWidth != null && parsedHeight != null) {
|
||||
return android.util.Size(parsedWidth.toInt(), parsedHeight.toInt())
|
||||
}
|
||||
|
||||
if (viewBox != null) {
|
||||
val parts = viewBox.trim().split("\\s+".toRegex()).map { it.toFloatOrNull() }
|
||||
if (parts.size == 4 && parts[2] != null && parts[3] != null) {
|
||||
val vbWidth = parts[2]!!.toLong().coerceAtLeast(1)
|
||||
val vbHeight = parts[3]!!.toLong().coerceAtLeast(1)
|
||||
return android.util.Size(vbWidth.toInt(), vbHeight.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
return android.util.Size((parsedWidth ?: defaultWidth).toInt(), (parsedHeight ?: defaultHeight).toInt())
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
android.util.Size(defaultWidth.toInt(), defaultHeight.toInt())
|
||||
} catch (_: Exception) {
|
||||
android.util.Size(defaultWidth.toInt(), defaultHeight.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLength(value: String): Long? {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return try {
|
||||
val numericPart = trimmed.replace(Regex("[^\\d.]"), "")
|
||||
if (numericPart.isEmpty()) null else numericPart.toFloat().toLong().coerceAtLeast(1)
|
||||
} catch (_: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+48
@@ -399,6 +399,54 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test processing svg`() = runTest {
|
||||
val mediaUploadInfo = process(
|
||||
asset = assetImageSvg,
|
||||
mediaOptimizationConfig = MediaOptimizationConfig(
|
||||
compressImages = true,
|
||||
videoCompressionPreset = VideoCompressionPreset.STANDARD,
|
||||
),
|
||||
)
|
||||
val info = mediaUploadInfo as MediaUploadInfo.Image
|
||||
assertThat(info.imageInfo).isEqualTo(
|
||||
ImageInfo(
|
||||
width = 800,
|
||||
height = 600,
|
||||
mimetype = MimeTypes.Svg,
|
||||
size = 210,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = null,
|
||||
blurhash = null,
|
||||
)
|
||||
)
|
||||
assertThat(info.thumbnailFile).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test processing svg without compression`() = runTest {
|
||||
val mediaUploadInfo = process(
|
||||
asset = assetImageSvg,
|
||||
mediaOptimizationConfig = MediaOptimizationConfig(
|
||||
compressImages = false,
|
||||
videoCompressionPreset = VideoCompressionPreset.STANDARD,
|
||||
),
|
||||
)
|
||||
val info = mediaUploadInfo as MediaUploadInfo.Image
|
||||
assertThat(info.imageInfo).isEqualTo(
|
||||
ImageInfo(
|
||||
width = 800,
|
||||
height = 600,
|
||||
mimetype = MimeTypes.Svg,
|
||||
size = 210,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = null,
|
||||
blurhash = null,
|
||||
)
|
||||
)
|
||||
assertThat(info.thumbnailFile).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test processing audio`() = runTest {
|
||||
val mediaUploadInfo = process(
|
||||
|
||||
+11
@@ -83,3 +83,14 @@ val assetAnimatedGif = Asset(
|
||||
width = 800,
|
||||
height = 600,
|
||||
)
|
||||
|
||||
/**
|
||||
* "image.svg" is an 800 x 600 SVG image with a size of 210 bytes.
|
||||
*/
|
||||
val assetImageSvg = Asset(
|
||||
filename = "image.svg",
|
||||
mimeType = MimeTypes.Svg,
|
||||
size = 210,
|
||||
width = 800,
|
||||
height = 600,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ 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.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- To be able to install APK from the application -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
</manifest>
|
||||
+4
@@ -16,6 +16,7 @@ import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
|
||||
import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger
|
||||
import io.element.android.libraries.network.interceptors.UserAgentInterceptor
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -36,6 +37,9 @@ object NetworkModule {
|
||||
addInterceptor(dynamicHttpLoggingInterceptor)
|
||||
}.build()
|
||||
|
||||
@Provides
|
||||
fun providesCallFactory(okHttpClient: OkHttpClient): Call.Factory = Call.Factory { request -> okHttpClient.newCall(request) }
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor {
|
||||
|
||||
+3
-3
@@ -11,19 +11,19 @@ package io.element.android.libraries.network
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.uri.ensureTrailingSlash
|
||||
import okhttp3.Call
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
|
||||
@Inject
|
||||
class RetrofitFactory(
|
||||
private val okHttpClient: () -> OkHttpClient,
|
||||
private val callFactory: () -> Call.Factory,
|
||||
private val json: () -> JsonProvider,
|
||||
) {
|
||||
fun create(baseUrl: String): Retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl.ensureTrailingSlash())
|
||||
.addConverterFactory(json()().asConverterFactory("application/json".toMediaType()))
|
||||
.callFactory { request -> okHttpClient().newCall(request) }
|
||||
.callFactory(callFactory())
|
||||
.build()
|
||||
}
|
||||
|
||||
+2
-4
@@ -1,12 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2025 Element Creations Ltd.
|
||||
~ Copyright 2022 New Vector Ltd.
|
||||
~ Copyright 2023 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.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
</manifest>
|
||||
+1
@@ -16,4 +16,5 @@ data class ElementWellKnown(
|
||||
val notificationSound: String?,
|
||||
val identityProviderAppScheme: String?,
|
||||
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
|
||||
val contentScannerUrl: String?,
|
||||
)
|
||||
|
||||
+14
@@ -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.wellknown.api
|
||||
|
||||
interface ElementWellknownStore {
|
||||
suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown>
|
||||
suspend fun update(domain: String, wellknown: String): Result<Unit>
|
||||
suspend fun delete(domain: String): Result<Unit>
|
||||
}
|
||||
+6
@@ -14,6 +14,11 @@ sealed interface WellknownRetrieverResult<out T> {
|
||||
*/
|
||||
data class Success<out T>(val data: T) : WellknownRetrieverResult<T>
|
||||
|
||||
/**
|
||||
* Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched.
|
||||
*/
|
||||
data class Outdated<out T>(val data: T) : WellknownRetrieverResult<T>
|
||||
|
||||
/**
|
||||
* Well-known data is not found (file does not exist server side, we got a 404).
|
||||
*/
|
||||
@@ -26,6 +31,7 @@ sealed interface WellknownRetrieverResult<out T> {
|
||||
|
||||
fun dataOrNull(): T? = when (this) {
|
||||
is Success<T> -> data
|
||||
is Outdated<T> -> data
|
||||
is Error -> null
|
||||
NotFound -> null
|
||||
}
|
||||
|
||||
+36
-34
@@ -10,17 +10,15 @@ package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.cachestore.api.CacheData
|
||||
import io.element.android.libraries.cachestore.api.CacheStore
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
@@ -29,34 +27,41 @@ import timber.log.Timber
|
||||
class DefaultSessionWellknownRetriever(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val json: JsonProvider,
|
||||
private val cacheStore: CacheStore,
|
||||
private val systemClock: SystemClock,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val elementWellknownStore: ElementWellknownStore,
|
||||
) : SessionWellknownRetriever {
|
||||
private val domain by lazy { matrixClient.userIdServerName() }
|
||||
|
||||
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
|
||||
val url = "https://$domain/.well-known/element/element.json"
|
||||
val cacheData = cacheStore.getData(url)
|
||||
if (cacheData != null) {
|
||||
Timber.d("Element .well-known data retrieved from cache for $domain")
|
||||
// If the cache is outdated, trigger a refresh in background but still return the cached value
|
||||
if (systemClock.epochMillis() > cacheData.updatedAt + CACHE_VALIDITY_MILLIS) {
|
||||
val cacheData = elementWellknownStore.get(domain)
|
||||
return when (cacheData) {
|
||||
is WellknownRetrieverResult.Success -> {
|
||||
Timber.d("Using cached well-known for domain $domain")
|
||||
cacheData
|
||||
}
|
||||
is WellknownRetrieverResult.Outdated -> {
|
||||
// Return the outdated data but refresh in the background
|
||||
// If the cache is missing or outdated, trigger a refresh in background but still return the cached value
|
||||
Timber.d("Outdated cached well-known for domain $domain, returning existing value and fetching new one from network")
|
||||
sessionCoroutineScope.launch {
|
||||
val url = "https://$domain/.well-known/element/element.json"
|
||||
fetchElementWellKnown(url)
|
||||
}
|
||||
cacheData
|
||||
}
|
||||
try {
|
||||
val parsed = json().decodeFromString<InternalElementWellKnown>(cacheData.value).map()
|
||||
return WellknownRetrieverResult.Success(parsed)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse cached Element .well-known data for $domain, deleting cache")
|
||||
cacheStore.deleteData(url)
|
||||
is WellknownRetrieverResult.NotFound -> {
|
||||
// Try to fetch from the server
|
||||
Timber.d("No cached well-known for domain $domain, fetching from network")
|
||||
val url = "https://$domain/.well-known/element/element.json"
|
||||
fetchElementWellKnown(url)
|
||||
}
|
||||
is WellknownRetrieverResult.Error -> {
|
||||
// Return the error
|
||||
Timber.e(cacheData.exception, "Error retrieving well-known for domain $domain")
|
||||
cacheData.exception.toWellknownRetrieverResult()
|
||||
}
|
||||
}
|
||||
|
||||
return fetchElementWellKnown(url)
|
||||
}
|
||||
|
||||
private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
@@ -66,13 +71,11 @@ class DefaultSessionWellknownRetriever(
|
||||
val data = String(it)
|
||||
val parsed = json().decodeFromString<InternalElementWellKnown>(data).map()
|
||||
// Also store in cache, if valid
|
||||
cacheStore.storeData(
|
||||
key = url,
|
||||
data = CacheData(
|
||||
value = data,
|
||||
updatedAt = systemClock.epochMillis(),
|
||||
)
|
||||
)
|
||||
elementWellknownStore.update(domain, data)
|
||||
.onFailure { exception ->
|
||||
Timber.e(exception, "Failed to parse cached Element .well-known data for $domain, deleting cache")
|
||||
elementWellknownStore.delete(domain)
|
||||
}
|
||||
parsed
|
||||
}
|
||||
.toWellknownRetrieverResult()
|
||||
@@ -85,16 +88,15 @@ class DefaultSessionWellknownRetriever(
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to retrieve Element .well-known from $domain")
|
||||
// This check on message value is not ideal but this is what we got from the SDK.
|
||||
if ((it as? ClientException.Generic)?.message?.contains("404") == true) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Error(it as Exception)
|
||||
}
|
||||
it.toWellknownRetrieverResult()
|
||||
}
|
||||
)
|
||||
|
||||
companion object {
|
||||
// 1 day
|
||||
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
|
||||
private fun <T> Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult<T> {
|
||||
return if ((this as? ClientException.Generic)?.message?.contains("404") == true) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Error(this as Exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.wellknown.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.cachestore.api.CacheData
|
||||
import io.element.android.libraries.cachestore.api.CacheStore
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultWellKnownStore(
|
||||
private val cacheStore: CacheStore,
|
||||
private val json: JsonProvider,
|
||||
private val systemClock: SystemClock,
|
||||
) : ElementWellknownStore {
|
||||
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
return runCatchingExceptions {
|
||||
val cachedData = cacheStore.getData(key(domain))
|
||||
if (cachedData != null) {
|
||||
val data = json().decodeFromString<InternalElementWellKnown>(cachedData.value).map()
|
||||
if (systemClock.epochMillis() > cachedData.updatedAt + CACHE_VALIDITY_MILLIS) {
|
||||
WellknownRetrieverResult.Outdated(data)
|
||||
} else {
|
||||
WellknownRetrieverResult.Success(data)
|
||||
}
|
||||
} else {
|
||||
WellknownRetrieverResult.NotFound
|
||||
}
|
||||
}.recover {
|
||||
WellknownRetrieverResult.Error(it as Exception)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
override suspend fun update(domain: String, wellknown: String): Result<Unit> {
|
||||
return runCatchingExceptions {
|
||||
cacheStore.storeData(key(domain), CacheData(wellknown, systemClock.epochMillis()))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(domain: String): Result<Unit> {
|
||||
return runCatchingExceptions {
|
||||
cacheStore.deleteData(key(domain))
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(domain: String): String = "https://$domain/.well-known/element/element.json"
|
||||
|
||||
companion object {
|
||||
// 1 day
|
||||
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -10,26 +10,34 @@ package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.network.RetrofitFactory
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import retrofit2.HttpException
|
||||
import timber.log.Timber
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultWellknownRetriever(
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val elementWellknownStore: ElementWellknownStore,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : WellknownRetriever {
|
||||
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
val domain = URL(baseUrl).host
|
||||
return buildWellknownApi(baseUrl)
|
||||
.map { wellknownApi ->
|
||||
try {
|
||||
val result = wellknownApi.getElementWellKnown().map()
|
||||
WellknownRetrieverResult.Success(result)
|
||||
val json = jsonProvider()
|
||||
val result = wellknownApi.getElementWellKnown()
|
||||
elementWellknownStore.update(domain, json.encodeToString(result))
|
||||
WellknownRetrieverResult.Success(result.map())
|
||||
} catch (e: Exception) {
|
||||
// Is it a 404?
|
||||
Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl")
|
||||
|
||||
+2
@@ -44,6 +44,8 @@ data class InternalElementWellKnown(
|
||||
val identityProviderAppScheme: String? = null,
|
||||
@SerialName("custom_recovery_passphrase")
|
||||
val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null,
|
||||
@SerialName("content_scanner_url")
|
||||
val contentScannerUrl: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
+2
-1
@@ -22,7 +22,7 @@ private val loggerTag = LoggerTag("Wellknown")
|
||||
*/
|
||||
private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1
|
||||
|
||||
internal fun InternalElementWellKnown.map() = ElementWellKnown(
|
||||
fun InternalElementWellKnown.map() = ElementWellKnown(
|
||||
registrationHelperUrl = registrationHelperUrl,
|
||||
enforceElementPro = enforceElementPro,
|
||||
rageshakeUrl = rageshakeUrl,
|
||||
@@ -30,6 +30,7 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown(
|
||||
notificationSound = notificationSound,
|
||||
identityProviderAppScheme = identityProviderAppScheme,
|
||||
customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(),
|
||||
contentScannerUrl = contentScannerUrl,
|
||||
)
|
||||
|
||||
private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase {
|
||||
|
||||
+35
-33
@@ -11,20 +11,16 @@
|
||||
package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.wellknown.test.FakeElementWellknownStore
|
||||
import io.element.android.features.wellknown.test.anElementWellKnown
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.cachestore.api.CacheData
|
||||
import io.element.android.libraries.cachestore.api.CacheStore
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
|
||||
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -53,6 +49,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
notificationSound = null,
|
||||
identityProviderAppScheme = null,
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -79,6 +76,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -108,6 +106,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
brandColor = null,
|
||||
notificationSound = null,
|
||||
identityProviderAppScheme = null,
|
||||
contentScannerUrl = null,
|
||||
customRecoveryPassphrase = null,
|
||||
)
|
||||
)
|
||||
@@ -225,16 +224,23 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown 404 http error counts as not found`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.failure(ClientException.Generic("Not Found: 404 status code", "The file could not be found"))
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting cache`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = { lambdaError() },
|
||||
cacheStore = InMemoryCacheStore(
|
||||
cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to CacheData(
|
||||
value = WELLKNOWN_CONTENT,
|
||||
updatedAt = A_FAKE_TIMESTAMP,
|
||||
)
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Success(parsedWellKnownContent())
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -248,6 +254,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -255,12 +262,9 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting cache containing invalid json`() = runTest {
|
||||
val cacheStore = InMemoryCacheStore(
|
||||
val cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to CacheData(
|
||||
value = WELLKNOWN_CONTENT,
|
||||
updatedAt = A_FAKE_TIMESTAMP,
|
||||
)
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
|
||||
)
|
||||
)
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
@@ -272,28 +276,24 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
// Ensure that the cache is deleted after the failure to parse it
|
||||
assertThat(cacheStore.dataMap).isEmpty()
|
||||
assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting outdated cache`() = runTest {
|
||||
val cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Outdated(parsedWellKnownContent()),
|
||||
)
|
||||
)
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success("{}".toByteArray())
|
||||
},
|
||||
cacheStore = InMemoryCacheStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to CacheData(
|
||||
value = WELLKNOWN_CONTENT,
|
||||
updatedAt = 0L,
|
||||
)
|
||||
),
|
||||
),
|
||||
// 3 days later, so the cache is outdated
|
||||
systemClock = FakeSystemClock(3 * 24 * 60 * 60 * 1000L)
|
||||
cacheStore = cacheStore,
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
WellknownRetrieverResult.Outdated(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = "a_registration_url",
|
||||
enforceElementPro = true,
|
||||
@@ -302,6 +302,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -314,20 +315,20 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsedWellKnownContent() = DefaultJsonProvider().invoke().decodeFromString<InternalElementWellKnown>(WELLKNOWN_CONTENT).map()
|
||||
|
||||
private fun TestScope.createDefaultSessionWellknownRetriever(
|
||||
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
|
||||
getUrlLambda: (String) -> Result<ByteArray>,
|
||||
jsonProvider: JsonProvider = DefaultJsonProvider(),
|
||||
cacheStore: CacheStore = InMemoryCacheStore(),
|
||||
systemClock: SystemClock = FakeSystemClock(),
|
||||
) = DefaultSessionWellknownRetriever(
|
||||
matrixClient = FakeMatrixClient(
|
||||
userIdServerNameLambda = { "user.domain.org" },
|
||||
getUrlLambda = getUrlLambda,
|
||||
),
|
||||
json = jsonProvider,
|
||||
cacheStore = cacheStore,
|
||||
systemClock = systemClock,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
elementWellknownStore = cacheStore,
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -338,7 +339,8 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
"rageshake_url": "a_rageshake_url",
|
||||
"brand_color": "#FF0000",
|
||||
"notification_sound": "a_notification_sound.flac",
|
||||
"idp_app_scheme": "an_app_scheme"
|
||||
"idp_app_scheme": "an_app_scheme",
|
||||
"content_scanner_url": "https://content-scanner.example.com"
|
||||
}"""
|
||||
}
|
||||
}
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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.wellknown.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.wellknown.test.anElementWellKnown
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.cachestore.api.CacheData
|
||||
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
|
||||
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
private const val A_DOMAIN = "matrix.example.com"
|
||||
private const val A_CACHE_KEY = "https://$A_DOMAIN/.well-known/element/element.json"
|
||||
|
||||
// 1 day in millis
|
||||
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
|
||||
|
||||
class DefaultWellKnownStoreTest {
|
||||
private val jsonProvider = DefaultJsonProvider()
|
||||
|
||||
@Test
|
||||
fun `get returns NotFound when cache is empty`() = runTest {
|
||||
val sut = createDefaultWellKnownStore()
|
||||
assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get returns Success when cache has fresh data`() = runTest {
|
||||
val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com")
|
||||
val jsonString = jsonProvider().encodeToString(wellKnown)
|
||||
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
|
||||
val cacheStore = InMemoryCacheStore(
|
||||
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP))
|
||||
)
|
||||
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
|
||||
|
||||
val result = sut.get(A_DOMAIN)
|
||||
|
||||
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get returns Outdated when cached data has expired`() = runTest {
|
||||
val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com")
|
||||
val jsonString = jsonProvider().encodeToString(wellKnown)
|
||||
// Store data at timestamp 0, but clock is past the validity window
|
||||
val expiredTimestamp = 0L
|
||||
val nowTimestamp = expiredTimestamp + CACHE_VALIDITY_MILLIS + 1
|
||||
val clock = FakeSystemClock(epochMillisResult = nowTimestamp)
|
||||
val cacheStore = InMemoryCacheStore(
|
||||
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, expiredTimestamp))
|
||||
)
|
||||
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
|
||||
|
||||
val result = sut.get(A_DOMAIN)
|
||||
|
||||
assertThat(result).isEqualTo(WellknownRetrieverResult.Outdated(wellKnown.map()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get returns Error when cached data is invalid JSON`() = runTest {
|
||||
val cacheStore = InMemoryCacheStore(
|
||||
initialData = mapOf(A_CACHE_KEY to CacheData("not valid json at all", A_FAKE_TIMESTAMP))
|
||||
)
|
||||
val sut = createDefaultWellKnownStore(cacheStore = cacheStore)
|
||||
|
||||
val result = sut.get(A_DOMAIN)
|
||||
|
||||
assertThat(result).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update stores data and get returns Success`() = runTest {
|
||||
val wellKnown = """
|
||||
{
|
||||
"registration_helper_url": "https://element.io",
|
||||
"enforce_element_pro": true,
|
||||
"rageshake_url": "https://example.org/rageshake",
|
||||
"brand_color": "#FF0000",
|
||||
"notification_sound": "ring.flac",
|
||||
"idp_app_scheme": "io.element.app",
|
||||
"custom_recovery_passphrase": {
|
||||
"min_character_count": 8
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
|
||||
val cacheStore = InMemoryCacheStore()
|
||||
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
|
||||
|
||||
val updateResult = sut.update(A_DOMAIN, wellKnown)
|
||||
assertThat(updateResult.isSuccess).isTrue()
|
||||
|
||||
val result = sut.get(A_DOMAIN)
|
||||
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(anElementWellKnown(
|
||||
registrationHelperUrl = "https://element.io",
|
||||
enforceElementPro = true,
|
||||
rageshakeUrl = "https://example.org/rageshake",
|
||||
brandColor = "#FF0000",
|
||||
notificationSound = "ring.flac",
|
||||
identityProviderAppScheme = "io.element.app",
|
||||
customRecoveryPassphrase = CustomRecoveryPassphrase(8),
|
||||
)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete removes data and get returns NotFound`() = runTest {
|
||||
val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com")
|
||||
val jsonString = jsonProvider().encodeToString(wellKnown)
|
||||
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
|
||||
val cacheStore = InMemoryCacheStore(
|
||||
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP))
|
||||
)
|
||||
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
|
||||
|
||||
val deleteResult = sut.delete(A_DOMAIN)
|
||||
assertThat(deleteResult.isSuccess).isTrue()
|
||||
|
||||
assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get returns data at the exact cache validity boundary as Success`() = runTest {
|
||||
val wellKnown = InternalElementWellKnown(notificationSound = "ping.flac")
|
||||
val jsonString = jsonProvider().encodeToString(wellKnown)
|
||||
// Exactly at the boundary: epochMillis == updatedAt + CACHE_VALIDITY_MILLIS → not expired
|
||||
val storedAt = 1000L
|
||||
val nowAtBoundary = storedAt + CACHE_VALIDITY_MILLIS
|
||||
val clock = FakeSystemClock(epochMillisResult = nowAtBoundary)
|
||||
val cacheStore = InMemoryCacheStore(
|
||||
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, storedAt))
|
||||
)
|
||||
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
|
||||
|
||||
val result = sut.get(A_DOMAIN)
|
||||
|
||||
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map()))
|
||||
}
|
||||
|
||||
private fun createDefaultWellKnownStore(
|
||||
cacheStore: InMemoryCacheStore = InMemoryCacheStore(),
|
||||
systemClock: FakeSystemClock = FakeSystemClock(),
|
||||
) = DefaultWellKnownStore(
|
||||
cacheStore = cacheStore,
|
||||
json = jsonProvider,
|
||||
systemClock = systemClock,
|
||||
)
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.wellknown.test.FakeElementWellknownStore
|
||||
import io.element.android.features.wellknown.test.anElementWellKnown
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.network.RetrofitFactory
|
||||
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.Call
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import okio.IOException
|
||||
import org.junit.Test
|
||||
import java.net.PortUnreachableException
|
||||
|
||||
class DefaultWellknownRetrieverTest {
|
||||
@Test
|
||||
fun `get empty element wellknown`() = runTest {
|
||||
val mockCall = mockCall(defaultResponse(body = "{}"))
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = { mockCall },
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = null,
|
||||
enforceElementPro = null,
|
||||
rageshakeUrl = null,
|
||||
brandColor = null,
|
||||
notificationSound = null,
|
||||
identityProviderAppScheme = null,
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
verify(exactly = 1) { mockCall.enqueue(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with full content`() = runTest {
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = { mockCall(defaultResponse()) }
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = "a_registration_url",
|
||||
enforceElementPro = true,
|
||||
rageshakeUrl = "a_rageshake_url",
|
||||
brandColor = "#FF0000",
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with unknown key`() = runTest {
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = {
|
||||
mockCall(defaultResponse(body = """{
|
||||
"registration_helper_url": "a_registration_url",
|
||||
"enforce_element_pro": true,
|
||||
"rageshake_url": "a_rageshake_url",
|
||||
// Note the trailing comma, and the comment!
|
||||
"other": true,
|
||||
}""".trimIndent()))
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = "a_registration_url",
|
||||
enforceElementPro = true,
|
||||
rageshakeUrl = "a_rageshake_url",
|
||||
brandColor = null,
|
||||
notificationSound = null,
|
||||
identityProviderAppScheme = null,
|
||||
contentScannerUrl = null,
|
||||
customRecoveryPassphrase = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with custom recovery passphrase settings`() = runTest {
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = {
|
||||
mockCall(defaultResponse(body = """{
|
||||
"custom_recovery_passphrase": {
|
||||
"min_character_count": 8
|
||||
}
|
||||
}""".trimIndent()))
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown(
|
||||
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 8)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with custom recovery passphrase settings missing min character count floors to 1`() = runTest {
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = {
|
||||
mockCall(defaultResponse(body = """{
|
||||
"custom_recovery_passphrase": {}
|
||||
}""".trimIndent()))
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown(
|
||||
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with zero min character count floors to 1`() = runTest {
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = {
|
||||
mockCall(
|
||||
defaultResponse(
|
||||
body = """{
|
||||
"custom_recovery_passphrase": {
|
||||
"min_character_count": 0
|
||||
}
|
||||
}""".trimIndent()
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown(
|
||||
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with negative min character count floors to 1`() = runTest {
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = {
|
||||
mockCall(
|
||||
defaultResponse(
|
||||
body = """{
|
||||
"custom_recovery_passphrase": {
|
||||
"min_character_count": -5
|
||||
}
|
||||
}""".trimIndent()
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown(
|
||||
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown json error`() = runTest {
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = { mockCall(defaultResponse(status = 500)) }
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown network error`() = runTest {
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = { mockCall(error = PortUnreachableException()) }
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown 404 http error counts as not found`() = runTest {
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = { mockCall(defaultResponse(status = 404)) }
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting cache containing invalid json`() = runTest {
|
||||
val cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
|
||||
)
|
||||
)
|
||||
val sut = createDefaultWellknownRetriever(
|
||||
callFactory = { mockCall(defaultResponse(body = "invalid json")) },
|
||||
cacheStore = cacheStore,
|
||||
jsonProvider = JsonProvider { error("Failed to parse JSON") }
|
||||
)
|
||||
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
// Ensure that the cache is deleted after the failure to parse it
|
||||
assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound)
|
||||
}
|
||||
|
||||
private fun defaultResponse(
|
||||
body: String = WELLKNOWN_CONTENT,
|
||||
status: Int = 200,
|
||||
) = Response.Builder()
|
||||
.code(status)
|
||||
.message("OK")
|
||||
.protocol(okhttp3.Protocol.HTTP_1_1)
|
||||
.request(Request.Builder().url(WELLKNOWN_URL).build())
|
||||
.body(body.toByteArray().toResponseBody())
|
||||
.build()
|
||||
|
||||
private fun createDefaultWellknownRetriever(
|
||||
callFactory: Call.Factory = Call.Factory { _: Request -> mockCall(defaultResponse()) },
|
||||
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
|
||||
jsonProvider: JsonProvider = DefaultJsonProvider(),
|
||||
) = DefaultWellknownRetriever(
|
||||
retrofitFactory = RetrofitFactory(
|
||||
callFactory = { callFactory },
|
||||
json = { jsonProvider }
|
||||
),
|
||||
jsonProvider = jsonProvider,
|
||||
elementWellknownStore = cacheStore,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val WELLKNOWN_URL = "https://user.domain.org/.well-known/element/element.json"
|
||||
private const val WELLKNOWN_CONTENT = """{
|
||||
"registration_helper_url": "a_registration_url",
|
||||
"enforce_element_pro": true,
|
||||
"rageshake_url": "a_rageshake_url",
|
||||
"brand_color": "#FF0000",
|
||||
"notification_sound": "a_notification_sound.flac",
|
||||
"idp_app_scheme": "an_app_scheme",
|
||||
"content_scanner_url": "https://content-scanner.example.com"
|
||||
}"""
|
||||
}
|
||||
}
|
||||
|
||||
private fun mockCall(
|
||||
response: Response? = null,
|
||||
error: IOException? = null,
|
||||
): Call = mockk<Call> {
|
||||
if (error != null) {
|
||||
every { enqueue(any()) } answers {
|
||||
val callback = firstArg<okhttp3.Callback>()
|
||||
callback.onFailure(this@mockk, error)
|
||||
}
|
||||
} else if (response != null) {
|
||||
every { enqueue(any()) } answers {
|
||||
val callback = firstArg<okhttp3.Callback>()
|
||||
callback.onResponse(this@mockk, response)
|
||||
}
|
||||
} else {
|
||||
error("Either response or error must be provided")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.serialization.json)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.wellknown.api)
|
||||
implementation(projects.libraries.wellknown.impl)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.features.wellknown.test
|
||||
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.libraries.wellknown.impl.InternalElementWellKnown
|
||||
import io.element.android.libraries.wellknown.impl.map
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.JsonDecodingException
|
||||
import java.net.URL
|
||||
|
||||
class FakeElementWellknownStore(
|
||||
initialData: Map<String, WellknownRetrieverResult<ElementWellKnown>> = emptyMap(),
|
||||
private val jsonProvider: JsonProvider = DefaultJsonProvider(),
|
||||
) : ElementWellknownStore {
|
||||
private val cachedData = mutableMapOf<String, WellknownRetrieverResult<ElementWellKnown>>()
|
||||
|
||||
init {
|
||||
initialData.forEach { (url, result) ->
|
||||
cachedData[URL(url).host] = result
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
return cachedData[domain] ?: WellknownRetrieverResult.NotFound
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
override suspend fun update(domain: String, wellknown: String): Result<Unit> {
|
||||
val json = jsonProvider()
|
||||
val mapped = try {
|
||||
json.decodeFromString<InternalElementWellKnown>(wellknown).map()
|
||||
} catch (e: JsonDecodingException) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
cachedData[domain] = WellknownRetrieverResult.Success(mapped)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun delete(domain: String): Result<Unit> {
|
||||
cachedData.remove(domain)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
+2
@@ -19,6 +19,7 @@ fun anElementWellKnown(
|
||||
notificationSound: String? = null,
|
||||
identityProviderAppScheme: String? = null,
|
||||
customRecoveryPassphrase: CustomRecoveryPassphrase? = null,
|
||||
contentScannerUrl: String? = null,
|
||||
) = ElementWellKnown(
|
||||
registrationHelperUrl = registrationHelperUrl,
|
||||
enforceElementPro = enforceElementPro,
|
||||
@@ -27,6 +28,7 @@ fun anElementWellKnown(
|
||||
notificationSound = notificationSound,
|
||||
identityProviderAppScheme = identityProviderAppScheme,
|
||||
customRecoveryPassphrase = customRecoveryPassphrase,
|
||||
contentScannerUrl = contentScannerUrl,
|
||||
)
|
||||
|
||||
fun aCustomRecoveryPassphrase(
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class KonsistParameterNameTest {
|
||||
Konsist.scopeFromProject()
|
||||
.functions()
|
||||
.withParameter { parameter ->
|
||||
parameter.name.endsWith("Press")
|
||||
parameter.name.endsWith("Press") && !parameter.name.endsWith("LongPress")
|
||||
}
|
||||
.assertEmpty(additionalMessage = "Please rename the parameter, for instance from 'onBackPress' to 'onBackClick'.")
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ class KonsistPreviewTest {
|
||||
"BackgroundVerticalGradientDisabledPreview",
|
||||
"BackgroundVerticalGradientPreview",
|
||||
"ColorAliasesPreview",
|
||||
"EmojiItemWithPopupPreview",
|
||||
"FocusedEventPreview",
|
||||
"GradientFloatingActionButtonCircleShapePreview",
|
||||
"HeaderFooterPageScrollablePreview",
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4746d11c30648f7c517b0880021e5a1dc6ed2819af6c800b081b0d842ac2c384
|
||||
size 5325
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:837ebeb30aebfb154e98b1b4603156cf0ac877b58efae17d534aaf269ce8ba10
|
||||
size 20037
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:07ad7e092d6b8e41064bcbce131ef53d70a552dd6f9292dd286dd7c10948cf9a
|
||||
size 20459
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user