Merge branch 'release/25.07.1' into main

This commit is contained in:
ganfra 2025-07-15 17:02:06 +02:00
commit 5739a28098
440 changed files with 2533 additions and 2536 deletions

View file

@ -10,8 +10,7 @@ updates:
directory: "/"
schedule:
interval: "daily"
ignore:
- dependency-name: "*"
open-pull-requests-limit: 0
reviewers:
- "element-hq/element-x-android-reviewers"
# Updates for Gradle dependencies used in the app
@ -19,8 +18,6 @@ updates:
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 200
ignore:
- dependency-name: "*"
open-pull-requests-limit: 0
reviewers:
- "element-hq/element-x-android-reviewers"

View file

@ -69,7 +69,7 @@ jobs:
retention-days: 5
overwrite: true
if-no-files-found: error
- uses: rnkdsh/action-upload-diawi@605adbad0db6c000eee26adfd8fc128d7df8f7ab # v1.5.9
- uses: rnkdsh/action-upload-diawi@993c2e9f871486114a171cf7fb40554716dac3c5 # v1.5.10
id: diawi
# Do not fail the whole build if Diawi upload fails
continue-on-error: true

View file

@ -1,3 +1,79 @@
Changes in Element X v25.07.0
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.07.0 -->
## What's Changed
### 🙌 Improvements
* Change : handle invalid invite error by @ganfra in https://github.com/element-hq/element-x-android/pull/4909
* Add ability to zoom on video. by @bmarty in https://github.com/element-hq/element-x-android/pull/4916
* Change : sync moderation and safety preferences with server by @ganfra in https://github.com/element-hq/element-x-android/pull/4962
### 🐛 Bugfixes
* Restore `MarkdownEditText.focusSearch` override by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4908
* Fix duplicate usage of a `modifier` variable in `TextInputBox` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4928
### 🗣 Translations
* Sync Strings - new translations to Danish by @ElementBot in https://github.com/element-hq/element-x-android/pull/4913
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4983
### 🧱 Build
* a11y: Add scripts to enable and disable the talkback service by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4906
* Update min api level to 33 for Element enterprise by @bmarty in https://github.com/element-hq/element-x-android/pull/4960
### 🚧 In development 🚧
* Rename module roomlist to home by @bmarty in https://github.com/element-hq/element-x-android/pull/4955
* Home navigation bar by @bmarty in https://github.com/element-hq/element-x-android/pull/4964
### Dependency upgrades
* fix(deps): update dependency org.unifiedpush.android:connector to v3.0.10 by @renovate in https://github.com/element-hq/element-x-android/pull/4871
* fix(deps): update dependency io.sentry:sentry-android to v8.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4892
* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4897
* fix(deps): update wysiwyg to v2.38.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4907
* fix(deps): update dependency org.robolectric:robolectric to v4.15 by @renovate in https://github.com/element-hq/element-x-android/pull/4901
* fix(deps): update dependency androidx.sqlite:sqlite-ktx to v2.5.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4898
* fix(deps): update dependency io.mockk:mockk to v1.14.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4912
* fix(deps): update dependency org.robolectric:robolectric to v4.15.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4911
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.23 by @renovate in https://github.com/element-hq/element-x-android/pull/4917
* fix(deps): update dependencyanalysis to v2.19.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4932
* fix(deps): update dependency org.jsoup:jsoup to v1.21.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4914
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.25 by @renovate in https://github.com/element-hq/element-x-android/pull/4936
* fix(deps): update dependency io.sentry:sentry-android to v8.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4938
* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4939
* fix(deps): update dependency com.google.firebase:firebase-bom to v33.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4945
* fix(deps): update dependency io.sentry:sentry-android to v8.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4941
* Update sdk to version 25.7.1 by @bmarty in https://github.com/element-hq/element-x-android/pull/4966
* Update haze to v1.6.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4968
* Update dependency com.google.gms:google-services to v4.4.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4946
* Update android.gradle.plugin to v8.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4931
* Update dependency io.element.android:element-call-embedded to v0.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4969
* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4967
* Upgrade compose bom to 2025.06.01 by @bmarty in https://github.com/element-hq/element-x-android/pull/4970
* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4918
* Update dependency io.element.android:element-call-embedded to v0.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4977
* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.3 by @ganfra in https://github.com/element-hq/element-x-android/pull/4976
### Others
* a11y: Make isTalkbackActive() live. by @bmarty in https://github.com/element-hq/element-x-android/pull/4903
* a11y: improve accessibility on grouped state events header. by @bmarty in https://github.com/element-hq/element-x-android/pull/4902
* Room debug info by @bmarty in https://github.com/element-hq/element-x-android/pull/4904
* [a11y] Improve accessibility of message composer by @bmarty in https://github.com/element-hq/element-x-android/pull/4900
* refactor: Migrate SQLCipher Android to new API by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4874
* Iterate on avatar to be able to render Space avatar. by @bmarty in https://github.com/element-hq/element-x-android/pull/4921
* Simplify syncing the room list when receiving a push by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4915
* Add unit test on ChooseAccountProviderState so that the coverage is above 90% by @bmarty in https://github.com/element-hq/element-x-android/pull/4924
* Iterate on avatar to be able to render Space avatar Part2 by @bmarty in https://github.com/element-hq/element-x-android/pull/4923
* Introduce SessionEnterpriseService. by @bmarty in https://github.com/element-hq/element-x-android/pull/4925
* Simplify message composer layout by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4884
* Display error dialog if Element Call can't be joined by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4919
* misc : simplify timeline diff logic by @ganfra in https://github.com/element-hq/element-x-android/pull/4930
* Navigation bar component by @bmarty in https://github.com/element-hq/element-x-android/pull/4940
* a11y: improve content description of the close buttons by @bmarty in https://github.com/element-hq/element-x-android/pull/4943
* Element Call: remove top app bar and add it inside the webview instead by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4927
* Replace the Report a problem button with the app's version on the on boading screen. by @bmarty in https://github.com/element-hq/element-x-android/pull/4944
* Split RoomListPresenter and introduce HomePresenter by @bmarty in https://github.com/element-hq/element-x-android/pull/4958
* Add "View avatar" content description to all clickable Avatar that will open the avatar preview. by @bmarty in https://github.com/element-hq/element-x-android/pull/4948
* [a11y] Ensure that the focus is not lost when the send button state change by @bmarty in https://github.com/element-hq/element-x-android/pull/4975
* [a11y] add missing heading() qualifier on screen titles and other headers by @bmarty in https://github.com/element-hq/element-x-android/pull/4980
* misc (tracing) : add new TraceLogPack.Notification by @ganfra in https://github.com/element-hq/element-x-android/pull/4981
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.06.3...v25.07.0
Changes in Element X v25.06.3
=============================

View file

@ -8,11 +8,9 @@
# Element X Android
Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/).
Element X Android is the next-generation [Matrix](https://matrix.org/) client provided by [Element](https://element.io/).
The application is a total rewrite of [Element-Android](https://github.com/element-hq/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
Learn more about why we are building Element X in our blog post: [https://element.io/blog/element-x-experience-the-future-of-element/](https://element.io/blog/element-x-experience-the-future-of-element/).
Compared to the previous-generation [Element Classic](https://github.com/element-hq/element-android), the application is a total rewrite, using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=io.element.android.x)[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/io.element.android.x)
@ -72,7 +70,7 @@ We're doing this as a way to share code between platforms and while we've seen p
## Status
This project is in an early rollout and migration phase.
This project is actively developed and supported. New users are recommended to use Element X instead of the previous-generation app.
## Minimum SDK version

View file

@ -0,0 +1,2 @@
Main changes in this version: improvements and bug fixes.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -16,18 +16,18 @@ enum class HomeNavigationBarItem(
val labelRes: Int,
) {
Chats(
labelRes = R.string.screen_roomlist_main_space_title
labelRes = R.string.screen_home_tab_chats
),
Spaces(
// TODO Create a new entry in Localazy
labelRes = R.string.screen_roomlist_main_space_title
labelRes = R.string.screen_home_tab_spaces
);
@Composable
fun icon() = when (this) {
Chats -> CompoundIcons.ChatSolid()
// TODO Spaces -> CompoundIcons.Workspace()
Spaces -> CompoundIcons.Code()
fun icon(
isSelected: Boolean,
) = when (this) {
Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat()
Spaces -> if (isSelected) CompoundIcons.WorkspaceSolid() else CompoundIcons.Workspace()
}
companion object {

View file

@ -12,6 +12,7 @@ package io.element.android.features.home.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
@ -56,7 +57,9 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.NavigationBar
import io.element.android.libraries.designsystem.theme.components.NavigationBarIcon
import io.element.android.libraries.designsystem.theme.components.NavigationBarItem
import io.element.android.libraries.designsystem.theme.components.NavigationBarText
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
@ -179,6 +182,15 @@ private fun HomeScaffold(
displayFilters = roomListState.displayFilters && state.currentHomeNavigationBarItem == HomeNavigationBarItem.Chats,
filtersState = roomListState.filtersState,
canReportBug = state.canReportBug,
modifier = if (state.isSpaceFeatureEnabled) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick(),
)
} else {
Modifier
.background(ElementTheme.colors.bgCanvasDefault)
}
)
},
bottomBar = {
@ -188,23 +200,25 @@ private fun HomeScaffold(
modifier = Modifier
.hazeEffect(
state = hazeState,
style = HazeMaterials.regular(),
style = HazeMaterials.thick(),
)
) {
HomeNavigationBarItem.entries.forEach { item ->
val isSelected = state.currentHomeNavigationBarItem == item
NavigationBarItem(
selected = state.currentHomeNavigationBarItem == item,
selected = isSelected,
onClick = {
state.eventSink(HomeEvents.SelectHomeNavigationBarItem(item))
},
icon = {
Icon(
imageVector = item.icon(),
contentDescription = null
NavigationBarIcon(
imageVector = item.icon(isSelected),
)
},
label = {
Text(stringResource(item.labelRes))
NavigationBarText(
text = stringResource(item.labelRes),
)
}
)
}
@ -223,15 +237,18 @@ private fun HomeScaffold(
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = ::onRoomClick,
onCreateRoomClick = onCreateRoomClick,
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80,
// and include provided bottom padding
contentBottomPadding = 80.dp + padding.calculateBottomPadding(),
contentPadding = PaddingValues(
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80,
// and include provided bottom padding
bottom = 80.dp + padding.calculateBottomPadding(),
top = padding.calculateTopPadding()
),
modifier = Modifier
.padding(
top = padding.calculateTopPadding(),
bottom = 0.dp,
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
PaddingValues(
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
)
)
.consumeWindowInsets(padding)
.hazeSource(state = hazeState)

View file

@ -31,7 +31,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@ -67,44 +66,53 @@ fun RoomListContentView(
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
onCreateRoomClick: () -> Unit,
contentBottomPadding: Dp,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
when (contentState) {
is RoomListContentState.Skeleton -> {
SkeletonView(
count = contentState.count,
)
}
is RoomListContentState.Empty -> {
EmptyView(
state = contentState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onCreateRoomClick = onCreateRoomClick,
)
}
is RoomListContentState.Rooms -> {
RoomsView(
state = contentState,
hideInvitesAvatars = hideInvitesAvatars,
filtersState = filtersState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick,
contentBottomPadding = contentBottomPadding,
)
}
when (contentState) {
is RoomListContentState.Skeleton -> {
SkeletonView(
modifier = modifier,
count = contentState.count,
contentPadding = contentPadding,
)
}
is RoomListContentState.Empty -> {
EmptyView(
modifier = modifier.padding(contentPadding),
state = contentState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onCreateRoomClick = onCreateRoomClick,
)
}
is RoomListContentState.Rooms -> {
RoomsView(
modifier = modifier,
state = contentState,
hideInvitesAvatars = hideInvitesAvatars,
filtersState = filtersState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick,
contentPadding = contentPadding,
)
}
}
}
@Composable
private fun SkeletonView(count: Int, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
private fun SkeletonView(
count: Int,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
contentPadding = contentPadding,
) {
repeat(count) { index ->
item {
RoomSummaryPlaceholderRow()
@ -167,7 +175,7 @@ private fun RoomsView(
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
contentBottomPadding: Dp,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
@ -183,7 +191,7 @@ private fun RoomsView(
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick,
contentBottomPadding = contentBottomPadding,
contentPadding = contentPadding,
modifier = modifier.fillMaxSize(),
)
}
@ -197,7 +205,7 @@ private fun RoomsViewList(
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
contentBottomPadding: Dp,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
@ -216,7 +224,7 @@ private fun RoomsViewList(
LazyColumn(
state = lazyListState,
modifier = modifier,
contentPadding = PaddingValues(bottom = contentBottomPadding)
contentPadding = contentPadding,
) {
when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> {
@ -329,6 +337,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
onConfirmRecoveryKeyClick = {},
onRoomClick = {},
onCreateRoomClick = {},
contentBottomPadding = 0.dp,
contentPadding = PaddingValues(0.dp),
)
}

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@ -22,7 +21,6 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -30,14 +28,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
@ -51,12 +44,10 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGradient
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.applyScaleDown
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
@ -73,8 +64,6 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListTopBar(
@ -126,25 +115,13 @@ private fun DefaultRoomListTopBar(
canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
// We need this to manually clip the top app bar in preview mode
val previewAppBarHeight = if (LocalInspectionMode.current) {
112.dp.roundToPx()
} else {
null
}
val collapsedFraction = scrollBehavior.state.collapsedFraction
var appBarHeight by remember {
mutableIntStateOf(previewAppBarHeight ?: 0)
}
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
val statusBarPadding = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
Box(modifier = modifier) {
val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle
val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy(
@ -160,40 +137,13 @@ private fun DefaultRoomListTopBar(
titleLarge = collapsedTitleTextStyle
),
) {
Column(
modifier = Modifier
.onSizeChanged {
appBarHeight = it.height
}
.avatarBloom(
avatarData = avatarData,
background = if (ElementTheme.isLightTheme) {
// Workaround to display a very subtle bloom for avatars with very soft colors
Color(0xFFF9F9F9)
} else {
ElementTheme.colors.bgCanvasDefault
},
blurSize = DpSize(avatarBloomSize, avatarBloomSize),
offset = DpOffset(24.dp, 24.dp + statusBarPadding),
clipToSize = if (appBarHeight > 0) {
DpSize(
avatarBloomSize,
appBarHeight.toDp()
)
} else {
DpSize.Unspecified
},
bottomSoftEdgeColor = ElementTheme.colors.bgCanvasDefault,
bottomSoftEdgeAlpha = if (displayFilters) {
1f
} else {
1f - collapsedFraction
},
alpha = if (areSearchResultsDisplayed) 0f else 1f,
)
.statusBarsPadding(),
) {
Column {
MediumTopAppBar(
modifier = Modifier
.backgroundVerticalGradient(
isVisible = !areSearchResultsDisplayed,
)
.statusBarsPadding(),
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,

View file

@ -219,7 +219,7 @@ private fun NameAndTimestampRow(
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.roomListRoomName(),
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -230,7 +230,7 @@ private fun NameAndTimestampRow(
color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator
} else {
ElementTheme.roomListRoomMessageDate()
ElementTheme.colors.roomListRoomMessageDate
},
)
}
@ -253,7 +253,7 @@ private fun InviteSubtitle(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.roomListRoomMessage(),
color = ElementTheme.colors.roomListRoomMessage,
modifier = modifier,
)
}
@ -277,7 +277,7 @@ private fun MessagePreviewAndIndicatorRow(
Text(
modifier = Modifier.weight(1f),
text = annotatedMessagePreview,
color = ElementTheme.roomListRoomMessage(),
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
@ -325,7 +325,7 @@ private fun InviteNameAndIndicatorRow(
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.roomListRoomName(),
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)

View file

@ -119,7 +119,7 @@ class RoomListDataSource @Inject constructor(
private suspend fun rebuildAllRoomSummaries() {
lock.withLock {
roomListService.allRooms.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
roomListService.allRooms.filteredSummaries.replayCache.firstOrNull()?.let { roomSummaries ->
buildAndEmitAllRooms(roomSummaries, useCache = false)
}
}

View file

@ -176,7 +176,6 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.ChatProblem(),
contentDescription = stringResource(CommonStrings.action_report_room),
)
),
style = ListItemStyle.Destructive,

View file

@ -42,6 +42,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
)
}

View file

@ -12,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"Your key storage is out of sync"</string>
<string name="full_screen_intent_banner_message">"To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked."</string>
<string name="full_screen_intent_banner_title">"Enhance your call experience"</string>
<string name="screen_home_tab_chats">"Chats"</string>
<string name="screen_home_tab_spaces">"Spaces"</string>
<string name="screen_invites_decline_chat_message">"Are you sure you want to decline the invitation to join %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Decline invite"</string>
<string name="screen_invites_decline_direct_chat_message">"Are you sure you want to decline this private chat with %1$s?"</string>

View file

@ -18,8 +18,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@ -44,15 +42,15 @@ fun TimelineEventTimestampView(
modifier: Modifier = Modifier,
) {
val formattedTime = event.sentTime
val hasError = event.localSendState is LocalEventSendState.Failed
val hasError = event.failedToSend
val hasEncryptionCritical = event.messageShield?.isCritical.orFalse()
val isMessageEdited = event.content.isEdited()
val isMessageRedacted = event.content.isRedacted()
val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary
Row(
modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier),
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
if (isMessageEdited) {
@ -76,11 +74,13 @@ fun TimelineEventTimestampView(
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint,
modifier = Modifier
.size(15.dp, 18.dp)
.clickable(isVerifiedUserSendFailure) {
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
}
.semantics { hideFromAccessibility() }
.size(15.dp, 18.dp)
.clickable(
enabled = isVerifiedUserSendFailure,
onClickLabel = stringResource(CommonStrings.action_open_context_menu),
) {
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
}
)
}
@ -89,13 +89,14 @@ fun TimelineEventTimestampView(
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = shield.toIcon(),
contentDescription = shield.toText(),
contentDescription = stringResource(id = CommonStrings.a11y_encryption_details),
modifier = Modifier
.size(15.dp)
.clickable {
.clickable(
onClickLabel = stringResource(CommonStrings.a11y_view_details),
) {
eventSink(TimelineEvents.ShowShieldDialog(shield))
}
.semantics { hideFromAccessibility() },
},
tint = shield.toIconColor(),
)
Spacer(modifier = Modifier.width(4.dp))

View file

@ -37,11 +37,11 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.libraries.designsystem.modifiers.subtleColorStops
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@ -140,7 +140,10 @@ internal fun TimelineItemRow(
timelineItem.safeSenderName
}
// For Polls, allow the answers to be traversed by Talkback
isTraversalGroup = timelineItem.content is TimelineItemPollContent
isTraversalGroup = timelineItem.content is TimelineItemPollContent ||
timelineItem.failedToSend ||
timelineItem.messageShield != null
// TODO Also set to true when the event has link(s)
}
// Custom clickable that applies over the whole item for accessibility
.then(
@ -206,23 +209,20 @@ internal fun TimelineItemRow(
@Suppress("ModifierComposable")
@Composable
private fun Modifier.focusedEvent(
focusedEventOffset: Dp
focusedEventOffset: Dp,
isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild,
): Modifier {
val highlightedLineColor = ElementTheme.colors.textActionAccent
val gradientFirstColor = if (LocalBuildMeta.current.isEnterpriseBuild) {
ElementTheme.colors.textActionAccent.copy(alpha = 0.125f)
val highlightedLineColor = if (isEnterpriseBuild) {
ElementTheme.colors.textActionAccent
} else {
ElementTheme.colors.highlightedMessageBackgroundColor
ElementTheme.colors.borderAccentSubtle
}
val gradientColors = listOf(
gradientFirstColor,
ElementTheme.colors.bgCanvasDefault,
)
val gradientColors = subtleColorStops(isEnterpriseBuild)
val verticalOffset = focusedEventOffset.toPx()
val verticalRatio = 0.7f
return drawWithCache {
val brush = Brush.verticalGradient(
colors = gradientColors,
colorStops = gradientColors,
endY = size.height * verticalRatio,
)
onDrawBehind {
@ -251,3 +251,18 @@ internal fun FocusedEventPreview() = ElementPreview {
.focusedEvent(0.dp),
)
}
@PreviewsDayNight
@Composable
internal fun FocusedEventEnterprisePreview() = ElementPreview {
Box(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(160.dp)
.focusedEvent(
focusedEventOffset = 0.dp,
isEnterpriseBuild = true,
),
)
}

View file

@ -112,12 +112,11 @@ fun ContentAvoidingLayout(
* @param nonOverlappingContentWidth The width of the part of the content that can't overlap with the timestamp.
* @param nonOverlappingContentHeight The height of the part of the content that can't overlap with the timestamp.
*/
@Suppress("DataClassShouldBeImmutable")
data class ContentAvoidingLayoutData(
var contentWidth: Int = 0,
var contentHeight: Int = 0,
var nonOverlappingContentWidth: Int = contentWidth,
var nonOverlappingContentHeight: Int = contentHeight,
val contentWidth: Int = 0,
val contentHeight: Int = 0,
val nonOverlappingContentWidth: Int = contentWidth,
val nonOverlappingContentHeight: Int = contentHeight,
)
/**

View file

@ -116,7 +116,7 @@ class TimelineViewTest {
eventSink = eventsRecorder,
),
)
val contentDescription = rule.activity.getString(CommonStrings.event_shield_reason_unverified_identity)
val contentDescription = rule.activity.getString(CommonStrings.a11y_encryption_details)
rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertList(
listOf(

View file

@ -4,5 +4,6 @@
<item quantity="one">"%1$d percent of total votes"</item>
<item quantity="other">"%1$d percents of total votes"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Will remove previous selection"</string>
<string name="a11y_polls_winning_answer">"This is the winning answer"</string>
</resources>

View file

@ -1,290 +0,0 @@
/*
* Copyright 2023, 2024 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.features.rageshake.impl.reporter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import okhttp3.Headers;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.internal.Util;
import okio.Buffer;
import okio.BufferedSink;
import okio.ByteString;
// simplified version of MultipartBody (OkHttp 3.6.0)
public class BugReporterMultipartBody extends RequestBody {
/**
* Listener
*/
public interface WriteListener {
/**
* Upload listener
*
* @param totalWritten total written bytes
* @param contentLength content length
*/
void onWrite(long totalWritten, long contentLength);
}
private static final MediaType FORM = MediaType.parse("multipart/form-data");
private static final byte[] COLONSPACE = {':', ' '};
private static final byte[] CRLF = {'\r', '\n'};
private static final byte[] DASHDASH = {'-', '-'};
private final ByteString mBoundary;
private final MediaType mContentType;
private final List<Part> mParts;
private long mContentLength = -1L;
// listener
private WriteListener mWriteListener;
//
private List<Long> mContentLengthSize = null;
private BugReporterMultipartBody(ByteString boundary, List<Part> parts) {
mBoundary = boundary;
mContentType = MediaType.parse(FORM + "; boundary=" + boundary.utf8());
mParts = Util.toImmutableList(parts);
}
@Override
public MediaType contentType() {
return mContentType;
}
@Override
public long contentLength() throws IOException {
long result = mContentLength;
if (result != -1L) return result;
return mContentLength = writeOrCountBytes(null, true);
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
writeOrCountBytes(sink, false);
}
/**
* Set the listener
*
* @param listener the
*/
public void setWriteListener(WriteListener listener) {
mWriteListener = listener;
}
/**
* Warn the listener that some bytes have been written
*
* @param totalWrittenBytes the total written bytes
*/
private void onWrite(long totalWrittenBytes) {
if ((null != mWriteListener) && (mContentLength > 0)) {
mWriteListener.onWrite(totalWrittenBytes, mContentLength);
}
}
/**
* Either writes this request to {@code sink} or measures its content length. We have one method
* do double-duty to make sure the counting and content are consistent, particularly when it comes
* to awkward operations like measuring the encoded length of header strings, or the
* length-in-digits of an encoded integer.
*/
private long writeOrCountBytes(BufferedSink sink, boolean countBytes) throws IOException {
long byteCount = 0L;
Buffer byteCountBuffer = null;
if (countBytes) {
sink = byteCountBuffer = new Buffer();
mContentLengthSize = new ArrayList<>();
}
for (int p = 0, partCount = mParts.size(); p < partCount; p++) {
Part part = mParts.get(p);
Headers headers = part.headers;
RequestBody body = part.body;
sink.write(DASHDASH);
sink.write(mBoundary);
sink.write(CRLF);
if (headers != null) {
for (int h = 0, headerCount = headers.size(); h < headerCount; h++) {
sink.writeUtf8(headers.name(h))
.write(COLONSPACE)
.writeUtf8(headers.value(h))
.write(CRLF);
}
}
MediaType contentType = body.contentType();
if (contentType != null) {
sink.writeUtf8("Content-Type: ")
.writeUtf8(contentType.toString())
.write(CRLF);
}
int contentLength = (int) body.contentLength();
if (contentLength != -1) {
sink.writeUtf8("Content-Length: ")
.writeUtf8(contentLength + "")
.write(CRLF);
} else if (countBytes) {
// We can't measure the body's size without the sizes of its components.
byteCountBuffer.clear();
return -1L;
}
sink.write(CRLF);
if (countBytes) {
byteCount += contentLength;
mContentLengthSize.add(byteCount);
} else {
body.writeTo(sink);
// warn the listener of upload progress
// sink.buffer().size() does not give the right value
// assume that some data are popped
if ((null != mContentLengthSize) && (p < mContentLengthSize.size())) {
onWrite(mContentLengthSize.get(p));
}
}
sink.write(CRLF);
}
sink.write(DASHDASH);
sink.write(mBoundary);
sink.write(DASHDASH);
sink.write(CRLF);
if (countBytes) {
byteCount += byteCountBuffer.size();
byteCountBuffer.clear();
}
return byteCount;
}
private static void appendQuotedString(StringBuilder target, String key) {
target.append('"');
for (int i = 0, len = key.length(); i < len; i++) {
char ch = key.charAt(i);
switch (ch) {
case '\n':
target.append("%0A");
break;
case '\r':
target.append("%0D");
break;
case '"':
target.append("%22");
break;
default:
target.append(ch);
break;
}
}
target.append('"');
}
public static final class Part {
public static Part create(Headers headers, RequestBody body) {
if (body == null) {
throw new NullPointerException("body == null");
}
if (headers != null && headers.get("Content-Type") != null) {
throw new IllegalArgumentException("Unexpected header: Content-Type");
}
if (headers != null && headers.get("Content-Length") != null) {
throw new IllegalArgumentException("Unexpected header: Content-Length");
}
return new Part(headers, body);
}
public static Part createFormData(String name, String value) {
return createFormData(name, null, RequestBody.create(value, null));
}
public static Part createFormData(String name, String filename, RequestBody body) {
if (name == null) {
throw new NullPointerException("name == null");
}
StringBuilder disposition = new StringBuilder("form-data; name=");
appendQuotedString(disposition, name);
if (filename != null) {
disposition.append("; filename=");
appendQuotedString(disposition, filename);
}
return create(Headers.of("Content-Disposition", disposition.toString()), body);
}
final Headers headers;
final RequestBody body;
private Part(Headers headers, RequestBody body) {
this.headers = headers;
this.body = body;
}
}
public static final class Builder {
private final ByteString boundary;
private final List<Part> parts = new ArrayList<>();
public Builder() {
this(UUID.randomUUID().toString());
}
public Builder(String boundary) {
this.boundary = ByteString.encodeUtf8(boundary);
}
/**
* Add a form data part to the body.
*/
public Builder addFormDataPart(String name, String value) {
return addPart(Part.createFormData(name, value));
}
/**
* Add a form data part to the body.
*/
public Builder addFormDataPart(String name, String filename, RequestBody body) {
return addPart(Part.createFormData(name, filename, body));
}
/**
* Add a part to the body.
*/
public Builder addPart(Part part) {
if (part == null) throw new NullPointerException("part == null");
parts.add(part);
return this;
}
/**
* Assemble the specified parts into a request body.
*/
public BugReporterMultipartBody build() {
if (parts.isEmpty()) {
throw new IllegalStateException("Multipart body must have at least one part.");
}
return new BugReporterMultipartBody(boundary, parts);
}
}
}

View file

@ -0,0 +1,419 @@
/*
* Copyright (C) 2014 Square, Inc.
* Copyright 2023, 2024 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:Suppress(
"unused",
"KDocUnresolvedReference",
"SpellCheckingInspection",
)
package io.element.android.features.rageshake.impl.reporter
import kotlinx.collections.immutable.toImmutableList
import okhttp3.Headers
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okio.Buffer
import okio.BufferedSink
import okio.ByteString
import okio.ByteString.Companion.encodeUtf8
import java.io.IOException
import java.util.UUID
/**
* Copy of [okhttp3.MultipartBody] with addition of a listener to track progress (Last imported from OkHttp 5.0.0).
* Patches are surrounded by ELEMENT-START and ELEMENT-END
*
* An [RFC 2387][rfc_2387]-compliant request body.
*
* [rfc_2387]: http://www.ietf.org/rfc/rfc2387.txt
*/
@Suppress("NAME_SHADOWING")
class BugReporterMultipartBody internal constructor(
private val boundaryByteString: ByteString,
@get:JvmName("type") val type: MediaType,
@get:JvmName("parts") val parts: List<Part>,
) : RequestBody() {
// ELEMENT-START
private var listener: BugReporterMultipartBodyListener? = null
private fun onWrite(totalWrittenBytes: Long) {
listener
?.takeIf { contentLength > 0 }
?.onWrite(totalWrittenBytes, contentLength)
}
private val contentLengthSize = mutableListOf<Long>()
fun setWriteListener(listener: BugReporterMultipartBodyListener?) {
this.listener = listener
}
// ELEMENT-END
private val contentType: MediaType = "$type; boundary=$boundary".toMediaType()
private var contentLength = -1L
@get:JvmName("boundary")
val boundary: String
get() = boundaryByteString.utf8()
/** The number of parts in this multipart body. */
@get:JvmName("size")
val size: Int
get() = parts.size
fun part(index: Int): Part = parts[index]
override fun isOneShot(): Boolean = parts.any { it.body.isOneShot() }
/** A combination of [type] and [boundaryByteString]. */
override fun contentType(): MediaType = contentType
@JvmName("-deprecated_type")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "type"),
level = DeprecationLevel.ERROR,
)
fun type(): MediaType = type
@JvmName("-deprecated_boundary")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "boundary"),
level = DeprecationLevel.ERROR,
)
fun boundary(): String = boundary
@JvmName("-deprecated_size")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "size"),
level = DeprecationLevel.ERROR,
)
fun size(): Int = size
@JvmName("-deprecated_parts")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "parts"),
level = DeprecationLevel.ERROR,
)
fun parts(): List<Part> = parts
@Throws(IOException::class)
override fun contentLength(): Long {
var result = contentLength
if (result == -1L) {
result = writeOrCountBytes(null, true)
contentLength = result
}
return result
}
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
writeOrCountBytes(sink, false)
}
/**
* Either writes this request to [sink] or measures its content length. We have one method do
* double-duty to make sure the counting and content are consistent, particularly when it comes
* to awkward operations like measuring the encoded length of header strings, or the
* length-in-digits of an encoded integer.
*/
@Throws(IOException::class)
private fun writeOrCountBytes(
sink: BufferedSink?,
countBytes: Boolean,
): Long {
var sink = sink
var byteCount = 0L
var byteCountBuffer: Buffer? = null
if (countBytes) {
byteCountBuffer = Buffer()
sink = byteCountBuffer
// ELEMENT-START
contentLengthSize.clear()
// ELEMENT-END
}
for (p in 0 until parts.size) {
val part = parts[p]
val headers = part.headers
val body = part.body
sink!!.write(DASHDASH)
sink.write(boundaryByteString)
sink.write(CRLF)
if (headers != null) {
for (h in 0 until headers.size) {
sink
.writeUtf8(headers.name(h))
.write(COLONSPACE)
.writeUtf8(headers.value(h))
.write(CRLF)
}
}
val contentType = body.contentType()
if (contentType != null) {
sink
.writeUtf8("Content-Type: ")
.writeUtf8(contentType.toString())
.write(CRLF)
}
// We can't measure the body's size without the sizes of its components.
val contentLength = body.contentLength()
if (contentLength == -1L && countBytes) {
byteCountBuffer!!.clear()
return -1L
}
sink.write(CRLF)
if (countBytes) {
byteCount += contentLength
// ELEMENT-START
contentLengthSize.add(byteCount)
// ELEMENT-END
} else {
body.writeTo(sink)
// ELEMENT-START
// warn the listener of upload progress
// sink.buffer().size() does not give the right value
// assume that some data are popped
contentLengthSize.getOrNull(p)?.let { writtenByte ->
onWrite(writtenByte)
}
// ELEMENT-END
}
sink.write(CRLF)
}
sink!!.write(DASHDASH)
sink.write(boundaryByteString)
sink.write(DASHDASH)
sink.write(CRLF)
if (countBytes) {
byteCount += byteCountBuffer!!.size
byteCountBuffer.clear()
}
return byteCount
}
class Part private constructor(
@get:JvmName("headers") val headers: Headers?,
@get:JvmName("body") val body: RequestBody,
) {
@JvmName("-deprecated_headers")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "headers"),
level = DeprecationLevel.ERROR,
)
fun headers(): Headers? = headers
@JvmName("-deprecated_body")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "body"),
level = DeprecationLevel.ERROR,
)
fun body(): RequestBody = body
companion object {
@JvmStatic
fun create(body: RequestBody): Part = create(null, body)
@JvmStatic
fun create(
headers: Headers?,
body: RequestBody,
): Part {
require(headers?.get("Content-Type") == null) { "Unexpected header: Content-Type" }
require(headers?.get("Content-Length") == null) { "Unexpected header: Content-Length" }
return Part(headers, body)
}
@JvmStatic
fun createFormData(
name: String,
value: String,
): Part = createFormData(name, null, value.toRequestBody())
@JvmStatic
fun createFormData(
name: String,
filename: String?,
body: RequestBody,
): Part {
val disposition =
buildString {
append("form-data; name=")
appendQuotedString(name)
if (filename != null) {
append("; filename=")
appendQuotedString(filename)
}
}
val headers =
Headers
.Builder()
.addUnsafeNonAscii("Content-Disposition", disposition)
.build()
return create(headers, body)
}
}
}
class Builder
@JvmOverloads
constructor(
boundary: String = UUID.randomUUID().toString(),
) {
private val boundary: ByteString = boundary.encodeUtf8()
// ELEMENT-START
// Element: use FORM as default type
private var type = FORM
// ELEMENT-END
private val parts = mutableListOf<Part>()
/**
* Set the MIME type. Expected values for `type` are [MIXED] (the default), [ALTERNATIVE],
* [DIGEST], [PARALLEL] and [FORM].
*/
fun setType(type: MediaType) =
apply {
require(type.type == "multipart") { "multipart != $type" }
this.type = type
}
/** Add a part to the body. */
fun addPart(body: RequestBody) =
apply {
addPart(Part.create(body))
}
/** Add a part to the body. */
fun addPart(
headers: Headers?,
body: RequestBody,
) = apply {
addPart(Part.create(headers, body))
}
/** Add a form data part to the body. */
fun addFormDataPart(
name: String,
value: String,
) = apply {
addPart(Part.createFormData(name, value))
}
/** Add a form data part to the body. */
fun addFormDataPart(
name: String,
filename: String?,
body: RequestBody,
) = apply {
addPart(Part.createFormData(name, filename, body))
}
/** Add a part to the body. */
fun addPart(part: Part) =
apply {
parts += part
}
/** Assemble the specified parts into a request body. */
fun build(): BugReporterMultipartBody {
check(parts.isNotEmpty()) { "Multipart body must have at least one part." }
return BugReporterMultipartBody(boundary, type, parts.toImmutableList())
}
}
companion object {
/**
* The "mixed" subtype of "multipart" is intended for use when the body parts are independent
* and need to be bundled in a particular order. Any "multipart" subtypes that an implementation
* does not recognize must be treated as being of subtype "mixed".
*/
@JvmField
val MIXED = "multipart/mixed".toMediaType()
/**
* The "multipart/alternative" type is syntactically identical to "multipart/mixed", but the
* semantics are different. In particular, each of the body parts is an "alternative" version of
* the same information.
*/
@JvmField
val ALTERNATIVE = "multipart/alternative".toMediaType()
/**
* This type is syntactically identical to "multipart/mixed", but the semantics are different.
* In particular, in a digest, the default `Content-Type` value for a body part is changed from
* "text/plain" to "message/rfc822".
*/
@JvmField
val DIGEST = "multipart/digest".toMediaType()
/**
* This type is syntactically identical to "multipart/mixed", but the semantics are different.
* In particular, in a parallel entity, the order of body parts is not significant.
*/
@JvmField
val PARALLEL = "multipart/parallel".toMediaType()
/**
* The media-type multipart/form-data follows the rules of all multipart MIME data streams as
* outlined in RFC 2046. In forms, there are a series of fields to be supplied by the user who
* fills out the form. Each field has a name. Within a given form, the names are unique.
*/
@JvmField
val FORM = "multipart/form-data".toMediaType()
private val COLONSPACE = byteArrayOf(':'.code.toByte(), ' '.code.toByte())
private val CRLF = byteArrayOf('\r'.code.toByte(), '\n'.code.toByte())
private val DASHDASH = byteArrayOf('-'.code.toByte(), '-'.code.toByte())
/**
* Appends a quoted-string to a StringBuilder.
*
* RFC 2388 is rather vague about how one should escape special characters in form-data
* parameters, and as it turns out Firefox and Chrome actually do rather different things, and
* both say in their comments that they're not really sure what the right approach is. We go
* with Chrome's behavior (which also experimentally seems to match what IE does), but if you
* actually want to have a good chance of things working, please avoid double-quotes, newlines,
* percent signs, and the like in your field names.
*/
internal fun StringBuilder.appendQuotedString(key: String) {
append('"')
for (i in 0 until key.length) {
when (val ch = key[i]) {
'\n' -> append("%0A")
'\r' -> append("%0D")
'"' -> append("%22")
else -> append(ch)
}
}
append('"')
}
}
}

View file

@ -0,0 +1,18 @@
/*
* 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.features.rageshake.impl.reporter
fun interface BugReporterMultipartBodyListener {
/**
* Upload listener.
*
* @param totalWritten total written bytes
* @param contentLength content length
*/
fun onWrite(totalWritten: Long, contentLength: Long)
}

View file

@ -225,7 +225,10 @@ class ChangeRolesViewTest {
)
// Unselect the user from the row list
val contentDescription = rule.activity.getString(CommonStrings.action_remove)
rule.onNodeWithContentDescription(contentDescription).performClick()
rule.onNodeWithContentDescription(
label = contentDescription,
useUnmergedTree = true,
).performClick()
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
@ -248,7 +251,7 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent(
state = state,
)
// Select the user from the row list
// Select the user from the user list
rule.onNodeWithText("Carol").performClick()
eventsRecorder.assertList(
listOf(
@ -271,8 +274,11 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent(
state = state,
)
// Select the user from the rom list
rule.onAllNodesWithText("Bob")[1].performClick()
// Unselect the user from the user list
rule.onAllNodesWithText(
text = "Bob",
useUnmergedTree = true,
)[1].performClick()
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),

View file

@ -3,7 +3,7 @@
[versions]
# Project
android_gradle_plugin = "8.11.0"
android_gradle_plugin = "8.11.1"
kotlin = "2.2.0"
kotlinpoet = "2.2.0"
ksp = "2.2.0-2.0.2"
@ -33,7 +33,7 @@ accompanist = "0.37.3"
test_core = "1.6.1"
# Jetbrain
datetime = "0.7.0"
datetime = "0.7.1"
serialization_json = "1.9.0"
#other
@ -42,9 +42,9 @@ coil = "3.2.0"
showkase = "1.0.4"
appyx = "1.7.1"
sqldelight = "2.1.0"
wysiwyg = "2.38.4"
wysiwyg = "2.38.5"
telephoto = "0.16.0"
haze = "1.6.6"
haze = "1.6.8"
# Dependency analysis
dependencyAnalysis = "2.19.0"
@ -100,6 +100,9 @@ androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process",
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
androidx_media3_transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" }
androidx_media3_effect = { module = "androidx.media3:media3-effect", version.ref = "media3" }
androidx_media3_common = { module = "androidx.media3:media3-common", version.ref = "media3" }
androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
@ -131,7 +134,7 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi
squareup_seismic = "com.squareup:seismic:1.0.3"
# network
network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:4.12.0"
network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.1.0"
network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" }
network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" }
network_okhttp = { module = "com.squareup.okhttp3:okhttp" }
@ -163,7 +166,7 @@ coil_network_okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version
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_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" }
compound = { module = "io.element.android:compound-android", version = "25.4.4" }
compound = { module = "io.element.android:compound-android", version = "25.7.4" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0"
@ -173,7 +176,7 @@ jsoup = "org.jsoup:jsoup:1.21.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.1.0"
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.7.3"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.7.15"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -182,12 +185,11 @@ sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions",
sqlcipher = "net.zetetic:sqlcipher-android:4.9.0"
sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
unifiedpush = "org.unifiedpush.android:connector:3.0.10"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.11.0"
maplibre = "org.maplibre.gl:android-sdk:11.12.1"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.2.0"
@ -196,8 +198,8 @@ haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
# Analytics
posthog = "com.posthog:posthog-android:3.19.0"
sentry = "io.sentry:sentry-android:8.16.0"
posthog = "com.posthog:posthog-android:3.19.2"
sentry = "io.sentry:sentry-android:8.17.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
@ -238,7 +240,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ktlint = "org.jlleitschuh.gradle.ktlint:12.3.0"
ktlint = "org.jlleitschuh.gradle.ktlint:13.0.0"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:12.1.3"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }

View file

@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View file

@ -1,29 +0,0 @@
/*
* Copyright 2023, 2024 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.accessibility
import android.content.Context
import android.view.accessibility.AccessibilityManager
import androidx.core.content.getSystemService
/**
* Whether a screen reader is enabled.
*
* Avoid changing UI or app behavior based on the state of accessibility.
* See [AccessibilityManager.isTouchExplorationEnabled] for more details.
*
* @return true if the screen reader is enabled.
*/
fun Context.isScreenReaderEnabled(): Boolean {
val accessibilityManager = getSystemService<AccessibilityManager>()
?: return false
return accessibilityManager.let {
it.isEnabled && it.isTouchExplorationEnabled
}
}

View file

@ -14,10 +14,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.Badge
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.badgeNegativeBackgroundColor
import io.element.android.libraries.designsystem.theme.badgeNegativeContentColor
import io.element.android.libraries.designsystem.theme.badgeNeutralBackgroundColor
import io.element.android.libraries.designsystem.theme.badgeNeutralContentColor
object MatrixBadgeAtom {
data class MatrixBadgeData(
@ -39,21 +35,21 @@ object MatrixBadgeAtom {
) {
val backgroundColor = when (data.type) {
Type.Positive -> ElementTheme.colors.bgBadgeAccent
Type.Neutral -> ElementTheme.colors.badgeNeutralBackgroundColor
Type.Negative -> ElementTheme.colors.badgeNegativeBackgroundColor
Type.Neutral -> ElementTheme.colors.bgBadgeDefault
Type.Negative -> ElementTheme.colors.bgCriticalSubtle
Type.Info -> ElementTheme.colors.bgBadgeInfo
}
val textColor = when (data.type) {
Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.badgeNeutralContentColor
Type.Negative -> ElementTheme.colors.badgeNegativeContentColor
Type.Neutral -> ElementTheme.colors.textPrimary
Type.Negative -> ElementTheme.colors.textCriticalPrimary
Type.Info -> ElementTheme.colors.textBadgeInfo
}
val iconColor = when (data.type) {
Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.iconSecondary
Type.Positive -> ElementTheme.colors.iconAccentPrimary
Type.Neutral -> ElementTheme.colors.iconPrimary
Type.Negative -> ElementTheme.colors.iconCriticalPrimary
Type.Info -> ElementTheme.colors.textBadgeInfo
Type.Info -> ElementTheme.colors.iconInfoPrimary
}
Badge(
text = data.text,

View file

@ -23,9 +23,9 @@ import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.drawWithLayer
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.drawWithLayer
/**
* Gradient background for FTUE (onboarding) screens.

View file

@ -0,0 +1,44 @@
/*
* 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.designsystem.colors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import io.element.android.compound.theme.ElementTheme
@Composable
@ReadOnlyComposable
fun gradientActionColors(): List<Color> = listOf(
ElementTheme.colors.gradientActionStop1,
ElementTheme.colors.gradientActionStop2,
ElementTheme.colors.gradientActionStop3,
ElementTheme.colors.gradientActionStop4,
)
@Composable
@ReadOnlyComposable
fun gradientSubtleColors(): List<Color> = listOf(
ElementTheme.colors.gradientSubtleStop1,
ElementTheme.colors.gradientSubtleStop2,
ElementTheme.colors.gradientSubtleStop3,
ElementTheme.colors.gradientSubtleStop4,
ElementTheme.colors.gradientSubtleStop5,
ElementTheme.colors.gradientSubtleStop6,
)
@Composable
@ReadOnlyComposable
fun gradientInfoColors(): List<Color> = listOf(
ElementTheme.colors.gradientInfoStop1,
ElementTheme.colors.gradientInfoStop2,
ElementTheme.colors.gradientInfoStop3,
ElementTheme.colors.gradientInfoStop4,
ElementTheme.colors.gradientInfoStop5,
ElementTheme.colors.gradientInfoStop6,
)

View file

@ -1,67 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.bigCheckmarkBorderColor
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Compound component that displays a big checkmark centered in a rounded square.
*
* @param modifier the modifier to apply to this layout
*/
@Composable
fun BigCheckmark(
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.size(120.dp),
shape = RoundedCornerShape(14.dp),
color = ElementTheme.colors.bgCanvasDefault,
border = BorderStroke(1.dp, ElementTheme.colors.bigCheckmarkBorderColor),
shadowElevation = 4.dp,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
modifier = Modifier.size(72.dp),
tint = ElementTheme.colors.iconSuccessPrimary,
imageVector = CompoundIcons.CheckCircleSolid(),
contentDescription = stringResource(CommonStrings.common_success)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun BigCheckmarkPreview() {
ElementPreview {
Box(
modifier = Modifier.padding(10.dp),
contentAlignment = Alignment.Center,
) {
BigCheckmark()
}
}
}

View file

@ -1,578 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components
import android.graphics.Bitmap
import android.graphics.Typeface
import android.os.Build
import android.text.TextPaint
import androidx.annotation.FloatRange
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import coil3.SingletonImageLoader
import coil3.request.ImageRequest
import coil3.request.allowHardware
import coil3.toBitmap
import com.airbnb.android.showkase.annotation.ShowkaseComposable
import com.vanniktech.blurhash.BlurHash
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Default bloom configuration values.
*/
object BloomDefaults {
/**
* Number of components to use with BlurHash to generate the blur effect.
* Larger values mean more detailed blurs.
*/
const val HASH_COMPONENTS = 4
const val ENCODE_SIZE_PX = 20
const val DECODE_SIZE_PX = 5
/** Default bloom layers. */
@Composable
fun defaultLayers() = persistentListOf(
// Bottom layer
if (ElementTheme.isLightTheme) {
BloomLayer(0.2f, BlendMode.Hardlight)
} else {
BloomLayer(0.5f, BlendMode.Exclusion)
},
// Top layer
BloomLayer(if (ElementTheme.isLightTheme) 0.8f else 0.2f, BlendMode.Color),
)
}
/**
* Bloom layer configuration.
* @param alpha The alpha value to apply to the layer.
* @param blendMode The blend mode to apply to the layer.
*/
data class BloomLayer(
val alpha: Float,
val blendMode: BlendMode,
)
/**
* Bloom effect modifier. Applies a bloom effect to the component.
* @param hash The BlurHash to use as the bloom source.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param layerConfiguration The configuration for the bloom layers. If not specified the default layers configuration will be used.
* @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect.
*/
@SuppressWarnings("ModifierComposed")
fun Modifier.bloom(
hash: String?,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
layerConfiguration: ImmutableList<BloomLayer>? = null,
bottomSoftEdgeColor: Color = background,
bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f,
@FloatRange(from = 0.0, to = 1.0)
alpha: Float = 1f,
) = composed {
val defaultLayers = BloomDefaults.defaultLayers()
val layers = layerConfiguration ?: defaultLayers
// Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
if (hash == null) return@composed this
val hashedBitmap = remember(hash) {
BlurHash.decode(
blurHash = hash,
width = BloomDefaults.DECODE_SIZE_PX,
height = BloomDefaults.DECODE_SIZE_PX,
)?.asImageBitmap()
} ?: return@composed this
val density = LocalDensity.current
val pixelSize = remember(blurSize, density) { blurSize.toIntSize(density) }
val clipToPixelSize = remember(clipToSize, density) { clipToSize.toIntSize(density) }
val bottomSoftEdgeHeightPixels = remember(bottomSoftEdgeHeight, density) { with(density) { bottomSoftEdgeHeight.roundToPx() } }
val isRTL = LocalLayoutDirection.current == LayoutDirection.Rtl
drawWithCache {
val dstSize = if (pixelSize != IntSize.Zero) {
pixelSize
} else {
IntSize(size.width.toInt(), size.height.toInt())
}
// Calculate where to place the center of the bloom effect
val centerOffset = if (offset.isSpecified) {
if (isRTL) {
IntOffset(
size.width.roundToInt() - offset.x.roundToPx(),
size.height.roundToInt() - offset.y.roundToPx(),
)
} else {
IntOffset(
offset.x.roundToPx(),
offset.y.roundToPx(),
)
}
} else {
IntOffset(
size.center.x.toInt(),
size.center.y.toInt(),
)
}
// Calculate the offset to draw the different layers and apply clipping
// This offset is applied to place the top left corner of the bloom effect
val layersOffset = if (offset.isSpecified) {
// Offsets the layers so the center of the bloom effect is at the provided offset value
IntOffset(
centerOffset.x - dstSize.width / 2,
centerOffset.y - dstSize.height / 2,
)
} else {
// Places the layers at the center of the component
IntOffset.Zero
}
val radius = max(dstSize.width, dstSize.height).toFloat() / 2
val circularGradientShader = RadialGradientShader(
centerOffset.toOffset(),
radius,
listOf(Color.Red, Color.Transparent),
listOf(0f, 1f)
)
val circularGradientBrush = ShaderBrush(circularGradientShader)
val bottomEdgeGradient = LinearGradientShader(
from = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeightPixels).toOffset(),
to = IntOffset(0, clipToPixelSize.height).toOffset(),
listOf(Color.Transparent, bottomSoftEdgeColor),
listOf(0f, 1f)
)
val bottomEdgeGradientBrush = ShaderBrush(bottomEdgeGradient)
onDrawBehind {
if (dstSize != IntSize.Zero) {
val circleClipPath = Path().apply {
addOval(Rect(centerOffset.toOffset(), radius - 1))
}
// Clip the external radius of bloom gradient too, otherwise we have a 1px border
clipPath(circleClipPath, clipOp = ClipOp.Intersect) {
// Draw the bloom layers
drawWithLayer {
// Clip rect to the provided size if needed
if (clipToPixelSize != IntSize.Zero) {
drawContext.canvas.clipRect(Rect(Offset.Zero, clipToPixelSize.toSize()), ClipOp.Intersect)
}
// Draw background color for blending
drawRect(background, size = pixelSize.toSize())
// Draw layers
for (layer in layers) {
drawImage(
hashedBitmap,
srcSize = IntSize(BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS),
dstSize = dstSize,
dstOffset = layersOffset,
alpha = layer.alpha * alpha,
blendMode = layer.blendMode,
)
}
// Mask the layers erasing the outer radius using the gradient brush
drawCircle(
circularGradientBrush,
radius,
centerOffset.toOffset(),
blendMode = BlendMode.DstIn
)
}
}
// Draw the bottom soft edge
drawRect(
bottomEdgeGradientBrush,
topLeft = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeight.roundToPx()).toOffset(),
size = IntSize(pixelSize.width, bottomSoftEdgeHeight.roundToPx()).toSize(),
alpha = bottomSoftEdgeAlpha
)
}
}
}
}
/**
* Bloom effect modifier for avatars. Applies a bloom effect to the component.
* @param avatarData The avatar data to use as the bloom source.
* If the avatar data has a URL it will be used as the bloom source, otherwise the initials will be used.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect.
*/
@SuppressWarnings("ModifierComposed")
fun Modifier.avatarBloom(
avatarData: AvatarData,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
bottomSoftEdgeColor: Color = background,
bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f,
@FloatRange(from = 0.0, to = 1.0)
alpha: Float = 1f,
) = composed {
// Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
// Request the avatar contents to use as the bloom source
val context = LocalContext.current
if (avatarData.url != null) {
val painterRequest = remember(avatarData) {
ImageRequest.Builder(context)
.data(avatarData)
// Allow cache and default dispatchers
.defaults(ImageRequest.Defaults())
// Needed to be able to read pixels from the Bitmap for the hash
.allowHardware(false)
// Reduce size so it loads faster for large avatars
.size(BloomDefaults.ENCODE_SIZE_PX, BloomDefaults.ENCODE_SIZE_PX)
.build()
}
// By making it saveable, we'll 'cache' the previous bloom effect until a new one is loaded
var blurHash by rememberSaveable(avatarData) { mutableStateOf<String?>(null) }
LaunchedEffect(avatarData) {
withContext(Dispatchers.IO) {
val bitmap = SingletonImageLoader.get(context)
.execute(painterRequest)
.image
?.toBitmap()
?: return@withContext
blurHash = BlurHash.encode(
bitmap = bitmap,
componentX = BloomDefaults.HASH_COMPONENTS,
componentY = BloomDefaults.HASH_COMPONENTS,
)
}
}
bloom(
hash = blurHash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
bottomSoftEdgeColor = bottomSoftEdgeColor,
bottomSoftEdgeHeight = bottomSoftEdgeHeight,
bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
alpha = alpha,
)
} else {
// There is no URL so we'll generate an avatar with the initials and use that as the bloom source
val avatarColors = AvatarColorsProvider.provide(avatarData.id)
val initialsBitmap = initialsBitmap(
width = BloomDefaults.ENCODE_SIZE_PX.toDp(),
height = BloomDefaults.ENCODE_SIZE_PX.toDp(),
text = avatarData.initialLetter,
textColor = avatarColors.foreground,
backgroundColor = avatarColors.background,
)
val hash = remember(avatarData, avatarColors) {
BlurHash.encode(
bitmap = initialsBitmap.asAndroidBitmap(),
componentX = BloomDefaults.HASH_COMPONENTS,
componentY = BloomDefaults.HASH_COMPONENTS,
)
}
bloom(
hash = hash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
bottomSoftEdgeColor = bottomSoftEdgeColor,
bottomSoftEdgeHeight = bottomSoftEdgeHeight,
bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
alpha = alpha,
)
}
}
// Used to create a Bitmap version of the initials avatar
@Composable
private fun initialsBitmap(
text: String,
backgroundColor: Color,
textColor: Color,
width: Dp = 32.dp,
height: Dp = 32.dp,
): ImageBitmap = with(LocalDensity.current) {
val backgroundPaint = remember(backgroundColor) {
Paint().also { it.color = backgroundColor }
}
val resolver: FontFamily.Resolver = LocalFontFamilyResolver.current
val fontSize = remember { height.toSp() / 2 }
val typeface: Typeface = remember(resolver) {
resolver.resolve(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Normal,
)
}.value as Typeface
val textPaint = remember(textColor, typeface) {
TextPaint().apply {
color = textColor.toArgb()
textSize = fontSize.toPx()
this.typeface = typeface
}
}
val textMeasurer = rememberTextMeasurer()
val result = remember(text) { textMeasurer.measure(text, TextStyle.Default.copy(fontSize = fontSize)) }
val centerPx = remember(width, height) { IntOffset(width.roundToPx() / 2, height.roundToPx() / 2) }
remember(text, width, height, backgroundColor, textColor) {
val bitmap = Bitmap.createBitmap(width.roundToPx(), height.roundToPx(), Bitmap.Config.ARGB_8888).asImageBitmap()
androidx.compose.ui.graphics.Canvas(bitmap).also { canvas ->
canvas.drawCircle(centerPx.toOffset(), width.toPx() / 2, backgroundPaint)
canvas.nativeCanvas.drawText(text, centerPx.x.toFloat() - result.size.width / 2, centerPx.y * 2f - result.size.height / 2 - 4, textPaint)
}
bitmap
}
}
// Translates DP sizes into pixel sizes, taking into account unspecified values
private fun DpSize.toIntSize(density: Density) = with(density) {
if (isSpecified) {
IntSize(width.roundToPx(), height.roundToPx())
} else {
IntSize.Zero
}
}
/**
* Helper to draw to a canvas using layers. This allows us to apply clipping to those layers only.
*/
fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@ShowkaseComposable(group = PreviewGroup.Bloom)
@Composable
internal fun BloomPreview() {
val blurhash = "eePn{tI?xExEja}ooKWWodjtNJoKR,j@a|sBWpS3WDbGazoKWWWWj@"
var topAppBarHeight by remember { mutableIntStateOf(-1) }
val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState)
ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) {
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Box {
MediumTopAppBar(
modifier = Modifier
.onSizeChanged { size ->
topAppBarHeight = size.height
}
.bloom(
hash = blurhash,
background = ElementTheme.colors.bgCanvasDefault,
blurSize = DpSize(430.dp, 430.dp),
offset = DpOffset(24.dp, 24.dp),
clipToSize = if (topAppBarHeight > 0) DpSize(430.dp, topAppBarHeight.toDp()) else DpSize.Zero,
),
colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Black.copy(alpha = 0.05f),
),
navigationIcon = {
Avatar(
avatarData = AvatarData(
id = "sample-avatar",
name = "sample",
url = "aURL",
size = AvatarSize.CurrentUserTopBar,
),
avatarType = AvatarType.User,
)
},
actions = {
IconButton(onClick = {}) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = null,
)
}
},
title = {
Text("Title")
},
scrollBehavior = scrollBehavior,
)
}
},
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
repeat(20) {
Text("Content", modifier = Modifier.padding(vertical = 20.dp))
}
}
}
}
}
class InitialsColorIntProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int>
get() = sequenceOf(0, 1, 2, 3, 4, 5, 6, 7)
}
@PreviewsDayNight
@Composable
@ShowkaseComposable(group = PreviewGroup.Bloom)
internal fun BloomInitialsPreview(@PreviewParameter(InitialsColorIntProvider::class) color: Int) {
ElementPreview {
val avatarColors = AvatarColorsProvider.provide("$color")
val bitmap = initialsBitmap(text = "F", backgroundColor = avatarColors.background, textColor = avatarColors.foreground)
val hash = BlurHash.encode(
bitmap = bitmap.asAndroidBitmap(),
componentX = BloomDefaults.HASH_COMPONENTS,
componentY = BloomDefaults.HASH_COMPONENTS,
)
Box(
modifier = Modifier
.size(256.dp)
.bloom(
hash = hash,
background = if (ElementTheme.isLightTheme) {
// Workaround to display a very subtle bloom for avatars with very soft colors
Color(0xFFF9F9F9)
} else {
ElementTheme.colors.bgCanvasDefault
},
bottomSoftEdgeColor = ElementTheme.colors.bgCanvasDefault,
blurSize = DpSize(256.dp, 256.dp),
),
contentAlignment = Alignment.Center
) {
Image(
modifier = Modifier
.size(32.dp)
.clip(CircleShape),
painter = BitmapPainter(bitmap),
contentDescription = null
)
}
}
}

View file

@ -42,7 +42,6 @@ fun MainActionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentDescription: String = title,
) {
val ripple = ripple(bounded = false)
val interactionSource = remember { MutableInteractionSource() }
@ -58,8 +57,8 @@ fun MainActionButton(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
contentDescription = null,
imageVector = imageVector,
contentDescription = contentDescription,
tint = if (enabled) LocalContentColor.current else ElementTheme.colors.iconDisabled,
)
Spacer(modifier = Modifier.height(14.dp))

View file

@ -36,10 +36,8 @@ import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import io.element.android.compound.annotations.CoreColorToken
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.internal.DarkColorTokens
import io.element.android.compound.tokens.generated.internal.LightColorTokens
import io.element.android.libraries.designsystem.colors.gradientActionColors
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
@ -47,7 +45,6 @@ import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.lowHorizontalPaddingValue
@OptIn(CoreColorToken::class)
@Composable
fun SuperButton(
onClick: () -> Unit,
@ -66,34 +63,21 @@ fun SuperButton(
ButtonSize.Small -> PaddingValues(horizontal = 16.dp, vertical = 5.dp)
}
}
val isLightTheme = ElementTheme.isLightTheme
val colors = if (LocalBuildMeta.current.isEnterpriseBuild) {
listOf(
ElementTheme.colors.textActionAccent,
ElementTheme.colors.textActionAccent,
)
} else {
remember(isLightTheme) {
if (isLightTheme) {
listOf(
LightColorTokens.colorBlue900,
LightColorTokens.colorGreen1100,
)
} else {
listOf(
DarkColorTokens.colorBlue900,
DarkColorTokens.colorGreen1100,
)
}
}
gradientActionColors()
}
val shaderBrush = remember(colors) {
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
return LinearGradientShader(
from = Offset(0f, size.height),
to = Offset(size.width, 0f),
from = Offset(0f, 0f),
to = Offset(0f, size.height),
colors = colors,
)
}

View file

@ -0,0 +1,97 @@
/*
* 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.designsystem.modifiers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.gradientSubtleColors
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
/**
* Ref: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Workspaces-V1?node-id=1141-24692
*/
@Stable
@Composable
fun Modifier.backgroundVerticalGradient(
isVisible: Boolean = true,
isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild,
): Modifier {
if (!isVisible) return this
return background(
brush = Brush.verticalGradient(
colorStops = subtleColorStops(isEnterpriseBuild),
),
)
}
@Composable
fun subtleColorStops(
isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild,
): Array<Pair<Float, Color>> {
return buildList {
if (isEnterpriseBuild) {
// For enterprise builds, ensure that we are theming the gradient
add(0f to ElementTheme.colors.textActionAccent.copy(alpha = 0.5f))
add(0.75f to ElementTheme.colors.bgCanvasDefault)
add(1f to Color.Transparent)
} else {
val colors = gradientSubtleColors()
colors.forEachIndexed { index, color ->
add(index.toFloat() / (colors.size - 1) to color)
}
}
}.toTypedArray()
}
@PreviewsDayNight
@Composable
internal fun BackgroundVerticalGradientPreview() = ElementPreview {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = 100.dp)
.backgroundVerticalGradient()
)
}
@PreviewsDayNight
@Composable
internal fun BackgroundVerticalGradientEnterprisePreview() = ElementPreview {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = 100.dp)
.backgroundVerticalGradient(
isEnterpriseBuild = true,
)
)
}
@PreviewsDayNight
@Composable
internal fun BackgroundVerticalGradientDisabledPreview() = ElementPreview {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = 100.dp)
.backgroundVerticalGradient(
isVisible = false,
)
)
}

View file

@ -11,7 +11,6 @@ package io.element.android.libraries.designsystem.preview
object PreviewGroup {
const val AppBars = "App Bars"
const val Avatars = "Avatars"
const val Bloom = "Bloom"
const val BottomSheets = "Bottom Sheets"
const val Buttons = "Buttons"
const val DateTimePickers = "DateTime pickers"

View file

@ -22,14 +22,14 @@ import kotlinx.collections.immutable.persistentMapOf
/**
* Room list.
*/
@Composable
fun ElementTheme.roomListRoomName() = colors.textPrimary
val SemanticColors.roomListRoomName
get() = textPrimary
@Composable
fun ElementTheme.roomListRoomMessage() = colors.textSecondary
val SemanticColors.roomListRoomMessage
get() = textSecondary
@Composable
fun ElementTheme.roomListRoomMessageDate() = colors.textSecondary
val SemanticColors.roomListRoomMessageDate
get() = textSecondary
val SemanticColors.unreadIndicator
get() = iconAccentTertiary
@ -52,11 +52,6 @@ val SemanticColors.messageFromOtherBackground
val SemanticColors.progressIndicatorTrackColor
get() = if (isLight) LightColorTokens.colorAlphaGray500 else DarkColorTokens.colorAlphaGray500
// This color is not present in Semantic color, so put hard-coded value for now
@OptIn(CoreColorToken::class)
val SemanticColors.iconSuccessPrimaryBackground
get() = if (isLight) LightColorTokens.colorGreen300 else DarkColorTokens.colorGreen300
// This color is not present in Semantic color, so put hard-coded value for now
@OptIn(CoreColorToken::class)
val SemanticColors.bgSubtleTertiary
@ -71,57 +66,6 @@ val SemanticColors.temporaryColorBgSpecial
val SemanticColors.pinDigitBg
get() = if (isLight) LightColorTokens.colorGray300 else DarkColorTokens.colorGray400
@OptIn(CoreColorToken::class)
val SemanticColors.currentUserMentionPillText
get() = if (isLight) LightColorTokens.colorGreen1100 else DarkColorTokens.colorGreen1100
val SemanticColors.currentUserMentionPillBackground
get() = if (isLight) {
// We want LightDesignTokens.colorGreenAlpha400
Color(0x3b07b661)
} else {
// We want DarkDesignTokens.colorGreenAlpha500
Color(0xff003d29)
}
val SemanticColors.mentionPillText
get() = textPrimary
val SemanticColors.mentionPillBackground
get() = if (isLight) {
// We want LightDesignTokens.colorGray400
Color(0x1f052e61)
} else {
// We want DarkDesignTokens.colorGray500
Color(0x26f4f7fa)
}
@OptIn(CoreColorToken::class)
val SemanticColors.bigCheckmarkBorderColor
get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray400
@OptIn(CoreColorToken::class)
val SemanticColors.highlightedMessageBackgroundColor
get() = if (isLight) LightColorTokens.colorGreen300 else DarkColorTokens.colorGreen300
// Badge colors
@OptIn(CoreColorToken::class)
val SemanticColors.badgeNeutralBackgroundColor
get() = if (isLight) LightColorTokens.colorAlphaGray300 else DarkColorTokens.colorAlphaGray300
@OptIn(CoreColorToken::class)
val SemanticColors.badgeNeutralContentColor
get() = if (isLight) LightColorTokens.colorGray1100 else DarkColorTokens.colorGray1100
@OptIn(CoreColorToken::class)
val SemanticColors.badgeNegativeBackgroundColor
get() = if (isLight) LightColorTokens.colorAlphaRed300 else DarkColorTokens.colorAlphaRed300
@OptIn(CoreColorToken::class)
val SemanticColors.badgeNegativeContentColor
get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100
@OptIn(CoreColorToken::class)
val SemanticColors.pinnedMessageBannerIndicator
get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600
@ -137,18 +81,15 @@ internal fun ColorAliasesPreview() = ElementPreview {
backgroundColor = Color.Black,
foregroundColor = Color.White,
colors = persistentMapOf(
"roomListRoomName" to ElementTheme.roomListRoomName(),
"roomListRoomMessage" to ElementTheme.roomListRoomMessage(),
"roomListRoomMessageDate" to ElementTheme.roomListRoomMessageDate(),
"roomListRoomName" to ElementTheme.colors.roomListRoomName,
"roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage,
"roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate,
"unreadIndicator" to ElementTheme.colors.unreadIndicator,
"placeholderBackground" to ElementTheme.colors.placeholderBackground,
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
"progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor,
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
"bigCheckmarkBorderColor" to ElementTheme.colors.bigCheckmarkBorderColor,
"highlightedMessageBackgroundColor" to ElementTheme.colors.highlightedMessageBackgroundColor,
)
)
}

View file

@ -0,0 +1,22 @@
/*
* 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.designsystem.utils
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
/**
* Helper to draw to a canvas using layers. This allows us to apply clipping to those layers only.
*/
fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}

View file

@ -175,4 +175,13 @@ enum class FeatureFlags(
// False so it's displayed in the developer options screen
isFinished = false,
),
SharePos(
key = "feature.share_pos",
title = "Share pos in sliding sync",
description = "Keep the sliding sync pos to make initial syncs faster. Requires an app restart to take effect." +
"\n\nWARNING: this may cause issues with syncs.",
defaultValue = { false },
// False so it's displayed in the developer options screen
isFinished = false,
),
}

View file

@ -0,0 +1,28 @@
/*
* 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.matrix.api.exception
/**
* Exceptions that can occur while resolving the events associated to push notifications.
*/
sealed class NotificationResolverException : Exception() {
/**
* The event was not found by the notification service.
*/
data object EventNotFound : NotificationResolverException()
/**
* The event was found but it was filtered out by the notification service.
*/
data object EventFilteredOut : NotificationResolverException()
/**
* An unexpected error occurred while trying to resolve the event.
*/
data class UnknownError(override val message: String) : NotificationResolverException()
}

View file

@ -68,7 +68,6 @@ sealed interface NotificationContent {
) : MessageLike
data object RoomEncrypted : MessageLike
data object UnableToResolve : MessageLike
data class RoomMessage(
val senderId: UserId,
val messageType: MessageType

View file

@ -10,6 +10,19 @@ package io.element.android.libraries.matrix.api.notification
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
/**
* Represents the resolution state of an attempt to retrieve notification data for a set of event ids.
* The outer [Result] indicates the success or failure of the setup to retrieve notifications.
* The inner [Result] for each [EventId] in the map indicates whether the notification data was successfully retrieved or if there was an error.
*/
typealias GetNotificationDataResult = Result<Map<EventId, Result<NotificationData>>>
/**
* Service to retrieve notifications for a given set of event ids in specific rooms.
*/
interface NotificationService {
suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): Result<Map<EventId, NotificationData>>
/**
* Fetch notifications for the specified event ids in the given rooms.
*/
suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): GetNotificationDataResult
}

View file

@ -78,6 +78,7 @@ class RustMatrixClientFactory @Inject constructor(
client.setUtdDelegate(UtdTracker(analyticsService))
val syncService = client.syncService()
.withSharePos(enable = featureFlagService.isFeatureEnabled(FeatureFlags.SharePos))
.withOfflineMode()
.finish()

View file

@ -43,7 +43,6 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.HumanQrLoginException
import org.matrix.rustcomponents.sdk.OidcConfiguration
import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress
@ -199,6 +198,8 @@ class RustMatrixAuthenticationService @Inject constructor(
oidcConfiguration = oidcConfigurationProvider.get(),
prompt = prompt.toRustPrompt(),
loginHint = loginHint,
// If we want to restore a previous session for which we have encryption keys, we can pass the deviceId here. At the moment, we don't
deviceId = null,
)
val url = oAuthAuthorizationData.loginUrl()
pendingOAuthAuthorizationData = oAuthAuthorizationData
@ -274,9 +275,13 @@ class RustMatrixAuthenticationService @Inject constructor(
sessionPaths = emptySessionPaths,
passphrase = pendingPassphrase,
qrCodeData = sdkQrCodeLoginData,
)
client.loginWithQrCode(
qrCodeData = qrCodeData.rustQrCodeData,
oidcConfiguration = oidcConfiguration,
progressListener = progressListener,
)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
@ -324,8 +329,6 @@ class RustMatrixAuthenticationService @Inject constructor(
sessionPaths: SessionPaths,
passphrase: String?,
qrCodeData: QrCodeData,
oidcConfiguration: OidcConfiguration,
progressListener: QrLoginProgressListener,
): Client {
Timber.d("Creating client for QR Code login with simplified sliding sync")
return rustMatrixClientFactory
@ -335,7 +338,8 @@ class RustMatrixAuthenticationService @Inject constructor(
slidingSyncType = ClientBuilderSlidingSync.Discovered,
)
.sessionPassphrase(passphrase)
.buildWithQrCode(qrCodeData, oidcConfiguration, progressListener)
.serverNameOrHomeserverUrl(qrCodeData.serverName()!!)
.build()
}
private fun clear() {

View file

@ -12,26 +12,29 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.GetNotificationDataResult
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BatchNotificationResult
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
import org.matrix.rustcomponents.sdk.NotificationStatus
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
class RustNotificationService(
private val sessionId: SessionId,
private val notificationClient: NotificationClient,
private val dispatchers: CoroutineDispatchers,
private val clock: SystemClock,
clock: SystemClock,
) : NotificationService {
private val notificationMapper: NotificationMapper = NotificationMapper(clock)
override suspend fun getNotifications(
ids: Map<RoomId, List<EventId>>
): Result<Map<EventId, NotificationData>> = withContext(dispatchers.io) {
): GetNotificationDataResult = withContext(dispatchers.io) {
runCatchingExceptions {
val requests = ids.map { (roomId, eventIds) ->
NotificationItemsRequest(
@ -42,34 +45,41 @@ class RustNotificationService(
val items = notificationClient.getNotifications(requests)
buildMap {
val eventIds = requests.flatMap { it.eventIds }
for (eventId in eventIds) {
val item = items[eventId]
val roomId = RoomId(requests.find { it.eventIds.contains(eventId) }?.roomId!!)
if (item != null) {
put(EventId(eventId), notificationMapper.map(sessionId, EventId(eventId), roomId, item))
} else {
Timber.e("Could not retrieve event for notification with $eventId")
put(
EventId(eventId),
NotificationData(
sessionId = sessionId,
eventId = EventId(eventId),
threadId = null,
roomId = roomId,
senderAvatarUrl = null,
senderDisplayName = null,
senderIsNameAmbiguous = false,
roomAvatarUrl = null,
roomDisplayName = null,
isDirect = false,
isDm = false,
isEncrypted = false,
isNoisy = false,
timestamp = clock.epochMillis(),
content = NotificationContent.MessageLike.UnableToResolve,
hasMention = false
)
)
for (rawEventId in eventIds) {
val roomId = RoomId(requests.find { it.eventIds.contains(rawEventId) }?.roomId!!)
val eventId = EventId(rawEventId)
items[rawEventId].use { result ->
when (result) {
is BatchNotificationResult.Ok -> {
when (val status = result.status) {
is NotificationStatus.Event -> {
put(eventId, Result.success(notificationMapper.map(sessionId, eventId, roomId, status.item)))
}
is NotificationStatus.EventNotFound -> {
Timber.e("Could not retrieve event for notification with $eventId - event not found")
put(eventId, Result.failure(NotificationResolverException.EventNotFound))
}
is NotificationStatus.EventFilteredOut -> {
Timber.d("Could not retrieve event for notification with $eventId - event filtered out")
put(eventId, Result.failure(NotificationResolverException.EventFilteredOut))
}
}
}
is BatchNotificationResult.Error -> {
Timber.e("Error while retrieving notification with $rawEventId - ${result.message}")
put(
eventId,
Result.failure(NotificationResolverException.UnknownError(result.message))
)
}
null -> {
Timber.e("The notification data for $rawEventId was not in the retrieved results. This is unexpected.")
put(
eventId,
Result.failure(NotificationResolverException.UnknownError("Notification data not found"))
)
}
}
}
}
}

View file

@ -16,6 +16,7 @@ import org.matrix.rustcomponents.sdk.NotificationEvent
import org.matrix.rustcomponents.sdk.NotificationItem
import org.matrix.rustcomponents.sdk.NotificationRoomInfo
import org.matrix.rustcomponents.sdk.NotificationSenderInfo
import org.matrix.rustcomponents.sdk.NotificationStatus
import org.matrix.rustcomponents.sdk.TimelineEvent
fun aRustNotificationItem(
@ -34,6 +35,12 @@ fun aRustNotificationItem(
threadId = threadId?.value,
)
fun aRustBatchNotificationResult(
notificationStatus: NotificationStatus = NotificationStatus.Event(aRustNotificationItem()),
) = org.matrix.rustcomponents.sdk.BatchNotificationResult.Ok(
status = notificationStatus,
)
fun aRustNotificationSenderInfo(
displayName: String? = A_USER_NAME,
avatarUrl: String? = null,

View file

@ -11,9 +11,6 @@ import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.ClientSessionDelegate
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.OidcConfiguration
import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
import org.matrix.rustcomponents.sdk.RequestConfig
import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder
import uniffi.matrix_sdk.BackupDownloadStrategy
@ -43,10 +40,6 @@ class FakeFfiClientBuilder : ClientBuilder(NoPointer) {
override fun username(username: String) = this
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
override suspend fun buildWithQrCode(qrCodeData: QrCodeData, oidcConfiguration: OidcConfiguration, progressListener: QrLoginProgressListener): Client {
return FakeFfiClient()
}
override suspend fun build(): Client {
return FakeFfiClient(withUtdHook = {})
}

View file

@ -7,16 +7,16 @@
package io.element.android.libraries.matrix.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.BatchNotificationResult
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationItem
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
class FakeFfiNotificationClient(
var notificationItemResult: Map<String, NotificationItem> = emptyMap(),
var notificationItemResult: Map<String, BatchNotificationResult> = emptyMap(),
val closeResult: () -> Unit = { }
) : NotificationClient(NoPointer) {
override suspend fun getNotifications(requests: List<NotificationItemsRequest>): Map<String, NotificationItem> {
override suspend fun getNotifications(requests: List<NotificationItemsRequest>): Map<String, BatchNotificationResult> {
return notificationItemResult
}

View file

@ -13,5 +13,6 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoPointer) {
override fun withOfflineMode(): SyncServiceBuilder = this
override fun withSharePos(enable: Boolean): SyncServiceBuilder = this
override suspend fun finish(): SyncService = FakeFfiSyncService()
}

View file

@ -8,9 +8,10 @@
package io.element.android.libraries.matrix.impl.notification
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
@ -30,12 +31,12 @@ class RustNotificationServiceTest {
@Test
fun test() = runTest {
val notificationClient = FakeFfiNotificationClient(
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustNotificationItem()),
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResult()),
)
val sut = createRustNotificationService(
notificationClient = notificationClient,
)
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!.getOrThrow()
assertThat(result.isEncrypted).isTrue()
assertThat(result.content).isEqualTo(
NotificationContent.MessageLike.RoomMessage(
@ -56,10 +57,8 @@ class RustNotificationServiceTest {
val sut = createRustNotificationService(
notificationClient = notificationClient,
)
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!
assertThat(result.content).isEqualTo(
NotificationContent.MessageLike.UnableToResolve
)
val exception = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!.exceptionOrNull()
assertThat(exception).isInstanceOf(NotificationResolverException::class.java)
}
@Test

View file

@ -13,13 +13,13 @@ import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService
class FakeNotificationService : NotificationService {
private var getNotificationsResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap())
private var getNotificationsResult: Result<Map<EventId, Result<NotificationData>>> = Result.success(emptyMap())
fun givenGetNotificationsResult(result: Result<Map<EventId, NotificationData>>) {
fun givenGetNotificationsResult(result: Result<Map<EventId, Result<NotificationData>>>) {
getNotificationsResult = result
}
override suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): Result<Map<EventId, NotificationData>> {
override suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): Result<Map<EventId, Result<NotificationData>>> {
return getNotificationsResult
}
}

View file

@ -62,6 +62,7 @@ fun EditableAvatarView(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
onClickLabel = stringResource(CommonStrings.a11y_edit_avatar),
onClick = onAvatarClick,
indication = ripple(bounded = false),
)

View file

@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -48,9 +51,24 @@ fun SelectedUser(
onUserRemove: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
) {
val actionRemove = stringResource(id = CommonStrings.action_remove)
Box(
modifier = modifier
.width(AvatarSize.SelectedUser.dp)
.clearAndSetSemantics {
contentDescription = matrixUser.getBestName()
if (canRemove) {
// Note: this does not set the click effect to the whole Box
// when talkback is not enabled
onClick(
label = actionRemove,
action = {
onUserRemove(matrixUser)
true
}
)
}
}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@ -83,6 +101,7 @@ fun SelectedUser(
) {
Icon(
imageVector = CompoundIcons.Close(),
// Note: keep the context description for the test
contentDescription = stringResource(id = CommonStrings.action_remove),
tint = ElementTheme.colors.iconOnSolidPrimary,
modifier = Modifier.padding(2.dp)

View file

@ -33,8 +33,10 @@ dependencies {
implementation(projects.services.toolbox.api)
implementation(libs.inject)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.media3.transformer)
implementation(libs.androidx.media3.effect)
implementation(libs.androidx.media3.common)
implementation(libs.coroutines.core)
implementation(libs.otaliastudios.transcoder)
implementation(libs.vanniktech.blurhash)
testImplementation(libs.test.junit)

View file

@ -192,12 +192,19 @@ class AndroidMediaPreProcessor @Inject constructor(
val resultFile = runCatchingExceptions {
videoCompressor.compress(uri, shouldBeCompressed)
.onEach {
// TODO handle progress
if (it is VideoTranscodingEvent.Progress) {
Timber.d("Video compression progress: ${it.value}%")
} else if (it is VideoTranscodingEvent.Completed) {
Timber.d("Video compression completed: ${it.file.path}")
}
}
.filterIsInstance<VideoTranscodingEvent.Completed>()
.first()
.file
}
.onFailure {
Timber.e(it, "Failed to compress video: $uri")
}
.getOrNull()
if (resultFile != null) {
@ -283,10 +290,17 @@ class AndroidMediaPreProcessor @Inject constructor(
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult?): VideoInfo =
MediaMetadataRetriever().runAndRelease {
setDataSource(context, Uri.fromFile(file))
val rotation = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
val rawWidth = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L
val rawHeight = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L
val (width, height) = if (rotation == 90 || rotation == 270) rawHeight to rawWidth else rawWidth to rawHeight
VideoInfo(
duration = extractDuration(),
width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L,
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
width = width,
height = height,
mimetype = mimeType,
size = file.length(),
thumbnailInfo = thumbnailResult?.info,

View file

@ -8,76 +8,146 @@
package io.element.android.libraries.mediaupload.impl
import android.content.Context
import android.media.MediaCodecInfo
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.webkit.MimeTypeMap
import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.internal.media.MediaFormatConstants
import com.otaliastudios.transcoder.resize.AtMostResizer
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy
import com.otaliastudios.transcoder.strategy.TrackStrategy
import com.otaliastudios.transcoder.validator.WriteAlwaysValidator
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.Size
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.Presentation
import androidx.media3.transformer.Composition
import androidx.media3.transformer.DefaultEncoderFactory
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.Effects
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.ProgressHolder
import androidx.media3.transformer.TransformationRequest
import androidx.media3.transformer.Transformer
import androidx.media3.transformer.VideoEncoderSettings
import io.element.android.libraries.androidutils.file.createTmpFile
import io.element.android.libraries.androidutils.file.getMimeType
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import javax.inject.Inject
private const val MP4_EXTENSION = "mp4"
class VideoCompressor @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun compress(uri: Uri, shouldBeCompressed: Boolean) = callbackFlow {
@OptIn(UnstableApi::class)
fun compress(uri: Uri, shouldBeCompressed: Boolean): Flow<VideoTranscodingEvent> = callbackFlow {
val metadata = getVideoMetadata(uri)
val expectedExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(context.getMimeType(uri))
val videoStrategy = VideoStrategyFactory.create(
expectedExtension = expectedExtension,
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
val tmpFile = context.createTmpFile(extension = MP4_EXTENSION)
val future = Transcoder.into(tmpFile.path)
.setVideoTrackStrategy(videoStrategy)
.addDataSource(context, uri)
// Force the output to be written, even if no transcoding was actually needed
.setValidator(WriteAlwaysValidator())
.setListener(object : TranscoderListener {
override fun onTranscodeProgress(progress: Double) {
trySend(VideoTranscodingEvent.Progress(progress.toFloat()))
}
val tmpFile = context.createTmpFile(extension = "mp4")
override fun onTranscodeCompleted(successCode: Int) {
val width = metadata?.width ?: Int.MAX_VALUE
val height = metadata?.height ?: Int.MAX_VALUE
val videoResizeEffect = videoCompressorConfig.resizer?.let {
val outputSize = it.getOutputSize(Size(width, height))
if (metadata?.rotation == 90 || metadata?.rotation == 270) {
// If the video is rotated, we need to swap width and height
Presentation.createForWidthAndHeight(
outputSize.height,
outputSize.width,
Presentation.LAYOUT_SCALE_TO_FIT,
)
} else {
// Otherwise, we can use the original width and height
Presentation.createForWidthAndHeight(
outputSize.width,
outputSize.height,
Presentation.LAYOUT_SCALE_TO_FIT,
)
}
}
// If we are resizing, we also want to reduce set frame rate to the default value (30fps)
val newFrameRate = videoCompressorConfig.newFrameRate
// If we need to resize the video, we also want to recalculate the bitrate
val newBitrate = videoCompressorConfig.newBitRate
val inputMediaItem = MediaItem.fromUri(uri)
val outputMediaItem = EditedMediaItem.Builder(inputMediaItem)
.setFrameRate(newFrameRate)
.run {
if (videoResizeEffect != null) {
setEffects(Effects(emptyList(), listOf(videoResizeEffect)))
} else {
this
}
}
.build()
val encoderFactory = DefaultEncoderFactory.Builder(context)
.setRequestedVideoEncoderSettings(
VideoEncoderSettings.Builder()
.setBitrateMode(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR)
.setBitrate(newBitrate)
.build()
)
.build()
val videoTransformer = Transformer.Builder(context)
.setVideoMimeType(MimeTypes.VIDEO_H264)
.setAudioMimeType(MimeTypes.AUDIO_AAC)
.setPortraitEncodingEnabled(false)
.setEncoderFactory(encoderFactory)
.addListener(object : Transformer.Listener {
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
trySend(VideoTranscodingEvent.Completed(tmpFile))
close()
}
override fun onTranscodeCanceled() {
override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) {
Timber.e(exportException, "Video transcoding failed")
tmpFile.safeDelete()
close()
close(exportException)
}
override fun onTranscodeFailed(exception: Throwable) {
tmpFile.safeDelete()
close(exception)
}
override fun onFallbackApplied(
composition: Composition,
originalTransformationRequest: TransformationRequest,
fallbackTransformationRequest: TransformationRequest
) = Unit
})
.transcode()
.build()
val progressJob = launch(Dispatchers.Main) {
val progressHolder = ProgressHolder()
while (isActive) {
val state = videoTransformer.getProgress(progressHolder)
if (state != Transformer.PROGRESS_STATE_NOT_STARTED) {
channel.send(VideoTranscodingEvent.Progress(progressHolder.progress.toFloat()))
}
delay(500)
}
}
withContext(Dispatchers.Main) {
videoTransformer.start(outputMediaItem, tmpFile.path)
}
awaitClose {
if (!future.isDone) {
future.cancel(true)
}
progressJob.cancel()
}
}
@ -89,7 +159,8 @@ class VideoCompressor @Inject constructor(
val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: -1
val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: -1
val bitrate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toLongOrNull() ?: -1
val framerate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)?.toIntOrNull() ?: -1
val frameRate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)?.toIntOrNull() ?: -1
val rotation = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
val (actualWidth, actualHeight) = if (width == -1 || height == -1) {
// Try getting the first frame instead
@ -103,7 +174,8 @@ class VideoCompressor @Inject constructor(
width = actualWidth,
height = actualHeight,
bitrate = bitrate,
frameRate = framerate
frameRate = frameRate,
rotation = rotation,
)
}
}.onFailure {
@ -113,53 +185,14 @@ class VideoCompressor @Inject constructor(
}
internal data class VideoFileMetadata(
val width: Int?,
val height: Int?,
val bitrate: Long?,
val frameRate: Int?,
val width: Int,
val height: Int,
val bitrate: Long,
val frameRate: Int,
val rotation: Int,
)
sealed interface VideoTranscodingEvent {
data class Progress(val value: Float) : VideoTranscodingEvent
data class Completed(val file: File) : VideoTranscodingEvent
}
internal object VideoStrategyFactory {
// 720p
private const val MAX_COMPRESSED_PIXEL_SIZE = 1280
// 1080p
private const val MAX_PIXEL_SIZE = 1920
fun create(
expectedExtension: String?,
metadata: VideoFileMetadata?,
shouldBeCompressed: Boolean,
): TrackStrategy {
val width = metadata?.width ?: Int.MAX_VALUE
val height = metadata?.height ?: Int.MAX_VALUE
val bitrate = metadata?.bitrate
val frameRate = metadata?.frameRate
// We only create a resizer if needed
val resizer = when {
shouldBeCompressed && (width > MAX_COMPRESSED_PIXEL_SIZE || height > MAX_COMPRESSED_PIXEL_SIZE) -> AtMostResizer(MAX_COMPRESSED_PIXEL_SIZE)
width > MAX_PIXEL_SIZE || height > MAX_PIXEL_SIZE -> AtMostResizer(MAX_PIXEL_SIZE)
else -> null
}
return if (resizer == null && expectedExtension == MP4_EXTENSION) {
// If there's no transcoding or resizing needed for the video file, just create a new file with the same contents but no metadata
PassThroughTrackStrategy()
} else {
DefaultVideoStrategy.Builder()
.apply {
resizer?.let { addResizer(it) }
bitrate?.let { bitRate(it) }
frameRate?.let { frameRate(it) }
}
.mimeType(MediaFormatConstants.MIMETYPE_VIDEO_AVC)
.build()
}
}
}

View file

@ -0,0 +1,93 @@
/*
* 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.mediaupload.impl
import androidx.annotation.OptIn
import androidx.media3.common.util.Size
import androidx.media3.common.util.UnstableApi
import androidx.media3.transformer.VideoEncoderSettings
import kotlin.math.min
import kotlin.math.roundToInt
@OptIn(UnstableApi::class)
internal object VideoCompressorConfigFactory {
// Major dimension of 720p
private const val MAX_COMPRESSED_PIXEL_SIZE = 1280
// Major dimension of 1080p
private const val MAX_PIXEL_SIZE = 1920
private const val DEFAULT_FRAME_RATE = 30
fun create(
metadata: VideoFileMetadata?,
shouldBeCompressed: Boolean,
): VideoCompressorConfig {
val width = metadata?.width?.takeIf { it >= 0 } ?: Int.MAX_VALUE
val height = metadata?.height?.takeIf { it >= 0 } ?: Int.MAX_VALUE
val originalBitrate = metadata?.bitrate?.takeIf { it >= 0 }
val originalFrameRate = metadata?.frameRate?.takeIf { it >= 0 } ?: DEFAULT_FRAME_RATE
// We only create a resizer if needed
val resizer = when {
shouldBeCompressed && (width > MAX_COMPRESSED_PIXEL_SIZE || height > MAX_COMPRESSED_PIXEL_SIZE) -> VideoResizer(MAX_COMPRESSED_PIXEL_SIZE)
width > MAX_PIXEL_SIZE || height > MAX_PIXEL_SIZE -> VideoResizer(MAX_PIXEL_SIZE)
else -> null
}
// If we are resizing, we also want to reduce the frame rate to the default value (30fps)
val newFrameRate = if (resizer is VideoResizer) {
min(originalFrameRate, DEFAULT_FRAME_RATE)
} else {
originalFrameRate
}
// If we need to resize the video, we also want to recalculate the bitrate
val newBitrate = if (resizer is VideoResizer) {
val maxSize = resizer.getOutputSize(Size(width, height))
val pixelsPerFrame = maxSize.width * maxSize.height
val frameRate = newFrameRate
// Apparently, 0.1 bits per pixel is a sweet spot for video compression
val bitsPerPixel = 0.1f
(pixelsPerFrame * bitsPerPixel * frameRate).toLong()
} else {
originalBitrate
}
return VideoCompressorConfig(
resizer = resizer,
newBitRate = newBitrate?.toInt() ?: VideoEncoderSettings.NO_VALUE,
newFrameRate = newFrameRate,
)
}
}
@OptIn(UnstableApi::class)
internal data class VideoCompressorConfig(
val resizer: VideoResizer?,
val newBitRate: Int,
val newFrameRate: Int,
)
@OptIn(UnstableApi::class)
internal class VideoResizer(
val maxSize: Int,
) {
fun getOutputSize(inputSize: Size): Size {
val resultMajor = min(inputSize.major(), maxSize)
val aspectRatio = inputSize.major().toFloat() / inputSize.minor().toFloat()
return Size(resultMajor, (resultMajor / aspectRatio).roundToInt())
}
}
@OptIn(UnstableApi::class)
internal fun Size.major(): Int = if (width > height) width else height
@OptIn(UnstableApi::class)
internal fun Size.minor(): Int = if (width < height) width else height

View file

@ -0,0 +1,108 @@
/*
* 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.mediaupload.impl
import androidx.media3.transformer.VideoEncoderSettings
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@Suppress("NOTHING_TO_INLINE")
@RunWith(RobolectricTestRunner::class)
class VideoCompressorConfigFactoryTest {
@Test
fun `if we don't have metadata the video will be resized`() {
// Given
val metadata = null
val shouldBeCompressed = false
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertThat(videoCompressorConfig.resizer).isNotNull()
assertThat(videoCompressorConfig.newFrameRate).isEqualTo(30)
assertThat(videoCompressorConfig.newBitRate).isNotEqualTo(VideoEncoderSettings.NO_VALUE)
}
@Test
fun `if the video should be compressed and is larger than 720p it will be resized`() {
// Given
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0)
val shouldBeCompressed = true
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsResized(videoCompressorConfig)
}
@Test
fun `if the video should be compressed and is smaller or equal to 720p it will not be resized`() {
// Given
val metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = 1_000_000, frameRate = 50, rotation = 0)
val shouldBeCompressed = true
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsNotResized(videoCompressorConfig)
}
@Test
fun `if the video should not be compressed and is larger than 1080p it will be resized`() {
// Given
val metadata = VideoFileMetadata(width = 2560, height = 1440, bitrate = 1_000_000, frameRate = 50, rotation = 0)
val shouldBeCompressed = false
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsResized(videoCompressorConfig)
}
@Test
fun `if the video should not be compressed and is smaller or equal than 1080p it will not be resized`() {
// Given
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0)
val shouldBeCompressed = false
// When
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsNotResized(videoCompressorConfig)
}
private inline fun assertIsResized(videoCompressorConfig: VideoCompressorConfig) {
assertThat(videoCompressorConfig.resizer).isNotNull()
}
private inline fun assertIsNotResized(videoCompressorConfig: VideoCompressorConfig) {
assertThat(videoCompressorConfig.resizer).isNull()
}
}

View file

@ -1,153 +0,0 @@
/*
* 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.mediaupload.impl
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy
import com.otaliastudios.transcoder.strategy.TrackStrategy
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@Suppress("NOTHING_TO_INLINE")
@RunWith(RobolectricTestRunner::class)
class VideoStrategyFactoryTest {
@Test
fun `if we don't have metadata the video will be transcoded just in case`() {
// Given
val expectedExtension = "mp4"
val metadata = null
val shouldBeCompressed = true
// When
val videoStrategy = VideoStrategyFactory.create(
expectedExtension = expectedExtension,
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsTranscoded(videoStrategy)
}
@Test
fun `if the video should be compressed and is larger than 720p it will be transcoded`() {
// Given
val expectedExtension = "mp4"
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50)
val shouldBeCompressed = true
// When
val videoStrategy = VideoStrategyFactory.create(
expectedExtension = expectedExtension,
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsTranscoded(videoStrategy)
}
@Test
fun `if the video should be compressed, has the right format and is smaller or equal to 720p it will not be transcoded`() {
// Given
val expectedExtension = "mp4"
val metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = 1_000_000, frameRate = 50)
val shouldBeCompressed = true
// When
val videoStrategy = VideoStrategyFactory.create(
expectedExtension = expectedExtension,
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsNotTranscoded(videoStrategy)
}
@Test
fun `if the video should not be compressed and is larger than 1080p it will be transcoded`() {
// Given
val expectedExtension = "mp4"
val metadata = VideoFileMetadata(width = 2560, height = 1440, bitrate = 1_000_000, frameRate = 50)
val shouldBeCompressed = false
// When
val videoStrategy = VideoStrategyFactory.create(
expectedExtension = expectedExtension,
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsTranscoded(videoStrategy)
}
@Test
fun `if the video should not be compressed, has the right format and is smaller or equal than 1080p it will not be transcoded`() {
// Given
val expectedExtension = "mp4"
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50)
val shouldBeCompressed = false
// When
val videoStrategy = VideoStrategyFactory.create(
expectedExtension = expectedExtension,
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsNotTranscoded(videoStrategy)
}
@Test
fun `if the video should not be compressed but has a wrong format it will be transcoded`() {
// Given
val expectedExtension = "mkv"
val metadata = VideoFileMetadata(width = 320, height = 240, bitrate = 1_000_000, frameRate = 50)
val shouldBeCompressed = false
// When
val videoStrategy = VideoStrategyFactory.create(
expectedExtension = expectedExtension,
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsTranscoded(videoStrategy)
}
@Test
fun `if the video should be compressed and has a wrong format it will be transcoded`() {
// Given
val expectedExtension = "mkv"
val metadata = VideoFileMetadata(width = 320, height = 240, bitrate = 1_000_000, frameRate = 50)
val shouldBeCompressed = true
// When
val videoStrategy = VideoStrategyFactory.create(
expectedExtension = expectedExtension,
metadata = metadata,
shouldBeCompressed = shouldBeCompressed
)
// Then
assertIsTranscoded(videoStrategy)
}
private inline fun assertIsTranscoded(videoStrategy: TrackStrategy) {
assert(videoStrategy is DefaultVideoStrategy)
}
private inline fun assertIsNotTranscoded(videoStrategy: TrackStrategy) {
assert(videoStrategy is PassThroughTrackStrategy)
}
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.network.interceptors
import io.element.android.libraries.core.extensions.ellipsize
import okhttp3.logging.HttpLoggingInterceptor
import org.json.JSONArray
import org.json.JSONException
@ -28,7 +29,7 @@ internal class FormattedJsonHttpLogger(
*/
@Synchronized
override fun log(message: String) {
Timber.v(message)
Timber.v(message.ellipsize(200_000))
// Try to log formatted Json only if there is a chance that [message] contains Json.
// It can be only the case if we log the bodies of Http requests.

View file

@ -12,6 +12,7 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
@ -57,7 +58,7 @@ class DefaultCallNotificationEventResolver @Inject constructor(
forceNotify: Boolean
): Result<NotifiableEvent> = runCatchingExceptions {
val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
?: throw ResolvingException("content is not a call notify")
?: throw NotificationResolverException.UnknownError("content is not a call notify")
val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value
// We need the sync service working to get the updated room info
@ -65,8 +66,12 @@ class DefaultCallNotificationEventResolver @Inject constructor(
if (content.type == CallNotifyType.RING) {
appForegroundStateService.updateHasRingingCall(true)
val client = clientProvider.getOrRestore(sessionId).getOrNull() ?: throw ResolvingException("Session $sessionId not found")
val room = client.getRoom(notificationData.roomId) ?: throw ResolvingException("Room ${notificationData.roomId} not found")
val client = clientProvider.getOrRestore(
sessionId
).getOrNull() ?: throw NotificationResolverException.UnknownError("Session $sessionId not found")
val room = client.getRoom(
notificationData.roomId
) ?: throw NotificationResolverException.UnknownError("Room ${notificationData.roomId} not found")
// Give a few seconds for the room info flow to catch up with the sync, if needed - this is usually instant
val isActive = withTimeoutOrNull(3.seconds) { room.roomInfoFlow.firstOrNull { it.hasRoomCall } }?.hasRoomCall ?: false

View file

@ -11,7 +11,7 @@ import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope
@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.media.getMediaPreviewValue
import io.element.android.libraries.matrix.api.notification.NotificationContent
@ -43,18 +44,24 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.NotificationLoggerTag)
/**
* Result of resolving a batch of push events.
* The outermost [Result] indicates whether the setup to resolve the events was successful.
* The results for each push notification will be a map of [NotificationEventRequest] to [Result] of [ResolvedPushEvent].
* If the resolution of a specific event fails, the innermost [Result] will contain an exception.
*/
typealias ResolvePushEventsResult = Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>
/**
* The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event.
* It is used as a bridge between the Event Thread and the NotificationDrawerManager.
@ -65,24 +72,24 @@ interface NotifiableEventResolver {
suspend fun resolveEvents(
sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest>
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>
): ResolvePushEventsResult
}
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultNotifiableEventResolver @Inject constructor(
private val stringProvider: StringProvider,
private val clock: SystemClock,
private val matrixClientProvider: MatrixClientProvider,
private val notificationMediaRepoFactory: NotificationMediaRepo.Factory,
@ApplicationContext private val context: Context,
private val permalinkParser: PermalinkParser,
private val callNotificationEventResolver: CallNotificationEventResolver,
private val fallbackNotificationFactory: FallbackNotificationFactory,
) : NotifiableEventResolver {
override suspend fun resolveEvents(
sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest>
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> {
): ResolvePushEventsResult {
Timber.d("Queueing notifications: $notificationEventRequests")
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId"))
@ -90,20 +97,28 @@ class DefaultNotifiableEventResolver @Inject constructor(
val ids = notificationEventRequests.groupBy { it.roomId }.mapValues { (_, value) -> value.map { it.eventId } }
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
val notifications = client.notificationService().getNotifications(ids).mapCatchingExceptions { map ->
map.mapValues { (_, notificationData) ->
notificationData.asNotifiableEvent(client, sessionId)
val notificationsResult = client.notificationService().getNotifications(ids)
if (notificationsResult.isFailure) {
val exception = notificationsResult.exceptionOrNull()
Timber.tag(loggerTag.value).e(exception, "Failed to get notifications for $ids")
return Result.failure(exception ?: NotificationResolverException.UnknownError("Unknown error while fetching notifications"))
}
// The null check is done above
val notificationDataMap = notificationsResult.getOrNull()!!.mapValues { (_, notificationData) ->
notificationData.flatMap { data ->
data.asNotifiableEvent(client, sessionId)
}
}
return Result.success(
notificationEventRequests.associate {
val notificationData = notifications.getOrNull()?.get(it.eventId)
if (notificationData != null) {
it to notificationData
notificationEventRequests.associate { request ->
val notificationDataResult = notificationDataMap[request.eventId]
if (notificationDataResult == null) {
request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}"))
} else {
// TODO once the SDK can actually return what went wrong, we should return it here instead of this generic error
it to Result.failure(ResolvingException("No notification data for ${it.roomId} - ${it.eventId}"))
request to notificationDataResult
}
}
)
@ -164,7 +179,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
NotificationContent.MessageLike.CallCandidates,
NotificationContent.MessageLike.CallHangup -> {
Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}")
throw ResolvingException("Ignoring notification for call ${content.javaClass.simpleName}")
throw NotificationResolverException.EventFilteredOut
}
is NotificationContent.MessageLike.CallInvite -> {
val notifiableMessageEvent = buildNotifiableMessageEvent(
@ -195,7 +210,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
NotificationContent.MessageLike.KeyVerificationReady,
NotificationContent.MessageLike.KeyVerificationStart -> {
Timber.tag(loggerTag.value).d("Ignoring notification for verification ${content.javaClass.simpleName}")
throw ResolvingException("Ignoring notification for verification ${content.javaClass.simpleName}")
throw NotificationResolverException.EventFilteredOut
}
is NotificationContent.MessageLike.Poll -> {
val notifiableEventMessage = buildNotifiableMessageEvent(
@ -217,16 +232,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
}
is NotificationContent.MessageLike.ReactionContent -> {
Timber.tag(loggerTag.value).d("Ignoring notification for reaction")
throw ResolvingException("Ignoring notification for reaction")
throw NotificationResolverException.EventFilteredOut
}
NotificationContent.MessageLike.RoomEncrypted -> {
Timber.tag(loggerTag.value).w("Notification with encrypted content -> fallback")
val fallbackNotifiableEvent = fallbackNotifiableEvent(userId, roomId, eventId)
ResolvedPushEvent.Event(fallbackNotifiableEvent)
}
NotificationContent.MessageLike.UnableToResolve -> {
Timber.tag(loggerTag.value).w("Unable to resolve notification -> fallback")
val fallbackNotifiableEvent = fallbackNotifiableEvent(userId, roomId, eventId)
val fallbackNotifiableEvent = fallbackNotificationFactory.create(userId, roomId, eventId)
ResolvedPushEvent.Event(fallbackNotifiableEvent)
}
is NotificationContent.MessageLike.RoomRedaction -> {
@ -234,7 +244,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
val redactedEventId = content.redactedEventId
if (redactedEventId == null) {
Timber.tag(loggerTag.value).d("redactedEventId is null.")
throw ResolvingException("redactedEventId is null")
throw NotificationResolverException.UnknownError("redactedEventId is null")
} else {
ResolvedPushEvent.Redaction(
sessionId = userId,
@ -246,7 +256,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
}
NotificationContent.MessageLike.Sticker -> {
Timber.tag(loggerTag.value).d("Ignoring notification for sticker")
throw ResolvingException("Ignoring notification for reaction")
throw NotificationResolverException.EventFilteredOut
}
is NotificationContent.StateEvent.RoomMemberContent,
NotificationContent.StateEvent.PolicyRuleRoom,
@ -270,27 +280,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
NotificationContent.StateEvent.SpaceChild,
NotificationContent.StateEvent.SpaceParent -> {
Timber.tag(loggerTag.value).d("Ignoring notification for state event ${content.javaClass.simpleName}")
throw ResolvingException("Ignoring notification for state event ${content.javaClass.simpleName}")
throw NotificationResolverException.EventFilteredOut
}
}
}
private fun fallbackNotifiableEvent(
userId: SessionId,
roomId: RoomId,
eventId: EventId
) = FallbackNotifiableEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
editedEventId = null,
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
timestamp = clock.epochMillis(),
description = stringProvider.getString(R.string.notification_fallback_content),
)
private fun descriptionFromMessageContent(
content: NotificationContent.MessageLike.RoomMessage,
senderDisambiguatedDisplayName: String,

View file

@ -33,6 +33,7 @@ class DefaultOnMissedCallNotificationHandler @Inject constructor(
?.getNotifications(mapOf(roomId to listOf(eventId)))
?.getOrNull()
?.get(eventId)
?.getOrNull()
?: return
val notifiableEvent = callNotificationEventResolver.resolveEvent(

View file

@ -0,0 +1,38 @@
/*
* 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.push.impl.notifications
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import javax.inject.Inject
class FallbackNotificationFactory @Inject constructor(
private val clock: SystemClock,
private val stringProvider: StringProvider,
) {
fun create(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
): FallbackNotifiableEvent = FallbackNotifiableEvent(
sessionId = sessionId,
roomId = roomId,
eventId = eventId,
editedEventId = null,
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
timestamp = clock.epochMillis(),
description = stringProvider.getString(R.string.notification_fallback_content),
)
}

View file

@ -1,10 +0,0 @@
/*
* 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.push.impl.notifications
class ResolvingException(message: String) : Exception(message)

View file

@ -16,16 +16,17 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.history.onDiagnosticPush
import io.element.android.libraries.push.impl.history.onInvalidPushReceived
import io.element.android.libraries.push.impl.history.onSuccess
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
import io.element.android.libraries.push.impl.notifications.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
@ -63,6 +64,7 @@ class DefaultPushHandler @Inject constructor(
private val resolverQueue: NotificationResolverQueue,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val fallbackNotificationFactory: FallbackNotificationFactory,
) : PushHandler {
init {
processPushEventResults()
@ -88,34 +90,37 @@ class DefaultPushHandler @Inject constructor(
} else {
result.fold(
onSuccess = {
if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) {
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
reason = "Showing fallback notification",
)
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
} else {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
comment = "Push handled successfully",
)
},
onFailure = { exception ->
if (exception is NotificationResolverException.EventFilteredOut) {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
comment = "Push handled successfully",
comment = "Push handled successfully but notification was filtered out",
)
} else {
val reason = when (exception) {
is NotificationResolverException.EventNotFound -> "Event not found"
else -> "Unknown error: ${exception.message}"
}
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
reason = "$reason - Showing fallback notification",
)
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
}
},
onFailure = { exception ->
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
reason = exception.message ?: exception.javaClass.simpleName,
)
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
}
)
}
@ -125,8 +130,21 @@ class DefaultPushHandler @Inject constructor(
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
@Suppress("LoopWithTooManyJumpStatements")
for (result in resolvedEvents.values) {
val event = result.getOrNull() ?: continue
for ((request, result) in resolvedEvents) {
val event = result.recover { exception ->
// If the event could not be resolved, we create a fallback notification
when (exception) {
is NotificationResolverException.EventFilteredOut -> {
// Do nothing, we don't want to show a notification for filtered out events
null
}
else -> {
Timber.tag(loggerTag.value).e(exception, "Failed to resolve push event")
ResolvedPushEvent.Event(fallbackNotificationFactory.create(request.sessionId, request.roomId, request.eventId))
}
}
}.getOrNull() ?: continue
val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId)
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
// If notifications are disabled for this session and device, we don't want to show the notification

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
@ -51,6 +52,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableMess
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
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
@ -71,12 +73,22 @@ class DefaultNotifiableEventResolverTest {
}
@Test
fun `resolve event failure`() = runTest {
fun `resolve fetching failure`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.failure(AN_EXCEPTION)
)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.isFailure).isTrue()
}
@Test
fun `resolve event failure`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(mapOf(AN_EVENT_ID to Result.failure(AN_EXCEPTION)))
)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.isFailure).isTrue()
}
@ -85,12 +97,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(body = "Hello world", formatted = null)
),
)
))
)
)
)
@ -108,13 +120,13 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(body = "Hello world", formatted = null)
),
hasMention = true,
)
))
)
)
)
@ -131,7 +143,7 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(
@ -142,7 +154,7 @@ class DefaultNotifiableEventResolverTest {
)
)
),
)
))
)
)
)
@ -159,7 +171,7 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(
@ -170,7 +182,7 @@ class DefaultNotifiableEventResolverTest {
)
)
),
)
))
)
)
)
@ -187,12 +199,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = AudioMessageType("Audio", null, null, MediaSource("url"), null)
),
)
))
)
)
)
@ -209,12 +221,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = VideoMessageType("Video", null, null, MediaSource("url"), null)
),
)
))
)
)
)
@ -231,12 +243,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null)
),
)
))
)
)
)
@ -253,12 +265,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = ImageMessageType("Image", null, null, MediaSource("url"), null),
),
)
))
)
)
)
@ -275,12 +287,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = StickerMessageType("Sticker", null, null, MediaSource("url"), null),
),
)
))
)
)
)
@ -297,12 +309,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = FileMessageType("File", null, null, MediaSource("url"), null),
),
)
))
)
)
)
@ -319,12 +331,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = LocationMessageType("Location", "geo:1,2", null),
),
)
))
)
)
)
@ -341,12 +353,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = NoticeMessageType("Notice", null),
),
)
))
)
)
)
@ -363,12 +375,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = EmoteMessageType("is happy", null),
),
)
))
)
)
)
@ -385,12 +397,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.Poll(
senderId = A_USER_ID_2,
question = "A question"
),
)
))
)
)
)
@ -407,13 +419,13 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.StateEvent.RoomMemberContent(
userId = A_USER_ID_2,
membershipState = RoomMembershipState.INVITE
),
isDirect = false,
)
))
)
)
)
@ -427,12 +439,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = false,
)
))
)
)
)
@ -464,12 +476,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = true,
)
))
)
)
)
@ -501,13 +513,13 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = true,
senderDisplayName = null,
)
))
)
)
)
@ -539,7 +551,8 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(
aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
@ -547,6 +560,7 @@ class DefaultNotifiableEventResolverTest {
senderIsNameAmbiguous = true,
)
)
)
)
)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
@ -577,12 +591,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.StateEvent.RoomMemberContent(
userId = A_USER_ID_2,
membershipState = RoomMembershipState.JOIN
)
)
))
)
)
)
@ -595,7 +609,7 @@ class DefaultNotifiableEventResolverTest {
fun `resolve RoomEncrypted`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(AN_EVENT_ID to aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted))
mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted)))
)
)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
@ -620,25 +634,12 @@ class DefaultNotifiableEventResolverTest {
fun `resolve UnableToResolve`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(AN_EVENT_ID to aNotificationData(content = NotificationContent.MessageLike.UnableToResolve))
mapOf(AN_EVENT_ID to Result.failure(NotificationResolverException.EventNotFound))
)
)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
FallbackNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
description = "You have new messages.",
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
timestamp = A_FAKE_TIMESTAMP,
)
)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
assertThat(result.getEvent(request)).isEqualTo(Result.failure<ResolvedPushEvent?>(NotificationResolverException.EventNotFound))
}
@Test
@ -646,10 +647,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(
aNotificationData(
content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2),
)
)
)
)
)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
@ -688,12 +691,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.NOTIFY
),
)
))
)
),
callNotificationEventResolver = callNotificationEventResolver,
@ -729,12 +732,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomRedaction(
AN_EVENT_ID_2,
A_REDACTION_REASON,
)
)
))
)
)
)
@ -754,12 +757,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to aNotificationData(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomRedaction(
null,
A_REDACTION_REASON,
)
)
))
)
)
)
@ -807,7 +810,7 @@ class DefaultNotifiableEventResolverTest {
private fun testNoResults(content: NotificationContent) = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(AN_EVENT_ID to aNotificationData(content = content))
mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = content)))
)
)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
@ -823,7 +826,7 @@ class DefaultNotifiableEventResolverTest {
private fun createDefaultNotifiableEventResolver(
notificationService: FakeNotificationService? = FakeNotificationService(),
notificationResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap()),
notificationResult: Result<Map<EventId, Result<NotificationData>>> = Result.success(emptyMap()),
callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
): DefaultNotifiableEventResolver {
val context = RuntimeEnvironment.getApplication() as Context
@ -840,12 +843,15 @@ class DefaultNotifiableEventResolverTest {
}
return DefaultNotifiableEventResolver(
stringProvider = AndroidStringProvider(context.resources),
clock = FakeSystemClock(),
matrixClientProvider = matrixClientProvider,
notificationMediaRepoFactory = notificationMediaRepoFactory,
context = context,
permalinkParser = FakePermalinkParser(),
callNotificationEventResolver = callNotificationEventResolver,
fallbackNotificationFactory = FallbackNotificationFactory(
clock = FakeSystemClock(),
stringProvider = FakeStringProvider(defaultResult = "You have new messages.")
)
)
}
}

View file

@ -43,7 +43,7 @@ class DefaultOnMissedCallNotificationHandlerTest {
val matrixClientProvider = FakeMatrixClientProvider(getClient = {
val notificationService = FakeNotificationService().apply {
givenGetNotificationsResult(
Result.success(mapOf(AN_EVENT_ID to aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false)))
Result.success(mapOf(AN_EVENT_ID to Result.success(aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false))))
)
}
Result.success(FakeMatrixClient(notificationService = notificationService))

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -31,9 +32,9 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.push.impl.history.FakePushHistoryService
import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
import io.element.android.libraries.push.impl.notifications.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
import io.element.android.libraries.push.impl.notifications.ResolvingException
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
@ -47,6 +48,8 @@ import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -271,7 +274,7 @@ class DefaultPushHandlerTest {
fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() {
`test notification resolver failure`(
notificationResolveResult = { _ ->
Result.failure(ResolvingException("Unable to restore session"))
Result.failure(NotificationResolverException.UnknownError("Unable to restore session"))
},
shouldSetOptimizationBatteryBanner = false,
)
@ -282,7 +285,7 @@ class DefaultPushHandlerTest {
`test notification resolver failure`(
notificationResolveResult = { requests: List<NotificationEventRequest> ->
Result.success(
requests.associateWith { Result.failure(ResolvingException("Unable to resolve event")) }
requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) }
)
},
shouldSetOptimizationBatteryBanner = true,
@ -336,8 +339,6 @@ class DefaultPushHandlerTest {
notifiableEventResult.assertions()
.isCalledOnce()
.with(value(A_USER_ID), any())
onNotifiableEventsReceived.assertions()
.isNeverCalled()
onPushReceivedResult.assertions()
.isCalledOnce()
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any())
@ -662,6 +663,10 @@ class DefaultPushHandlerTest {
pushHistoryService = pushHistoryService,
resolverQueue = NotificationResolverQueue(notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult), backgroundScope),
appCoroutineScope = backgroundScope,
fallbackNotificationFactory = FallbackNotificationFactory(
clock = FakeSystemClock(),
stringProvider = FakeStringProvider(),
)
)
}
}

View file

@ -30,7 +30,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
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.iconSuccessPrimaryBackground
@Composable
internal fun FormattingOption(
@ -42,13 +41,13 @@ internal fun FormattingOption(
modifier: Modifier = Modifier,
) {
val backgroundColor = when (state) {
FormattingOptionState.Selected -> ElementTheme.colors.iconSuccessPrimaryBackground
FormattingOptionState.Selected -> ElementTheme.colors.bgAccentSelected
FormattingOptionState.Default,
FormattingOptionState.Disabled -> Color.Transparent
}
val foregroundColor = when (state) {
FormattingOptionState.Selected -> ElementTheme.colors.iconSuccessPrimary
FormattingOptionState.Selected -> ElementTheme.colors.iconAccentPrimary
FormattingOptionState.Default -> ElementTheme.colors.iconSecondary
FormattingOptionState.Disabled -> ElementTheme.colors.iconDisabled
}

View file

@ -18,12 +18,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush
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.libraries.designsystem.colors.gradientActionColors
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
@ -62,13 +62,7 @@ internal fun SendButton(
modifier = Modifier
.clip(CircleShape)
.size(36.dp)
.then(
if (canSendMessage) {
buttonBackgroundModifier()
} else {
Modifier
}
)
.buttonBackgroundModifier(canSendMessage)
) {
Icon(
modifier = Modifier
@ -91,27 +85,30 @@ internal fun SendButton(
}
}
private fun buttonBackgroundModifier() = Modifier.drawWithCache {
// We have a square button, so height == width.
val height = size.height
val verticalGradientBrush = ShaderBrush(
LinearGradientShader(
from = Offset(0f, 0f),
to = Offset(0f, height),
colors = listOf(
Color(0xFF79DD98),
Color(0xFF0DBD8B),
Color(0xFF128585),
Color(0xFF24446B),
@Composable
private fun Modifier.buttonBackgroundModifier(
canSendMessage: Boolean,
) = then(
if (canSendMessage) {
val colors = gradientActionColors()
Modifier.drawWithCache {
val verticalGradientBrush = ShaderBrush(
LinearGradientShader(
from = Offset(0f, 0f),
to = Offset(0f, size.height),
colors = colors,
)
)
)
)
onDrawBehind {
drawRect(
brush = verticalGradientBrush,
)
onDrawBehind {
drawRect(
brush = verticalGradientBrush,
)
}
}
} else {
Modifier
}
}
)
@PreviewsDayNight
@Composable

View file

@ -13,11 +13,17 @@ import android.net.Uri
import android.text.Spanned
import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
@ -28,10 +34,9 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.rememberTypeface
import io.element.android.libraries.designsystem.theme.currentUserMentionPillBackground
import io.element.android.libraries.designsystem.theme.currentUserMentionPillText
import io.element.android.libraries.designsystem.theme.mentionPillBackground
import io.element.android.libraries.designsystem.theme.mentionPillText
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
@ -52,7 +57,8 @@ import javax.inject.Inject
@Stable
@SingleIn(SessionScope::class)
class MentionSpanTheme(val currentUserId: UserId) {
@Inject constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId)
@Inject
constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId)
internal var currentUserTextColor: Int = 0
internal var currentUserBackgroundColor: Int = Color.WHITE
@ -69,10 +75,10 @@ class MentionSpanTheme(val currentUserId: UserId) {
@Suppress("ComposableNaming")
@Composable
fun updateStyles() {
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
otherBackgroundColor = ElementTheme.colors.mentionPillBackground.toArgb()
currentUserTextColor = ElementTheme.colors.textBadgeAccent.toArgb()
currentUserBackgroundColor = ElementTheme.colors.bgBadgeAccent.toArgb()
otherTextColor = ElementTheme.colors.textPrimary.toArgb()
otherBackgroundColor = ElementTheme.colors.bgBadgeDefault.toArgb()
typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value
val density = LocalDensity.current
@ -166,3 +172,115 @@ internal fun MentionSpanThemePreview() {
})
}
}
@Composable
private fun MentionSpanThemeInTimelineContent(
bgColor: Int,
modifier: Modifier = Modifier,
) {
val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) }
val provider = remember {
MentionSpanProvider(
mentionSpanTheme = mentionSpanTheme,
mentionSpanFormatter = object : MentionSpanFormatter {
override fun formatDisplayText(mentionType: MentionType): CharSequence {
return when (mentionType) {
is MentionType.User -> mentionType.userId.value
else -> throw AssertionError("Unexpected value $mentionType")
}
}
},
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return when (uriString) {
"https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org"))
"https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org"))
else -> throw AssertionError("Unexpected value $uriString")
}
}
},
)
}
val textColor = ElementTheme.colors.textPrimary.toArgb()
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
mentionSpanTheme.updateStyles()
AndroidView(
modifier = modifier,
factory = { context ->
TextView(context).apply {
includeFontPadding = false
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
text = buildSpannedString {
append("Hello ")
append("@mention", mentionSpanMe(), 0)
append(" ")
append("@mention", mentionSpanOther(), 0)
}
setTextColor(textColor)
setBackgroundColor(bgColor)
}
}
)
}
@PreviewsDayNight
@Composable
internal fun MentionSpanThemeInTimelinePreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Message from me
Text(
text = "Message from me",
style = ElementTheme.typography.fontBodySmMedium,
)
ElementTheme.colors.messageFromMeBackground.let { color ->
MentionSpanThemeInTimelineContent(
modifier = Modifier
.padding(start = 60.dp, end = 8.dp)
.background(
color = color,
shape = RoundedCornerShape(12.dp),
)
.padding(8.dp),
bgColor = color.toArgb()
)
}
// Message from other
ElementTheme.colors.messageFromOtherBackground.let { color ->
Text(
text = "Message from other",
style = ElementTheme.typography.fontBodySmMedium,
)
MentionSpanThemeInTimelineContent(
modifier = Modifier
.padding(start = 8.dp, end = 60.dp)
.padding(4.dp)
.background(
color = color,
shape = RoundedCornerShape(12.dp)
)
.padding(8.dp),
bgColor = color.toArgb()
)
}
// Composer
ElementTheme.colors.bgSubtleSecondary.let { color ->
Text(
text = "Composer",
style = ElementTheme.typography.fontBodySmMedium,
)
MentionSpanThemeInTimelineContent(
modifier = Modifier
.padding(start = 4.dp, end = 4.dp)
.background(color)
.padding(8.dp),
bgColor = color.toArgb()
)
}
}
}

View file

@ -12,6 +12,7 @@
<string name="a11y_jump_to_bottom">"Mine lõppu"</string>
<string name="a11y_notifications_mentions_only">"Ainult mainimised"</string>
<string name="a11y_notifications_muted">"Summutatud"</string>
<string name="a11y_other_user_avatar">"Teise kasutaja tunnuspilt"</string>
<string name="a11y_page_n">"%1$d. lehekülg"</string>
<string name="a11y_pause">"Peata"</string>
<string name="a11y_paused_voice_message">"Häälsõnum, kestus:%1$s, praegune asukoht: %2$s"</string>
@ -30,15 +31,18 @@
<string name="a11y_read_receipts_tap_to_show_all">"Vaata kõiki"</string>
<string name="a11y_remove_reaction">"Eemalda reageerimine %1$s emotikoniga"</string>
<string name="a11y_remove_reaction_with">"Eemalda reageerimine: %1$s"</string>
<string name="a11y_room_avatar">"Jututoa tunnuspilt"</string>
<string name="a11y_send_files">"Saada faile"</string>
<string name="a11y_show_password">"Näita salasõna"</string>
<string name="a11y_start_call">"Helista"</string>
<string name="a11y_user_avatar">"Kasutaja tunnuspilt"</string>
<string name="a11y_user_menu">"Kasutajamenüü"</string>
<string name="a11y_view_avatar">"Vaata tunnuspilti"</string>
<string name="a11y_view_details">"Vaata üksikasju"</string>
<string name="a11y_voice_message">"Häälsõnum, kestus:%1$s"</string>
<string name="a11y_voice_message_record">"Salvesta häälsõnum."</string>
<string name="a11y_voice_message_stop_recording">"Lõpeta salvestamine"</string>
<string name="a11y_your_avatar">"Sinu tunnuspilt"</string>
<string name="action_accept">"Nõustu"</string>
<string name="action_add_caption">"Lisa selgitus"</string>
<string name="action_add_to_timeline">"Lisa ajajoonele"</string>
@ -97,6 +101,7 @@
<string name="action_no">"Ei"</string>
<string name="action_not_now">"Mitte praegu"</string>
<string name="action_ok">"OK"</string>
<string name="action_open_context_menu">"Ava kontekstimenüü"</string>
<string name="action_open_settings">"Seadistused"</string>
<string name="action_open_with">"Ava rakendusega"</string>
<string name="action_pin">"Tõsta esile"</string>
@ -121,7 +126,9 @@
<string name="action_save">"Salvesta"</string>
<string name="action_search">"Otsi"</string>
<string name="action_send">"Saada"</string>
<string name="action_send_edited_message">"Saada muudetud sõnum"</string>
<string name="action_send_message">"Saada sõnum"</string>
<string name="action_send_voice_message">"Saada häälsõnum"</string>
<string name="action_share">"Jaga"</string>
<string name="action_share_link">"Jaga linki"</string>
<string name="action_show">"Näita"</string>
@ -137,6 +144,7 @@
<string name="action_tap_for_options">"Valikuteks klõpsa"</string>
<string name="action_try_again">"Proovi uuesti"</string>
<string name="action_unpin">"Eemalda esiletõstmine"</string>
<string name="action_view">"Vaata"</string>
<string name="action_view_in_timeline">"Vaata ajajoonel"</string>
<string name="action_view_source">"Vaata lähtekoodi"</string>
<string name="action_yes">"Jah"</string>

View file

@ -126,7 +126,7 @@
<string name="action_save">"Enregistrer"</string>
<string name="action_search">"Rechercher"</string>
<string name="action_send">"Envoyer"</string>
<string name="action_send_edited_message">"Envoyer un message modifié"</string>
<string name="action_send_edited_message">"Envoyer les modifications"</string>
<string name="action_send_message">"Envoyer un message"</string>
<string name="action_send_voice_message">"Envoyer un message vocal"</string>
<string name="action_share">"Partager"</string>

View file

@ -7,11 +7,16 @@
<item quantity="one">"%1$d digit entered"</item>
<item quantity="other">"%1$d digits entered"</item>
</plurals>
<string name="a11y_edit_avatar">"Edit avatar"</string>
<string name="a11y_encryption_details">"Encryption details"</string>
<string name="a11y_hide_password">"Hide password"</string>
<string name="a11y_join_call">"Join call"</string>
<string name="a11y_jump_to_bottom">"Jump to bottom"</string>
<string name="a11y_notifications_mentions_only">"Mentions only"</string>
<string name="a11y_notifications_muted">"Muted"</string>
<string name="a11y_notifications_new_mentions">"New mentions"</string>
<string name="a11y_notifications_new_messages">"New messages"</string>
<string name="a11y_notifications_ongoing_call">"Ongoing call"</string>
<string name="a11y_other_user_avatar">"Other user\'s avatar"</string>
<string name="a11y_page_n">"Page %1$d"</string>
<string name="a11y_pause">"Pause"</string>
@ -35,6 +40,7 @@
<string name="a11y_send_files">"Send files"</string>
<string name="a11y_show_password">"Show password"</string>
<string name="a11y_start_call">"Start a call"</string>
<string name="a11y_time_limited_action_required">"Time limited action required"</string>
<string name="a11y_user_avatar">"User avatar"</string>
<string name="a11y_user_menu">"User menu"</string>
<string name="a11y_view_avatar">"View avatar"</string>

View file

@ -32,7 +32,7 @@ private const val versionYear = 25
private const val versionMonth = 7
// Note: must be in [0,99]
private const val versionReleaseNumber = 0
private const val versionReleaseNumber = 1
object Versions {
const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:daa1ee2d17b392c702067cfd3bd73a64ed393e68e242b342abe1db97baa696db
size 11218
oid sha256:03d9396d033f227408e8acdb424f187e2c0bf7c5493c13faee5eef51b01e4c93
size 10726

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e2b03077131e1b6b37762fbfd89519e726ed0a86685d0107a39024873ee0e670
size 10706
oid sha256:1b719ebe0ef05845daa58e6e1e0c72b9c30da66e639fc6bed9edeb483ed3d1d0
size 10206

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9fb2f05e2eea4d25a5bb87f3fc6391df2bf0923967d6ad973152557517fa7819
size 29864
oid sha256:78267cc22ef57370eb378a19d37be1826a7da737903ab38f1db984bfaac71014
size 29368

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c5c32861bbae702e9f45263ed3ee710d450b4eb66f1919a4f11eb5ccce7a0354
size 31120
oid sha256:368438eee2c58b62920c4568d4f727fc760f9e858841015c0fc053a7c1794f34
size 30633

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a2a9e4c1bf333604bb0a1b17c83af340e7d17a4dd3c465e8a49394dd236c3b45
oid sha256:676c512eb43f3784c98e8344abc1c76a3950d3765eb888d79e8b3b7a03178fe8
size 25844

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0733dabade5399a419fa04c553a719c32c4925826d34091f7a64a0b4eb929e23
oid sha256:d01f2725c05013a8a5fbd2cc925ea692a1ced85e2dba1ee057891bf675696226
size 65637

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae6680679576c8b573f247eacc79970197f6c544df2a498e83950d84d441a635
size 24413
oid sha256:b6927e063867986095e5e95026fcbb2bdc04977cee5fd80bbf89c5c331909acf
size 25047

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c7a6f4ead21dff32579260eb8afb65d5b5748a3fd37ef12dd881fa450fa48a8
size 9166
oid sha256:b33738bbd3ec0a20eefacac4bd5fc4f03c71ac0dbce772d914801b316c91625c
size 10018

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c4c13bfeff36e70a209271d37d9df050098ff082097f706b8550fba469db7e8
size 34501
oid sha256:f2e6774496aa545174428999235b4ba4825b1255706dcb829c92b9dfb1c1fa09
size 35290

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f08c84f2f4e4334e1e3062f3971e2e9ef0211a9af6c76de7ec90f34a470d0119
size 42777
oid sha256:e185a725d6d72f67f87549eebb436abd6b5c252674e38a217af27d2b67fd1a1f
size 43737

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:afba149de7c094ec7c49c094df22e22e5482dbc2196decd2af029f00921ac5e2
oid sha256:5f3da666595fb927aad97e0f50762534d49e587122a304062baf1322b23b7d48
size 56367

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a117fd360262156cef595eabfaee2648cdb185678527d9a0aec602160b7823f4
size 64801
oid sha256:d540ccf947f37ec51f16a35c824d0eb0ffe0534424b889a170b81907db576605
size 64802

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1abd9d8600f32dc471e7befaec73fef7bd67234532b6dbcbfd4e94daec927fe5
size 22152
oid sha256:5a6855a2444515fbb98c1a3d01fb6cc0c5a938b9ab032e81d090490a3b1968a2
size 22254

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:78688c13e131c5000e7dec50addad0053bb5d3f7dd90ac092eab81623dac69cc
size 70524
oid sha256:dd9721a15aaf20c0b1dbcf4a10dc8041cc07ec09f268a9408cc2cdf501ba6f5f
size 70638

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d4a68eb39431cdcccd8b35abaf82b5cd830ddce1fa1d1af5764f2c67248a16b
size 29718
oid sha256:177a5d2d49d1c7bc27be8a03371b074e98cb163c3fb2bd996ef34d58a58e142d
size 29787

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5634d1088411e2e3204b40c8098fe9f0dde28847f33a7002aaf017791472cc3b
size 24039
oid sha256:98f07fe6746bc81551919c3924deb04f61f999f640371c35ac10fd3a9957c678
size 24101

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8bda53ae653315c654003c6b441bff6c35ddb1fa5bbd2233e1a630f7a74fc6a9
size 30885
oid sha256:12bb9ce3e39f0734d79f9c877545ca9eeccab4c720ff21152b350bedf2194992
size 30951

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:39c4b72f7469c06e7086ce40340cfa4f326e6ee845420e34b8dd4c98f8434c29
size 55361
oid sha256:1b73e62309cae93bc83abd6f5015cf44f9b0c18eff266da9bc36be85576477c6
size 55416

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:322609531df2bcf28a78fe21a0180658a07ac754128d611b8bca0a201731e77d
size 29827
oid sha256:36e919bf716639ddd32464d3b864891e4bc18fcc5a504f405d44a811b9961fa3
size 29896

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c07aa7a1aa31b8866bd1981bee11476cd1daa32d02b2f7b6aa368b2fdd3c3e2
size 29868
oid sha256:a8f5d2ee21ff6279f35a2772f5696db3fde1b7a14719002e70b6a0560a5ba324
size 29940

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e3d4acb84c68ac6147a8255e1897e0e658086982d9b732d421d3d52fe0ea0f19
size 27020
oid sha256:f3f43373818117ea406135038b04e4529a1f343286e5d7bacab08b9e69e5204f
size 27098

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f485e12af75bbfdf57b1f54a67548f950cc31204a867e662b7252199dfb5a2a
size 29608
oid sha256:3c89cc3b86ddea6373e4bf0ca8021a03faac3f40104029cf3118755d75c8ad0e
size 29676

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8fc2a9f674d1a1301b376c7e6f17b29710b51b68a1c8590993139367b25c58dc
size 26171
oid sha256:62831e4322a6b712389809f630176b18641a05bc3ca73b93aaf7b0ebd0de521e
size 25958

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae0bab78138a338cced2929a32ecbef81736247dfdd0b6985f69ef41174d2719
oid sha256:2eb622c603392e5da8ebd3aba69e0bdbe238e9de46cb6ade6306fc2e8fc4ad7a
size 20001

View file

@ -1431,6 +1431,7 @@ export const screenshots = [
["features.messages.impl.timeline_TimelineView_Day_8_en","features.messages.impl.timeline_TimelineView_Night_8_en",20273,],
["features.messages.impl.timeline_TimelineView_Day_9_en","features.messages.impl.timeline_TimelineView_Night_9_en",0,],
["libraries.designsystem.components.avatar.internal_TombstonedRoomAvatar_Avatars_en","",0,],
["libraries.designsystem.theme.components_TopAppBarStr_App_Bars_en","",0,],
["libraries.designsystem.theme.components_TopAppBar_App_Bars_en","",0,],
["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_0_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_0_en",20273,],
["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_1_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_1_en",20273,],

Some files were not shown because too many files have changed in this diff Show more