Merge branch 'release/25.07.1' into main
This commit is contained in:
commit
5739a28098
440 changed files with 2533 additions and 2536 deletions
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
|
|
@ -10,8 +10,7 @@ updates:
|
|||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
open-pull-requests-limit: 0
|
||||
reviewers:
|
||||
- "element-hq/element-x-android-reviewers"
|
||||
# Updates for Gradle dependencies used in the app
|
||||
|
|
@ -19,8 +18,6 @@ updates:
|
|||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 200
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
open-pull-requests-limit: 0
|
||||
reviewers:
|
||||
- "element-hq/element-x-android-reviewers"
|
||||
|
|
|
|||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
retention-days: 5
|
||||
overwrite: true
|
||||
if-no-files-found: error
|
||||
- uses: rnkdsh/action-upload-diawi@605adbad0db6c000eee26adfd8fc128d7df8f7ab # v1.5.9
|
||||
- uses: rnkdsh/action-upload-diawi@993c2e9f871486114a171cf7fb40554716dac3c5 # v1.5.10
|
||||
id: diawi
|
||||
# Do not fail the whole build if Diawi upload fails
|
||||
continue-on-error: true
|
||||
|
|
|
|||
76
CHANGES.md
76
CHANGES.md
|
|
@ -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
|
||||
=============================
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,9 @@
|
|||
|
||||
# Element X Android
|
||||
|
||||
Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/).
|
||||
Element X Android is the next-generation [Matrix](https://matrix.org/) client provided by [Element](https://element.io/).
|
||||
|
||||
The application is a total rewrite of [Element-Android](https://github.com/element-hq/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
|
||||
|
||||
Learn more about why we are building Element X in our blog post: [https://element.io/blog/element-x-experience-the-future-of-element/](https://element.io/blog/element-x-experience-the-future-of-element/).
|
||||
Compared to the previous-generation [Element Classic](https://github.com/element-hq/element-android), the application is a total rewrite, using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
|
||||
|
||||
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=io.element.android.x)[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/io.element.android.x)
|
||||
|
||||
|
|
@ -72,7 +70,7 @@ We're doing this as a way to share code between platforms and while we've seen p
|
|||
|
||||
## Status
|
||||
|
||||
This project is in an early rollout and migration phase.
|
||||
This project is actively developed and supported. New users are recommended to use Element X instead of the previous-generation app.
|
||||
|
||||
## Minimum SDK version
|
||||
|
||||
|
|
|
|||
2
fastlane/metadata/android/en-US/changelogs/202507010.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202507010.txt
Normal 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
|
||||
|
|
@ -16,18 +16,18 @@ enum class HomeNavigationBarItem(
|
|||
val labelRes: Int,
|
||||
) {
|
||||
Chats(
|
||||
labelRes = R.string.screen_roomlist_main_space_title
|
||||
labelRes = R.string.screen_home_tab_chats
|
||||
),
|
||||
Spaces(
|
||||
// TODO Create a new entry in Localazy
|
||||
labelRes = R.string.screen_roomlist_main_space_title
|
||||
labelRes = R.string.screen_home_tab_spaces
|
||||
);
|
||||
|
||||
@Composable
|
||||
fun icon() = when (this) {
|
||||
Chats -> CompoundIcons.ChatSolid()
|
||||
// TODO Spaces -> CompoundIcons.Workspace()
|
||||
Spaces -> CompoundIcons.Code()
|
||||
fun icon(
|
||||
isSelected: Boolean,
|
||||
) = when (this) {
|
||||
Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat()
|
||||
Spaces -> if (isSelected) CompoundIcons.WorkspaceSolid() else CompoundIcons.Workspace()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ package io.element.android.features.home.impl
|
|||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
|
|
@ -56,7 +57,9 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.NavigationBar
|
||||
import io.element.android.libraries.designsystem.theme.components.NavigationBarIcon
|
||||
import io.element.android.libraries.designsystem.theme.components.NavigationBarItem
|
||||
import io.element.android.libraries.designsystem.theme.components.NavigationBarText
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
|
|
@ -179,6 +182,15 @@ private fun HomeScaffold(
|
|||
displayFilters = roomListState.displayFilters && state.currentHomeNavigationBarItem == HomeNavigationBarItem.Chats,
|
||||
filtersState = roomListState.filtersState,
|
||||
canReportBug = state.canReportBug,
|
||||
modifier = if (state.isSpaceFeatureEnabled) {
|
||||
Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thick(),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
|
|
@ -188,23 +200,25 @@ private fun HomeScaffold(
|
|||
modifier = Modifier
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.regular(),
|
||||
style = HazeMaterials.thick(),
|
||||
)
|
||||
) {
|
||||
HomeNavigationBarItem.entries.forEach { item ->
|
||||
val isSelected = state.currentHomeNavigationBarItem == item
|
||||
NavigationBarItem(
|
||||
selected = state.currentHomeNavigationBarItem == item,
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
state.eventSink(HomeEvents.SelectHomeNavigationBarItem(item))
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = item.icon(),
|
||||
contentDescription = null
|
||||
NavigationBarIcon(
|
||||
imageVector = item.icon(isSelected),
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(item.labelRes))
|
||||
NavigationBarText(
|
||||
text = stringResource(item.labelRes),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -223,15 +237,18 @@ private fun HomeScaffold(
|
|||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onRoomClick = ::onRoomClick,
|
||||
onCreateRoomClick = onCreateRoomClick,
|
||||
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80,
|
||||
// and include provided bottom padding
|
||||
contentBottomPadding = 80.dp + padding.calculateBottomPadding(),
|
||||
contentPadding = PaddingValues(
|
||||
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80,
|
||||
// and include provided bottom padding
|
||||
bottom = 80.dp + padding.calculateBottomPadding(),
|
||||
top = padding.calculateTopPadding()
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = padding.calculateTopPadding(),
|
||||
bottom = 0.dp,
|
||||
start = padding.calculateStartPadding(LocalLayoutDirection.current),
|
||||
end = padding.calculateEndPadding(LocalLayoutDirection.current),
|
||||
PaddingValues(
|
||||
start = padding.calculateStartPadding(LocalLayoutDirection.current),
|
||||
end = padding.calculateEndPadding(LocalLayoutDirection.current),
|
||||
)
|
||||
)
|
||||
.consumeWindowInsets(padding)
|
||||
.hazeSource(state = hazeState)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
|
|
@ -67,44 +66,53 @@ fun RoomListContentView(
|
|||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
onRoomClick: (RoomListRoomSummary) -> Unit,
|
||||
onCreateRoomClick: () -> Unit,
|
||||
contentBottomPadding: Dp,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
when (contentState) {
|
||||
is RoomListContentState.Skeleton -> {
|
||||
SkeletonView(
|
||||
count = contentState.count,
|
||||
)
|
||||
}
|
||||
is RoomListContentState.Empty -> {
|
||||
EmptyView(
|
||||
state = contentState,
|
||||
eventSink = eventSink,
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onCreateRoomClick = onCreateRoomClick,
|
||||
)
|
||||
}
|
||||
is RoomListContentState.Rooms -> {
|
||||
RoomsView(
|
||||
state = contentState,
|
||||
hideInvitesAvatars = hideInvitesAvatars,
|
||||
filtersState = filtersState,
|
||||
eventSink = eventSink,
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onRoomClick = onRoomClick,
|
||||
contentBottomPadding = contentBottomPadding,
|
||||
)
|
||||
}
|
||||
when (contentState) {
|
||||
is RoomListContentState.Skeleton -> {
|
||||
SkeletonView(
|
||||
modifier = modifier,
|
||||
count = contentState.count,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}
|
||||
is RoomListContentState.Empty -> {
|
||||
EmptyView(
|
||||
modifier = modifier.padding(contentPadding),
|
||||
state = contentState,
|
||||
eventSink = eventSink,
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onCreateRoomClick = onCreateRoomClick,
|
||||
)
|
||||
}
|
||||
is RoomListContentState.Rooms -> {
|
||||
RoomsView(
|
||||
modifier = modifier,
|
||||
state = contentState,
|
||||
hideInvitesAvatars = hideInvitesAvatars,
|
||||
filtersState = filtersState,
|
||||
eventSink = eventSink,
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onRoomClick = onRoomClick,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkeletonView(count: Int, modifier: Modifier = Modifier) {
|
||||
LazyColumn(modifier = modifier) {
|
||||
private fun SkeletonView(
|
||||
count: Int,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
repeat(count) { index ->
|
||||
item {
|
||||
RoomSummaryPlaceholderRow()
|
||||
|
|
@ -167,7 +175,7 @@ private fun RoomsView(
|
|||
onSetUpRecoveryClick: () -> Unit,
|
||||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
onRoomClick: (RoomListRoomSummary) -> Unit,
|
||||
contentBottomPadding: Dp,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
|
||||
|
|
@ -183,7 +191,7 @@ private fun RoomsView(
|
|||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onRoomClick = onRoomClick,
|
||||
contentBottomPadding = contentBottomPadding,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
|
@ -197,7 +205,7 @@ private fun RoomsViewList(
|
|||
onSetUpRecoveryClick: () -> Unit,
|
||||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
onRoomClick: (RoomListRoomSummary) -> Unit,
|
||||
contentBottomPadding: Dp,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
|
@ -216,7 +224,7 @@ private fun RoomsViewList(
|
|||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(bottom = contentBottomPadding)
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
when (state.securityBannerState) {
|
||||
SecurityBannerState.SetUpRecovery -> {
|
||||
|
|
@ -329,6 +337,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
|
|||
onConfirmRecoveryKeyClick = {},
|
||||
onRoomClick = {},
|
||||
onCreateRoomClick = {},
|
||||
contentBottomPadding = 0.dp,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -22,7 +21,6 @@ import androidx.compose.material3.rememberTopAppBarState
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -30,14 +28,9 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.appconfig.RoomListConfig
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
|
|
@ -51,12 +44,10 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.components.avatarBloom
|
||||
import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGradient
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.applyScaleDown
|
||||
import io.element.android.libraries.designsystem.text.roundToPx
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
|
||||
|
|
@ -73,8 +64,6 @@ import io.element.android.libraries.testtags.TestTags
|
|||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
private val avatarBloomSize = 430.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RoomListTopBar(
|
||||
|
|
@ -126,25 +115,13 @@ private fun DefaultRoomListTopBar(
|
|||
canReportBug: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// We need this to manually clip the top app bar in preview mode
|
||||
val previewAppBarHeight = if (LocalInspectionMode.current) {
|
||||
112.dp.roundToPx()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val collapsedFraction = scrollBehavior.state.collapsedFraction
|
||||
var appBarHeight by remember {
|
||||
mutableIntStateOf(previewAppBarHeight ?: 0)
|
||||
}
|
||||
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||
}
|
||||
}
|
||||
|
||||
val statusBarPadding = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle
|
||||
val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy(
|
||||
|
|
@ -160,40 +137,13 @@ private fun DefaultRoomListTopBar(
|
|||
titleLarge = collapsedTitleTextStyle
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.onSizeChanged {
|
||||
appBarHeight = it.height
|
||||
}
|
||||
.avatarBloom(
|
||||
avatarData = avatarData,
|
||||
background = if (ElementTheme.isLightTheme) {
|
||||
// Workaround to display a very subtle bloom for avatars with very soft colors
|
||||
Color(0xFFF9F9F9)
|
||||
} else {
|
||||
ElementTheme.colors.bgCanvasDefault
|
||||
},
|
||||
blurSize = DpSize(avatarBloomSize, avatarBloomSize),
|
||||
offset = DpOffset(24.dp, 24.dp + statusBarPadding),
|
||||
clipToSize = if (appBarHeight > 0) {
|
||||
DpSize(
|
||||
avatarBloomSize,
|
||||
appBarHeight.toDp()
|
||||
)
|
||||
} else {
|
||||
DpSize.Unspecified
|
||||
},
|
||||
bottomSoftEdgeColor = ElementTheme.colors.bgCanvasDefault,
|
||||
bottomSoftEdgeAlpha = if (displayFilters) {
|
||||
1f
|
||||
} else {
|
||||
1f - collapsedFraction
|
||||
},
|
||||
alpha = if (areSearchResultsDisplayed) 0f else 1f,
|
||||
)
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
Column {
|
||||
MediumTopAppBar(
|
||||
modifier = Modifier
|
||||
.backgroundVerticalGradient(
|
||||
isVisible = !areSearchResultsDisplayed,
|
||||
)
|
||||
.statusBarsPadding(),
|
||||
colors = TopAppBarDefaults.mediumTopAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent,
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ private fun NameAndTimestampRow(
|
|||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
|
||||
fontStyle = FontStyle.Italic.takeIf { name == null },
|
||||
color = ElementTheme.roomListRoomName(),
|
||||
color = ElementTheme.colors.roomListRoomName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
|
@ -230,7 +230,7 @@ private fun NameAndTimestampRow(
|
|||
color = if (isHighlighted) {
|
||||
ElementTheme.colors.unreadIndicator
|
||||
} else {
|
||||
ElementTheme.roomListRoomMessageDate()
|
||||
ElementTheme.colors.roomListRoomMessageDate
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -253,7 +253,7 @@ private fun InviteSubtitle(
|
|||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.roomListRoomMessage(),
|
||||
color = ElementTheme.colors.roomListRoomMessage,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -277,7 +277,7 @@ private fun MessagePreviewAndIndicatorRow(
|
|||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = annotatedMessagePreview,
|
||||
color = ElementTheme.roomListRoomMessage(),
|
||||
color = ElementTheme.colors.roomListRoomMessage,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
|
|
@ -325,7 +325,7 @@ private fun InviteNameAndIndicatorRow(
|
|||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
|
||||
fontStyle = FontStyle.Italic.takeIf { name == null },
|
||||
color = ElementTheme.roomListRoomName(),
|
||||
color = ElementTheme.colors.roomListRoomName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ class RoomListDataSource @Inject constructor(
|
|||
|
||||
private suspend fun rebuildAllRoomSummaries() {
|
||||
lock.withLock {
|
||||
roomListService.allRooms.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
|
||||
roomListService.allRooms.filteredSummaries.replayCache.firstOrNull()?.let { roomSummaries ->
|
||||
buildAndEmitAllRooms(roomSummaries, useCache = false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,7 +176,6 @@ private fun RoomListModalBottomSheetContent(
|
|||
leadingContent = ListItemContent.Icon(
|
||||
iconSource = IconSource.Vector(
|
||||
CompoundIcons.ChatProblem(),
|
||||
contentDescription = stringResource(CommonStrings.action_report_room),
|
||||
)
|
||||
),
|
||||
style = ListItemStyle.Destructive,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
|||
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
|
||||
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
|
||||
aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
|
||||
aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
<string name="confirm_recovery_key_banner_title">"Your key storage is out of sync"</string>
|
||||
<string name="full_screen_intent_banner_message">"To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked."</string>
|
||||
<string name="full_screen_intent_banner_title">"Enhance your call experience"</string>
|
||||
<string name="screen_home_tab_chats">"Chats"</string>
|
||||
<string name="screen_home_tab_spaces">"Spaces"</string>
|
||||
<string name="screen_invites_decline_chat_message">"Are you sure you want to decline the invitation to join %1$s?"</string>
|
||||
<string name="screen_invites_decline_chat_title">"Decline invite"</string>
|
||||
<string name="screen_invites_decline_direct_chat_message">"Are you sure you want to decline this private chat with %1$s?"</string>
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
|
|
@ -44,15 +42,15 @@ fun TimelineEventTimestampView(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val formattedTime = event.sentTime
|
||||
val hasError = event.localSendState is LocalEventSendState.Failed
|
||||
val hasError = event.failedToSend
|
||||
val hasEncryptionCritical = event.messageShield?.isCritical.orFalse()
|
||||
val isMessageEdited = event.content.isEdited()
|
||||
val isMessageRedacted = event.content.isRedacted()
|
||||
val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
|
||||
.then(modifier),
|
||||
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
|
||||
.then(modifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isMessageEdited) {
|
||||
|
|
@ -76,11 +74,13 @@ fun TimelineEventTimestampView(
|
|||
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
|
||||
tint = tint,
|
||||
modifier = Modifier
|
||||
.size(15.dp, 18.dp)
|
||||
.clickable(isVerifiedUserSendFailure) {
|
||||
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
|
||||
}
|
||||
.semantics { hideFromAccessibility() }
|
||||
.size(15.dp, 18.dp)
|
||||
.clickable(
|
||||
enabled = isVerifiedUserSendFailure,
|
||||
onClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
) {
|
||||
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -89,13 +89,14 @@ fun TimelineEventTimestampView(
|
|||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = shield.toIcon(),
|
||||
contentDescription = shield.toText(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_encryption_details),
|
||||
modifier = Modifier
|
||||
.size(15.dp)
|
||||
.clickable {
|
||||
.clickable(
|
||||
onClickLabel = stringResource(CommonStrings.a11y_view_details),
|
||||
) {
|
||||
eventSink(TimelineEvents.ShowShieldDialog(shield))
|
||||
}
|
||||
.semantics { hideFromAccessibility() },
|
||||
},
|
||||
tint = shield.toIconColor(),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
|
|
|||
|
|
@ -37,11 +37,11 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.libraries.designsystem.modifiers.subtleColorStops
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
||||
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -140,7 +140,10 @@ internal fun TimelineItemRow(
|
|||
timelineItem.safeSenderName
|
||||
}
|
||||
// For Polls, allow the answers to be traversed by Talkback
|
||||
isTraversalGroup = timelineItem.content is TimelineItemPollContent
|
||||
isTraversalGroup = timelineItem.content is TimelineItemPollContent ||
|
||||
timelineItem.failedToSend ||
|
||||
timelineItem.messageShield != null
|
||||
// TODO Also set to true when the event has link(s)
|
||||
}
|
||||
// Custom clickable that applies over the whole item for accessibility
|
||||
.then(
|
||||
|
|
@ -206,23 +209,20 @@ internal fun TimelineItemRow(
|
|||
@Suppress("ModifierComposable")
|
||||
@Composable
|
||||
private fun Modifier.focusedEvent(
|
||||
focusedEventOffset: Dp
|
||||
focusedEventOffset: Dp,
|
||||
isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild,
|
||||
): Modifier {
|
||||
val highlightedLineColor = ElementTheme.colors.textActionAccent
|
||||
val gradientFirstColor = if (LocalBuildMeta.current.isEnterpriseBuild) {
|
||||
ElementTheme.colors.textActionAccent.copy(alpha = 0.125f)
|
||||
val highlightedLineColor = if (isEnterpriseBuild) {
|
||||
ElementTheme.colors.textActionAccent
|
||||
} else {
|
||||
ElementTheme.colors.highlightedMessageBackgroundColor
|
||||
ElementTheme.colors.borderAccentSubtle
|
||||
}
|
||||
val gradientColors = listOf(
|
||||
gradientFirstColor,
|
||||
ElementTheme.colors.bgCanvasDefault,
|
||||
)
|
||||
val gradientColors = subtleColorStops(isEnterpriseBuild)
|
||||
val verticalOffset = focusedEventOffset.toPx()
|
||||
val verticalRatio = 0.7f
|
||||
return drawWithCache {
|
||||
val brush = Brush.verticalGradient(
|
||||
colors = gradientColors,
|
||||
colorStops = gradientColors,
|
||||
endY = size.height * verticalRatio,
|
||||
)
|
||||
onDrawBehind {
|
||||
|
|
@ -251,3 +251,18 @@ internal fun FocusedEventPreview() = ElementPreview {
|
|||
.focusedEvent(0.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun FocusedEventEnterprisePreview() = ElementPreview {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(160.dp)
|
||||
.focusedEvent(
|
||||
focusedEventOffset = 0.dp,
|
||||
isEnterpriseBuild = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,12 +112,11 @@ fun ContentAvoidingLayout(
|
|||
* @param nonOverlappingContentWidth The width of the part of the content that can't overlap with the timestamp.
|
||||
* @param nonOverlappingContentHeight The height of the part of the content that can't overlap with the timestamp.
|
||||
*/
|
||||
@Suppress("DataClassShouldBeImmutable")
|
||||
data class ContentAvoidingLayoutData(
|
||||
var contentWidth: Int = 0,
|
||||
var contentHeight: Int = 0,
|
||||
var nonOverlappingContentWidth: Int = contentWidth,
|
||||
var nonOverlappingContentHeight: Int = contentHeight,
|
||||
val contentWidth: Int = 0,
|
||||
val contentHeight: Int = 0,
|
||||
val nonOverlappingContentWidth: Int = contentWidth,
|
||||
val nonOverlappingContentHeight: Int = contentHeight,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ class TimelineViewTest {
|
|||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val contentDescription = rule.activity.getString(CommonStrings.event_shield_reason_unverified_identity)
|
||||
val contentDescription = rule.activity.getString(CommonStrings.a11y_encryption_details)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@
|
|||
<item quantity="one">"%1$d percent of total votes"</item>
|
||||
<item quantity="other">"%1$d percents of total votes"</item>
|
||||
</plurals>
|
||||
<string name="a11y_polls_will_remove_selection">"Will remove previous selection"</string>
|
||||
<string name="a11y_polls_winning_answer">"This is the winning answer"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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('"')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -225,7 +225,10 @@ class ChangeRolesViewTest {
|
|||
)
|
||||
// Unselect the user from the row list
|
||||
val contentDescription = rule.activity.getString(CommonStrings.action_remove)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
rule.onNodeWithContentDescription(
|
||||
label = contentDescription,
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
ChangeRolesEvent.QueryChanged(""),
|
||||
|
|
@ -248,7 +251,7 @@ class ChangeRolesViewTest {
|
|||
rule.setChangeRolesContent(
|
||||
state = state,
|
||||
)
|
||||
// Select the user from the row list
|
||||
// Select the user from the user list
|
||||
rule.onNodeWithText("Carol").performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
|
|
@ -271,8 +274,11 @@ class ChangeRolesViewTest {
|
|||
rule.setChangeRolesContent(
|
||||
state = state,
|
||||
)
|
||||
// Select the user from the rom list
|
||||
rule.onAllNodesWithText("Bob")[1].performClick()
|
||||
// Unselect the user from the user list
|
||||
rule.onAllNodesWithText(
|
||||
text = "Bob",
|
||||
useUnmergedTree = true,
|
||||
)[1].performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
ChangeRolesEvent.QueryChanged(""),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
[versions]
|
||||
# Project
|
||||
android_gradle_plugin = "8.11.0"
|
||||
android_gradle_plugin = "8.11.1"
|
||||
kotlin = "2.2.0"
|
||||
kotlinpoet = "2.2.0"
|
||||
ksp = "2.2.0-2.0.2"
|
||||
|
|
@ -33,7 +33,7 @@ accompanist = "0.37.3"
|
|||
test_core = "1.6.1"
|
||||
|
||||
# Jetbrain
|
||||
datetime = "0.7.0"
|
||||
datetime = "0.7.1"
|
||||
serialization_json = "1.9.0"
|
||||
|
||||
#other
|
||||
|
|
@ -42,9 +42,9 @@ coil = "3.2.0"
|
|||
showkase = "1.0.4"
|
||||
appyx = "1.7.1"
|
||||
sqldelight = "2.1.0"
|
||||
wysiwyg = "2.38.4"
|
||||
wysiwyg = "2.38.5"
|
||||
telephoto = "0.16.0"
|
||||
haze = "1.6.6"
|
||||
haze = "1.6.8"
|
||||
|
||||
# Dependency analysis
|
||||
dependencyAnalysis = "2.19.0"
|
||||
|
|
@ -100,6 +100,9 @@ androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process",
|
|||
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
|
||||
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||
androidx_media3_transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" }
|
||||
androidx_media3_effect = { module = "androidx.media3:media3-effect", version.ref = "media3" }
|
||||
androidx_media3_common = { module = "androidx.media3:media3-common", version.ref = "media3" }
|
||||
androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
|
||||
|
|
@ -131,7 +134,7 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi
|
|||
squareup_seismic = "com.squareup:seismic:1.0.3"
|
||||
|
||||
# network
|
||||
network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:4.12.0"
|
||||
network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.1.0"
|
||||
network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" }
|
||||
network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" }
|
||||
network_okhttp = { module = "com.squareup.okhttp3:okhttp" }
|
||||
|
|
@ -163,7 +166,7 @@ coil_network_okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version
|
|||
coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
|
||||
coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" }
|
||||
compound = { module = "io.element.android:compound-android", version = "25.4.4" }
|
||||
compound = { module = "io.element.android:compound-android", version = "25.7.4" }
|
||||
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
||||
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
|
||||
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0"
|
||||
|
|
@ -173,7 +176,7 @@ jsoup = "org.jsoup:jsoup:1.21.1"
|
|||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||
molecule-runtime = "app.cash.molecule:molecule-runtime:2.1.0"
|
||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.7.3"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.7.15"
|
||||
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
|
||||
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
|
||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||
|
|
@ -182,12 +185,11 @@ sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions",
|
|||
sqlcipher = "net.zetetic:sqlcipher-android:4.9.0"
|
||||
sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
|
||||
unifiedpush = "org.unifiedpush.android:connector:3.0.10"
|
||||
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2"
|
||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
||||
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
||||
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
|
||||
statemachine = "com.freeletics.flowredux:compose:1.2.2"
|
||||
maplibre = "org.maplibre.gl:android-sdk:11.11.0"
|
||||
maplibre = "org.maplibre.gl:android-sdk:11.12.1"
|
||||
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
|
||||
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
|
||||
opusencoder = "io.element.android:opusencoder:1.2.0"
|
||||
|
|
@ -196,8 +198,8 @@ haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
|||
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
|
||||
|
||||
# Analytics
|
||||
posthog = "com.posthog:posthog-android:3.19.0"
|
||||
sentry = "io.sentry:sentry-android:8.16.0"
|
||||
posthog = "com.posthog:posthog-android:3.19.2"
|
||||
sentry = "io.sentry:sentry-android:8.17.0"
|
||||
# main branch can be tested replacing the version with main-SNAPSHOT
|
||||
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
|
||||
|
||||
|
|
@ -238,7 +240,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
|||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
|
||||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||
ktlint = "org.jlleitschuh.gradle.ktlint:12.3.0"
|
||||
ktlint = "org.jlleitschuh.gradle.ktlint:13.0.0"
|
||||
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
|
||||
dependencycheck = "org.owasp.dependencycheck:12.1.3"
|
||||
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
|
||||
|
|
|
|||
4
gradle/wrapper/gradle-wrapper.properties
vendored
4
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,7 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
|
||||
distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -14,10 +14,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
|||
import io.element.android.libraries.designsystem.components.Badge
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.badgeNegativeBackgroundColor
|
||||
import io.element.android.libraries.designsystem.theme.badgeNegativeContentColor
|
||||
import io.element.android.libraries.designsystem.theme.badgeNeutralBackgroundColor
|
||||
import io.element.android.libraries.designsystem.theme.badgeNeutralContentColor
|
||||
|
||||
object MatrixBadgeAtom {
|
||||
data class MatrixBadgeData(
|
||||
|
|
@ -39,21 +35,21 @@ object MatrixBadgeAtom {
|
|||
) {
|
||||
val backgroundColor = when (data.type) {
|
||||
Type.Positive -> ElementTheme.colors.bgBadgeAccent
|
||||
Type.Neutral -> ElementTheme.colors.badgeNeutralBackgroundColor
|
||||
Type.Negative -> ElementTheme.colors.badgeNegativeBackgroundColor
|
||||
Type.Neutral -> ElementTheme.colors.bgBadgeDefault
|
||||
Type.Negative -> ElementTheme.colors.bgCriticalSubtle
|
||||
Type.Info -> ElementTheme.colors.bgBadgeInfo
|
||||
}
|
||||
val textColor = when (data.type) {
|
||||
Type.Positive -> ElementTheme.colors.textBadgeAccent
|
||||
Type.Neutral -> ElementTheme.colors.badgeNeutralContentColor
|
||||
Type.Negative -> ElementTheme.colors.badgeNegativeContentColor
|
||||
Type.Neutral -> ElementTheme.colors.textPrimary
|
||||
Type.Negative -> ElementTheme.colors.textCriticalPrimary
|
||||
Type.Info -> ElementTheme.colors.textBadgeInfo
|
||||
}
|
||||
val iconColor = when (data.type) {
|
||||
Type.Positive -> ElementTheme.colors.textBadgeAccent
|
||||
Type.Neutral -> ElementTheme.colors.iconSecondary
|
||||
Type.Positive -> ElementTheme.colors.iconAccentPrimary
|
||||
Type.Neutral -> ElementTheme.colors.iconPrimary
|
||||
Type.Negative -> ElementTheme.colors.iconCriticalPrimary
|
||||
Type.Info -> ElementTheme.colors.textBadgeInfo
|
||||
Type.Info -> ElementTheme.colors.iconInfoPrimary
|
||||
}
|
||||
Badge(
|
||||
text = data.text,
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ import androidx.compose.ui.graphics.LinearGradientShader
|
|||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.drawWithLayer
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.utils.drawWithLayer
|
||||
|
||||
/**
|
||||
* Gradient background for FTUE (onboarding) screens.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -42,7 +42,6 @@ fun MainActionButton(
|
|||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
contentDescription: String = title,
|
||||
) {
|
||||
val ripple = ripple(bounded = false)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
|
@ -58,8 +57,8 @@ fun MainActionButton(
|
|||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
contentDescription = null,
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription,
|
||||
tint = if (enabled) LocalContentColor.current else ElementTheme.colors.iconDisabled,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
|
|
|||
|
|
@ -36,10 +36,8 @@ import androidx.compose.ui.graphics.ShaderBrush
|
|||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.annotations.CoreColorToken
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.internal.DarkColorTokens
|
||||
import io.element.android.compound.tokens.generated.internal.LightColorTokens
|
||||
import io.element.android.libraries.designsystem.colors.gradientActionColors
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
|
||||
|
|
@ -47,7 +45,6 @@ import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
|||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.lowHorizontalPaddingValue
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
@Composable
|
||||
fun SuperButton(
|
||||
onClick: () -> Unit,
|
||||
|
|
@ -66,34 +63,21 @@ fun SuperButton(
|
|||
ButtonSize.Small -> PaddingValues(horizontal = 16.dp, vertical = 5.dp)
|
||||
}
|
||||
}
|
||||
val isLightTheme = ElementTheme.isLightTheme
|
||||
val colors = if (LocalBuildMeta.current.isEnterpriseBuild) {
|
||||
listOf(
|
||||
ElementTheme.colors.textActionAccent,
|
||||
ElementTheme.colors.textActionAccent,
|
||||
)
|
||||
} else {
|
||||
remember(isLightTheme) {
|
||||
if (isLightTheme) {
|
||||
listOf(
|
||||
LightColorTokens.colorBlue900,
|
||||
LightColorTokens.colorGreen1100,
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
DarkColorTokens.colorBlue900,
|
||||
DarkColorTokens.colorGreen1100,
|
||||
)
|
||||
}
|
||||
}
|
||||
gradientActionColors()
|
||||
}
|
||||
|
||||
val shaderBrush = remember(colors) {
|
||||
object : ShaderBrush() {
|
||||
override fun createShader(size: Size): Shader {
|
||||
return LinearGradientShader(
|
||||
from = Offset(0f, size.height),
|
||||
to = Offset(size.width, 0f),
|
||||
from = Offset(0f, 0f),
|
||||
to = Offset(0f, size.height),
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ package io.element.android.libraries.designsystem.preview
|
|||
object PreviewGroup {
|
||||
const val AppBars = "App Bars"
|
||||
const val Avatars = "Avatars"
|
||||
const val Bloom = "Bloom"
|
||||
const val BottomSheets = "Bottom Sheets"
|
||||
const val Buttons = "Buttons"
|
||||
const val DateTimePickers = "DateTime pickers"
|
||||
|
|
|
|||
|
|
@ -22,14 +22,14 @@ import kotlinx.collections.immutable.persistentMapOf
|
|||
/**
|
||||
* Room list.
|
||||
*/
|
||||
@Composable
|
||||
fun ElementTheme.roomListRoomName() = colors.textPrimary
|
||||
val SemanticColors.roomListRoomName
|
||||
get() = textPrimary
|
||||
|
||||
@Composable
|
||||
fun ElementTheme.roomListRoomMessage() = colors.textSecondary
|
||||
val SemanticColors.roomListRoomMessage
|
||||
get() = textSecondary
|
||||
|
||||
@Composable
|
||||
fun ElementTheme.roomListRoomMessageDate() = colors.textSecondary
|
||||
val SemanticColors.roomListRoomMessageDate
|
||||
get() = textSecondary
|
||||
|
||||
val SemanticColors.unreadIndicator
|
||||
get() = iconAccentTertiary
|
||||
|
|
@ -52,11 +52,6 @@ val SemanticColors.messageFromOtherBackground
|
|||
val SemanticColors.progressIndicatorTrackColor
|
||||
get() = if (isLight) LightColorTokens.colorAlphaGray500 else DarkColorTokens.colorAlphaGray500
|
||||
|
||||
// This color is not present in Semantic color, so put hard-coded value for now
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.iconSuccessPrimaryBackground
|
||||
get() = if (isLight) LightColorTokens.colorGreen300 else DarkColorTokens.colorGreen300
|
||||
|
||||
// This color is not present in Semantic color, so put hard-coded value for now
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.bgSubtleTertiary
|
||||
|
|
@ -71,57 +66,6 @@ val SemanticColors.temporaryColorBgSpecial
|
|||
val SemanticColors.pinDigitBg
|
||||
get() = if (isLight) LightColorTokens.colorGray300 else DarkColorTokens.colorGray400
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.currentUserMentionPillText
|
||||
get() = if (isLight) LightColorTokens.colorGreen1100 else DarkColorTokens.colorGreen1100
|
||||
|
||||
val SemanticColors.currentUserMentionPillBackground
|
||||
get() = if (isLight) {
|
||||
// We want LightDesignTokens.colorGreenAlpha400
|
||||
Color(0x3b07b661)
|
||||
} else {
|
||||
// We want DarkDesignTokens.colorGreenAlpha500
|
||||
Color(0xff003d29)
|
||||
}
|
||||
|
||||
val SemanticColors.mentionPillText
|
||||
get() = textPrimary
|
||||
|
||||
val SemanticColors.mentionPillBackground
|
||||
get() = if (isLight) {
|
||||
// We want LightDesignTokens.colorGray400
|
||||
Color(0x1f052e61)
|
||||
} else {
|
||||
// We want DarkDesignTokens.colorGray500
|
||||
Color(0x26f4f7fa)
|
||||
}
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.bigCheckmarkBorderColor
|
||||
get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray400
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.highlightedMessageBackgroundColor
|
||||
get() = if (isLight) LightColorTokens.colorGreen300 else DarkColorTokens.colorGreen300
|
||||
|
||||
// Badge colors
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.badgeNeutralBackgroundColor
|
||||
get() = if (isLight) LightColorTokens.colorAlphaGray300 else DarkColorTokens.colorAlphaGray300
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.badgeNeutralContentColor
|
||||
get() = if (isLight) LightColorTokens.colorGray1100 else DarkColorTokens.colorGray1100
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.badgeNegativeBackgroundColor
|
||||
get() = if (isLight) LightColorTokens.colorAlphaRed300 else DarkColorTokens.colorAlphaRed300
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.badgeNegativeContentColor
|
||||
get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100
|
||||
|
||||
@OptIn(CoreColorToken::class)
|
||||
val SemanticColors.pinnedMessageBannerIndicator
|
||||
get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600
|
||||
|
|
@ -137,18 +81,15 @@ internal fun ColorAliasesPreview() = ElementPreview {
|
|||
backgroundColor = Color.Black,
|
||||
foregroundColor = Color.White,
|
||||
colors = persistentMapOf(
|
||||
"roomListRoomName" to ElementTheme.roomListRoomName(),
|
||||
"roomListRoomMessage" to ElementTheme.roomListRoomMessage(),
|
||||
"roomListRoomMessageDate" to ElementTheme.roomListRoomMessageDate(),
|
||||
"roomListRoomName" to ElementTheme.colors.roomListRoomName,
|
||||
"roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage,
|
||||
"roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate,
|
||||
"unreadIndicator" to ElementTheme.colors.unreadIndicator,
|
||||
"placeholderBackground" to ElementTheme.colors.placeholderBackground,
|
||||
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
|
||||
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
|
||||
"progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor,
|
||||
"temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial,
|
||||
"iconSuccessPrimaryBackground" to ElementTheme.colors.iconSuccessPrimaryBackground,
|
||||
"bigCheckmarkBorderColor" to ElementTheme.colors.bigCheckmarkBorderColor,
|
||||
"highlightedMessageBackgroundColor" to ElementTheme.colors.highlightedMessageBackgroundColor,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -175,4 +175,13 @@ enum class FeatureFlags(
|
|||
// False so it's displayed in the developer options screen
|
||||
isFinished = false,
|
||||
),
|
||||
SharePos(
|
||||
key = "feature.share_pos",
|
||||
title = "Share pos in sliding sync",
|
||||
description = "Keep the sliding sync pos to make initial syncs faster. Requires an app restart to take effect." +
|
||||
"\n\nWARNING: this may cause issues with syncs.",
|
||||
defaultValue = { false },
|
||||
// False so it's displayed in the developer options screen
|
||||
isFinished = false,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -68,7 +68,6 @@ sealed interface NotificationContent {
|
|||
) : MessageLike
|
||||
|
||||
data object RoomEncrypted : MessageLike
|
||||
data object UnableToResolve : MessageLike
|
||||
data class RoomMessage(
|
||||
val senderId: UserId,
|
||||
val messageType: MessageType
|
||||
|
|
|
|||
|
|
@ -10,6 +10,19 @@ package io.element.android.libraries.matrix.api.notification
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
/**
|
||||
* Represents the resolution state of an attempt to retrieve notification data for a set of event ids.
|
||||
* The outer [Result] indicates the success or failure of the setup to retrieve notifications.
|
||||
* The inner [Result] for each [EventId] in the map indicates whether the notification data was successfully retrieved or if there was an error.
|
||||
*/
|
||||
typealias GetNotificationDataResult = Result<Map<EventId, Result<NotificationData>>>
|
||||
|
||||
/**
|
||||
* Service to retrieve notifications for a given set of event ids in specific rooms.
|
||||
*/
|
||||
interface NotificationService {
|
||||
suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): Result<Map<EventId, NotificationData>>
|
||||
/**
|
||||
* Fetch notifications for the specified event ids in the given rooms.
|
||||
*/
|
||||
suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): GetNotificationDataResult
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
client.setUtdDelegate(UtdTracker(analyticsService))
|
||||
|
||||
val syncService = client.syncService()
|
||||
.withSharePos(enable = featureFlagService.isFeatureEnabled(FeatureFlags.SharePos))
|
||||
.withOfflineMode()
|
||||
.finish()
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import kotlinx.coroutines.withContext
|
|||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||
import org.matrix.rustcomponents.sdk.HumanQrLoginException
|
||||
import org.matrix.rustcomponents.sdk.OidcConfiguration
|
||||
import org.matrix.rustcomponents.sdk.QrCodeData
|
||||
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
|
||||
import org.matrix.rustcomponents.sdk.QrLoginProgress
|
||||
|
|
@ -199,6 +198,8 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
oidcConfiguration = oidcConfigurationProvider.get(),
|
||||
prompt = prompt.toRustPrompt(),
|
||||
loginHint = loginHint,
|
||||
// If we want to restore a previous session for which we have encryption keys, we can pass the deviceId here. At the moment, we don't
|
||||
deviceId = null,
|
||||
)
|
||||
val url = oAuthAuthorizationData.loginUrl()
|
||||
pendingOAuthAuthorizationData = oAuthAuthorizationData
|
||||
|
|
@ -274,9 +275,13 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
sessionPaths = emptySessionPaths,
|
||||
passphrase = pendingPassphrase,
|
||||
qrCodeData = sdkQrCodeLoginData,
|
||||
)
|
||||
client.loginWithQrCode(
|
||||
qrCodeData = qrCodeData.rustQrCodeData,
|
||||
oidcConfiguration = oidcConfiguration,
|
||||
progressListener = progressListener,
|
||||
)
|
||||
|
||||
val sessionData = client.session()
|
||||
.toSessionData(
|
||||
isTokenValid = true,
|
||||
|
|
@ -324,8 +329,6 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
sessionPaths: SessionPaths,
|
||||
passphrase: String?,
|
||||
qrCodeData: QrCodeData,
|
||||
oidcConfiguration: OidcConfiguration,
|
||||
progressListener: QrLoginProgressListener,
|
||||
): Client {
|
||||
Timber.d("Creating client for QR Code login with simplified sliding sync")
|
||||
return rustMatrixClientFactory
|
||||
|
|
@ -335,7 +338,8 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
slidingSyncType = ClientBuilderSlidingSync.Discovered,
|
||||
)
|
||||
.sessionPassphrase(passphrase)
|
||||
.buildWithQrCode(qrCodeData, oidcConfiguration, progressListener)
|
||||
.serverNameOrHomeserverUrl(qrCodeData.serverName()!!)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun clear() {
|
||||
|
|
|
|||
|
|
@ -12,26 +12,29 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.notification.GetNotificationDataResult
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.BatchNotificationResult
|
||||
import org.matrix.rustcomponents.sdk.NotificationClient
|
||||
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
|
||||
import org.matrix.rustcomponents.sdk.NotificationStatus
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
|
||||
class RustNotificationService(
|
||||
private val sessionId: SessionId,
|
||||
private val notificationClient: NotificationClient,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val clock: SystemClock,
|
||||
clock: SystemClock,
|
||||
) : NotificationService {
|
||||
private val notificationMapper: NotificationMapper = NotificationMapper(clock)
|
||||
|
||||
override suspend fun getNotifications(
|
||||
ids: Map<RoomId, List<EventId>>
|
||||
): Result<Map<EventId, NotificationData>> = withContext(dispatchers.io) {
|
||||
): GetNotificationDataResult = withContext(dispatchers.io) {
|
||||
runCatchingExceptions {
|
||||
val requests = ids.map { (roomId, eventIds) ->
|
||||
NotificationItemsRequest(
|
||||
|
|
@ -42,34 +45,41 @@ class RustNotificationService(
|
|||
val items = notificationClient.getNotifications(requests)
|
||||
buildMap {
|
||||
val eventIds = requests.flatMap { it.eventIds }
|
||||
for (eventId in eventIds) {
|
||||
val item = items[eventId]
|
||||
val roomId = RoomId(requests.find { it.eventIds.contains(eventId) }?.roomId!!)
|
||||
if (item != null) {
|
||||
put(EventId(eventId), notificationMapper.map(sessionId, EventId(eventId), roomId, item))
|
||||
} else {
|
||||
Timber.e("Could not retrieve event for notification with $eventId")
|
||||
put(
|
||||
EventId(eventId),
|
||||
NotificationData(
|
||||
sessionId = sessionId,
|
||||
eventId = EventId(eventId),
|
||||
threadId = null,
|
||||
roomId = roomId,
|
||||
senderAvatarUrl = null,
|
||||
senderDisplayName = null,
|
||||
senderIsNameAmbiguous = false,
|
||||
roomAvatarUrl = null,
|
||||
roomDisplayName = null,
|
||||
isDirect = false,
|
||||
isDm = false,
|
||||
isEncrypted = false,
|
||||
isNoisy = false,
|
||||
timestamp = clock.epochMillis(),
|
||||
content = NotificationContent.MessageLike.UnableToResolve,
|
||||
hasMention = false
|
||||
)
|
||||
)
|
||||
for (rawEventId in eventIds) {
|
||||
val roomId = RoomId(requests.find { it.eventIds.contains(rawEventId) }?.roomId!!)
|
||||
val eventId = EventId(rawEventId)
|
||||
items[rawEventId].use { result ->
|
||||
when (result) {
|
||||
is BatchNotificationResult.Ok -> {
|
||||
when (val status = result.status) {
|
||||
is NotificationStatus.Event -> {
|
||||
put(eventId, Result.success(notificationMapper.map(sessionId, eventId, roomId, status.item)))
|
||||
}
|
||||
is NotificationStatus.EventNotFound -> {
|
||||
Timber.e("Could not retrieve event for notification with $eventId - event not found")
|
||||
put(eventId, Result.failure(NotificationResolverException.EventNotFound))
|
||||
}
|
||||
is NotificationStatus.EventFilteredOut -> {
|
||||
Timber.d("Could not retrieve event for notification with $eventId - event filtered out")
|
||||
put(eventId, Result.failure(NotificationResolverException.EventFilteredOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
is BatchNotificationResult.Error -> {
|
||||
Timber.e("Error while retrieving notification with $rawEventId - ${result.message}")
|
||||
put(
|
||||
eventId,
|
||||
Result.failure(NotificationResolverException.UnknownError(result.message))
|
||||
)
|
||||
}
|
||||
null -> {
|
||||
Timber.e("The notification data for $rawEventId was not in the retrieved results. This is unexpected.")
|
||||
put(
|
||||
eventId,
|
||||
Result.failure(NotificationResolverException.UnknownError("Notification data not found"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import org.matrix.rustcomponents.sdk.NotificationEvent
|
|||
import org.matrix.rustcomponents.sdk.NotificationItem
|
||||
import org.matrix.rustcomponents.sdk.NotificationRoomInfo
|
||||
import org.matrix.rustcomponents.sdk.NotificationSenderInfo
|
||||
import org.matrix.rustcomponents.sdk.NotificationStatus
|
||||
import org.matrix.rustcomponents.sdk.TimelineEvent
|
||||
|
||||
fun aRustNotificationItem(
|
||||
|
|
@ -34,6 +35,12 @@ fun aRustNotificationItem(
|
|||
threadId = threadId?.value,
|
||||
)
|
||||
|
||||
fun aRustBatchNotificationResult(
|
||||
notificationStatus: NotificationStatus = NotificationStatus.Event(aRustNotificationItem()),
|
||||
) = org.matrix.rustcomponents.sdk.BatchNotificationResult.Ok(
|
||||
status = notificationStatus,
|
||||
)
|
||||
|
||||
fun aRustNotificationSenderInfo(
|
||||
displayName: String? = A_USER_NAME,
|
||||
avatarUrl: String? = null,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@ import org.matrix.rustcomponents.sdk.Client
|
|||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||
import org.matrix.rustcomponents.sdk.ClientSessionDelegate
|
||||
import org.matrix.rustcomponents.sdk.NoPointer
|
||||
import org.matrix.rustcomponents.sdk.OidcConfiguration
|
||||
import org.matrix.rustcomponents.sdk.QrCodeData
|
||||
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
|
||||
import org.matrix.rustcomponents.sdk.RequestConfig
|
||||
import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder
|
||||
import uniffi.matrix_sdk.BackupDownloadStrategy
|
||||
|
|
@ -43,10 +40,6 @@ class FakeFfiClientBuilder : ClientBuilder(NoPointer) {
|
|||
override fun username(username: String) = this
|
||||
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
|
||||
|
||||
override suspend fun buildWithQrCode(qrCodeData: QrCodeData, oidcConfiguration: OidcConfiguration, progressListener: QrLoginProgressListener): Client {
|
||||
return FakeFfiClient()
|
||||
}
|
||||
|
||||
override suspend fun build(): Client {
|
||||
return FakeFfiClient(withUtdHook = {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@
|
|||
|
||||
package io.element.android.libraries.matrix.impl.fixtures.fakes
|
||||
|
||||
import org.matrix.rustcomponents.sdk.BatchNotificationResult
|
||||
import org.matrix.rustcomponents.sdk.NoPointer
|
||||
import org.matrix.rustcomponents.sdk.NotificationClient
|
||||
import org.matrix.rustcomponents.sdk.NotificationItem
|
||||
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
|
||||
|
||||
class FakeFfiNotificationClient(
|
||||
var notificationItemResult: Map<String, NotificationItem> = emptyMap(),
|
||||
var notificationItemResult: Map<String, BatchNotificationResult> = emptyMap(),
|
||||
val closeResult: () -> Unit = { }
|
||||
) : NotificationClient(NoPointer) {
|
||||
override suspend fun getNotifications(requests: List<NotificationItemsRequest>): Map<String, NotificationItem> {
|
||||
override suspend fun getNotifications(requests: List<NotificationItemsRequest>): Map<String, BatchNotificationResult> {
|
||||
return notificationItemResult
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
|
|||
|
||||
class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoPointer) {
|
||||
override fun withOfflineMode(): SyncServiceBuilder = this
|
||||
override fun withSharePos(enable: Boolean): SyncServiceBuilder = this
|
||||
override suspend fun finish(): SyncService = FakeFfiSyncService()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@
|
|||
package io.element.android.libraries.matrix.impl.notification
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
|
|
@ -30,12 +31,12 @@ class RustNotificationServiceTest {
|
|||
@Test
|
||||
fun test() = runTest {
|
||||
val notificationClient = FakeFfiNotificationClient(
|
||||
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustNotificationItem()),
|
||||
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResult()),
|
||||
)
|
||||
val sut = createRustNotificationService(
|
||||
notificationClient = notificationClient,
|
||||
)
|
||||
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!
|
||||
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!.getOrThrow()
|
||||
assertThat(result.isEncrypted).isTrue()
|
||||
assertThat(result.content).isEqualTo(
|
||||
NotificationContent.MessageLike.RoomMessage(
|
||||
|
|
@ -56,10 +57,8 @@ class RustNotificationServiceTest {
|
|||
val sut = createRustNotificationService(
|
||||
notificationClient = notificationClient,
|
||||
)
|
||||
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!
|
||||
assertThat(result.content).isEqualTo(
|
||||
NotificationContent.MessageLike.UnableToResolve
|
||||
)
|
||||
val exception = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!.exceptionOrNull()
|
||||
assertThat(exception).isInstanceOf(NotificationResolverException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ import io.element.android.libraries.matrix.api.notification.NotificationData
|
|||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||
|
||||
class FakeNotificationService : NotificationService {
|
||||
private var getNotificationsResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap())
|
||||
private var getNotificationsResult: Result<Map<EventId, Result<NotificationData>>> = Result.success(emptyMap())
|
||||
|
||||
fun givenGetNotificationsResult(result: Result<Map<EventId, NotificationData>>) {
|
||||
fun givenGetNotificationsResult(result: Result<Map<EventId, Result<NotificationData>>>) {
|
||||
getNotificationsResult = result
|
||||
}
|
||||
|
||||
override suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): Result<Map<EventId, NotificationData>> {
|
||||
override suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): Result<Map<EventId, Result<NotificationData>>> {
|
||||
return getNotificationsResult
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ fun EditableAvatarView(
|
|||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClickLabel = stringResource(CommonStrings.a11y_edit_avatar),
|
||||
onClick = onAvatarClick,
|
||||
indication = ripple(bounded = false),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -48,9 +51,24 @@ fun SelectedUser(
|
|||
onUserRemove: (MatrixUser) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val actionRemove = stringResource(id = CommonStrings.action_remove)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(AvatarSize.SelectedUser.dp)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = matrixUser.getBestName()
|
||||
if (canRemove) {
|
||||
// Note: this does not set the click effect to the whole Box
|
||||
// when talkback is not enabled
|
||||
onClick(
|
||||
label = actionRemove,
|
||||
action = {
|
||||
onUserRemove(matrixUser)
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
|
|
@ -83,6 +101,7 @@ fun SelectedUser(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
// Note: keep the context description for the test
|
||||
contentDescription = stringResource(id = CommonStrings.action_remove),
|
||||
tint = ElementTheme.colors.iconOnSolidPrimary,
|
||||
modifier = Modifier.padding(2.dp)
|
||||
|
|
|
|||
|
|
@ -33,8 +33,10 @@ dependencies {
|
|||
implementation(projects.services.toolbox.api)
|
||||
implementation(libs.inject)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
implementation(libs.androidx.media3.transformer)
|
||||
implementation(libs.androidx.media3.effect)
|
||||
implementation(libs.androidx.media3.common)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.otaliastudios.transcoder)
|
||||
implementation(libs.vanniktech.blurhash)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
|
|
|
|||
|
|
@ -192,12 +192,19 @@ class AndroidMediaPreProcessor @Inject constructor(
|
|||
val resultFile = runCatchingExceptions {
|
||||
videoCompressor.compress(uri, shouldBeCompressed)
|
||||
.onEach {
|
||||
// TODO handle progress
|
||||
if (it is VideoTranscodingEvent.Progress) {
|
||||
Timber.d("Video compression progress: ${it.value}%")
|
||||
} else if (it is VideoTranscodingEvent.Completed) {
|
||||
Timber.d("Video compression completed: ${it.file.path}")
|
||||
}
|
||||
}
|
||||
.filterIsInstance<VideoTranscodingEvent.Completed>()
|
||||
.first()
|
||||
.file
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to compress video: $uri")
|
||||
}
|
||||
.getOrNull()
|
||||
|
||||
if (resultFile != null) {
|
||||
|
|
@ -283,10 +290,17 @@ class AndroidMediaPreProcessor @Inject constructor(
|
|||
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult?): VideoInfo =
|
||||
MediaMetadataRetriever().runAndRelease {
|
||||
setDataSource(context, Uri.fromFile(file))
|
||||
|
||||
val rotation = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
|
||||
val rawWidth = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L
|
||||
val rawHeight = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L
|
||||
|
||||
val (width, height) = if (rotation == 90 || rotation == 270) rawHeight to rawWidth else rawWidth to rawHeight
|
||||
|
||||
VideoInfo(
|
||||
duration = extractDuration(),
|
||||
width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L,
|
||||
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
|
||||
width = width,
|
||||
height = height,
|
||||
mimetype = mimeType,
|
||||
size = file.length(),
|
||||
thumbnailInfo = thumbnailResult?.info,
|
||||
|
|
|
|||
|
|
@ -8,76 +8,146 @@
|
|||
package io.element.android.libraries.mediaupload.impl
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.otaliastudios.transcoder.Transcoder
|
||||
import com.otaliastudios.transcoder.TranscoderListener
|
||||
import com.otaliastudios.transcoder.internal.media.MediaFormatConstants
|
||||
import com.otaliastudios.transcoder.resize.AtMostResizer
|
||||
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
|
||||
import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy
|
||||
import com.otaliastudios.transcoder.strategy.TrackStrategy
|
||||
import com.otaliastudios.transcoder.validator.WriteAlwaysValidator
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.Size
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.effect.Presentation
|
||||
import androidx.media3.transformer.Composition
|
||||
import androidx.media3.transformer.DefaultEncoderFactory
|
||||
import androidx.media3.transformer.EditedMediaItem
|
||||
import androidx.media3.transformer.Effects
|
||||
import androidx.media3.transformer.ExportException
|
||||
import androidx.media3.transformer.ExportResult
|
||||
import androidx.media3.transformer.ProgressHolder
|
||||
import androidx.media3.transformer.TransformationRequest
|
||||
import androidx.media3.transformer.Transformer
|
||||
import androidx.media3.transformer.VideoEncoderSettings
|
||||
import io.element.android.libraries.androidutils.file.createTmpFile
|
||||
import io.element.android.libraries.androidutils.file.getMimeType
|
||||
import io.element.android.libraries.androidutils.file.safeDelete
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MP4_EXTENSION = "mp4"
|
||||
|
||||
class VideoCompressor @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
fun compress(uri: Uri, shouldBeCompressed: Boolean) = callbackFlow {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun compress(uri: Uri, shouldBeCompressed: Boolean): Flow<VideoTranscodingEvent> = callbackFlow {
|
||||
val metadata = getVideoMetadata(uri)
|
||||
|
||||
val expectedExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(context.getMimeType(uri))
|
||||
|
||||
val videoStrategy = VideoStrategyFactory.create(
|
||||
expectedExtension = expectedExtension,
|
||||
val videoCompressorConfig = VideoCompressorConfigFactory.create(
|
||||
metadata = metadata,
|
||||
shouldBeCompressed = shouldBeCompressed
|
||||
)
|
||||
|
||||
val tmpFile = context.createTmpFile(extension = MP4_EXTENSION)
|
||||
val future = Transcoder.into(tmpFile.path)
|
||||
.setVideoTrackStrategy(videoStrategy)
|
||||
.addDataSource(context, uri)
|
||||
// Force the output to be written, even if no transcoding was actually needed
|
||||
.setValidator(WriteAlwaysValidator())
|
||||
.setListener(object : TranscoderListener {
|
||||
override fun onTranscodeProgress(progress: Double) {
|
||||
trySend(VideoTranscodingEvent.Progress(progress.toFloat()))
|
||||
}
|
||||
val tmpFile = context.createTmpFile(extension = "mp4")
|
||||
|
||||
override fun onTranscodeCompleted(successCode: Int) {
|
||||
val width = metadata?.width ?: Int.MAX_VALUE
|
||||
val height = metadata?.height ?: Int.MAX_VALUE
|
||||
|
||||
val videoResizeEffect = videoCompressorConfig.resizer?.let {
|
||||
val outputSize = it.getOutputSize(Size(width, height))
|
||||
if (metadata?.rotation == 90 || metadata?.rotation == 270) {
|
||||
// If the video is rotated, we need to swap width and height
|
||||
Presentation.createForWidthAndHeight(
|
||||
outputSize.height,
|
||||
outputSize.width,
|
||||
Presentation.LAYOUT_SCALE_TO_FIT,
|
||||
)
|
||||
} else {
|
||||
// Otherwise, we can use the original width and height
|
||||
Presentation.createForWidthAndHeight(
|
||||
outputSize.width,
|
||||
outputSize.height,
|
||||
Presentation.LAYOUT_SCALE_TO_FIT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If we are resizing, we also want to reduce set frame rate to the default value (30fps)
|
||||
val newFrameRate = videoCompressorConfig.newFrameRate
|
||||
|
||||
// If we need to resize the video, we also want to recalculate the bitrate
|
||||
val newBitrate = videoCompressorConfig.newBitRate
|
||||
|
||||
val inputMediaItem = MediaItem.fromUri(uri)
|
||||
val outputMediaItem = EditedMediaItem.Builder(inputMediaItem)
|
||||
.setFrameRate(newFrameRate)
|
||||
.run {
|
||||
if (videoResizeEffect != null) {
|
||||
setEffects(Effects(emptyList(), listOf(videoResizeEffect)))
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
val encoderFactory = DefaultEncoderFactory.Builder(context)
|
||||
.setRequestedVideoEncoderSettings(
|
||||
VideoEncoderSettings.Builder()
|
||||
.setBitrateMode(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR)
|
||||
.setBitrate(newBitrate)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
val videoTransformer = Transformer.Builder(context)
|
||||
.setVideoMimeType(MimeTypes.VIDEO_H264)
|
||||
.setAudioMimeType(MimeTypes.AUDIO_AAC)
|
||||
.setPortraitEncodingEnabled(false)
|
||||
.setEncoderFactory(encoderFactory)
|
||||
.addListener(object : Transformer.Listener {
|
||||
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
|
||||
trySend(VideoTranscodingEvent.Completed(tmpFile))
|
||||
close()
|
||||
}
|
||||
|
||||
override fun onTranscodeCanceled() {
|
||||
override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) {
|
||||
Timber.e(exportException, "Video transcoding failed")
|
||||
tmpFile.safeDelete()
|
||||
close()
|
||||
close(exportException)
|
||||
}
|
||||
|
||||
override fun onTranscodeFailed(exception: Throwable) {
|
||||
tmpFile.safeDelete()
|
||||
close(exception)
|
||||
}
|
||||
override fun onFallbackApplied(
|
||||
composition: Composition,
|
||||
originalTransformationRequest: TransformationRequest,
|
||||
fallbackTransformationRequest: TransformationRequest
|
||||
) = Unit
|
||||
})
|
||||
.transcode()
|
||||
.build()
|
||||
|
||||
val progressJob = launch(Dispatchers.Main) {
|
||||
val progressHolder = ProgressHolder()
|
||||
while (isActive) {
|
||||
val state = videoTransformer.getProgress(progressHolder)
|
||||
if (state != Transformer.PROGRESS_STATE_NOT_STARTED) {
|
||||
channel.send(VideoTranscodingEvent.Progress(progressHolder.progress.toFloat()))
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
videoTransformer.start(outputMediaItem, tmpFile.path)
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
if (!future.isDone) {
|
||||
future.cancel(true)
|
||||
}
|
||||
progressJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +159,8 @@ class VideoCompressor @Inject constructor(
|
|||
val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: -1
|
||||
val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: -1
|
||||
val bitrate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toLongOrNull() ?: -1
|
||||
val framerate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)?.toIntOrNull() ?: -1
|
||||
val frameRate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)?.toIntOrNull() ?: -1
|
||||
val rotation = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
||||
|
||||
val (actualWidth, actualHeight) = if (width == -1 || height == -1) {
|
||||
// Try getting the first frame instead
|
||||
|
|
@ -103,7 +174,8 @@ class VideoCompressor @Inject constructor(
|
|||
width = actualWidth,
|
||||
height = actualHeight,
|
||||
bitrate = bitrate,
|
||||
frameRate = framerate
|
||||
frameRate = frameRate,
|
||||
rotation = rotation,
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
|
|
@ -113,53 +185,14 @@ class VideoCompressor @Inject constructor(
|
|||
}
|
||||
|
||||
internal data class VideoFileMetadata(
|
||||
val width: Int?,
|
||||
val height: Int?,
|
||||
val bitrate: Long?,
|
||||
val frameRate: Int?,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val bitrate: Long,
|
||||
val frameRate: Int,
|
||||
val rotation: Int,
|
||||
)
|
||||
|
||||
sealed interface VideoTranscodingEvent {
|
||||
data class Progress(val value: Float) : VideoTranscodingEvent
|
||||
data class Completed(val file: File) : VideoTranscodingEvent
|
||||
}
|
||||
|
||||
internal object VideoStrategyFactory {
|
||||
// 720p
|
||||
private const val MAX_COMPRESSED_PIXEL_SIZE = 1280
|
||||
|
||||
// 1080p
|
||||
private const val MAX_PIXEL_SIZE = 1920
|
||||
|
||||
fun create(
|
||||
expectedExtension: String?,
|
||||
metadata: VideoFileMetadata?,
|
||||
shouldBeCompressed: Boolean,
|
||||
): TrackStrategy {
|
||||
val width = metadata?.width ?: Int.MAX_VALUE
|
||||
val height = metadata?.height ?: Int.MAX_VALUE
|
||||
val bitrate = metadata?.bitrate
|
||||
val frameRate = metadata?.frameRate
|
||||
|
||||
// We only create a resizer if needed
|
||||
val resizer = when {
|
||||
shouldBeCompressed && (width > MAX_COMPRESSED_PIXEL_SIZE || height > MAX_COMPRESSED_PIXEL_SIZE) -> AtMostResizer(MAX_COMPRESSED_PIXEL_SIZE)
|
||||
width > MAX_PIXEL_SIZE || height > MAX_PIXEL_SIZE -> AtMostResizer(MAX_PIXEL_SIZE)
|
||||
else -> null
|
||||
}
|
||||
|
||||
return if (resizer == null && expectedExtension == MP4_EXTENSION) {
|
||||
// If there's no transcoding or resizing needed for the video file, just create a new file with the same contents but no metadata
|
||||
PassThroughTrackStrategy()
|
||||
} else {
|
||||
DefaultVideoStrategy.Builder()
|
||||
.apply {
|
||||
resizer?.let { addResizer(it) }
|
||||
bitrate?.let { bitRate(it) }
|
||||
frameRate?.let { frameRate(it) }
|
||||
}
|
||||
.mimeType(MediaFormatConstants.MIMETYPE_VIDEO_AVC)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
package io.element.android.libraries.network.interceptors
|
||||
|
||||
import io.element.android.libraries.core.extensions.ellipsize
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
|
|
@ -28,7 +29,7 @@ internal class FormattedJsonHttpLogger(
|
|||
*/
|
||||
@Synchronized
|
||||
override fun log(message: String) {
|
||||
Timber.v(message)
|
||||
Timber.v(message.ellipsize(200_000))
|
||||
|
||||
// Try to log formatted Json only if there is a chance that [message] contains Json.
|
||||
// It can be only the case if we log the bodies of Http requests.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
|
|||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.notification.CallNotifyType
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||
|
|
@ -57,7 +58,7 @@ class DefaultCallNotificationEventResolver @Inject constructor(
|
|||
forceNotify: Boolean
|
||||
): Result<NotifiableEvent> = runCatchingExceptions {
|
||||
val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
|
||||
?: throw ResolvingException("content is not a call notify")
|
||||
?: throw NotificationResolverException.UnknownError("content is not a call notify")
|
||||
|
||||
val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value
|
||||
// We need the sync service working to get the updated room info
|
||||
|
|
@ -65,8 +66,12 @@ class DefaultCallNotificationEventResolver @Inject constructor(
|
|||
if (content.type == CallNotifyType.RING) {
|
||||
appForegroundStateService.updateHasRingingCall(true)
|
||||
|
||||
val client = clientProvider.getOrRestore(sessionId).getOrNull() ?: throw ResolvingException("Session $sessionId not found")
|
||||
val room = client.getRoom(notificationData.roomId) ?: throw ResolvingException("Room ${notificationData.roomId} not found")
|
||||
val client = clientProvider.getOrRestore(
|
||||
sessionId
|
||||
).getOrNull() ?: throw NotificationResolverException.UnknownError("Session $sessionId not found")
|
||||
val room = client.getRoom(
|
||||
notificationData.roomId
|
||||
) ?: throw NotificationResolverException.UnknownError("Room ${notificationData.roomId} not found")
|
||||
// Give a few seconds for the room info flow to catch up with the sync, if needed - this is usually instant
|
||||
val isActive = withTimeoutOrNull(3.seconds) { room.roomInfoFlow.firstOrNull { it.hasRoomCall } }?.hasRoomCall ?: false
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
|
@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
|||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||
import io.element.android.libraries.matrix.api.media.getMediaPreviewValue
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||
|
|
@ -43,18 +44,24 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.NotificationLoggerTag)
|
||||
|
||||
/**
|
||||
* Result of resolving a batch of push events.
|
||||
* The outermost [Result] indicates whether the setup to resolve the events was successful.
|
||||
* The results for each push notification will be a map of [NotificationEventRequest] to [Result] of [ResolvedPushEvent].
|
||||
* If the resolution of a specific event fails, the innermost [Result] will contain an exception.
|
||||
*/
|
||||
typealias ResolvePushEventsResult = Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>
|
||||
|
||||
/**
|
||||
* The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event.
|
||||
* It is used as a bridge between the Event Thread and the NotificationDrawerManager.
|
||||
|
|
@ -65,24 +72,24 @@ interface NotifiableEventResolver {
|
|||
suspend fun resolveEvents(
|
||||
sessionId: SessionId,
|
||||
notificationEventRequests: List<NotificationEventRequest>
|
||||
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>
|
||||
): ResolvePushEventsResult
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class DefaultNotifiableEventResolver @Inject constructor(
|
||||
private val stringProvider: StringProvider,
|
||||
private val clock: SystemClock,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val notificationMediaRepoFactory: NotificationMediaRepo.Factory,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val callNotificationEventResolver: CallNotificationEventResolver,
|
||||
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
||||
) : NotifiableEventResolver {
|
||||
override suspend fun resolveEvents(
|
||||
sessionId: SessionId,
|
||||
notificationEventRequests: List<NotificationEventRequest>
|
||||
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> {
|
||||
): ResolvePushEventsResult {
|
||||
Timber.d("Queueing notifications: $notificationEventRequests")
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
|
||||
return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId"))
|
||||
|
|
@ -90,20 +97,28 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
|||
val ids = notificationEventRequests.groupBy { it.roomId }.mapValues { (_, value) -> value.map { it.eventId } }
|
||||
|
||||
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
|
||||
val notifications = client.notificationService().getNotifications(ids).mapCatchingExceptions { map ->
|
||||
map.mapValues { (_, notificationData) ->
|
||||
notificationData.asNotifiableEvent(client, sessionId)
|
||||
val notificationsResult = client.notificationService().getNotifications(ids)
|
||||
|
||||
if (notificationsResult.isFailure) {
|
||||
val exception = notificationsResult.exceptionOrNull()
|
||||
Timber.tag(loggerTag.value).e(exception, "Failed to get notifications for $ids")
|
||||
return Result.failure(exception ?: NotificationResolverException.UnknownError("Unknown error while fetching notifications"))
|
||||
}
|
||||
|
||||
// The null check is done above
|
||||
val notificationDataMap = notificationsResult.getOrNull()!!.mapValues { (_, notificationData) ->
|
||||
notificationData.flatMap { data ->
|
||||
data.asNotifiableEvent(client, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success(
|
||||
notificationEventRequests.associate {
|
||||
val notificationData = notifications.getOrNull()?.get(it.eventId)
|
||||
if (notificationData != null) {
|
||||
it to notificationData
|
||||
notificationEventRequests.associate { request ->
|
||||
val notificationDataResult = notificationDataMap[request.eventId]
|
||||
if (notificationDataResult == null) {
|
||||
request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}"))
|
||||
} else {
|
||||
// TODO once the SDK can actually return what went wrong, we should return it here instead of this generic error
|
||||
it to Result.failure(ResolvingException("No notification data for ${it.roomId} - ${it.eventId}"))
|
||||
request to notificationDataResult
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -164,7 +179,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
|||
NotificationContent.MessageLike.CallCandidates,
|
||||
NotificationContent.MessageLike.CallHangup -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}")
|
||||
throw ResolvingException("Ignoring notification for call ${content.javaClass.simpleName}")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
is NotificationContent.MessageLike.CallInvite -> {
|
||||
val notifiableMessageEvent = buildNotifiableMessageEvent(
|
||||
|
|
@ -195,7 +210,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
|||
NotificationContent.MessageLike.KeyVerificationReady,
|
||||
NotificationContent.MessageLike.KeyVerificationStart -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for verification ${content.javaClass.simpleName}")
|
||||
throw ResolvingException("Ignoring notification for verification ${content.javaClass.simpleName}")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
is NotificationContent.MessageLike.Poll -> {
|
||||
val notifiableEventMessage = buildNotifiableMessageEvent(
|
||||
|
|
@ -217,16 +232,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
|||
}
|
||||
is NotificationContent.MessageLike.ReactionContent -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for reaction")
|
||||
throw ResolvingException("Ignoring notification for reaction")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
NotificationContent.MessageLike.RoomEncrypted -> {
|
||||
Timber.tag(loggerTag.value).w("Notification with encrypted content -> fallback")
|
||||
val fallbackNotifiableEvent = fallbackNotifiableEvent(userId, roomId, eventId)
|
||||
ResolvedPushEvent.Event(fallbackNotifiableEvent)
|
||||
}
|
||||
NotificationContent.MessageLike.UnableToResolve -> {
|
||||
Timber.tag(loggerTag.value).w("Unable to resolve notification -> fallback")
|
||||
val fallbackNotifiableEvent = fallbackNotifiableEvent(userId, roomId, eventId)
|
||||
val fallbackNotifiableEvent = fallbackNotificationFactory.create(userId, roomId, eventId)
|
||||
ResolvedPushEvent.Event(fallbackNotifiableEvent)
|
||||
}
|
||||
is NotificationContent.MessageLike.RoomRedaction -> {
|
||||
|
|
@ -234,7 +244,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
|||
val redactedEventId = content.redactedEventId
|
||||
if (redactedEventId == null) {
|
||||
Timber.tag(loggerTag.value).d("redactedEventId is null.")
|
||||
throw ResolvingException("redactedEventId is null")
|
||||
throw NotificationResolverException.UnknownError("redactedEventId is null")
|
||||
} else {
|
||||
ResolvedPushEvent.Redaction(
|
||||
sessionId = userId,
|
||||
|
|
@ -246,7 +256,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
|||
}
|
||||
NotificationContent.MessageLike.Sticker -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for sticker")
|
||||
throw ResolvingException("Ignoring notification for reaction")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
is NotificationContent.StateEvent.RoomMemberContent,
|
||||
NotificationContent.StateEvent.PolicyRuleRoom,
|
||||
|
|
@ -270,27 +280,11 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
|||
NotificationContent.StateEvent.SpaceChild,
|
||||
NotificationContent.StateEvent.SpaceParent -> {
|
||||
Timber.tag(loggerTag.value).d("Ignoring notification for state event ${content.javaClass.simpleName}")
|
||||
throw ResolvingException("Ignoring notification for state event ${content.javaClass.simpleName}")
|
||||
throw NotificationResolverException.EventFilteredOut
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallbackNotifiableEvent(
|
||||
userId: SessionId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId
|
||||
) = FallbackNotifiableEvent(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
canBeReplaced = true,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
timestamp = clock.epochMillis(),
|
||||
description = stringProvider.getString(R.string.notification_fallback_content),
|
||||
)
|
||||
|
||||
private fun descriptionFromMessageContent(
|
||||
content: NotificationContent.MessageLike.RoomMessage,
|
||||
senderDisambiguatedDisplayName: String,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class DefaultOnMissedCallNotificationHandler @Inject constructor(
|
|||
?.getNotifications(mapOf(roomId to listOf(eventId)))
|
||||
?.getOrNull()
|
||||
?.get(eventId)
|
||||
?.getOrNull()
|
||||
?: return
|
||||
|
||||
val notifiableEvent = callNotificationEventResolver.resolveEvent(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -16,16 +16,17 @@ import io.element.android.libraries.di.AppScope
|
|||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||
import io.element.android.libraries.push.impl.history.onDiagnosticPush
|
||||
import io.element.android.libraries.push.impl.history.onInvalidPushReceived
|
||||
import io.element.android.libraries.push.impl.history.onSuccess
|
||||
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
|
||||
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
|
||||
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
|
|
@ -63,6 +64,7 @@ class DefaultPushHandler @Inject constructor(
|
|||
private val resolverQueue: NotificationResolverQueue,
|
||||
@AppCoroutineScope
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
||||
) : PushHandler {
|
||||
init {
|
||||
processPushEventResults()
|
||||
|
|
@ -88,34 +90,37 @@ class DefaultPushHandler @Inject constructor(
|
|||
} else {
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) {
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
reason = "Showing fallback notification",
|
||||
)
|
||||
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
|
||||
} else {
|
||||
pushHistoryService.onSuccess(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
comment = "Push handled successfully",
|
||||
)
|
||||
},
|
||||
onFailure = { exception ->
|
||||
if (exception is NotificationResolverException.EventFilteredOut) {
|
||||
pushHistoryService.onSuccess(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
comment = "Push handled successfully",
|
||||
comment = "Push handled successfully but notification was filtered out",
|
||||
)
|
||||
} else {
|
||||
val reason = when (exception) {
|
||||
is NotificationResolverException.EventNotFound -> "Event not found"
|
||||
else -> "Unknown error: ${exception.message}"
|
||||
}
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
reason = "$reason - Showing fallback notification",
|
||||
)
|
||||
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
|
||||
}
|
||||
},
|
||||
onFailure = { exception ->
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
reason = exception.message ?: exception.javaClass.simpleName,
|
||||
)
|
||||
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -125,8 +130,21 @@ class DefaultPushHandler @Inject constructor(
|
|||
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
|
||||
|
||||
@Suppress("LoopWithTooManyJumpStatements")
|
||||
for (result in resolvedEvents.values) {
|
||||
val event = result.getOrNull() ?: continue
|
||||
for ((request, result) in resolvedEvents) {
|
||||
val event = result.recover { exception ->
|
||||
// If the event could not be resolved, we create a fallback notification
|
||||
when (exception) {
|
||||
is NotificationResolverException.EventFilteredOut -> {
|
||||
// Do nothing, we don't want to show a notification for filtered out events
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
Timber.tag(loggerTag.value).e(exception, "Failed to resolve push event")
|
||||
ResolvedPushEvent.Event(fallbackNotificationFactory.create(request.sessionId, request.roomId, request.eventId))
|
||||
}
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
|
||||
val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId)
|
||||
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
|
||||
// If notifications are disabled for this session and device, we don't want to show the notification
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications
|
|||
import android.content.Context
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.notification.CallNotifyType
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||
|
|
@ -51,6 +52,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableMess
|
|||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
|
||||
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -71,12 +73,22 @@ class DefaultNotifiableEventResolverTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `resolve event failure`() = runTest {
|
||||
fun `resolve fetching failure`() = runTest {
|
||||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.failure(AN_EXCEPTION)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve event failure`() = runTest {
|
||||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(mapOf(AN_EVENT_ID to Result.failure(AN_EXCEPTION)))
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.getEvent(request)?.isFailure).isTrue()
|
||||
}
|
||||
|
||||
|
|
@ -85,12 +97,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = TextMessageType(body = "Hello world", formatted = null)
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -108,13 +120,13 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = TextMessageType(body = "Hello world", formatted = null)
|
||||
),
|
||||
hasMention = true,
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -131,7 +143,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = TextMessageType(
|
||||
|
|
@ -142,7 +154,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -159,7 +171,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = TextMessageType(
|
||||
|
|
@ -170,7 +182,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -187,12 +199,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = AudioMessageType("Audio", null, null, MediaSource("url"), null)
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -209,12 +221,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = VideoMessageType("Video", null, null, MediaSource("url"), null)
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -231,12 +243,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null)
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -253,12 +265,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = ImageMessageType("Image", null, null, MediaSource("url"), null),
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -275,12 +287,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = StickerMessageType("Sticker", null, null, MediaSource("url"), null),
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -297,12 +309,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = FileMessageType("File", null, null, MediaSource("url"), null),
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -319,12 +331,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = LocationMessageType("Location", "geo:1,2", null),
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -341,12 +353,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = NoticeMessageType("Notice", null),
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -363,12 +375,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = EmoteMessageType("is happy", null),
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -385,12 +397,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.Poll(
|
||||
senderId = A_USER_ID_2,
|
||||
question = "A question"
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -407,13 +419,13 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.StateEvent.RoomMemberContent(
|
||||
userId = A_USER_ID_2,
|
||||
membershipState = RoomMembershipState.INVITE
|
||||
),
|
||||
isDirect = false,
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -427,12 +439,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.Invite(
|
||||
senderId = A_USER_ID_2,
|
||||
),
|
||||
isDirect = false,
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -464,12 +476,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.Invite(
|
||||
senderId = A_USER_ID_2,
|
||||
),
|
||||
isDirect = true,
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -501,13 +513,13 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.Invite(
|
||||
senderId = A_USER_ID_2,
|
||||
),
|
||||
isDirect = true,
|
||||
senderDisplayName = null,
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -539,7 +551,8 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(
|
||||
aNotificationData(
|
||||
content = NotificationContent.Invite(
|
||||
senderId = A_USER_ID_2,
|
||||
),
|
||||
|
|
@ -547,6 +560,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
senderIsNameAmbiguous = true,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
|
|
@ -577,12 +591,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.StateEvent.RoomMemberContent(
|
||||
userId = A_USER_ID_2,
|
||||
membershipState = RoomMembershipState.JOIN
|
||||
)
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -595,7 +609,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
fun `resolve RoomEncrypted`() = runTest {
|
||||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(AN_EVENT_ID to aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted))
|
||||
mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted)))
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
|
|
@ -620,25 +634,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
fun `resolve UnableToResolve`() = runTest {
|
||||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(AN_EVENT_ID to aNotificationData(content = NotificationContent.MessageLike.UnableToResolve))
|
||||
mapOf(AN_EVENT_ID to Result.failure(NotificationResolverException.EventNotFound))
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
FallbackNotifiableEvent(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
editedEventId = null,
|
||||
description = "You have new messages.",
|
||||
canBeReplaced = true,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
timestamp = A_FAKE_TIMESTAMP,
|
||||
)
|
||||
)
|
||||
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
|
||||
assertThat(result.getEvent(request)).isEqualTo(Result.failure<ResolvedPushEvent?>(NotificationResolverException.EventNotFound))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -646,10 +647,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(
|
||||
aNotificationData(
|
||||
content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
|
|
@ -688,12 +691,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.CallNotify(
|
||||
A_USER_ID_2,
|
||||
CallNotifyType.NOTIFY
|
||||
),
|
||||
)
|
||||
))
|
||||
)
|
||||
),
|
||||
callNotificationEventResolver = callNotificationEventResolver,
|
||||
|
|
@ -729,12 +732,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomRedaction(
|
||||
AN_EVENT_ID_2,
|
||||
A_REDACTION_REASON,
|
||||
)
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -754,12 +757,12 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to aNotificationData(
|
||||
AN_EVENT_ID to Result.success(aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomRedaction(
|
||||
null,
|
||||
A_REDACTION_REASON,
|
||||
)
|
||||
)
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -807,7 +810,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
private fun testNoResults(content: NotificationContent) = runTest {
|
||||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(AN_EVENT_ID to aNotificationData(content = content))
|
||||
mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = content)))
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
|
|
@ -823,7 +826,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
|
||||
private fun createDefaultNotifiableEventResolver(
|
||||
notificationService: FakeNotificationService? = FakeNotificationService(),
|
||||
notificationResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap()),
|
||||
notificationResult: Result<Map<EventId, Result<NotificationData>>> = Result.success(emptyMap()),
|
||||
callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
|
||||
): DefaultNotifiableEventResolver {
|
||||
val context = RuntimeEnvironment.getApplication() as Context
|
||||
|
|
@ -840,12 +843,15 @@ class DefaultNotifiableEventResolverTest {
|
|||
}
|
||||
return DefaultNotifiableEventResolver(
|
||||
stringProvider = AndroidStringProvider(context.resources),
|
||||
clock = FakeSystemClock(),
|
||||
matrixClientProvider = matrixClientProvider,
|
||||
notificationMediaRepoFactory = notificationMediaRepoFactory,
|
||||
context = context,
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
callNotificationEventResolver = callNotificationEventResolver,
|
||||
fallbackNotificationFactory = FallbackNotificationFactory(
|
||||
clock = FakeSystemClock(),
|
||||
stringProvider = FakeStringProvider(defaultResult = "You have new messages.")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class DefaultOnMissedCallNotificationHandlerTest {
|
|||
val matrixClientProvider = FakeMatrixClientProvider(getClient = {
|
||||
val notificationService = FakeNotificationService().apply {
|
||||
givenGetNotificationsResult(
|
||||
Result.success(mapOf(AN_EVENT_ID to aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false)))
|
||||
Result.success(mapOf(AN_EVENT_ID to Result.success(aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false))))
|
||||
)
|
||||
}
|
||||
Result.success(FakeMatrixClient(notificationService = notificationService))
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.notification.CallNotifyType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
|
|
@ -31,9 +32,9 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
|
|||
import io.element.android.libraries.push.impl.history.FakePushHistoryService
|
||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
|
||||
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||
import io.element.android.libraries.push.impl.notifications.ResolvingException
|
||||
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||
|
|
@ -47,6 +48,8 @@ import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
|||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
|
||||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
|
||||
import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
|
@ -271,7 +274,7 @@ class DefaultPushHandlerTest {
|
|||
fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() {
|
||||
`test notification resolver failure`(
|
||||
notificationResolveResult = { _ ->
|
||||
Result.failure(ResolvingException("Unable to restore session"))
|
||||
Result.failure(NotificationResolverException.UnknownError("Unable to restore session"))
|
||||
},
|
||||
shouldSetOptimizationBatteryBanner = false,
|
||||
)
|
||||
|
|
@ -282,7 +285,7 @@ class DefaultPushHandlerTest {
|
|||
`test notification resolver failure`(
|
||||
notificationResolveResult = { requests: List<NotificationEventRequest> ->
|
||||
Result.success(
|
||||
requests.associateWith { Result.failure(ResolvingException("Unable to resolve event")) }
|
||||
requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) }
|
||||
)
|
||||
},
|
||||
shouldSetOptimizationBatteryBanner = true,
|
||||
|
|
@ -336,8 +339,6 @@ class DefaultPushHandlerTest {
|
|||
notifiableEventResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_USER_ID), any())
|
||||
onNotifiableEventsReceived.assertions()
|
||||
.isNeverCalled()
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any())
|
||||
|
|
@ -662,6 +663,10 @@ class DefaultPushHandlerTest {
|
|||
pushHistoryService = pushHistoryService,
|
||||
resolverQueue = NotificationResolverQueue(notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult), backgroundScope),
|
||||
appCoroutineScope = backgroundScope,
|
||||
fallbackNotificationFactory = FallbackNotificationFactory(
|
||||
clock = FakeSystemClock(),
|
||||
stringProvider = FakeStringProvider(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.iconSuccessPrimaryBackground
|
||||
|
||||
@Composable
|
||||
internal fun FormattingOption(
|
||||
|
|
@ -42,13 +41,13 @@ internal fun FormattingOption(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundColor = when (state) {
|
||||
FormattingOptionState.Selected -> ElementTheme.colors.iconSuccessPrimaryBackground
|
||||
FormattingOptionState.Selected -> ElementTheme.colors.bgAccentSelected
|
||||
FormattingOptionState.Default,
|
||||
FormattingOptionState.Disabled -> Color.Transparent
|
||||
}
|
||||
|
||||
val foregroundColor = when (state) {
|
||||
FormattingOptionState.Selected -> ElementTheme.colors.iconSuccessPrimary
|
||||
FormattingOptionState.Selected -> ElementTheme.colors.iconAccentPrimary
|
||||
FormattingOptionState.Default -> ElementTheme.colors.iconSecondary
|
||||
FormattingOptionState.Disabled -> ElementTheme.colors.iconDisabled
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.LinearGradientShader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.colors.gradientActionColors
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
|
@ -62,13 +62,7 @@ internal fun SendButton(
|
|||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(36.dp)
|
||||
.then(
|
||||
if (canSendMessage) {
|
||||
buttonBackgroundModifier()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.buttonBackgroundModifier(canSendMessage)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
|
|
@ -91,27 +85,30 @@ internal fun SendButton(
|
|||
}
|
||||
}
|
||||
|
||||
private fun buttonBackgroundModifier() = Modifier.drawWithCache {
|
||||
// We have a square button, so height == width.
|
||||
val height = size.height
|
||||
val verticalGradientBrush = ShaderBrush(
|
||||
LinearGradientShader(
|
||||
from = Offset(0f, 0f),
|
||||
to = Offset(0f, height),
|
||||
colors = listOf(
|
||||
Color(0xFF79DD98),
|
||||
Color(0xFF0DBD8B),
|
||||
Color(0xFF128585),
|
||||
Color(0xFF24446B),
|
||||
@Composable
|
||||
private fun Modifier.buttonBackgroundModifier(
|
||||
canSendMessage: Boolean,
|
||||
) = then(
|
||||
if (canSendMessage) {
|
||||
val colors = gradientActionColors()
|
||||
Modifier.drawWithCache {
|
||||
val verticalGradientBrush = ShaderBrush(
|
||||
LinearGradientShader(
|
||||
from = Offset(0f, 0f),
|
||||
to = Offset(0f, size.height),
|
||||
colors = colors,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
onDrawBehind {
|
||||
drawRect(
|
||||
brush = verticalGradientBrush,
|
||||
)
|
||||
onDrawBehind {
|
||||
drawRect(
|
||||
brush = verticalGradientBrush,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -13,11 +13,17 @@ import android.net.Uri
|
|||
import android.text.Spanned
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
|
|
@ -28,10 +34,9 @@ import io.element.android.compound.theme.ElementTheme
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.rememberTypeface
|
||||
import io.element.android.libraries.designsystem.theme.currentUserMentionPillBackground
|
||||
import io.element.android.libraries.designsystem.theme.currentUserMentionPillText
|
||||
import io.element.android.libraries.designsystem.theme.mentionPillBackground
|
||||
import io.element.android.libraries.designsystem.theme.mentionPillText
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
|
||||
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
|
@ -52,7 +57,8 @@ import javax.inject.Inject
|
|||
@Stable
|
||||
@SingleIn(SessionScope::class)
|
||||
class MentionSpanTheme(val currentUserId: UserId) {
|
||||
@Inject constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId)
|
||||
@Inject
|
||||
constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId)
|
||||
|
||||
internal var currentUserTextColor: Int = 0
|
||||
internal var currentUserBackgroundColor: Int = Color.WHITE
|
||||
|
|
@ -69,10 +75,10 @@ class MentionSpanTheme(val currentUserId: UserId) {
|
|||
@Suppress("ComposableNaming")
|
||||
@Composable
|
||||
fun updateStyles() {
|
||||
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
|
||||
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
|
||||
otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
|
||||
otherBackgroundColor = ElementTheme.colors.mentionPillBackground.toArgb()
|
||||
currentUserTextColor = ElementTheme.colors.textBadgeAccent.toArgb()
|
||||
currentUserBackgroundColor = ElementTheme.colors.bgBadgeAccent.toArgb()
|
||||
otherTextColor = ElementTheme.colors.textPrimary.toArgb()
|
||||
otherBackgroundColor = ElementTheme.colors.bgBadgeDefault.toArgb()
|
||||
|
||||
typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -166,3 +172,115 @@ internal fun MentionSpanThemePreview() {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MentionSpanThemeInTimelineContent(
|
||||
bgColor: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) }
|
||||
val provider = remember {
|
||||
MentionSpanProvider(
|
||||
mentionSpanTheme = mentionSpanTheme,
|
||||
mentionSpanFormatter = object : MentionSpanFormatter {
|
||||
override fun formatDisplayText(mentionType: MentionType): CharSequence {
|
||||
return when (mentionType) {
|
||||
is MentionType.User -> mentionType.userId.value
|
||||
else -> throw AssertionError("Unexpected value $mentionType")
|
||||
}
|
||||
}
|
||||
},
|
||||
permalinkParser = object : PermalinkParser {
|
||||
override fun parse(uriString: String): PermalinkData {
|
||||
return when (uriString) {
|
||||
"https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org"))
|
||||
"https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org"))
|
||||
else -> throw AssertionError("Unexpected value $uriString")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val textColor = ElementTheme.colors.textPrimary.toArgb()
|
||||
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
|
||||
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
|
||||
mentionSpanTheme.updateStyles()
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
includeFontPadding = false
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
text = buildSpannedString {
|
||||
append("Hello ")
|
||||
append("@mention", mentionSpanMe(), 0)
|
||||
append(" ")
|
||||
append("@mention", mentionSpanOther(), 0)
|
||||
}
|
||||
setTextColor(textColor)
|
||||
setBackgroundColor(bgColor)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MentionSpanThemeInTimelinePreview() = ElementPreview {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Message from me
|
||||
Text(
|
||||
text = "Message from me",
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
ElementTheme.colors.messageFromMeBackground.let { color ->
|
||||
MentionSpanThemeInTimelineContent(
|
||||
modifier = Modifier
|
||||
.padding(start = 60.dp, end = 8.dp)
|
||||
.background(
|
||||
color = color,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(8.dp),
|
||||
bgColor = color.toArgb()
|
||||
)
|
||||
}
|
||||
// Message from other
|
||||
ElementTheme.colors.messageFromOtherBackground.let { color ->
|
||||
Text(
|
||||
text = "Message from other",
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
MentionSpanThemeInTimelineContent(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 60.dp)
|
||||
.padding(4.dp)
|
||||
.background(
|
||||
color = color,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(8.dp),
|
||||
bgColor = color.toArgb()
|
||||
)
|
||||
}
|
||||
// Composer
|
||||
ElementTheme.colors.bgSubtleSecondary.let { color ->
|
||||
Text(
|
||||
text = "Composer",
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
MentionSpanThemeInTimelineContent(
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp, end = 4.dp)
|
||||
.background(color)
|
||||
.padding(8.dp),
|
||||
bgColor = color.toArgb()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
<string name="a11y_jump_to_bottom">"Mine lõppu"</string>
|
||||
<string name="a11y_notifications_mentions_only">"Ainult mainimised"</string>
|
||||
<string name="a11y_notifications_muted">"Summutatud"</string>
|
||||
<string name="a11y_other_user_avatar">"Teise kasutaja tunnuspilt"</string>
|
||||
<string name="a11y_page_n">"%1$d. lehekülg"</string>
|
||||
<string name="a11y_pause">"Peata"</string>
|
||||
<string name="a11y_paused_voice_message">"Häälsõnum, kestus:%1$s, praegune asukoht: %2$s"</string>
|
||||
|
|
@ -30,15 +31,18 @@
|
|||
<string name="a11y_read_receipts_tap_to_show_all">"Vaata kõiki"</string>
|
||||
<string name="a11y_remove_reaction">"Eemalda reageerimine %1$s emotikoniga"</string>
|
||||
<string name="a11y_remove_reaction_with">"Eemalda reageerimine: %1$s"</string>
|
||||
<string name="a11y_room_avatar">"Jututoa tunnuspilt"</string>
|
||||
<string name="a11y_send_files">"Saada faile"</string>
|
||||
<string name="a11y_show_password">"Näita salasõna"</string>
|
||||
<string name="a11y_start_call">"Helista"</string>
|
||||
<string name="a11y_user_avatar">"Kasutaja tunnuspilt"</string>
|
||||
<string name="a11y_user_menu">"Kasutajamenüü"</string>
|
||||
<string name="a11y_view_avatar">"Vaata tunnuspilti"</string>
|
||||
<string name="a11y_view_details">"Vaata üksikasju"</string>
|
||||
<string name="a11y_voice_message">"Häälsõnum, kestus:%1$s"</string>
|
||||
<string name="a11y_voice_message_record">"Salvesta häälsõnum."</string>
|
||||
<string name="a11y_voice_message_stop_recording">"Lõpeta salvestamine"</string>
|
||||
<string name="a11y_your_avatar">"Sinu tunnuspilt"</string>
|
||||
<string name="action_accept">"Nõustu"</string>
|
||||
<string name="action_add_caption">"Lisa selgitus"</string>
|
||||
<string name="action_add_to_timeline">"Lisa ajajoonele"</string>
|
||||
|
|
@ -97,6 +101,7 @@
|
|||
<string name="action_no">"Ei"</string>
|
||||
<string name="action_not_now">"Mitte praegu"</string>
|
||||
<string name="action_ok">"OK"</string>
|
||||
<string name="action_open_context_menu">"Ava kontekstimenüü"</string>
|
||||
<string name="action_open_settings">"Seadistused"</string>
|
||||
<string name="action_open_with">"Ava rakendusega"</string>
|
||||
<string name="action_pin">"Tõsta esile"</string>
|
||||
|
|
@ -121,7 +126,9 @@
|
|||
<string name="action_save">"Salvesta"</string>
|
||||
<string name="action_search">"Otsi"</string>
|
||||
<string name="action_send">"Saada"</string>
|
||||
<string name="action_send_edited_message">"Saada muudetud sõnum"</string>
|
||||
<string name="action_send_message">"Saada sõnum"</string>
|
||||
<string name="action_send_voice_message">"Saada häälsõnum"</string>
|
||||
<string name="action_share">"Jaga"</string>
|
||||
<string name="action_share_link">"Jaga linki"</string>
|
||||
<string name="action_show">"Näita"</string>
|
||||
|
|
@ -137,6 +144,7 @@
|
|||
<string name="action_tap_for_options">"Valikuteks klõpsa"</string>
|
||||
<string name="action_try_again">"Proovi uuesti"</string>
|
||||
<string name="action_unpin">"Eemalda esiletõstmine"</string>
|
||||
<string name="action_view">"Vaata"</string>
|
||||
<string name="action_view_in_timeline">"Vaata ajajoonel"</string>
|
||||
<string name="action_view_source">"Vaata lähtekoodi"</string>
|
||||
<string name="action_yes">"Jah"</string>
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@
|
|||
<string name="action_save">"Enregistrer"</string>
|
||||
<string name="action_search">"Rechercher"</string>
|
||||
<string name="action_send">"Envoyer"</string>
|
||||
<string name="action_send_edited_message">"Envoyer un message modifié"</string>
|
||||
<string name="action_send_edited_message">"Envoyer les modifications"</string>
|
||||
<string name="action_send_message">"Envoyer un message"</string>
|
||||
<string name="action_send_voice_message">"Envoyer un message vocal"</string>
|
||||
<string name="action_share">"Partager"</string>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@
|
|||
<item quantity="one">"%1$d digit entered"</item>
|
||||
<item quantity="other">"%1$d digits entered"</item>
|
||||
</plurals>
|
||||
<string name="a11y_edit_avatar">"Edit avatar"</string>
|
||||
<string name="a11y_encryption_details">"Encryption details"</string>
|
||||
<string name="a11y_hide_password">"Hide password"</string>
|
||||
<string name="a11y_join_call">"Join call"</string>
|
||||
<string name="a11y_jump_to_bottom">"Jump to bottom"</string>
|
||||
<string name="a11y_notifications_mentions_only">"Mentions only"</string>
|
||||
<string name="a11y_notifications_muted">"Muted"</string>
|
||||
<string name="a11y_notifications_new_mentions">"New mentions"</string>
|
||||
<string name="a11y_notifications_new_messages">"New messages"</string>
|
||||
<string name="a11y_notifications_ongoing_call">"Ongoing call"</string>
|
||||
<string name="a11y_other_user_avatar">"Other user\'s avatar"</string>
|
||||
<string name="a11y_page_n">"Page %1$d"</string>
|
||||
<string name="a11y_pause">"Pause"</string>
|
||||
|
|
@ -35,6 +40,7 @@
|
|||
<string name="a11y_send_files">"Send files"</string>
|
||||
<string name="a11y_show_password">"Show password"</string>
|
||||
<string name="a11y_start_call">"Start a call"</string>
|
||||
<string name="a11y_time_limited_action_required">"Time limited action required"</string>
|
||||
<string name="a11y_user_avatar">"User avatar"</string>
|
||||
<string name="a11y_user_menu">"User menu"</string>
|
||||
<string name="a11y_view_avatar">"View avatar"</string>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ private const val versionYear = 25
|
|||
private const val versionMonth = 7
|
||||
|
||||
// Note: must be in [0,99]
|
||||
private const val versionReleaseNumber = 0
|
||||
private const val versionReleaseNumber = 1
|
||||
|
||||
object Versions {
|
||||
const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:daa1ee2d17b392c702067cfd3bd73a64ed393e68e242b342abe1db97baa696db
|
||||
size 11218
|
||||
oid sha256:03d9396d033f227408e8acdb424f187e2c0bf7c5493c13faee5eef51b01e4c93
|
||||
size 10726
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e2b03077131e1b6b37762fbfd89519e726ed0a86685d0107a39024873ee0e670
|
||||
size 10706
|
||||
oid sha256:1b719ebe0ef05845daa58e6e1e0c72b9c30da66e639fc6bed9edeb483ed3d1d0
|
||||
size 10206
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9fb2f05e2eea4d25a5bb87f3fc6391df2bf0923967d6ad973152557517fa7819
|
||||
size 29864
|
||||
oid sha256:78267cc22ef57370eb378a19d37be1826a7da737903ab38f1db984bfaac71014
|
||||
size 29368
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5c32861bbae702e9f45263ed3ee710d450b4eb66f1919a4f11eb5ccce7a0354
|
||||
size 31120
|
||||
oid sha256:368438eee2c58b62920c4568d4f727fc760f9e858841015c0fc053a7c1794f34
|
||||
size 30633
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a2a9e4c1bf333604bb0a1b17c83af340e7d17a4dd3c465e8a49394dd236c3b45
|
||||
oid sha256:676c512eb43f3784c98e8344abc1c76a3950d3765eb888d79e8b3b7a03178fe8
|
||||
size 25844
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0733dabade5399a419fa04c553a719c32c4925826d34091f7a64a0b4eb929e23
|
||||
oid sha256:d01f2725c05013a8a5fbd2cc925ea692a1ced85e2dba1ee057891bf675696226
|
||||
size 65637
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ae6680679576c8b573f247eacc79970197f6c544df2a498e83950d84d441a635
|
||||
size 24413
|
||||
oid sha256:b6927e063867986095e5e95026fcbb2bdc04977cee5fd80bbf89c5c331909acf
|
||||
size 25047
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c7a6f4ead21dff32579260eb8afb65d5b5748a3fd37ef12dd881fa450fa48a8
|
||||
size 9166
|
||||
oid sha256:b33738bbd3ec0a20eefacac4bd5fc4f03c71ac0dbce772d914801b316c91625c
|
||||
size 10018
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c4c13bfeff36e70a209271d37d9df050098ff082097f706b8550fba469db7e8
|
||||
size 34501
|
||||
oid sha256:f2e6774496aa545174428999235b4ba4825b1255706dcb829c92b9dfb1c1fa09
|
||||
size 35290
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f08c84f2f4e4334e1e3062f3971e2e9ef0211a9af6c76de7ec90f34a470d0119
|
||||
size 42777
|
||||
oid sha256:e185a725d6d72f67f87549eebb436abd6b5c252674e38a217af27d2b67fd1a1f
|
||||
size 43737
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:afba149de7c094ec7c49c094df22e22e5482dbc2196decd2af029f00921ac5e2
|
||||
oid sha256:5f3da666595fb927aad97e0f50762534d49e587122a304062baf1322b23b7d48
|
||||
size 56367
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a117fd360262156cef595eabfaee2648cdb185678527d9a0aec602160b7823f4
|
||||
size 64801
|
||||
oid sha256:d540ccf947f37ec51f16a35c824d0eb0ffe0534424b889a170b81907db576605
|
||||
size 64802
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1abd9d8600f32dc471e7befaec73fef7bd67234532b6dbcbfd4e94daec927fe5
|
||||
size 22152
|
||||
oid sha256:5a6855a2444515fbb98c1a3d01fb6cc0c5a938b9ab032e81d090490a3b1968a2
|
||||
size 22254
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:78688c13e131c5000e7dec50addad0053bb5d3f7dd90ac092eab81623dac69cc
|
||||
size 70524
|
||||
oid sha256:dd9721a15aaf20c0b1dbcf4a10dc8041cc07ec09f268a9408cc2cdf501ba6f5f
|
||||
size 70638
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6d4a68eb39431cdcccd8b35abaf82b5cd830ddce1fa1d1af5764f2c67248a16b
|
||||
size 29718
|
||||
oid sha256:177a5d2d49d1c7bc27be8a03371b074e98cb163c3fb2bd996ef34d58a58e142d
|
||||
size 29787
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5634d1088411e2e3204b40c8098fe9f0dde28847f33a7002aaf017791472cc3b
|
||||
size 24039
|
||||
oid sha256:98f07fe6746bc81551919c3924deb04f61f999f640371c35ac10fd3a9957c678
|
||||
size 24101
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8bda53ae653315c654003c6b441bff6c35ddb1fa5bbd2233e1a630f7a74fc6a9
|
||||
size 30885
|
||||
oid sha256:12bb9ce3e39f0734d79f9c877545ca9eeccab4c720ff21152b350bedf2194992
|
||||
size 30951
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:39c4b72f7469c06e7086ce40340cfa4f326e6ee845420e34b8dd4c98f8434c29
|
||||
size 55361
|
||||
oid sha256:1b73e62309cae93bc83abd6f5015cf44f9b0c18eff266da9bc36be85576477c6
|
||||
size 55416
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:322609531df2bcf28a78fe21a0180658a07ac754128d611b8bca0a201731e77d
|
||||
size 29827
|
||||
oid sha256:36e919bf716639ddd32464d3b864891e4bc18fcc5a504f405d44a811b9961fa3
|
||||
size 29896
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2c07aa7a1aa31b8866bd1981bee11476cd1daa32d02b2f7b6aa368b2fdd3c3e2
|
||||
size 29868
|
||||
oid sha256:a8f5d2ee21ff6279f35a2772f5696db3fde1b7a14719002e70b6a0560a5ba324
|
||||
size 29940
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e3d4acb84c68ac6147a8255e1897e0e658086982d9b732d421d3d52fe0ea0f19
|
||||
size 27020
|
||||
oid sha256:f3f43373818117ea406135038b04e4529a1f343286e5d7bacab08b9e69e5204f
|
||||
size 27098
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f485e12af75bbfdf57b1f54a67548f950cc31204a867e662b7252199dfb5a2a
|
||||
size 29608
|
||||
oid sha256:3c89cc3b86ddea6373e4bf0ca8021a03faac3f40104029cf3118755d75c8ad0e
|
||||
size 29676
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8fc2a9f674d1a1301b376c7e6f17b29710b51b68a1c8590993139367b25c58dc
|
||||
size 26171
|
||||
oid sha256:62831e4322a6b712389809f630176b18641a05bc3ca73b93aaf7b0ebd0de521e
|
||||
size 25958
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ae0bab78138a338cced2929a32ecbef81736247dfdd0b6985f69ef41174d2719
|
||||
oid sha256:2eb622c603392e5da8ebd3aba69e0bdbe238e9de46cb6ade6306fc2e8fc4ad7a
|
||||
size 20001
|
||||
|
|
|
|||
|
|
@ -1431,6 +1431,7 @@ export const screenshots = [
|
|||
["features.messages.impl.timeline_TimelineView_Day_8_en","features.messages.impl.timeline_TimelineView_Night_8_en",20273,],
|
||||
["features.messages.impl.timeline_TimelineView_Day_9_en","features.messages.impl.timeline_TimelineView_Night_9_en",0,],
|
||||
["libraries.designsystem.components.avatar.internal_TombstonedRoomAvatar_Avatars_en","",0,],
|
||||
["libraries.designsystem.theme.components_TopAppBarStr_App_Bars_en","",0,],
|
||||
["libraries.designsystem.theme.components_TopAppBar_App_Bars_en","",0,],
|
||||
["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_0_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_0_en",20273,],
|
||||
["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_1_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_1_en",20273,],
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue