Merge branch 'release/25.07.1' into main

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

View file

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

View file

@ -69,7 +69,7 @@ jobs:
retention-days: 5 retention-days: 5
overwrite: true overwrite: true
if-no-files-found: error 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 id: diawi
# Do not fail the whole build if Diawi upload fails # Do not fail the whole build if Diawi upload fails
continue-on-error: true 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 Changes in Element X v25.06.3
============================= =============================

View file

@ -8,11 +8,9 @@
# Element X Android # 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). 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).
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/).
[<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) [<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 ## 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 ## 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, val labelRes: Int,
) { ) {
Chats( Chats(
labelRes = R.string.screen_roomlist_main_space_title labelRes = R.string.screen_home_tab_chats
), ),
Spaces( Spaces(
// TODO Create a new entry in Localazy labelRes = R.string.screen_home_tab_spaces
labelRes = R.string.screen_roomlist_main_space_title
); );
@Composable @Composable
fun icon() = when (this) { fun icon(
Chats -> CompoundIcons.ChatSolid() isSelected: Boolean,
// TODO Spaces -> CompoundIcons.Workspace() ) = when (this) {
Spaces -> CompoundIcons.Code() Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat()
Spaces -> if (isSelected) CompoundIcons.WorkspaceSolid() else CompoundIcons.Workspace()
} }
companion object { companion object {

View file

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

View file

@ -31,7 +31,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@ -67,44 +66,53 @@ fun RoomListContentView(
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit, onRoomClick: (RoomListRoomSummary) -> Unit,
onCreateRoomClick: () -> Unit, onCreateRoomClick: () -> Unit,
contentBottomPadding: Dp, contentPadding: PaddingValues,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier = modifier) { when (contentState) {
when (contentState) { is RoomListContentState.Skeleton -> {
is RoomListContentState.Skeleton -> { SkeletonView(
SkeletonView( modifier = modifier,
count = contentState.count, count = contentState.count,
) contentPadding = contentPadding,
} )
is RoomListContentState.Empty -> { }
EmptyView( is RoomListContentState.Empty -> {
state = contentState, EmptyView(
eventSink = eventSink, modifier = modifier.padding(contentPadding),
onSetUpRecoveryClick = onSetUpRecoveryClick, state = contentState,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, eventSink = eventSink,
onCreateRoomClick = onCreateRoomClick, onSetUpRecoveryClick = onSetUpRecoveryClick,
) onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
} onCreateRoomClick = onCreateRoomClick,
is RoomListContentState.Rooms -> { )
RoomsView( }
state = contentState, is RoomListContentState.Rooms -> {
hideInvitesAvatars = hideInvitesAvatars, RoomsView(
filtersState = filtersState, modifier = modifier,
eventSink = eventSink, state = contentState,
onSetUpRecoveryClick = onSetUpRecoveryClick, hideInvitesAvatars = hideInvitesAvatars,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, filtersState = filtersState,
onRoomClick = onRoomClick, eventSink = eventSink,
contentBottomPadding = contentBottomPadding, onSetUpRecoveryClick = onSetUpRecoveryClick,
) onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
} onRoomClick = onRoomClick,
contentPadding = contentPadding,
)
} }
} }
} }
@Composable @Composable
private fun SkeletonView(count: Int, modifier: Modifier = Modifier) { private fun SkeletonView(
LazyColumn(modifier = modifier) { count: Int,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
contentPadding = contentPadding,
) {
repeat(count) { index -> repeat(count) { index ->
item { item {
RoomSummaryPlaceholderRow() RoomSummaryPlaceholderRow()
@ -167,7 +175,7 @@ private fun RoomsView(
onSetUpRecoveryClick: () -> Unit, onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit, onRoomClick: (RoomListRoomSummary) -> Unit,
contentBottomPadding: Dp, contentPadding: PaddingValues,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) { if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
@ -183,7 +191,7 @@ private fun RoomsView(
onSetUpRecoveryClick = onSetUpRecoveryClick, onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick, onRoomClick = onRoomClick,
contentBottomPadding = contentBottomPadding, contentPadding = contentPadding,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) )
} }
@ -197,7 +205,7 @@ private fun RoomsViewList(
onSetUpRecoveryClick: () -> Unit, onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit, onRoomClick: (RoomListRoomSummary) -> Unit,
contentBottomPadding: Dp, contentPadding: PaddingValues,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@ -216,7 +224,7 @@ private fun RoomsViewList(
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
modifier = modifier, modifier = modifier,
contentPadding = PaddingValues(bottom = contentBottomPadding) contentPadding = contentPadding,
) { ) {
when (state.securityBannerState) { when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> { SecurityBannerState.SetUpRecovery -> {
@ -329,6 +337,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
onConfirmRecoveryKeyClick = {}, onConfirmRecoveryKeyClick = {},
onRoomClick = {}, onRoomClick = {},
onCreateRoomClick = {}, 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -22,7 +21,6 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -30,14 +28,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color 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.res.stringResource
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics 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 androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.applyScaleDown 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.text.toSp
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.DropdownMenu 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.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RoomListTopBar( fun RoomListTopBar(
@ -126,25 +115,13 @@ private fun DefaultRoomListTopBar(
canReportBug: Boolean, canReportBug: Boolean,
modifier: Modifier = Modifier, 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 val collapsedFraction = scrollBehavior.state.collapsedFraction
var appBarHeight by remember {
mutableIntStateOf(previewAppBarHeight ?: 0)
}
val avatarData by remember(matrixUser) { val avatarData by remember(matrixUser) {
derivedStateOf { derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
} }
} }
val statusBarPadding = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
Box(modifier = modifier) { Box(modifier = modifier) {
val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle
val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy( val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy(
@ -160,40 +137,13 @@ private fun DefaultRoomListTopBar(
titleLarge = collapsedTitleTextStyle titleLarge = collapsedTitleTextStyle
), ),
) { ) {
Column( 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(),
) {
MediumTopAppBar( MediumTopAppBar(
modifier = Modifier
.backgroundVerticalGradient(
isVisible = !areSearchResultsDisplayed,
)
.statusBarsPadding(),
colors = TopAppBarDefaults.mediumTopAppBarColors( colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,

View file

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

View file

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

View file

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

View file

@ -42,6 +42,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)), aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))), 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="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_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="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_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_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> <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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.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.theme.ElementTheme
@ -44,15 +42,15 @@ fun TimelineEventTimestampView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val formattedTime = event.sentTime val formattedTime = event.sentTime
val hasError = event.localSendState is LocalEventSendState.Failed val hasError = event.failedToSend
val hasEncryptionCritical = event.messageShield?.isCritical.orFalse() val hasEncryptionCritical = event.messageShield?.isCritical.orFalse()
val isMessageEdited = event.content.isEdited() val isMessageEdited = event.content.isEdited()
val isMessageRedacted = event.content.isRedacted() val isMessageRedacted = event.content.isRedacted()
val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary
Row( Row(
modifier = Modifier modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)) .padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier), .then(modifier),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isMessageEdited) { if (isMessageEdited) {
@ -76,11 +74,13 @@ fun TimelineEventTimestampView(
contentDescription = stringResource(id = CommonStrings.common_sending_failed), contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint, tint = tint,
modifier = Modifier modifier = Modifier
.size(15.dp, 18.dp) .size(15.dp, 18.dp)
.clickable(isVerifiedUserSendFailure) { .clickable(
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event)) enabled = isVerifiedUserSendFailure,
} onClickLabel = stringResource(CommonStrings.action_open_context_menu),
.semantics { hideFromAccessibility() } ) {
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
}
) )
} }
@ -89,13 +89,14 @@ fun TimelineEventTimestampView(
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
Icon( Icon(
imageVector = shield.toIcon(), imageVector = shield.toIcon(),
contentDescription = shield.toText(), contentDescription = stringResource(id = CommonStrings.a11y_encryption_details),
modifier = Modifier modifier = Modifier
.size(15.dp) .size(15.dp)
.clickable { .clickable(
onClickLabel = stringResource(CommonStrings.a11y_view_details),
) {
eventSink(TimelineEvents.ShowShieldDialog(shield)) eventSink(TimelineEvents.ShowShieldDialog(shield))
} },
.semantics { hideFromAccessibility() },
tint = shield.toIconColor(), tint = shield.toIconColor(),
) )
Spacer(modifier = Modifier.width(4.dp)) 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.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.LocalBuildMeta 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.core.EventId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -140,7 +140,10 @@ internal fun TimelineItemRow(
timelineItem.safeSenderName timelineItem.safeSenderName
} }
// For Polls, allow the answers to be traversed by Talkback // 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 // Custom clickable that applies over the whole item for accessibility
.then( .then(
@ -206,23 +209,20 @@ internal fun TimelineItemRow(
@Suppress("ModifierComposable") @Suppress("ModifierComposable")
@Composable @Composable
private fun Modifier.focusedEvent( private fun Modifier.focusedEvent(
focusedEventOffset: Dp focusedEventOffset: Dp,
isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild,
): Modifier { ): Modifier {
val highlightedLineColor = ElementTheme.colors.textActionAccent val highlightedLineColor = if (isEnterpriseBuild) {
val gradientFirstColor = if (LocalBuildMeta.current.isEnterpriseBuild) { ElementTheme.colors.textActionAccent
ElementTheme.colors.textActionAccent.copy(alpha = 0.125f)
} else { } else {
ElementTheme.colors.highlightedMessageBackgroundColor ElementTheme.colors.borderAccentSubtle
} }
val gradientColors = listOf( val gradientColors = subtleColorStops(isEnterpriseBuild)
gradientFirstColor,
ElementTheme.colors.bgCanvasDefault,
)
val verticalOffset = focusedEventOffset.toPx() val verticalOffset = focusedEventOffset.toPx()
val verticalRatio = 0.7f val verticalRatio = 0.7f
return drawWithCache { return drawWithCache {
val brush = Brush.verticalGradient( val brush = Brush.verticalGradient(
colors = gradientColors, colorStops = gradientColors,
endY = size.height * verticalRatio, endY = size.height * verticalRatio,
) )
onDrawBehind { onDrawBehind {
@ -251,3 +251,18 @@ internal fun FocusedEventPreview() = ElementPreview {
.focusedEvent(0.dp), .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 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. * @param nonOverlappingContentHeight The height of the part of the content that can't overlap with the timestamp.
*/ */
@Suppress("DataClassShouldBeImmutable")
data class ContentAvoidingLayoutData( data class ContentAvoidingLayoutData(
var contentWidth: Int = 0, val contentWidth: Int = 0,
var contentHeight: Int = 0, val contentHeight: Int = 0,
var nonOverlappingContentWidth: Int = contentWidth, val nonOverlappingContentWidth: Int = contentWidth,
var nonOverlappingContentHeight: Int = contentHeight, val nonOverlappingContentHeight: Int = contentHeight,
) )
/** /**

View file

@ -116,7 +116,7 @@ class TimelineViewTest {
eventSink = eventsRecorder, 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() rule.onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(

View file

@ -4,5 +4,6 @@
<item quantity="one">"%1$d percent of total votes"</item> <item quantity="one">"%1$d percent of total votes"</item>
<item quantity="other">"%1$d percents of total votes"</item> <item quantity="other">"%1$d percents of total votes"</item>
</plurals> </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> <string name="a11y_polls_winning_answer">"This is the winning answer"</string>
</resources> </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 // Unselect the user from the row list
val contentDescription = rule.activity.getString(CommonStrings.action_remove) val contentDescription = rule.activity.getString(CommonStrings.action_remove)
rule.onNodeWithContentDescription(contentDescription).performClick() rule.onNodeWithContentDescription(
label = contentDescription,
useUnmergedTree = true,
).performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.QueryChanged(""),
@ -248,7 +251,7 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent( rule.setChangeRolesContent(
state = state, state = state,
) )
// Select the user from the row list // Select the user from the user list
rule.onNodeWithText("Carol").performClick() rule.onNodeWithText("Carol").performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
@ -271,8 +274,11 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent( rule.setChangeRolesContent(
state = state, state = state,
) )
// Select the user from the rom list // Unselect the user from the user list
rule.onAllNodesWithText("Bob")[1].performClick() rule.onAllNodesWithText(
text = "Bob",
useUnmergedTree = true,
)[1].performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.QueryChanged(""),

View file

@ -3,7 +3,7 @@
[versions] [versions]
# Project # Project
android_gradle_plugin = "8.11.0" android_gradle_plugin = "8.11.1"
kotlin = "2.2.0" kotlin = "2.2.0"
kotlinpoet = "2.2.0" kotlinpoet = "2.2.0"
ksp = "2.2.0-2.0.2" ksp = "2.2.0-2.0.2"
@ -33,7 +33,7 @@ accompanist = "0.37.3"
test_core = "1.6.1" test_core = "1.6.1"
# Jetbrain # Jetbrain
datetime = "0.7.0" datetime = "0.7.1"
serialization_json = "1.9.0" serialization_json = "1.9.0"
#other #other
@ -42,9 +42,9 @@ coil = "3.2.0"
showkase = "1.0.4" showkase = "1.0.4"
appyx = "1.7.1" appyx = "1.7.1"
sqldelight = "2.1.0" sqldelight = "2.1.0"
wysiwyg = "2.38.4" wysiwyg = "2.38.5"
telephoto = "0.16.0" telephoto = "0.16.0"
haze = "1.6.6" haze = "1.6.8"
# Dependency analysis # Dependency analysis
dependencyAnalysis = "2.19.0" 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_splash = "androidx.core:core-splashscreen:1.0.1"
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx_media3_ui = { module = "androidx.media3:media3-ui", 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_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" } 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" squareup_seismic = "com.squareup:seismic:1.0.3"
# network # 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_logging = { module = "com.squareup.okhttp3:logging-interceptor" }
network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" } network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" }
network_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_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil_gif = { module = "io.coil-kt.coil3:coil-gif", 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" } 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" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } 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" 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" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.1.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.1.0"
timber = "com.jakewharton.timber:timber:5.0.1" 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 = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", 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" } 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" sqlcipher = "net.zetetic:sqlcipher-android:4.9.0"
sqlite = "androidx.sqlite:sqlite-ktx:2.5.2" sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
unifiedpush = "org.unifiedpush.android:connector:3.0.10" unifiedpush = "org.unifiedpush.android:connector:3.0.10"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2" 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_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.2.0" 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" } haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.19.0" posthog = "com.posthog:posthog-android:3.19.2"
sentry = "io.sentry:sentry-android:8.16.0" sentry = "io.sentry:sentry-android:8.17.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0" 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" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" } anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } 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" dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:12.1.3" dependencycheck = "org.owasp.dependencycheck:12.1.3"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }

View file

@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999 distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME 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.components.Badge
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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 { object MatrixBadgeAtom {
data class MatrixBadgeData( data class MatrixBadgeData(
@ -39,21 +35,21 @@ object MatrixBadgeAtom {
) { ) {
val backgroundColor = when (data.type) { val backgroundColor = when (data.type) {
Type.Positive -> ElementTheme.colors.bgBadgeAccent Type.Positive -> ElementTheme.colors.bgBadgeAccent
Type.Neutral -> ElementTheme.colors.badgeNeutralBackgroundColor Type.Neutral -> ElementTheme.colors.bgBadgeDefault
Type.Negative -> ElementTheme.colors.badgeNegativeBackgroundColor Type.Negative -> ElementTheme.colors.bgCriticalSubtle
Type.Info -> ElementTheme.colors.bgBadgeInfo Type.Info -> ElementTheme.colors.bgBadgeInfo
} }
val textColor = when (data.type) { val textColor = when (data.type) {
Type.Positive -> ElementTheme.colors.textBadgeAccent Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.badgeNeutralContentColor Type.Neutral -> ElementTheme.colors.textPrimary
Type.Negative -> ElementTheme.colors.badgeNegativeContentColor Type.Negative -> ElementTheme.colors.textCriticalPrimary
Type.Info -> ElementTheme.colors.textBadgeInfo Type.Info -> ElementTheme.colors.textBadgeInfo
} }
val iconColor = when (data.type) { val iconColor = when (data.type) {
Type.Positive -> ElementTheme.colors.textBadgeAccent Type.Positive -> ElementTheme.colors.iconAccentPrimary
Type.Neutral -> ElementTheme.colors.iconSecondary Type.Neutral -> ElementTheme.colors.iconPrimary
Type.Negative -> ElementTheme.colors.iconCriticalPrimary Type.Negative -> ElementTheme.colors.iconCriticalPrimary
Type.Info -> ElementTheme.colors.textBadgeInfo Type.Info -> ElementTheme.colors.iconInfoPrimary
} }
Badge( Badge(
text = data.text, 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.graphics.ShaderBrush
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.drawWithLayer
/** /**
* Gradient background for FTUE (onboarding) screens. * 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, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
contentDescription: String = title,
) { ) {
val ripple = ripple(bounded = false) val ripple = ripple(bounded = false)
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -58,8 +57,8 @@ fun MainActionButton(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Icon( Icon(
contentDescription = null,
imageVector = imageVector, imageVector = imageVector,
contentDescription = contentDescription,
tint = if (enabled) LocalContentColor.current else ElementTheme.colors.iconDisabled, tint = if (enabled) LocalContentColor.current else ElementTheme.colors.iconDisabled,
) )
Spacer(modifier = Modifier.height(14.dp)) 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.Shape
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp 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.theme.ElementTheme
import io.element.android.compound.tokens.generated.internal.DarkColorTokens import io.element.android.libraries.designsystem.colors.gradientActionColors
import io.element.android.compound.tokens.generated.internal.LightColorTokens
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta 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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.lowHorizontalPaddingValue import io.element.android.libraries.designsystem.theme.components.lowHorizontalPaddingValue
@OptIn(CoreColorToken::class)
@Composable @Composable
fun SuperButton( fun SuperButton(
onClick: () -> Unit, onClick: () -> Unit,
@ -66,34 +63,21 @@ fun SuperButton(
ButtonSize.Small -> PaddingValues(horizontal = 16.dp, vertical = 5.dp) ButtonSize.Small -> PaddingValues(horizontal = 16.dp, vertical = 5.dp)
} }
} }
val isLightTheme = ElementTheme.isLightTheme
val colors = if (LocalBuildMeta.current.isEnterpriseBuild) { val colors = if (LocalBuildMeta.current.isEnterpriseBuild) {
listOf( listOf(
ElementTheme.colors.textActionAccent, ElementTheme.colors.textActionAccent,
ElementTheme.colors.textActionAccent, ElementTheme.colors.textActionAccent,
) )
} else { } else {
remember(isLightTheme) { gradientActionColors()
if (isLightTheme) {
listOf(
LightColorTokens.colorBlue900,
LightColorTokens.colorGreen1100,
)
} else {
listOf(
DarkColorTokens.colorBlue900,
DarkColorTokens.colorGreen1100,
)
}
}
} }
val shaderBrush = remember(colors) { val shaderBrush = remember(colors) {
object : ShaderBrush() { object : ShaderBrush() {
override fun createShader(size: Size): Shader { override fun createShader(size: Size): Shader {
return LinearGradientShader( return LinearGradientShader(
from = Offset(0f, size.height), from = Offset(0f, 0f),
to = Offset(size.width, 0f), to = Offset(0f, size.height),
colors = colors, 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 { object PreviewGroup {
const val AppBars = "App Bars" const val AppBars = "App Bars"
const val Avatars = "Avatars" const val Avatars = "Avatars"
const val Bloom = "Bloom"
const val BottomSheets = "Bottom Sheets" const val BottomSheets = "Bottom Sheets"
const val Buttons = "Buttons" const val Buttons = "Buttons"
const val DateTimePickers = "DateTime pickers" const val DateTimePickers = "DateTime pickers"

View file

@ -22,14 +22,14 @@ import kotlinx.collections.immutable.persistentMapOf
/** /**
* Room list. * Room list.
*/ */
@Composable val SemanticColors.roomListRoomName
fun ElementTheme.roomListRoomName() = colors.textPrimary get() = textPrimary
@Composable val SemanticColors.roomListRoomMessage
fun ElementTheme.roomListRoomMessage() = colors.textSecondary get() = textSecondary
@Composable val SemanticColors.roomListRoomMessageDate
fun ElementTheme.roomListRoomMessageDate() = colors.textSecondary get() = textSecondary
val SemanticColors.unreadIndicator val SemanticColors.unreadIndicator
get() = iconAccentTertiary get() = iconAccentTertiary
@ -52,11 +52,6 @@ val SemanticColors.messageFromOtherBackground
val SemanticColors.progressIndicatorTrackColor val SemanticColors.progressIndicatorTrackColor
get() = if (isLight) LightColorTokens.colorAlphaGray500 else DarkColorTokens.colorAlphaGray500 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 // This color is not present in Semantic color, so put hard-coded value for now
@OptIn(CoreColorToken::class) @OptIn(CoreColorToken::class)
val SemanticColors.bgSubtleTertiary val SemanticColors.bgSubtleTertiary
@ -71,57 +66,6 @@ val SemanticColors.temporaryColorBgSpecial
val SemanticColors.pinDigitBg val SemanticColors.pinDigitBg
get() = if (isLight) LightColorTokens.colorGray300 else DarkColorTokens.colorGray400 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) @OptIn(CoreColorToken::class)
val SemanticColors.pinnedMessageBannerIndicator val SemanticColors.pinnedMessageBannerIndicator
get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600 get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600
@ -137,18 +81,15 @@ internal fun ColorAliasesPreview() = ElementPreview {
backgroundColor = Color.Black, backgroundColor = Color.Black,
foregroundColor = Color.White, foregroundColor = Color.White,
colors = persistentMapOf( colors = persistentMapOf(
"roomListRoomName" to ElementTheme.roomListRoomName(), "roomListRoomName" to ElementTheme.colors.roomListRoomName,
"roomListRoomMessage" to ElementTheme.roomListRoomMessage(), "roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage,
"roomListRoomMessageDate" to ElementTheme.roomListRoomMessageDate(), "roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate,
"unreadIndicator" to ElementTheme.colors.unreadIndicator, "unreadIndicator" to ElementTheme.colors.unreadIndicator,
"placeholderBackground" to ElementTheme.colors.placeholderBackground, "placeholderBackground" to ElementTheme.colors.placeholderBackground,
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground, "messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground, "messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
"progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor, "progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor,
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial, "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 // False so it's displayed in the developer options screen
isFinished = false, 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 ) : MessageLike
data object RoomEncrypted : MessageLike data object RoomEncrypted : MessageLike
data object UnableToResolve : MessageLike
data class RoomMessage( data class RoomMessage(
val senderId: UserId, val senderId: UserId,
val messageType: MessageType 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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 { 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)) client.setUtdDelegate(UtdTracker(analyticsService))
val syncService = client.syncService() val syncService = client.syncService()
.withSharePos(enable = featureFlagService.isFeatureEnabled(FeatureFlags.SharePos))
.withOfflineMode() .withOfflineMode()
.finish() .finish()

View file

@ -43,7 +43,6 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.HumanQrLoginException import org.matrix.rustcomponents.sdk.HumanQrLoginException
import org.matrix.rustcomponents.sdk.OidcConfiguration
import org.matrix.rustcomponents.sdk.QrCodeData import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress import org.matrix.rustcomponents.sdk.QrLoginProgress
@ -199,6 +198,8 @@ class RustMatrixAuthenticationService @Inject constructor(
oidcConfiguration = oidcConfigurationProvider.get(), oidcConfiguration = oidcConfigurationProvider.get(),
prompt = prompt.toRustPrompt(), prompt = prompt.toRustPrompt(),
loginHint = loginHint, 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() val url = oAuthAuthorizationData.loginUrl()
pendingOAuthAuthorizationData = oAuthAuthorizationData pendingOAuthAuthorizationData = oAuthAuthorizationData
@ -274,9 +275,13 @@ class RustMatrixAuthenticationService @Inject constructor(
sessionPaths = emptySessionPaths, sessionPaths = emptySessionPaths,
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
qrCodeData = sdkQrCodeLoginData, qrCodeData = sdkQrCodeLoginData,
)
client.loginWithQrCode(
qrCodeData = qrCodeData.rustQrCodeData,
oidcConfiguration = oidcConfiguration, oidcConfiguration = oidcConfiguration,
progressListener = progressListener, progressListener = progressListener,
) )
val sessionData = client.session() val sessionData = client.session()
.toSessionData( .toSessionData(
isTokenValid = true, isTokenValid = true,
@ -324,8 +329,6 @@ class RustMatrixAuthenticationService @Inject constructor(
sessionPaths: SessionPaths, sessionPaths: SessionPaths,
passphrase: String?, passphrase: String?,
qrCodeData: QrCodeData, qrCodeData: QrCodeData,
oidcConfiguration: OidcConfiguration,
progressListener: QrLoginProgressListener,
): Client { ): Client {
Timber.d("Creating client for QR Code login with simplified sliding sync") Timber.d("Creating client for QR Code login with simplified sliding sync")
return rustMatrixClientFactory return rustMatrixClientFactory
@ -335,7 +338,8 @@ class RustMatrixAuthenticationService @Inject constructor(
slidingSyncType = ClientBuilderSlidingSync.Discovered, slidingSyncType = ClientBuilderSlidingSync.Discovered,
) )
.sessionPassphrase(passphrase) .sessionPassphrase(passphrase)
.buildWithQrCode(qrCodeData, oidcConfiguration, progressListener) .serverNameOrHomeserverUrl(qrCodeData.serverName()!!)
.build()
} }
private fun clear() { 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.GetNotificationDataResult
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BatchNotificationResult
import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationItemsRequest import org.matrix.rustcomponents.sdk.NotificationItemsRequest
import org.matrix.rustcomponents.sdk.NotificationStatus
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
class RustNotificationService( class RustNotificationService(
private val sessionId: SessionId, private val sessionId: SessionId,
private val notificationClient: NotificationClient, private val notificationClient: NotificationClient,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val clock: SystemClock, clock: SystemClock,
) : NotificationService { ) : NotificationService {
private val notificationMapper: NotificationMapper = NotificationMapper(clock) private val notificationMapper: NotificationMapper = NotificationMapper(clock)
override suspend fun getNotifications( override suspend fun getNotifications(
ids: Map<RoomId, List<EventId>> ids: Map<RoomId, List<EventId>>
): Result<Map<EventId, NotificationData>> = withContext(dispatchers.io) { ): GetNotificationDataResult = withContext(dispatchers.io) {
runCatchingExceptions { runCatchingExceptions {
val requests = ids.map { (roomId, eventIds) -> val requests = ids.map { (roomId, eventIds) ->
NotificationItemsRequest( NotificationItemsRequest(
@ -42,34 +45,41 @@ class RustNotificationService(
val items = notificationClient.getNotifications(requests) val items = notificationClient.getNotifications(requests)
buildMap { buildMap {
val eventIds = requests.flatMap { it.eventIds } val eventIds = requests.flatMap { it.eventIds }
for (eventId in eventIds) { for (rawEventId in eventIds) {
val item = items[eventId] val roomId = RoomId(requests.find { it.eventIds.contains(rawEventId) }?.roomId!!)
val roomId = RoomId(requests.find { it.eventIds.contains(eventId) }?.roomId!!) val eventId = EventId(rawEventId)
if (item != null) { items[rawEventId].use { result ->
put(EventId(eventId), notificationMapper.map(sessionId, EventId(eventId), roomId, item)) when (result) {
} else { is BatchNotificationResult.Ok -> {
Timber.e("Could not retrieve event for notification with $eventId") when (val status = result.status) {
put( is NotificationStatus.Event -> {
EventId(eventId), put(eventId, Result.success(notificationMapper.map(sessionId, eventId, roomId, status.item)))
NotificationData( }
sessionId = sessionId, is NotificationStatus.EventNotFound -> {
eventId = EventId(eventId), Timber.e("Could not retrieve event for notification with $eventId - event not found")
threadId = null, put(eventId, Result.failure(NotificationResolverException.EventNotFound))
roomId = roomId, }
senderAvatarUrl = null, is NotificationStatus.EventFilteredOut -> {
senderDisplayName = null, Timber.d("Could not retrieve event for notification with $eventId - event filtered out")
senderIsNameAmbiguous = false, put(eventId, Result.failure(NotificationResolverException.EventFilteredOut))
roomAvatarUrl = null, }
roomDisplayName = null, }
isDirect = false, }
isDm = false, is BatchNotificationResult.Error -> {
isEncrypted = false, Timber.e("Error while retrieving notification with $rawEventId - ${result.message}")
isNoisy = false, put(
timestamp = clock.epochMillis(), eventId,
content = NotificationContent.MessageLike.UnableToResolve, Result.failure(NotificationResolverException.UnknownError(result.message))
hasMention = false )
) }
) 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.NotificationItem
import org.matrix.rustcomponents.sdk.NotificationRoomInfo import org.matrix.rustcomponents.sdk.NotificationRoomInfo
import org.matrix.rustcomponents.sdk.NotificationSenderInfo import org.matrix.rustcomponents.sdk.NotificationSenderInfo
import org.matrix.rustcomponents.sdk.NotificationStatus
import org.matrix.rustcomponents.sdk.TimelineEvent import org.matrix.rustcomponents.sdk.TimelineEvent
fun aRustNotificationItem( fun aRustNotificationItem(
@ -34,6 +35,12 @@ fun aRustNotificationItem(
threadId = threadId?.value, threadId = threadId?.value,
) )
fun aRustBatchNotificationResult(
notificationStatus: NotificationStatus = NotificationStatus.Event(aRustNotificationItem()),
) = org.matrix.rustcomponents.sdk.BatchNotificationResult.Ok(
status = notificationStatus,
)
fun aRustNotificationSenderInfo( fun aRustNotificationSenderInfo(
displayName: String? = A_USER_NAME, displayName: String? = A_USER_NAME,
avatarUrl: String? = null, 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.ClientBuilder
import org.matrix.rustcomponents.sdk.ClientSessionDelegate import org.matrix.rustcomponents.sdk.ClientSessionDelegate
import org.matrix.rustcomponents.sdk.NoPointer 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.RequestConfig
import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder
import uniffi.matrix_sdk.BackupDownloadStrategy import uniffi.matrix_sdk.BackupDownloadStrategy
@ -43,10 +40,6 @@ class FakeFfiClientBuilder : ClientBuilder(NoPointer) {
override fun username(username: String) = this override fun username(username: String) = this
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = 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 { override suspend fun build(): Client {
return FakeFfiClient(withUtdHook = {}) return FakeFfiClient(withUtdHook = {})
} }

View file

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

View file

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

View file

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

View file

@ -62,6 +62,7 @@ fun EditableAvatarView(
modifier = Modifier modifier = Modifier
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClickLabel = stringResource(CommonStrings.a11y_edit_avatar),
onClick = onAvatarClick, onClick = onAvatarClick,
indication = ripple(bounded = false), 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.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -48,9 +51,24 @@ fun SelectedUser(
onUserRemove: (MatrixUser) -> Unit, onUserRemove: (MatrixUser) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val actionRemove = stringResource(id = CommonStrings.action_remove)
Box( Box(
modifier = modifier modifier = modifier
.width(AvatarSize.SelectedUser.dp) .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( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -83,6 +101,7 @@ fun SelectedUser(
) { ) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
// Note: keep the context description for the test
contentDescription = stringResource(id = CommonStrings.action_remove), contentDescription = stringResource(id = CommonStrings.action_remove),
tint = ElementTheme.colors.iconOnSolidPrimary, tint = ElementTheme.colors.iconOnSolidPrimary,
modifier = Modifier.padding(2.dp) modifier = Modifier.padding(2.dp)

View file

@ -33,8 +33,10 @@ dependencies {
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(libs.inject) implementation(libs.inject)
implementation(libs.androidx.exifinterface) 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.coroutines.core)
implementation(libs.otaliastudios.transcoder)
implementation(libs.vanniktech.blurhash) implementation(libs.vanniktech.blurhash)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

View file

@ -192,12 +192,19 @@ class AndroidMediaPreProcessor @Inject constructor(
val resultFile = runCatchingExceptions { val resultFile = runCatchingExceptions {
videoCompressor.compress(uri, shouldBeCompressed) videoCompressor.compress(uri, shouldBeCompressed)
.onEach { .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>() .filterIsInstance<VideoTranscodingEvent.Completed>()
.first() .first()
.file .file
} }
.onFailure {
Timber.e(it, "Failed to compress video: $uri")
}
.getOrNull() .getOrNull()
if (resultFile != null) { if (resultFile != null) {
@ -283,10 +290,17 @@ class AndroidMediaPreProcessor @Inject constructor(
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult?): VideoInfo = private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult?): VideoInfo =
MediaMetadataRetriever().runAndRelease { MediaMetadataRetriever().runAndRelease {
setDataSource(context, Uri.fromFile(file)) 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( VideoInfo(
duration = extractDuration(), duration = extractDuration(),
width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L, width = width,
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L, height = height,
mimetype = mimeType, mimetype = mimeType,
size = file.length(), size = file.length(),
thumbnailInfo = thumbnailResult?.info, thumbnailInfo = thumbnailResult?.info,

View file

@ -8,76 +8,146 @@
package io.element.android.libraries.mediaupload.impl package io.element.android.libraries.mediaupload.impl
import android.content.Context import android.content.Context
import android.media.MediaCodecInfo
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import androidx.annotation.OptIn
import com.otaliastudios.transcoder.Transcoder import androidx.media3.common.MediaItem
import com.otaliastudios.transcoder.TranscoderListener import androidx.media3.common.MimeTypes
import com.otaliastudios.transcoder.internal.media.MediaFormatConstants import androidx.media3.common.util.Size
import com.otaliastudios.transcoder.resize.AtMostResizer import androidx.media3.common.util.UnstableApi
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy import androidx.media3.effect.Presentation
import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy import androidx.media3.transformer.Composition
import com.otaliastudios.transcoder.strategy.TrackStrategy import androidx.media3.transformer.DefaultEncoderFactory
import com.otaliastudios.transcoder.validator.WriteAlwaysValidator 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.createTmpFile
import io.element.android.libraries.androidutils.file.getMimeType
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
private const val MP4_EXTENSION = "mp4"
class VideoCompressor @Inject constructor( class VideoCompressor @Inject constructor(
@ApplicationContext private val context: Context, @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 metadata = getVideoMetadata(uri)
val expectedExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(context.getMimeType(uri)) val videoCompressorConfig = VideoCompressorConfigFactory.create(
val videoStrategy = VideoStrategyFactory.create(
expectedExtension = expectedExtension,
metadata = metadata, metadata = metadata,
shouldBeCompressed = shouldBeCompressed shouldBeCompressed = shouldBeCompressed
) )
val tmpFile = context.createTmpFile(extension = MP4_EXTENSION) val tmpFile = context.createTmpFile(extension = "mp4")
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()))
}
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)) trySend(VideoTranscodingEvent.Completed(tmpFile))
close() close()
} }
override fun onTranscodeCanceled() { override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) {
Timber.e(exportException, "Video transcoding failed")
tmpFile.safeDelete() tmpFile.safeDelete()
close() close(exportException)
} }
override fun onTranscodeFailed(exception: Throwable) { override fun onFallbackApplied(
tmpFile.safeDelete() composition: Composition,
close(exception) 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 { awaitClose {
if (!future.isDone) { progressJob.cancel()
future.cancel(true)
}
} }
} }
@ -89,7 +159,8 @@ class VideoCompressor @Inject constructor(
val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: -1 val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: -1
val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: -1 val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: -1
val bitrate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toLongOrNull() ?: -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) { val (actualWidth, actualHeight) = if (width == -1 || height == -1) {
// Try getting the first frame instead // Try getting the first frame instead
@ -103,7 +174,8 @@ class VideoCompressor @Inject constructor(
width = actualWidth, width = actualWidth,
height = actualHeight, height = actualHeight,
bitrate = bitrate, bitrate = bitrate,
frameRate = framerate frameRate = frameRate,
rotation = rotation,
) )
} }
}.onFailure { }.onFailure {
@ -113,53 +185,14 @@ class VideoCompressor @Inject constructor(
} }
internal data class VideoFileMetadata( internal data class VideoFileMetadata(
val width: Int?, val width: Int,
val height: Int?, val height: Int,
val bitrate: Long?, val bitrate: Long,
val frameRate: Int?, val frameRate: Int,
val rotation: Int,
) )
sealed interface VideoTranscodingEvent { sealed interface VideoTranscodingEvent {
data class Progress(val value: Float) : VideoTranscodingEvent data class Progress(val value: Float) : VideoTranscodingEvent
data class Completed(val file: File) : 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 package io.element.android.libraries.network.interceptors
import io.element.android.libraries.core.extensions.ellipsize
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
@ -28,7 +29,7 @@ internal class FormattedJsonHttpLogger(
*/ */
@Synchronized @Synchronized
override fun log(message: String) { 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. // 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. // 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.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId 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.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent 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.notification.NotificationData
@ -57,7 +58,7 @@ class DefaultCallNotificationEventResolver @Inject constructor(
forceNotify: Boolean forceNotify: Boolean
): Result<NotifiableEvent> = runCatchingExceptions { ): Result<NotifiableEvent> = runCatchingExceptions {
val content = notificationData.content as? NotificationContent.MessageLike.CallNotify 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 val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value
// We need the sync service working to get the updated room info // 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) { if (content.type == CallNotifyType.RING) {
appForegroundStateService.updateHasRingingCall(true) appForegroundStateService.updateHasRingingCall(true)
val client = clientProvider.getOrRestore(sessionId).getOrNull() ?: throw ResolvingException("Session $sessionId not found") val client = clientProvider.getOrRestore(
val room = client.getRoom(notificationData.roomId) ?: throw ResolvingException("Room ${notificationData.roomId} not found") 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 // 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 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 android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.squareup.anvil.annotations.ContributesBinding 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.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope 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.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId 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.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.MediaPreviewValue
import io.element.android.libraries.matrix.api.media.getMediaPreviewValue import io.element.android.libraries.matrix.api.media.getMediaPreviewValue
import io.element.android.libraries.matrix.api.notification.NotificationContent 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.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.push.impl.R 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.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent 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.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.NotificationLoggerTag) 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. * 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. * It is used as a bridge between the Event Thread and the NotificationDrawerManager.
@ -65,24 +72,24 @@ interface NotifiableEventResolver {
suspend fun resolveEvents( suspend fun resolveEvents(
sessionId: SessionId, sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest> notificationEventRequests: List<NotificationEventRequest>
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> ): ResolvePushEventsResult
} }
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class DefaultNotifiableEventResolver @Inject constructor( class DefaultNotifiableEventResolver @Inject constructor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val clock: SystemClock,
private val matrixClientProvider: MatrixClientProvider, private val matrixClientProvider: MatrixClientProvider,
private val notificationMediaRepoFactory: NotificationMediaRepo.Factory, private val notificationMediaRepoFactory: NotificationMediaRepo.Factory,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val callNotificationEventResolver: CallNotificationEventResolver, private val callNotificationEventResolver: CallNotificationEventResolver,
private val fallbackNotificationFactory: FallbackNotificationFactory,
) : NotifiableEventResolver { ) : NotifiableEventResolver {
override suspend fun resolveEvents( override suspend fun resolveEvents(
sessionId: SessionId, sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest> notificationEventRequests: List<NotificationEventRequest>
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> { ): ResolvePushEventsResult {
Timber.d("Queueing notifications: $notificationEventRequests") Timber.d("Queueing notifications: $notificationEventRequests")
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId")) 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 } } 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 // 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 -> val notificationsResult = client.notificationService().getNotifications(ids)
map.mapValues { (_, notificationData) ->
notificationData.asNotifiableEvent(client, sessionId) 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( return Result.success(
notificationEventRequests.associate { notificationEventRequests.associate { request ->
val notificationData = notifications.getOrNull()?.get(it.eventId) val notificationDataResult = notificationDataMap[request.eventId]
if (notificationData != null) { if (notificationDataResult == null) {
it to notificationData request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}"))
} else { } else {
// TODO once the SDK can actually return what went wrong, we should return it here instead of this generic error request to notificationDataResult
it to Result.failure(ResolvingException("No notification data for ${it.roomId} - ${it.eventId}"))
} }
} }
) )
@ -164,7 +179,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
NotificationContent.MessageLike.CallCandidates, NotificationContent.MessageLike.CallCandidates,
NotificationContent.MessageLike.CallHangup -> { NotificationContent.MessageLike.CallHangup -> {
Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}") 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 -> { is NotificationContent.MessageLike.CallInvite -> {
val notifiableMessageEvent = buildNotifiableMessageEvent( val notifiableMessageEvent = buildNotifiableMessageEvent(
@ -195,7 +210,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
NotificationContent.MessageLike.KeyVerificationReady, NotificationContent.MessageLike.KeyVerificationReady,
NotificationContent.MessageLike.KeyVerificationStart -> { NotificationContent.MessageLike.KeyVerificationStart -> {
Timber.tag(loggerTag.value).d("Ignoring notification for verification ${content.javaClass.simpleName}") 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 -> { is NotificationContent.MessageLike.Poll -> {
val notifiableEventMessage = buildNotifiableMessageEvent( val notifiableEventMessage = buildNotifiableMessageEvent(
@ -217,16 +232,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
} }
is NotificationContent.MessageLike.ReactionContent -> { is NotificationContent.MessageLike.ReactionContent -> {
Timber.tag(loggerTag.value).d("Ignoring notification for reaction") Timber.tag(loggerTag.value).d("Ignoring notification for reaction")
throw ResolvingException("Ignoring notification for reaction") throw NotificationResolverException.EventFilteredOut
} }
NotificationContent.MessageLike.RoomEncrypted -> { NotificationContent.MessageLike.RoomEncrypted -> {
Timber.tag(loggerTag.value).w("Notification with encrypted content -> fallback") Timber.tag(loggerTag.value).w("Notification with encrypted content -> fallback")
val fallbackNotifiableEvent = fallbackNotifiableEvent(userId, roomId, eventId) val fallbackNotifiableEvent = fallbackNotificationFactory.create(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)
ResolvedPushEvent.Event(fallbackNotifiableEvent) ResolvedPushEvent.Event(fallbackNotifiableEvent)
} }
is NotificationContent.MessageLike.RoomRedaction -> { is NotificationContent.MessageLike.RoomRedaction -> {
@ -234,7 +244,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
val redactedEventId = content.redactedEventId val redactedEventId = content.redactedEventId
if (redactedEventId == null) { if (redactedEventId == null) {
Timber.tag(loggerTag.value).d("redactedEventId is null.") Timber.tag(loggerTag.value).d("redactedEventId is null.")
throw ResolvingException("redactedEventId is null") throw NotificationResolverException.UnknownError("redactedEventId is null")
} else { } else {
ResolvedPushEvent.Redaction( ResolvedPushEvent.Redaction(
sessionId = userId, sessionId = userId,
@ -246,7 +256,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
} }
NotificationContent.MessageLike.Sticker -> { NotificationContent.MessageLike.Sticker -> {
Timber.tag(loggerTag.value).d("Ignoring notification for sticker") Timber.tag(loggerTag.value).d("Ignoring notification for sticker")
throw ResolvingException("Ignoring notification for reaction") throw NotificationResolverException.EventFilteredOut
} }
is NotificationContent.StateEvent.RoomMemberContent, is NotificationContent.StateEvent.RoomMemberContent,
NotificationContent.StateEvent.PolicyRuleRoom, NotificationContent.StateEvent.PolicyRuleRoom,
@ -270,27 +280,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
NotificationContent.StateEvent.SpaceChild, NotificationContent.StateEvent.SpaceChild,
NotificationContent.StateEvent.SpaceParent -> { NotificationContent.StateEvent.SpaceParent -> {
Timber.tag(loggerTag.value).d("Ignoring notification for state event ${content.javaClass.simpleName}") 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( private fun descriptionFromMessageContent(
content: NotificationContent.MessageLike.RoomMessage, content: NotificationContent.MessageLike.RoomMessage,
senderDisambiguatedDisplayName: String, senderDisambiguatedDisplayName: String,

View file

@ -33,6 +33,7 @@ class DefaultOnMissedCallNotificationHandler @Inject constructor(
?.getNotifications(mapOf(roomId to listOf(eventId))) ?.getNotifications(mapOf(roomId to listOf(eventId)))
?.getOrNull() ?.getOrNull()
?.get(eventId) ?.get(eventId)
?.getOrNull()
?: return ?: return
val notifiableEvent = callNotificationEventResolver.resolveEvent( 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.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService 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.PushHistoryService
import io.element.android.libraries.push.impl.history.onDiagnosticPush 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.onInvalidPushReceived
import io.element.android.libraries.push.impl.history.onSuccess 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.onUnableToResolveEvent
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession 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.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue 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.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.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
@ -63,6 +64,7 @@ class DefaultPushHandler @Inject constructor(
private val resolverQueue: NotificationResolverQueue, private val resolverQueue: NotificationResolverQueue,
@AppCoroutineScope @AppCoroutineScope
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val fallbackNotificationFactory: FallbackNotificationFactory,
) : PushHandler { ) : PushHandler {
init { init {
processPushEventResults() processPushEventResults()
@ -88,34 +90,37 @@ class DefaultPushHandler @Inject constructor(
} else { } else {
result.fold( result.fold(
onSuccess = { onSuccess = {
if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) { pushHistoryService.onSuccess(
pushHistoryService.onUnableToResolveEvent( providerInfo = request.providerInfo,
providerInfo = request.providerInfo, eventId = request.eventId,
eventId = request.eventId, roomId = request.roomId,
roomId = request.roomId, sessionId = request.sessionId,
sessionId = request.sessionId, comment = "Push handled successfully",
reason = "Showing fallback notification", )
) },
mutableBatteryOptimizationStore.showBatteryOptimizationBanner() onFailure = { exception ->
} else { if (exception is NotificationResolverException.EventFilteredOut) {
pushHistoryService.onSuccess( pushHistoryService.onSuccess(
providerInfo = request.providerInfo, providerInfo = request.providerInfo,
eventId = request.eventId, eventId = request.eventId,
roomId = request.roomId, roomId = request.roomId,
sessionId = request.sessionId, 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>() val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
@Suppress("LoopWithTooManyJumpStatements") @Suppress("LoopWithTooManyJumpStatements")
for (result in resolvedEvents.values) { for ((request, result) in resolvedEvents) {
val event = result.getOrNull() ?: continue 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 userPushStore = userPushStoreFactory.getOrCreate(event.sessionId)
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
// If notifications are disabled for this session and device, we don't want to show the notification // 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 android.content.Context
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId 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.media.MediaSource
import io.element.android.libraries.matrix.api.notification.CallNotifyType 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.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.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider 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.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -71,12 +73,22 @@ class DefaultNotifiableEventResolverTest {
} }
@Test @Test
fun `resolve event failure`() = runTest { fun `resolve fetching failure`() = runTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.failure(AN_EXCEPTION) notificationResult = Result.failure(AN_EXCEPTION)
) )
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) 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() assertThat(result.getEvent(request)?.isFailure).isTrue()
} }
@ -85,12 +97,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = TextMessageType(body = "Hello world", formatted = null) messageType = TextMessageType(body = "Hello world", formatted = null)
), ),
) ))
) )
) )
) )
@ -108,13 +120,13 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = TextMessageType(body = "Hello world", formatted = null) messageType = TextMessageType(body = "Hello world", formatted = null)
), ),
hasMention = true, hasMention = true,
) ))
) )
) )
) )
@ -131,7 +143,7 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = TextMessageType( messageType = TextMessageType(
@ -142,7 +154,7 @@ class DefaultNotifiableEventResolverTest {
) )
) )
), ),
) ))
) )
) )
) )
@ -159,7 +171,7 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = TextMessageType( messageType = TextMessageType(
@ -170,7 +182,7 @@ class DefaultNotifiableEventResolverTest {
) )
) )
), ),
) ))
) )
) )
) )
@ -187,12 +199,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = AudioMessageType("Audio", null, null, MediaSource("url"), null) messageType = AudioMessageType("Audio", null, null, MediaSource("url"), null)
), ),
) ))
) )
) )
) )
@ -209,12 +221,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = VideoMessageType("Video", null, null, MediaSource("url"), null) messageType = VideoMessageType("Video", null, null, MediaSource("url"), null)
), ),
) ))
) )
) )
) )
@ -231,12 +243,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null) messageType = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null)
), ),
) ))
) )
) )
) )
@ -253,12 +265,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = ImageMessageType("Image", null, null, MediaSource("url"), null), messageType = ImageMessageType("Image", null, null, MediaSource("url"), null),
), ),
) ))
) )
) )
) )
@ -275,12 +287,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = StickerMessageType("Sticker", null, null, MediaSource("url"), null), messageType = StickerMessageType("Sticker", null, null, MediaSource("url"), null),
), ),
) ))
) )
) )
) )
@ -297,12 +309,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = FileMessageType("File", null, null, MediaSource("url"), null), messageType = FileMessageType("File", null, null, MediaSource("url"), null),
), ),
) ))
) )
) )
) )
@ -319,12 +331,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = LocationMessageType("Location", "geo:1,2", null), messageType = LocationMessageType("Location", "geo:1,2", null),
), ),
) ))
) )
) )
) )
@ -341,12 +353,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = NoticeMessageType("Notice", null), messageType = NoticeMessageType("Notice", null),
), ),
) ))
) )
) )
) )
@ -363,12 +375,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomMessage( content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
messageType = EmoteMessageType("is happy", null), messageType = EmoteMessageType("is happy", null),
), ),
) ))
) )
) )
) )
@ -385,12 +397,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.Poll( content = NotificationContent.MessageLike.Poll(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
question = "A question" question = "A question"
), ),
) ))
) )
) )
) )
@ -407,13 +419,13 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.StateEvent.RoomMemberContent( content = NotificationContent.StateEvent.RoomMemberContent(
userId = A_USER_ID_2, userId = A_USER_ID_2,
membershipState = RoomMembershipState.INVITE membershipState = RoomMembershipState.INVITE
), ),
isDirect = false, isDirect = false,
) ))
) )
) )
) )
@ -427,12 +439,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.Invite( content = NotificationContent.Invite(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
), ),
isDirect = false, isDirect = false,
) ))
) )
) )
) )
@ -464,12 +476,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.Invite( content = NotificationContent.Invite(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
), ),
isDirect = true, isDirect = true,
) ))
) )
) )
) )
@ -501,13 +513,13 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.Invite( content = NotificationContent.Invite(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
), ),
isDirect = true, isDirect = true,
senderDisplayName = null, senderDisplayName = null,
) ))
) )
) )
) )
@ -539,7 +551,8 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(
aNotificationData(
content = NotificationContent.Invite( content = NotificationContent.Invite(
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
), ),
@ -547,6 +560,7 @@ class DefaultNotifiableEventResolverTest {
senderIsNameAmbiguous = true, senderIsNameAmbiguous = true,
) )
) )
)
) )
) )
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
@ -577,12 +591,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.StateEvent.RoomMemberContent( content = NotificationContent.StateEvent.RoomMemberContent(
userId = A_USER_ID_2, userId = A_USER_ID_2,
membershipState = RoomMembershipState.JOIN membershipState = RoomMembershipState.JOIN
) )
) ))
) )
) )
) )
@ -595,7 +609,7 @@ class DefaultNotifiableEventResolverTest {
fun `resolve RoomEncrypted`() = runTest { fun `resolve RoomEncrypted`() = runTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( 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") val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
@ -620,25 +634,12 @@ class DefaultNotifiableEventResolverTest {
fun `resolve UnableToResolve`() = runTest { fun `resolve UnableToResolve`() = runTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( 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 request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event( assertThat(result.getEvent(request)).isEqualTo(Result.failure<ResolvedPushEvent?>(NotificationResolverException.EventNotFound))
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))
} }
@Test @Test
@ -646,10 +647,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(
aNotificationData(
content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2), content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2),
) )
) )
)
) )
) )
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
@ -688,12 +691,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.CallNotify( content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2, A_USER_ID_2,
CallNotifyType.NOTIFY CallNotifyType.NOTIFY
), ),
) ))
) )
), ),
callNotificationEventResolver = callNotificationEventResolver, callNotificationEventResolver = callNotificationEventResolver,
@ -729,12 +732,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomRedaction( content = NotificationContent.MessageLike.RoomRedaction(
AN_EVENT_ID_2, AN_EVENT_ID_2,
A_REDACTION_REASON, A_REDACTION_REASON,
) )
) ))
) )
) )
) )
@ -754,12 +757,12 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( notificationResult = Result.success(
mapOf( mapOf(
AN_EVENT_ID to aNotificationData( AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.MessageLike.RoomRedaction( content = NotificationContent.MessageLike.RoomRedaction(
null, null,
A_REDACTION_REASON, A_REDACTION_REASON,
) )
) ))
) )
) )
) )
@ -807,7 +810,7 @@ class DefaultNotifiableEventResolverTest {
private fun testNoResults(content: NotificationContent) = runTest { private fun testNoResults(content: NotificationContent) = runTest {
val sut = createDefaultNotifiableEventResolver( val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success( 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") val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
@ -823,7 +826,7 @@ class DefaultNotifiableEventResolverTest {
private fun createDefaultNotifiableEventResolver( private fun createDefaultNotifiableEventResolver(
notificationService: FakeNotificationService? = FakeNotificationService(), notificationService: FakeNotificationService? = FakeNotificationService(),
notificationResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap()), notificationResult: Result<Map<EventId, Result<NotificationData>>> = Result.success(emptyMap()),
callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(), callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
): DefaultNotifiableEventResolver { ): DefaultNotifiableEventResolver {
val context = RuntimeEnvironment.getApplication() as Context val context = RuntimeEnvironment.getApplication() as Context
@ -840,12 +843,15 @@ class DefaultNotifiableEventResolverTest {
} }
return DefaultNotifiableEventResolver( return DefaultNotifiableEventResolver(
stringProvider = AndroidStringProvider(context.resources), stringProvider = AndroidStringProvider(context.resources),
clock = FakeSystemClock(),
matrixClientProvider = matrixClientProvider, matrixClientProvider = matrixClientProvider,
notificationMediaRepoFactory = notificationMediaRepoFactory, notificationMediaRepoFactory = notificationMediaRepoFactory,
context = context, context = context,
permalinkParser = FakePermalinkParser(), permalinkParser = FakePermalinkParser(),
callNotificationEventResolver = callNotificationEventResolver, 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 matrixClientProvider = FakeMatrixClientProvider(getClient = {
val notificationService = FakeNotificationService().apply { val notificationService = FakeNotificationService().apply {
givenGetNotificationsResult( 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)) 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId 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.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.notification.CallNotifyType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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.FakePushHistoryService
import io.element.android.libraries.push.impl.history.PushHistoryService 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.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.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue 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.channels.FakeNotificationChannels
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent 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.FakeUserPushStore
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret 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.any
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder 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`() { fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() {
`test notification resolver failure`( `test notification resolver failure`(
notificationResolveResult = { _ -> notificationResolveResult = { _ ->
Result.failure(ResolvingException("Unable to restore session")) Result.failure(NotificationResolverException.UnknownError("Unable to restore session"))
}, },
shouldSetOptimizationBatteryBanner = false, shouldSetOptimizationBatteryBanner = false,
) )
@ -282,7 +285,7 @@ class DefaultPushHandlerTest {
`test notification resolver failure`( `test notification resolver failure`(
notificationResolveResult = { requests: List<NotificationEventRequest> -> notificationResolveResult = { requests: List<NotificationEventRequest> ->
Result.success( Result.success(
requests.associateWith { Result.failure(ResolvingException("Unable to resolve event")) } requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) }
) )
}, },
shouldSetOptimizationBatteryBanner = true, shouldSetOptimizationBatteryBanner = true,
@ -336,8 +339,6 @@ class DefaultPushHandlerTest {
notifiableEventResult.assertions() notifiableEventResult.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_USER_ID), any()) .with(value(A_USER_ID), any())
onNotifiableEventsReceived.assertions()
.isNeverCalled()
onPushReceivedResult.assertions() onPushReceivedResult.assertions()
.isCalledOnce() .isCalledOnce()
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any()) .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, pushHistoryService = pushHistoryService,
resolverQueue = NotificationResolverQueue(notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult), backgroundScope), resolverQueue = NotificationResolverQueue(notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult), backgroundScope),
appCoroutineScope = 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.iconSuccessPrimaryBackground
@Composable @Composable
internal fun FormattingOption( internal fun FormattingOption(
@ -42,13 +41,13 @@ internal fun FormattingOption(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val backgroundColor = when (state) { val backgroundColor = when (state) {
FormattingOptionState.Selected -> ElementTheme.colors.iconSuccessPrimaryBackground FormattingOptionState.Selected -> ElementTheme.colors.bgAccentSelected
FormattingOptionState.Default, FormattingOptionState.Default,
FormattingOptionState.Disabled -> Color.Transparent FormattingOptionState.Disabled -> Color.Transparent
} }
val foregroundColor = when (state) { val foregroundColor = when (state) {
FormattingOptionState.Selected -> ElementTheme.colors.iconSuccessPrimary FormattingOptionState.Selected -> ElementTheme.colors.iconAccentPrimary
FormattingOptionState.Default -> ElementTheme.colors.iconSecondary FormattingOptionState.Default -> ElementTheme.colors.iconSecondary
FormattingOptionState.Disabled -> ElementTheme.colors.iconDisabled 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.clip
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@ -62,13 +62,7 @@ internal fun SendButton(
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.size(36.dp) .size(36.dp)
.then( .buttonBackgroundModifier(canSendMessage)
if (canSendMessage) {
buttonBackgroundModifier()
} else {
Modifier
}
)
) { ) {
Icon( Icon(
modifier = Modifier modifier = Modifier
@ -91,27 +85,30 @@ internal fun SendButton(
} }
} }
private fun buttonBackgroundModifier() = Modifier.drawWithCache { @Composable
// We have a square button, so height == width. private fun Modifier.buttonBackgroundModifier(
val height = size.height canSendMessage: Boolean,
val verticalGradientBrush = ShaderBrush( ) = then(
LinearGradientShader( if (canSendMessage) {
from = Offset(0f, 0f), val colors = gradientActionColors()
to = Offset(0f, height), Modifier.drawWithCache {
colors = listOf( val verticalGradientBrush = ShaderBrush(
Color(0xFF79DD98), LinearGradientShader(
Color(0xFF0DBD8B), from = Offset(0f, 0f),
Color(0xFF128585), to = Offset(0f, size.height),
Color(0xFF24446B), colors = colors,
)
) )
) onDrawBehind {
) drawRect(
onDrawBehind { brush = verticalGradientBrush,
drawRect( )
brush = verticalGradientBrush, }
) }
} else {
Modifier
} }
} )
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable

View file

@ -13,11 +13,17 @@ import android.net.Uri
import android.text.Spanned import android.text.Spanned
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView 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.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.rememberTypeface import io.element.android.libraries.designsystem.text.rememberTypeface
import io.element.android.libraries.designsystem.theme.currentUserMentionPillBackground import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.currentUserMentionPillText import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.mentionPillBackground import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.designsystem.theme.mentionPillText
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -52,7 +57,8 @@ import javax.inject.Inject
@Stable @Stable
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
class MentionSpanTheme(val currentUserId: UserId) { 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 currentUserTextColor: Int = 0
internal var currentUserBackgroundColor: Int = Color.WHITE internal var currentUserBackgroundColor: Int = Color.WHITE
@ -69,10 +75,10 @@ class MentionSpanTheme(val currentUserId: UserId) {
@Suppress("ComposableNaming") @Suppress("ComposableNaming")
@Composable @Composable
fun updateStyles() { fun updateStyles() {
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb() currentUserTextColor = ElementTheme.colors.textBadgeAccent.toArgb()
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb() currentUserBackgroundColor = ElementTheme.colors.bgBadgeAccent.toArgb()
otherTextColor = ElementTheme.colors.mentionPillText.toArgb() otherTextColor = ElementTheme.colors.textPrimary.toArgb()
otherBackgroundColor = ElementTheme.colors.mentionPillBackground.toArgb() otherBackgroundColor = ElementTheme.colors.bgBadgeDefault.toArgb()
typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value
val density = LocalDensity.current 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_jump_to_bottom">"Mine lõppu"</string>
<string name="a11y_notifications_mentions_only">"Ainult mainimised"</string> <string name="a11y_notifications_mentions_only">"Ainult mainimised"</string>
<string name="a11y_notifications_muted">"Summutatud"</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_page_n">"%1$d. lehekülg"</string>
<string name="a11y_pause">"Peata"</string> <string name="a11y_pause">"Peata"</string>
<string name="a11y_paused_voice_message">"Häälsõnum, kestus:%1$s, praegune asukoht: %2$s"</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_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">"Eemalda reageerimine %1$s emotikoniga"</string>
<string name="a11y_remove_reaction_with">"Eemalda reageerimine: %1$s"</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_send_files">"Saada faile"</string>
<string name="a11y_show_password">"Näita salasõna"</string> <string name="a11y_show_password">"Näita salasõna"</string>
<string name="a11y_start_call">"Helista"</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_user_menu">"Kasutajamenüü"</string>
<string name="a11y_view_avatar">"Vaata tunnuspilti"</string> <string name="a11y_view_avatar">"Vaata tunnuspilti"</string>
<string name="a11y_view_details">"Vaata üksikasju"</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">"Häälsõnum, kestus:%1$s"</string>
<string name="a11y_voice_message_record">"Salvesta häälsõnum."</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_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_accept">"Nõustu"</string>
<string name="action_add_caption">"Lisa selgitus"</string> <string name="action_add_caption">"Lisa selgitus"</string>
<string name="action_add_to_timeline">"Lisa ajajoonele"</string> <string name="action_add_to_timeline">"Lisa ajajoonele"</string>
@ -97,6 +101,7 @@
<string name="action_no">"Ei"</string> <string name="action_no">"Ei"</string>
<string name="action_not_now">"Mitte praegu"</string> <string name="action_not_now">"Mitte praegu"</string>
<string name="action_ok">"OK"</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_settings">"Seadistused"</string>
<string name="action_open_with">"Ava rakendusega"</string> <string name="action_open_with">"Ava rakendusega"</string>
<string name="action_pin">"Tõsta esile"</string> <string name="action_pin">"Tõsta esile"</string>
@ -121,7 +126,9 @@
<string name="action_save">"Salvesta"</string> <string name="action_save">"Salvesta"</string>
<string name="action_search">"Otsi"</string> <string name="action_search">"Otsi"</string>
<string name="action_send">"Saada"</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_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">"Jaga"</string>
<string name="action_share_link">"Jaga linki"</string> <string name="action_share_link">"Jaga linki"</string>
<string name="action_show">"Näita"</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_tap_for_options">"Valikuteks klõpsa"</string>
<string name="action_try_again">"Proovi uuesti"</string> <string name="action_try_again">"Proovi uuesti"</string>
<string name="action_unpin">"Eemalda esiletõstmine"</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_in_timeline">"Vaata ajajoonel"</string>
<string name="action_view_source">"Vaata lähtekoodi"</string> <string name="action_view_source">"Vaata lähtekoodi"</string>
<string name="action_yes">"Jah"</string> <string name="action_yes">"Jah"</string>

View file

@ -126,7 +126,7 @@
<string name="action_save">"Enregistrer"</string> <string name="action_save">"Enregistrer"</string>
<string name="action_search">"Rechercher"</string> <string name="action_search">"Rechercher"</string>
<string name="action_send">"Envoyer"</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_message">"Envoyer un message"</string>
<string name="action_send_voice_message">"Envoyer un message vocal"</string> <string name="action_send_voice_message">"Envoyer un message vocal"</string>
<string name="action_share">"Partager"</string> <string name="action_share">"Partager"</string>

View file

@ -7,11 +7,16 @@
<item quantity="one">"%1$d digit entered"</item> <item quantity="one">"%1$d digit entered"</item>
<item quantity="other">"%1$d digits entered"</item> <item quantity="other">"%1$d digits entered"</item>
</plurals> </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_hide_password">"Hide password"</string>
<string name="a11y_join_call">"Join call"</string> <string name="a11y_join_call">"Join call"</string>
<string name="a11y_jump_to_bottom">"Jump to bottom"</string> <string name="a11y_jump_to_bottom">"Jump to bottom"</string>
<string name="a11y_notifications_mentions_only">"Mentions only"</string> <string name="a11y_notifications_mentions_only">"Mentions only"</string>
<string name="a11y_notifications_muted">"Muted"</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_other_user_avatar">"Other user\'s avatar"</string>
<string name="a11y_page_n">"Page %1$d"</string> <string name="a11y_page_n">"Page %1$d"</string>
<string name="a11y_pause">"Pause"</string> <string name="a11y_pause">"Pause"</string>
@ -35,6 +40,7 @@
<string name="a11y_send_files">"Send files"</string> <string name="a11y_send_files">"Send files"</string>
<string name="a11y_show_password">"Show password"</string> <string name="a11y_show_password">"Show password"</string>
<string name="a11y_start_call">"Start a call"</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_avatar">"User avatar"</string>
<string name="a11y_user_menu">"User menu"</string> <string name="a11y_user_menu">"User menu"</string>
<string name="a11y_view_avatar">"View avatar"</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 private const val versionMonth = 7
// Note: must be in [0,99] // Note: must be in [0,99]
private const val versionReleaseNumber = 0 private const val versionReleaseNumber = 1
object Versions { object Versions {
const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber 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 version https://git-lfs.github.com/spec/v1
oid sha256:daa1ee2d17b392c702067cfd3bd73a64ed393e68e242b342abe1db97baa696db oid sha256:03d9396d033f227408e8acdb424f187e2c0bf7c5493c13faee5eef51b01e4c93
size 11218 size 10726

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ae0bab78138a338cced2929a32ecbef81736247dfdd0b6985f69ef41174d2719 oid sha256:2eb622c603392e5da8ebd3aba69e0bdbe238e9de46cb6ade6306fc2e8fc4ad7a
size 20001 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_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,], ["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.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.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_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,], ["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