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