Merge remote-tracking branch 'origin/develop' into feature/bma/assetReader

This commit is contained in:
Benoit Marty 2025-10-16 20:34:38 +02:00
commit 6d779770d7
103 changed files with 475 additions and 281 deletions

View file

@ -0,0 +1,61 @@
/*
* 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.
*/
package io.element.android.libraries.androidutils.browser
import android.util.Log
import android.webkit.ConsoleMessage
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import timber.log.Timber
interface ConsoleMessageLogger {
fun log(
tag: String,
consoleMessage: ConsoleMessage,
)
}
@ContributesBinding(AppScope::class)
@Inject
class DefaultConsoleMessageLogger : ConsoleMessageLogger {
override fun log(
tag: String,
consoleMessage: ConsoleMessage,
) {
val priority = when (consoleMessage.messageLevel()) {
ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
else -> Log.DEBUG
}
val message = buildString {
append(consoleMessage.sourceId())
append(":")
append(consoleMessage.lineNumber())
append(" ")
append(consoleMessage.message())
}
// Avoid logging any messages that contain "password" to prevent leaking sensitive information
if (message.contains("password=")) {
return
}
Timber.tag(tag).log(
priority = priority,
message = buildString {
append(consoleMessage.sourceId())
append(":")
append(consoleMessage.lineNumber())
append(" ")
append(consoleMessage.message())
},
)
}
}

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.core.bool.orFalse
@Suppress("ktlint:standard:property-naming")
object MimeTypes {
const val Any: String = "*/*"
const val Json = "application/json"
const val OctetStream = "application/octet-stream"
const val Apk = "application/vnd.android.package-archive"
const val Pdf = "application/pdf"

View file

@ -33,4 +33,5 @@ interface NotificationSettingsService {
suspend fun setInviteForMeEnabled(enabled: Boolean): Result<Unit>
suspend fun getRoomsWithUserDefinedRules(): Result<List<String>>
suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean>
suspend fun getRawPushRules(): Result<String?>
}

View file

@ -139,4 +139,8 @@ class RustNotificationSettingsService(
runCatchingExceptions {
notificationSettings.await().canPushEncryptedEventToDevice()
}
override suspend fun getRawPushRules(): Result<String?> = runCatchingExceptions {
notificationSettings.await().getRawPushRules()
}
}

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -23,6 +24,7 @@ class FakeNotificationSettingsService(
initialEncryptedGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
initialEncryptedOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
private val getRawPushRulesResult: () -> Result<String> = { lambdaError() },
) : NotificationSettingsService {
private val notificationSettingsStateFlow = MutableStateFlow(Unit)
private var defaultGroupRoomNotificationMode: RoomNotificationMode = initialGroupDefaultMode
@ -178,4 +180,8 @@ class FakeNotificationSettingsService(
fun givenCanHomeServerPushEncryptedEventsToDeviceResult(result: Result<Boolean>) {
canHomeServerPushEncryptedEventsToDeviceResult = result
}
override suspend fun getRawPushRules(): Result<String?> {
return getRawPushRulesResult()
}
}

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
@ -42,6 +43,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.unreadIndicator
@ -56,6 +58,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Figma reference: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2079&m=dev
*/
@Composable
fun SpaceRoomItemView(
spaceRoom: SpaceRoom,
@ -67,43 +72,65 @@ fun SpaceRoomItemView(
trailingAction: @Composable (() -> Unit)? = null,
bottomAction: @Composable (() -> Unit)? = null,
) {
SpaceRoomItemScaffold(
modifier = modifier,
avatarData = spaceRoom.getAvatarData(AvatarSize.SpaceListItem),
isSpace = spaceRoom.isSpace,
hideAvatars = hideAvatars,
heroes = spaceRoom.heroes
.map { hero -> hero.getAvatarData(AvatarSize.SpaceListItem) }
.toImmutableList(),
onClick = onClick,
onLongClick = onLongClick,
trailingAction = trailingAction,
) {
NameAndIndicatorRow(
name = spaceRoom.displayName,
showIndicator = showUnreadIndicator
val clickModifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
indication = ripple(),
interactionSource = remember { MutableInteractionSource() }
)
Spacer(modifier = Modifier.height(1.dp))
SubtitleRow(
visibilityIcon = spaceRoom.visibilityIcon(),
subtitle = spaceRoom.subtitle()
.onKeyboardContextMenuAction { onLongClick }
Box(modifier = modifier.then(clickModifier)) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
) {
SpaceRoomItemScaffold(
avatarData = spaceRoom.getAvatarData(AvatarSize.SpaceListItem),
isSpace = spaceRoom.isSpace,
hideAvatars = hideAvatars,
heroes = spaceRoom.heroes
.map { hero -> hero.getAvatarData(AvatarSize.SpaceListItem) }
.toImmutableList(),
trailingAction = trailingAction,
) {
NameAndIndicatorRow(
name = spaceRoom.displayName,
showIndicator = showUnreadIndicator
)
Spacer(modifier = Modifier.height(1.dp))
SubtitleRow(
visibilityIcon = spaceRoom.visibilityIcon(),
subtitle = spaceRoom.subtitle()
)
Spacer(modifier = Modifier.height(1.dp))
val info = spaceRoom.info()
if (info.isNotBlank()) {
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = info,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (bottomAction != null) {
Spacer(modifier = Modifier.height(12.dp))
// Match the padding of the text content (avatar + spacer)
Box(modifier = Modifier.padding(start = AvatarSize.SpaceListItem.dp + 16.dp)) {
bottomAction()
}
Spacer(modifier = Modifier.height(4.dp))
}
}
HorizontalDivider(
modifier = Modifier
// Match the padding of the text content (padding + avatar + spacer)
.padding(start = AvatarSize.SpaceListItem.dp + 16.dp + 16.dp)
.align(Alignment.BottomCenter)
)
Spacer(modifier = Modifier.height(1.dp))
val info = spaceRoom.info()
if (info.isNotBlank()) {
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = info,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (bottomAction != null) {
Spacer(modifier = Modifier.height(12.dp))
bottomAction()
}
}
}
@ -170,28 +197,16 @@ private fun SpaceRoomItemScaffold(
avatarData: AvatarData,
isSpace: Boolean,
heroes: ImmutableList<AvatarData>,
onClick: () -> Unit,
onLongClick: () -> Unit,
hideAvatars: Boolean,
modifier: Modifier = Modifier,
trailingAction: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val clickModifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
indication = ripple(),
interactionSource = remember { MutableInteractionSource() }
)
.onKeyboardContextMenuAction { onLongClick }
Row(
modifier = modifier
.fillMaxWidth()
.then(clickModifier)
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = avatarData,
@ -249,7 +264,7 @@ internal fun SpaceRoomItemViewPreview(@PreviewParameter(SpaceRoomProvider::class
hideAvatars = false,
onClick = {},
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().padding(8.dp),
bottomAction = if (spaceRoom.state == CurrentUserMembership.INVITED) {
{ InviteButtonsRowMolecule({}, {}) }
} else {

View file

@ -15,7 +15,6 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger
import io.element.android.libraries.network.interceptors.UserAgentInterceptor
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
@ -35,12 +34,6 @@ object NetworkModule {
addInterceptor(userAgentInterceptor)
if (buildMeta.isDebuggable) addInterceptor(providesHttpLoggingInterceptor())
}.build()
@Provides
@SingleIn(AppScope::class)
fun providesJson(): Json = Json {
ignoreUnknownKeys = true
}
}
private fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor {

View file

@ -21,5 +21,14 @@ data class PushGatewayDevice(
* Required. The pushkey given when the pusher was created.
*/
@SerialName("pushkey")
val pushKey: String
val pushKey: String,
/** Optional. Additional pusher data. */
@SerialName("data")
val data: PusherData? = null,
)
@Serializable
data class PusherData(
@SerialName("default_payload")
val defaultPayload: Map<String, String>,
)

View file

@ -20,5 +20,5 @@ data class PushGatewayNotification(
* Required. This is an array of devices that the notification should be sent to.
*/
@SerialName("devices")
val devices: List<PushGatewayDevice>
val devices: List<PushGatewayDevice>,
)

View file

@ -42,9 +42,12 @@ class DefaultPushGatewayNotifyRequest(
devices = listOf(
PushGatewayDevice(
params.appId,
params.pushKey
params.pushKey,
PusherData(mapOf(
"cs" to "A_FAKE_SECRET",
))
)
)
),
)
)
)

View file

@ -13,9 +13,9 @@ import io.element.android.libraries.pushproviders.api.PushData
import kotlinx.serialization.json.Json
@Inject
class UnifiedPushParser {
private val json by lazy { Json { ignoreUnknownKeys = true } }
class UnifiedPushParser(
private val json: Json,
) {
fun parse(message: ByteArray, clientSecret: String): PushData? {
return tryOrNull { json.decodeFromString<PushDataUnifiedPush>(String(message)) }?.toPushData(clientSecret)
}

View file

@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.tests.testutils.assertThrowsInDebug
import kotlinx.serialization.json.Json
import org.junit.Test
class UnifiedPushParserTest {
@ -25,7 +26,7 @@ class UnifiedPushParserTest {
@Test
fun `test edge cases UnifiedPush`() {
val pushParser = UnifiedPushParser()
val pushParser = createUnifiedPushParser()
// Empty string
assertThat(pushParser.parse("".toByteArray(), aClientSecret)).isNull()
// Empty Json
@ -36,13 +37,13 @@ class UnifiedPushParserTest {
@Test
fun `test UnifiedPush format`() {
val pushParser = UnifiedPushParser()
val pushParser = createUnifiedPushParser()
assertThat(pushParser.parse(UNIFIED_PUSH_DATA.toByteArray(), aClientSecret)).isEqualTo(validData)
}
@Test
fun `test empty roomId`() {
val pushParser = UnifiedPushParser()
val pushParser = createUnifiedPushParser()
assertThrowsInDebug {
pushParser.parse(UNIFIED_PUSH_DATA.replace(A_ROOM_ID.value, "").toByteArray(), aClientSecret)
}
@ -50,7 +51,7 @@ class UnifiedPushParserTest {
@Test
fun `test invalid roomId`() {
val pushParser = UnifiedPushParser()
val pushParser = createUnifiedPushParser()
assertThrowsInDebug {
pushParser.parse(UNIFIED_PUSH_DATA.mutate(A_ROOM_ID.value, "aRoomId:domain"), aClientSecret)
}
@ -58,7 +59,7 @@ class UnifiedPushParserTest {
@Test
fun `test empty eventId`() {
val pushParser = UnifiedPushParser()
val pushParser = createUnifiedPushParser()
assertThrowsInDebug {
pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, ""), aClientSecret)
}
@ -66,7 +67,7 @@ class UnifiedPushParserTest {
@Test
fun `test invalid eventId`() {
val pushParser = UnifiedPushParser()
val pushParser = createUnifiedPushParser()
assertThrowsInDebug {
pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, "anEventId"), aClientSecret)
}
@ -81,3 +82,9 @@ class UnifiedPushParserTest {
private fun String.mutate(oldValue: String, newValue: String): ByteArray {
return replace(oldValue, newValue).toByteArray()
}
fun createUnifiedPushParser(
json: Json = Json { ignoreUnknownKeys = true },
) = UnifiedPushParser(
json = json,
)

View file

@ -191,6 +191,7 @@ class VectorUnifiedPushMessagingReceiverTest {
}
private fun TestScope.createVectorUnifiedPushMessagingReceiver(
unifiedPushParser: UnifiedPushParser = createUnifiedPushParser(),
pushHandler: PushHandler = FakePushHandler(),
unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(),
unifiedPushGatewayResolver: UnifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver(),
@ -199,7 +200,7 @@ class VectorUnifiedPushMessagingReceiverTest {
endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(),
): VectorUnifiedPushMessagingReceiver {
return VectorUnifiedPushMessagingReceiver().apply {
this.pushParser = UnifiedPushParser()
this.pushParser = unifiedPushParser
this.pushHandler = pushHandler
this.guardServiceStarter = NoopGuardServiceStarter()
this.unifiedPushStore = unifiedPushStore

View file

@ -22,7 +22,7 @@ import timber.log.Timber
@Inject
class DefaultSessionWellknownRetriever(
private val matrixClient: MatrixClient,
private val parser: Json,
private val json: Json,
) : SessionWellknownRetriever {
private val domain by lazy { matrixClient.userIdServerName() }
@ -32,7 +32,7 @@ class DefaultSessionWellknownRetriever(
.getUrl(url)
.mapCatchingExceptions {
val data = String(it)
parser.decodeFromString(InternalWellKnown.serializer(), data)
json.decodeFromString(InternalWellKnown.serializer(), data)
}
.onFailure { Timber.e(it, "Failed to retrieve .well-known from $domain") }
.map { it.map() }
@ -45,7 +45,7 @@ class DefaultSessionWellknownRetriever(
.getUrl(url)
.mapCatchingExceptions {
val data = String(it)
parser.decodeFromString(InternalElementWellKnown.serializer(), data)
json.decodeFromString(InternalElementWellKnown.serializer(), data)
}
.onFailure { Timber.e(it, "Failed to retrieve Element .well-known from $domain") }
.map { it.map() }

View file

@ -244,6 +244,6 @@ class DefaultSessionWellknownRetrieverTest {
userIdServerNameLambda = { "user.domain.org" },
getUrlLambda = getUrlLambda,
),
parser = Json { ignoreUnknownKeys = true }
json = Json { ignoreUnknownKeys = true },
)
}