mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Merge remote-tracking branch 'upstream/develop' into fix/5356-sender-name-fallback
# Conflicts: # features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt
This commit is contained in:
@@ -63,44 +63,6 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
app/build/outputs/bundle/gplayRelease/app-gplay-release.aab
|
app/build/outputs/bundle/gplayRelease/app-gplay-release.aab
|
||||||
|
|
||||||
enterprise:
|
|
||||||
name: Create App Bundle Enterprise
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
|
||||||
concurrency:
|
|
||||||
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Add SSH private keys for submodule repositories
|
|
||||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
|
||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
|
||||||
with:
|
|
||||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
|
||||||
- name: Clone submodules
|
|
||||||
run: git submodule update --init --recursive
|
|
||||||
- name: Use JDK 21
|
|
||||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
|
||||||
with:
|
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
|
||||||
java-version: '21'
|
|
||||||
- name: Configure gradle
|
|
||||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
|
||||||
- name: Create Enterprise app bundle
|
|
||||||
env:
|
|
||||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
|
||||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
|
||||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
|
||||||
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
|
|
||||||
- name: Upload bundle as artifact
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: elementx-enterprise-app-gplay-bundle-unsigned
|
|
||||||
path: |
|
|
||||||
app/build/outputs/bundle/gplayRelease/app-gplay-release.aab
|
|
||||||
|
|
||||||
fdroid:
|
fdroid:
|
||||||
name: Create APKs (FDroid)
|
name: Create APKs (FDroid)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ PRs must meet these rules.
|
|||||||
- Title = changelog entry — descriptive, no "Fixes #…".
|
- Title = changelog entry — descriptive, no "Fixes #…".
|
||||||
- Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette).
|
- Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette).
|
||||||
- Screenshots/videos for visual changes.
|
- Screenshots/videos for visual changes.
|
||||||
- 500 additions max — split large changes.
|
- 500 additions max into production code. Test code can be larger — split large changes.
|
||||||
- Commits need a title and description; no tiny or massive commits.
|
- Commits need a title and description; no tiny or massive commits.
|
||||||
- No history rewrites.
|
- No history rewrites.
|
||||||
|
- When creating a pull request and if changes contain new or update(s) of the Composable Previews, add the label "Record-Screenshots" to the PR so that the CI will record the screenshots.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+84
@@ -1,3 +1,87 @@
|
|||||||
|
Changes in Element X v26.08.4
|
||||||
|
=============================
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at v26.08.4 -->
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
### 🧱 Build
|
||||||
|
* Add releaseV2.sh, a release script automating the GitHub release by @bmarty in https://github.com/element-hq/element-x-android/pull/7579
|
||||||
|
* Update submodule ref. by @bmarty in https://github.com/element-hq/element-x-android/pull/7582
|
||||||
|
### Others
|
||||||
|
* Keep the formatting of a caption that is being edited by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7572
|
||||||
|
* Say when the server cannot be reached instead of showing nothing by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7567
|
||||||
|
* Keep saving files to Downloads once 32 files share the same name by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7344
|
||||||
|
* Fix removed messages hiding its thread on deletion by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7480
|
||||||
|
* Drop the sender's name from the room list preview of two-person rooms by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7562
|
||||||
|
* Document how the Element Call base URL developer option is used by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7546
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.3...v26.08.4
|
||||||
|
|
||||||
|
Changes in Element X v26.08.3
|
||||||
|
=============================
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at v26.08.3 -->
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
### ✨ Features
|
||||||
|
* Let users enter their full Matrix user ID on the account provider screen by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7500
|
||||||
|
* Enable user status feature and hide status text in the timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/7517
|
||||||
|
### 🐛 Bugfixes
|
||||||
|
* Fix cold start with a mobile deep link by @bmarty in https://github.com/element-hq/element-x-android/pull/7488
|
||||||
|
* Make the account provider screen scrollable so its description is not clipped by the keyboard by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7521
|
||||||
|
### 🧱 Build
|
||||||
|
* Update Gradle Wrapper from 9.7.0 to 9.7.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7485
|
||||||
|
* Fix flaky test in `DefaultVoiceMessageComposerPresenterTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7487
|
||||||
|
* Fix warning about deprecated Gradle `keepRules.file` API by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7498
|
||||||
|
* Update Enterprise Copyright in idea settings, so that new files get an expected header. by @bmarty in https://github.com/element-hq/element-x-android/pull/7501
|
||||||
|
* Remove workaround for Renovate. by @bmarty in https://github.com/element-hq/element-x-android/pull/7502
|
||||||
|
### 🚧 In development 🚧
|
||||||
|
* Make PIP react to camera orientation changes in the other participant's device by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6951
|
||||||
|
* Feature: global search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7410
|
||||||
|
### Dependency upgrades
|
||||||
|
* Update dependency org.maplibre.gl:android-sdk to v13.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7486
|
||||||
|
* Update dependency androidx.webkit:webkit to v1.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7484
|
||||||
|
* Update dependency io.sentry:sentry-android to v8.53.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7481
|
||||||
|
* Update dependency io.element.android:element-call-embedded to v0.24.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7474
|
||||||
|
* Update roborazzi to v1.72.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7489
|
||||||
|
* Update metro to v1.4.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7493
|
||||||
|
* Update dependency com.squareup.okhttp3:okhttp-bom to v5.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7512
|
||||||
|
* Update dependency com.google.guava:guava to v33.7.0-android by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7516
|
||||||
|
* Update sdk to version 26.08.25 by @bmarty in https://github.com/element-hq/element-x-android/pull/7550
|
||||||
|
### 💼 Element Pro
|
||||||
|
* Add `ClientEnterpriseHook` and `ClientBuilderEnterpriseHook` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7495
|
||||||
|
* Remove dependency on enterprise module. by @bmarty in https://github.com/element-hq/element-x-android/pull/7505
|
||||||
|
### Others
|
||||||
|
* Show the right image when opening one that has not finished sending by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7359
|
||||||
|
* Move strings to the login module by @bmarty in https://github.com/element-hq/element-x-android/pull/7494
|
||||||
|
* Allow empty problem description if the GitHub issue number is present for rageshakes by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7497
|
||||||
|
* Apply the system theme when switching back to it from a forced light or dark theme by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7453
|
||||||
|
* Show dialog with reason when user clicks on a message with a failure by @bmarty in https://github.com/element-hq/element-x-android/pull/7490
|
||||||
|
* Keep a focused timeline focused until the event has been rendered by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7463
|
||||||
|
* Do not render in-chat key verification requests in the timeline by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7443
|
||||||
|
* Share a file whose mime type is not one of the supported ones by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7435
|
||||||
|
* Show reactions in the pinned messages list by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6676
|
||||||
|
* Send a concrete mime type for media shared with a wildcard one by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7449
|
||||||
|
* Rename module and ensure that naming convention is followed by @bmarty in https://github.com/element-hq/element-x-android/pull/7514
|
||||||
|
* Fix lint issue, ensure that PiP methods are not called on old devices not supporting them. by @bmarty in https://github.com/element-hq/element-x-android/pull/7515
|
||||||
|
* Rename PreviewParameterProvider subclasses to the PreviewParam suffix by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7468
|
||||||
|
* Update Ref. by @bmarty in https://github.com/element-hq/element-x-android/pull/7518
|
||||||
|
* Remove the reference to the decommissioned OIDC playground by @sandhose in https://github.com/element-hq/element-x-android/pull/7519
|
||||||
|
* Hide the encryption badge when key storage is set up on the server by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7447
|
||||||
|
* Rename remaining events sealed interface by @bmarty in https://github.com/element-hq/element-x-android/pull/7533
|
||||||
|
* Stop waiting on a verification request the other session never accepts by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7457
|
||||||
|
* Point the proxy debugging doc at the method that builds the clients by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7543
|
||||||
|
* Show the build date in the screenshot gallery footer by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7544
|
||||||
|
* Run the database migrations again when a database is upgraded by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7570
|
||||||
|
* Update fonts on the headings by @bmarty in https://github.com/element-hq/element-x-android/pull/7520
|
||||||
|
* Add a "Push rules" entry in the developer options by @bmarty in https://github.com/element-hq/element-x-android/pull/7577
|
||||||
|
|
||||||
|
## New Contributors
|
||||||
|
* @sandhose made their first contribution in https://github.com/element-hq/element-x-android/pull/7519
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.2...v26.08.3
|
||||||
|
|
||||||
Changes in Element X v26.08.2
|
Changes in Element X v26.08.2
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|||||||
@@ -105,9 +105,18 @@ class LoggedInPresenter(
|
|||||||
}
|
}
|
||||||
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
||||||
val isOnline by syncService.isOnline.collectAsState()
|
val isOnline by syncService.isOnline.collectAsState()
|
||||||
val showSyncSpinner by remember {
|
val hasNetwork by networkMonitor.connectivity.collectAsState()
|
||||||
|
val syncIndicatorState by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
|
when {
|
||||||
|
isOnline -> if (syncIndicator == RoomListService.SyncIndicator.Show) {
|
||||||
|
SyncIndicatorState.Syncing
|
||||||
|
} else {
|
||||||
|
SyncIndicatorState.Hidden
|
||||||
|
}
|
||||||
|
hasNetwork == NetworkStatus.Connected -> SyncIndicatorState.ServerUnreachable
|
||||||
|
else -> SyncIndicatorState.Hidden
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var forceNativeSlidingSyncMigration by remember { mutableStateOf(false) }
|
var forceNativeSlidingSyncMigration by remember { mutableStateOf(false) }
|
||||||
@@ -181,7 +190,7 @@ class LoggedInPresenter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return LoggedInState(
|
return LoggedInState(
|
||||||
showSyncSpinner = showSyncSpinner,
|
syncIndicatorState = syncIndicatorState,
|
||||||
pusherRegistrationState = pusherRegistrationState.value,
|
pusherRegistrationState = pusherRegistrationState.value,
|
||||||
ignoreRegistrationError = ignoreRegistrationError,
|
ignoreRegistrationError = ignoreRegistrationError,
|
||||||
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ package io.element.android.appnav.loggedin
|
|||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||||
|
|
||||||
|
enum class SyncIndicatorState {
|
||||||
|
Hidden,
|
||||||
|
Syncing,
|
||||||
|
ServerUnreachable,
|
||||||
|
}
|
||||||
|
|
||||||
data class LoggedInState(
|
data class LoggedInState(
|
||||||
val showSyncSpinner: Boolean,
|
val syncIndicatorState: SyncIndicatorState,
|
||||||
val pusherRegistrationState: AsyncData<Unit>,
|
val pusherRegistrationState: AsyncData<Unit>,
|
||||||
val ignoreRegistrationError: Boolean,
|
val ignoreRegistrationError: Boolean,
|
||||||
val forceNativeSlidingSyncMigration: Boolean,
|
val forceNativeSlidingSyncMigration: Boolean,
|
||||||
|
|||||||
+4
-3
@@ -17,7 +17,8 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider<LoggedInState> {
|
|||||||
override val values: Sequence<LoggedInState>
|
override val values: Sequence<LoggedInState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aLoggedInState(),
|
aLoggedInState(),
|
||||||
aLoggedInState(showSyncSpinner = true),
|
aLoggedInState(syncIndicatorState = SyncIndicatorState.Syncing),
|
||||||
|
aLoggedInState(syncIndicatorState = SyncIndicatorState.ServerUnreachable),
|
||||||
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
|
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
|
||||||
aLoggedInState(forceNativeSlidingSyncMigration = true),
|
aLoggedInState(forceNativeSlidingSyncMigration = true),
|
||||||
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
|
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
|
||||||
@@ -26,13 +27,13 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider<LoggedInState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun aLoggedInState(
|
fun aLoggedInState(
|
||||||
showSyncSpinner: Boolean = false,
|
syncIndicatorState: SyncIndicatorState = SyncIndicatorState.Hidden,
|
||||||
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
|
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
|
||||||
forceNativeSlidingSyncMigration: Boolean = false,
|
forceNativeSlidingSyncMigration: Boolean = false,
|
||||||
appName: String = "Element X",
|
appName: String = "Element X",
|
||||||
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
|
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
|
||||||
) = LoggedInState(
|
) = LoggedInState(
|
||||||
showSyncSpinner = showSyncSpinner,
|
syncIndicatorState = syncIndicatorState,
|
||||||
pusherRegistrationState = pusherRegistrationState,
|
pusherRegistrationState = pusherRegistrationState,
|
||||||
ignoreRegistrationError = false,
|
ignoreRegistrationError = false,
|
||||||
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ fun LoggedInView(
|
|||||||
) {
|
) {
|
||||||
SyncStateView(
|
SyncStateView(
|
||||||
modifier = Modifier.align(Alignment.TopCenter),
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
isVisible = state.showSyncSpinner,
|
state = state.syncIndicatorState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (state.pusherRegistrationState) {
|
when (state.pusherRegistrationState) {
|
||||||
|
|||||||
@@ -25,18 +25,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SyncStateView(
|
fun SyncStateView(
|
||||||
isVisible: Boolean,
|
state: SyncIndicatorState,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isVisible,
|
visible = state != SyncIndicatorState.Hidden,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
enter = fadeIn(spring(stiffness = 500F)),
|
enter = fadeIn(spring(stiffness = 500F)),
|
||||||
exit = fadeOut(spring(stiffness = 500F)),
|
exit = fadeOut(spring(stiffness = 500F)),
|
||||||
) {
|
) {
|
||||||
AsyncIndicator.Loading(
|
when (state) {
|
||||||
text = stringResource(id = CommonStrings.common_syncing),
|
SyncIndicatorState.ServerUnreachable -> AsyncIndicator.Failure(
|
||||||
)
|
text = stringResource(id = CommonStrings.common_server_unreachable),
|
||||||
|
)
|
||||||
|
else -> AsyncIndicator.Loading(
|
||||||
|
text = stringResource(id = CommonStrings.common_syncing),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +51,18 @@ internal fun SyncStateViewPreview() = ElementPreview {
|
|||||||
// Add a box to see the shadow
|
// Add a box to see the shadow
|
||||||
Box(modifier = Modifier.padding(24.dp)) {
|
Box(modifier = Modifier.padding(24.dp)) {
|
||||||
SyncStateView(
|
SyncStateView(
|
||||||
isVisible = true
|
state = SyncIndicatorState.Syncing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun SyncStateViewServerUnreachablePreview() = ElementPreview {
|
||||||
|
// Add a box to see the shadow
|
||||||
|
Box(modifier = Modifier.padding(24.dp)) {
|
||||||
|
SyncStateView(
|
||||||
|
state = SyncIndicatorState.ServerUnreachable
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class LoggedInPresenterTest {
|
|||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
createLoggedInPresenter().test {
|
createLoggedInPresenter().test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.showSyncSpinner).isFalse()
|
assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden)
|
||||||
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
|
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
|
||||||
assertThat(initialState.ignoreRegistrationError).isFalse()
|
assertThat(initialState.ignoreRegistrationError).isFalse()
|
||||||
}
|
}
|
||||||
@@ -97,11 +97,33 @@ class LoggedInPresenterTest {
|
|||||||
matrixClient = FakeMatrixClient(roomListService = roomListService),
|
matrixClient = FakeMatrixClient(roomListService = roomListService),
|
||||||
).test {
|
).test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.showSyncSpinner).isFalse()
|
assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden)
|
||||||
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
|
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
|
||||||
consumeItemsUntilPredicate { it.showSyncSpinner }
|
consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.Syncing }
|
||||||
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Hide)
|
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Hide)
|
||||||
consumeItemsUntilPredicate { !it.showSyncSpinner }
|
consumeItemsUntilPredicate { it.syncIndicatorState != SyncIndicatorState.Syncing }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - say the server is unreachable when the device has network but the sync is offline`() = runTest {
|
||||||
|
createLoggedInPresenter(
|
||||||
|
syncState = SyncState.Offline,
|
||||||
|
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||||
|
).test {
|
||||||
|
consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.ServerUnreachable }
|
||||||
|
cancelAndConsumeRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - say nothing when the device itself has no network`() = runTest {
|
||||||
|
createLoggedInPresenter(
|
||||||
|
syncState = SyncState.Offline,
|
||||||
|
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected),
|
||||||
|
).test {
|
||||||
|
consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.Hidden }
|
||||||
|
cancelAndConsumeRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,18 @@
|
|||||||
1) Set `127.0.0.1` as "Host name" and `8080` as "Port number".
|
1) Set `127.0.0.1` as "Host name" and `8080` as "Port number".
|
||||||
1) Click "Apply" and verify that "Proxy status" is "Success" and close the settings window.
|
1) Click "Apply" and verify that "Proxy status" is "Success" and close the settings window.
|
||||||
<img width="932" alt="Screenshot 2023-10-04 at 14 48 47" src="https://github.com/element-hq/element-x-android/assets/1273124/bf99a053-20b0-42a4-91d3-9602f709f684">
|
<img width="932" alt="Screenshot 2023-10-04 at 14 48 47" src="https://github.com/element-hq/element-x-android/assets/1273124/bf99a053-20b0-42a4-91d3-9602f709f684">
|
||||||
|
1) Alternatively, set the proxy on the device itself, which the app honours through `DefaultProxyProvider`, and which also works on a physical device:
|
||||||
|
```
|
||||||
|
adb shell settings put global http_proxy 10.0.2.2:8080
|
||||||
|
```
|
||||||
|
Use `adb shell settings delete global http_proxy` to remove it afterwards. On a physical device, replace `10.0.2.2` with the address of the machine running mitmproxy.
|
||||||
1) Install the mitmproxy CA cert (this is needed to see traffic from java/kotlin code, it's not needed for traffic coming from native code e.g. the matrix-rust-sdk).
|
1) Install the mitmproxy CA cert (this is needed to see traffic from java/kotlin code, it's not needed for traffic coming from native code e.g. the matrix-rust-sdk).
|
||||||
1) Open the emulator Chrome browser app
|
1) Open the emulator Chrome browser app
|
||||||
1) Go to the url `mitm.it`
|
1) Go to the url `mitm.it`
|
||||||
1) Follow the instructions to install the CA cert on Android devices.
|
1) Follow the instructions to install the CA cert on Android devices.
|
||||||
<img width="606" alt="Screenshot 2023-10-04 at 14 51 27" src="https://github.com/element-hq/element-x-android/assets/1273124/5f2b6f27-6958-4ea7-97fe-c7f06d105da5">
|
<img width="606" alt="Screenshot 2023-10-04 at 14 51 27" src="https://github.com/element-hq/element-x-android/assets/1273124/5f2b6f27-6958-4ea7-97fe-c7f06d105da5">
|
||||||
1) Slightly modify the Element X app source code.
|
1) Slightly modify the Element X app source code.
|
||||||
1) Go to the `RustMatrixClientFactory.create()` method.
|
1) Go to the `RustMatrixClientFactory.getBaseClientBuilder()` method, which builds every client the app creates, whether it is logging in or restoring a session.
|
||||||
1) Add `.disableSslVerification()` in the `ClientBuilder` method chain.
|
1) Add `.disableSslVerification()` in the `ClientBuilder` method chain.
|
||||||
1) Build and run the Element X app.
|
1) Build and run the Element X app.
|
||||||
1) Enjoy, you will see all the traffic in mitmproxy's web interface.
|
1) Enjoy, you will see all the traffic in mitmproxy's web interface.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Element Call
|
||||||
|
|
||||||
|
<!--- TOC -->
|
||||||
|
|
||||||
|
* [Overview](#overview)
|
||||||
|
* [Setting a custom base URL](#setting-a-custom-base-url)
|
||||||
|
* [Why the application does not add the room path itself](#why-the-application-does-not-add-the-room-path-itself)
|
||||||
|
* [Where the value is read](#where-the-value-is-read)
|
||||||
|
|
||||||
|
<!--- END -->
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Element X embeds a copy of [Element Call](https://github.com/element-hq/element-call) in the
|
||||||
|
application assets and loads it from
|
||||||
|
`https://appassets.androidplatform.net/element-call/index.html`. Calls therefore work out of the box
|
||||||
|
with no extra deployment, and the embedded copy is the one every user gets.
|
||||||
|
|
||||||
|
Developers can point the application at another Element Call deployment instead — a local
|
||||||
|
development server, a staging deployment, or a self-hosted one — with the **Element Call base URL**
|
||||||
|
developer option.
|
||||||
|
|
||||||
|
## Setting a custom base URL
|
||||||
|
|
||||||
|
The option lives in *Settings* → *Developer options* → *Element Call*, under "Element Call base
|
||||||
|
URL". On a release build the *Developer options* entry is hidden until the version number at the
|
||||||
|
bottom of *Settings* is tapped seven times. Leaving the field empty restores the embedded copy.
|
||||||
|
|
||||||
|
The value is used verbatim as the widget base URL, so it must be the URL of the page that joins a
|
||||||
|
room, not the URL of the deployment root. For the public deployments that means:
|
||||||
|
|
||||||
|
| Deployment | Value to enter |
|
||||||
|
|---|---|
|
||||||
|
| `call.element.io` | `https://call.element.io/room` |
|
||||||
|
| `call.element.dev` | `https://call.element.dev/room` |
|
||||||
|
|
||||||
|
Entering `https://call.element.io` without the `/room` suffix loads the Element Call lobby rather
|
||||||
|
than the room, so the call is never joined.
|
||||||
|
|
||||||
|
## Why the application does not add the room path itself
|
||||||
|
|
||||||
|
The path that joins a room is a property of the deployment, not of Element Call. Unpacking the
|
||||||
|
Element Call release tarball behind a reverse proxy can put it at any path — `https://call.foo.tld`,
|
||||||
|
`https://call.foo.tld/some/path/somewhere` — and a proxy that already maps a subdomain onto
|
||||||
|
`https://1.2.3.4/room` would end up with the suffix twice. The application passes the value through
|
||||||
|
unchanged so that every one of those deployments can be reached.
|
||||||
|
|
||||||
|
## Where the value is read
|
||||||
|
|
||||||
|
`AppDeveloperSettingsView` renders the field and sends
|
||||||
|
`AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl`, which `DefaultAppPreferencesStore` persists.
|
||||||
|
`DefaultCallWidgetProvider.getWidget` reads it back and falls back to the embedded copy when it is
|
||||||
|
unset.
|
||||||
@@ -64,16 +64,16 @@ brew install bundletool
|
|||||||
### Steps
|
### Steps
|
||||||
|
|
||||||
1. Open the GitHub release that you want to install from https://github.com/element-hq/element-x-android/releases
|
1. Open the GitHub release that you want to install from https://github.com/element-hq/element-x-android/releases
|
||||||
2. Download the asset `app-release-signed.aab`
|
2. Download the asset `app-gplay-release-signed.aab`
|
||||||
3. Navigate to the folder where you cloned the project and run the following command:
|
3. Navigate to the folder where you cloned the project and run the following command:
|
||||||
```bash
|
```bash
|
||||||
bundletool build-apks --bundle=<PATH_TO_YOUR_APP-RELEASE-SIGNED.AAB_FILE> --output=./tmp/elementx.apks \
|
bundletool build-apks --bundle=<PATH_TO_YOUR_APP-GPLAY-RELEASE-SIGNED.AAB_FILE> --output=./tmp/elementx.apks \
|
||||||
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
|
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
|
||||||
--overwrite
|
--overwrite
|
||||||
```
|
```
|
||||||
For instance:
|
For instance:
|
||||||
```bash
|
```bash
|
||||||
bundletool build-apks --bundle=./tmp/Element/0.1.5/app-release-signed.aab --output=./tmp/elementx.apks \
|
bundletool build-apks --bundle=./tmp/Element/26.08.3/app-gplay-release-signed.aab --output=./tmp/elementx.apks \
|
||||||
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
|
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
|
||||||
--overwrite
|
--overwrite
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
Submodule enterprise updated: e633a343b1...53750903f2
@@ -0,0 +1,2 @@
|
|||||||
|
Main changes in this version: bug fixes and improvements.
|
||||||
|
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Main changes in this version: bug fixes and improvements.
|
||||||
|
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||||
+55
-49
@@ -53,8 +53,6 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.heading
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
@@ -80,7 +78,7 @@ import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGra
|
|||||||
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.preview.USER_NAME_ALICE
|
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
||||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
import io.element.android.libraries.designsystem.text.AdaptativeTitle
|
||||||
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
|
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
|
||||||
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
|
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
@@ -141,15 +139,21 @@ fun HomeTopBar(
|
|||||||
else -> stringResource(selectedNavigationItem.labelRes)
|
else -> stringResource(selectedNavigationItem.labelRes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes)
|
HomeNavigationBarItem.Spaces -> null
|
||||||
|
}
|
||||||
|
displayTitle?.let {
|
||||||
|
val style = when (spaceFiltersState) {
|
||||||
|
// Space name
|
||||||
|
is SpaceFiltersState.Selected -> ElementTheme.typography.fontHeadingSmMedium
|
||||||
|
// "Chats"
|
||||||
|
else -> ElementTheme.typography.fontHeadingLgBold
|
||||||
|
}
|
||||||
|
AdaptativeTitle(
|
||||||
|
title = displayTitle,
|
||||||
|
style = style,
|
||||||
|
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
modifier = Modifier.semantics {
|
|
||||||
heading()
|
|
||||||
},
|
|
||||||
style = ElementTheme.typography.aliasScreenTitle,
|
|
||||||
text = displayTitle,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
NavigationIcon(
|
NavigationIcon(
|
||||||
@@ -179,7 +183,9 @@ fun HomeTopBar(
|
|||||||
TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) {
|
TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) {
|
||||||
RoomListFiltersView(
|
RoomListFiltersView(
|
||||||
state = filtersState,
|
state = filtersState,
|
||||||
modifier = Modifier.padding(bottom = 16.dp).padding(contentPadding)
|
modifier = Modifier
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.padding(contentPadding)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,45 +354,45 @@ private fun AccountIcon(
|
|||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
val avatarData by remember(matrixUser) {
|
val avatarData by remember(matrixUser) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
|
}
|
||||||
val avatarModifier = if (statusEmoji != null) {
|
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
|
||||||
Modifier.eraseStatusEmojiBackground(
|
val avatarModifier = if (statusEmoji != null) {
|
||||||
parentSize = AvatarSize.CurrentUserTopBar.dp,
|
Modifier.eraseStatusEmojiBackground(
|
||||||
layoutDirection = LocalLayoutDirection.current,
|
parentSize = AvatarSize.CurrentUserTopBar.dp,
|
||||||
)
|
layoutDirection = LocalLayoutDirection.current,
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
Avatar(
|
|
||||||
avatarData = avatarData,
|
|
||||||
avatarType = AvatarType.User,
|
|
||||||
modifier = avatarModifier,
|
|
||||||
contentDescription = if (isCurrentAccount) {
|
|
||||||
if (showAvatarIndicator) {
|
|
||||||
stringResource(CommonStrings.a11y_settings_with_required_action)
|
|
||||||
} else {
|
|
||||||
stringResource(CommonStrings.common_settings)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if (statusEmoji != null) {
|
} else {
|
||||||
StatusEmojiBadge(
|
Modifier
|
||||||
emoji = statusEmoji,
|
}
|
||||||
modifier = Modifier.align(Alignment.BottomEnd),
|
Avatar(
|
||||||
)
|
avatarData = avatarData,
|
||||||
}
|
avatarType = AvatarType.User,
|
||||||
if (showAvatarIndicator) {
|
modifier = avatarModifier,
|
||||||
RedIndicatorAtom(
|
contentDescription = if (isCurrentAccount) {
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
if (showAvatarIndicator) {
|
||||||
)
|
stringResource(CommonStrings.a11y_settings_with_required_action)
|
||||||
}
|
} else {
|
||||||
|
stringResource(CommonStrings.common_settings)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (statusEmoji != null) {
|
||||||
|
StatusEmojiBadge(
|
||||||
|
emoji = statusEmoji,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAvatarIndicator) {
|
||||||
|
RedIndicatorAtom(
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -19,6 +19,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
|||||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
||||||
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
|
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
|
||||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||||
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||||
import io.element.android.libraries.matrix.ui.model.dmUserStatus
|
import io.element.android.libraries.matrix.ui.model.dmUserStatus
|
||||||
@@ -47,7 +48,7 @@ class RoomListRoomSummaryFactory(
|
|||||||
mode = DateFormatterMode.TimeOrDate,
|
mode = DateFormatterMode.TimeOrDate,
|
||||||
useRelative = true,
|
useRelative = true,
|
||||||
),
|
),
|
||||||
latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm),
|
latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.hasOnlyTwoMembers()),
|
||||||
avatarData = avatarData,
|
avatarData = avatarData,
|
||||||
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
|
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
|
||||||
hasRoomCall = roomInfo.hasRoomCall,
|
hasRoomCall = roomInfo.hasRoomCall,
|
||||||
@@ -81,6 +82,10 @@ class RoomListRoomSummaryFactory(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun RoomInfo.hasOnlyTwoMembers(): Boolean {
|
||||||
|
return isDm || activeMembersCount <= 2
|
||||||
|
}
|
||||||
|
|
||||||
private fun computeLatestEvent(latestEvent: LatestEventValue, dm: Boolean): LatestEvent {
|
private fun computeLatestEvent(latestEvent: LatestEventValue, dm: Boolean): LatestEvent {
|
||||||
return when (latestEvent) {
|
return when (latestEvent) {
|
||||||
is LatestEventValue.None -> {
|
is LatestEventValue.None -> {
|
||||||
|
|||||||
+1
-1
@@ -143,7 +143,7 @@ private fun EmptySpaceHomeView(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(CommonStrings.screen_space_list_empty_state_title),
|
text = stringResource(CommonStrings.screen_space_list_empty_state_title),
|
||||||
style = ElementTheme.typography.fontHeadingLgBold,
|
style = ElementTheme.typography.fontHeadingMdBold,
|
||||||
color = ElementTheme.colors.textPrimary,
|
color = ElementTheme.colors.textPrimary,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|||||||
+26
@@ -8,10 +8,36 @@
|
|||||||
|
|
||||||
package io.element.android.features.home.impl.datasource
|
package io.element.android.features.home.impl.datasource
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
||||||
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
|
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RoomListRoomSummaryFactoryTest {
|
||||||
|
@Test
|
||||||
|
fun `a room with two members does not prefix its preview with the sender`() {
|
||||||
|
val formatter = FakeRoomLatestEventFormatter()
|
||||||
|
val factory = aRoomListRoomSummaryFactory(roomLatestEventFormatter = formatter)
|
||||||
|
|
||||||
|
factory.create(aRoomSummary(info = aRoomInfo(isDm = false, activeMembersCount = 2)))
|
||||||
|
|
||||||
|
assertThat(formatter.lastIsDmRoom).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a room with more than two members prefixes its preview with the sender`() {
|
||||||
|
val formatter = FakeRoomLatestEventFormatter()
|
||||||
|
val factory = aRoomListRoomSummaryFactory(roomLatestEventFormatter = formatter)
|
||||||
|
|
||||||
|
factory.create(aRoomSummary(info = aRoomInfo(isDm = false, activeMembersCount = 3)))
|
||||||
|
|
||||||
|
assertThat(formatter.lastIsDmRoom).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun aRoomListRoomSummaryFactory(
|
fun aRoomListRoomSummaryFactory(
|
||||||
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
|
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
|
||||||
|
|||||||
+3
@@ -115,6 +115,9 @@ fun ConfirmAccountProviderView(
|
|||||||
|
|
||||||
HeaderFooterPage(
|
HeaderFooterPage(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
// Let the header and content scroll so the supporting text is not clipped by the footer when the
|
||||||
|
// keyboard is visible and vertical space is reduced.
|
||||||
|
isScrollable = true,
|
||||||
header = {
|
header = {
|
||||||
IconTitleSubtitleMolecule(
|
IconTitleSubtitleMolecule(
|
||||||
modifier = Modifier.padding(top = 60.dp),
|
modifier = Modifier.padding(top = 60.dp),
|
||||||
|
|||||||
+8
-2
@@ -51,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
|
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
|
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
|
||||||
import io.element.android.features.roomcall.api.RoomCallState
|
import io.element.android.features.roomcall.api.RoomCallState
|
||||||
@@ -377,7 +378,7 @@ class MessagesPresenter(
|
|||||||
TimelineItemAction.Edit,
|
TimelineItemAction.Edit,
|
||||||
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
|
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
|
||||||
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
|
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
|
||||||
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
|
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState, enableTextFormatting)
|
||||||
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
|
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
|
||||||
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
|
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
|
||||||
TimelineItemAction.ReplyInThread -> {
|
TimelineItemAction.ReplyInThread -> {
|
||||||
@@ -546,10 +547,15 @@ class MessagesPresenter(
|
|||||||
private suspend fun handleActionEditCaption(
|
private suspend fun handleActionEditCaption(
|
||||||
targetEvent: TimelineItem.Event,
|
targetEvent: TimelineItem.Event,
|
||||||
composerState: MessageComposerState,
|
composerState: MessageComposerState,
|
||||||
|
enableTextFormatting: Boolean,
|
||||||
) {
|
) {
|
||||||
val composerMode = MessageComposerMode.EditCaption(
|
val composerMode = MessageComposerMode.EditCaption(
|
||||||
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
||||||
content = targetEvent.content.captionOrNull().orEmpty(),
|
content = if (enableTextFormatting) {
|
||||||
|
targetEvent.content.htmlCaptionOrNull() ?: targetEvent.content.captionOrNull()
|
||||||
|
} else {
|
||||||
|
targetEvent.content.captionOrNull()
|
||||||
|
}.orEmpty(),
|
||||||
)
|
)
|
||||||
composerState.eventSink(
|
composerState.eventSink(
|
||||||
MessageComposerEvent.SetMode(composerMode)
|
MessageComposerEvent.SetMode(composerMode)
|
||||||
|
|||||||
+8
@@ -108,6 +108,7 @@ class TimelineItemContentMessageFactory(
|
|||||||
fileSize = messageType.info?.size ?: 0,
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = formattedCaption,
|
formattedCaption = formattedCaption,
|
||||||
|
htmlCaption = messageType.formattedCaption?.body,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
@@ -132,6 +133,7 @@ class TimelineItemContentMessageFactory(
|
|||||||
fileSize = messageType.info?.size ?: 0,
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = formattedCaption,
|
formattedCaption = formattedCaption,
|
||||||
|
htmlCaption = messageType.formattedCaption?.body,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
@@ -174,6 +176,7 @@ class TimelineItemContentMessageFactory(
|
|||||||
fileSize = messageType.info?.size ?: 0,
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = formattedCaption,
|
formattedCaption = formattedCaption,
|
||||||
|
htmlCaption = messageType.formattedCaption?.body,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
@@ -198,6 +201,7 @@ class TimelineItemContentMessageFactory(
|
|||||||
fileSize = messageType.info?.size ?: 0,
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = formattedCaption,
|
formattedCaption = formattedCaption,
|
||||||
|
htmlCaption = messageType.formattedCaption?.body,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||||
@@ -216,6 +220,7 @@ class TimelineItemContentMessageFactory(
|
|||||||
fileSize = messageType.info?.size ?: 0,
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = formattedCaption,
|
formattedCaption = formattedCaption,
|
||||||
|
htmlCaption = messageType.formattedCaption?.body,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||||
@@ -235,6 +240,7 @@ class TimelineItemContentMessageFactory(
|
|||||||
fileSize = messageType.info?.size ?: 0,
|
fileSize = messageType.info?.size ?: 0,
|
||||||
caption = messageType.caption?.trimEnd(),
|
caption = messageType.caption?.trimEnd(),
|
||||||
formattedCaption = formattedCaption,
|
formattedCaption = formattedCaption,
|
||||||
|
htmlCaption = messageType.formattedCaption?.body,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
thumbnailSource = messageType.info?.thumbnailSource,
|
thumbnailSource = messageType.info?.thumbnailSource,
|
||||||
mediaSource = messageType.source,
|
mediaSource = messageType.source,
|
||||||
@@ -345,6 +351,7 @@ class TimelineItemContentMessageFactory(
|
|||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
|
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
|
||||||
formattedCaption = formattedCaption,
|
formattedCaption = formattedCaption,
|
||||||
|
htmlCaption = messageType.formatted?.body,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
items = galleryItems.toImmutableList(),
|
items = galleryItems.toImmutableList(),
|
||||||
)
|
)
|
||||||
@@ -402,6 +409,7 @@ class TimelineItemContentMessageFactory(
|
|||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
|
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
|
||||||
formattedCaption = formattedCaption,
|
formattedCaption = formattedCaption,
|
||||||
|
htmlCaption = messageType.formatted?.body,
|
||||||
isEdited = content.isEdited,
|
isEdited = content.isEdited,
|
||||||
attachments = attachments.toImmutableList(),
|
attachments = attachments.toImmutableList(),
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -91,7 +91,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
|
|||||||
// Can be grouped
|
// Can be grouped
|
||||||
is FailedToParseMessageLikeContent,
|
is FailedToParseMessageLikeContent,
|
||||||
is MessageContent,
|
is MessageContent,
|
||||||
RedactedContent,
|
is RedactedContent,
|
||||||
is StickerContent,
|
is StickerContent,
|
||||||
is PollContent,
|
is PollContent,
|
||||||
is UnableToDecryptContent,
|
is UnableToDecryptContent,
|
||||||
|
|||||||
+4
-2
@@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting
|
|||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
@@ -99,7 +100,8 @@ internal const val MIN_REDACTED_RUN_SIZE = 3
|
|||||||
* Fold runs of [MIN_REDACTED_RUN_SIZE] or more consecutive deleted (redacted) messages into a single
|
* Fold runs of [MIN_REDACTED_RUN_SIZE] or more consecutive deleted (redacted) messages into a single
|
||||||
* [TimelineItem.GroupedEvents], so a long stretch of "Message removed" tiles shows as one expandable
|
* [TimelineItem.GroupedEvents], so a long stretch of "Message removed" tiles shows as one expandable
|
||||||
* "N removed messages" line, like element-web. Shorter runs, and anything that is not a redacted event
|
* "N removed messages" line, like element-web. Shorter runs, and anything that is not a redacted event
|
||||||
* (day dividers included), are left untouched.
|
* (day dividers included), are left untouched. A deleted message that still heads a thread is left
|
||||||
|
* untouched too, so that its thread summary stays reachable.
|
||||||
*
|
*
|
||||||
* This runs as the final step of [TimelineItemGrouper.group], after the state and membership events
|
* This runs as the final step of [TimelineItemGrouper.group], after the state and membership events
|
||||||
* have been grouped. It is deliberately kept out of the normal grouping: a run needs a higher minimum
|
* have been grouped. It is deliberately kept out of the normal grouping: a run needs a higher minimum
|
||||||
@@ -134,7 +136,7 @@ internal fun List<TimelineItem>.collapseRedactedRuns(groupIds: MutableMap<String
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (item in this) {
|
for (item in this) {
|
||||||
if (item is TimelineItem.Event && item.content is TimelineItemRedactedContent) {
|
if (item is TimelineItem.Event && item.content is TimelineItemRedactedContent && item.threadInfo !is TimelineItemThreadInfo.ThreadRoot) {
|
||||||
run.add(item)
|
run.add(item)
|
||||||
} else {
|
} else {
|
||||||
flushRun()
|
flushRun()
|
||||||
|
|||||||
+1
@@ -14,6 +14,7 @@ data class TimelineItemAttachmentsContent(
|
|||||||
val body: String,
|
val body: String,
|
||||||
val caption: String?,
|
val caption: String?,
|
||||||
val formattedCaption: CharSequence?,
|
val formattedCaption: CharSequence?,
|
||||||
|
val htmlCaption: String? = null,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
val attachments: ImmutableList<AttachmentItem>,
|
val attachments: ImmutableList<AttachmentItem>,
|
||||||
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
||||||
|
|||||||
+1
@@ -17,6 +17,7 @@ data class TimelineItemAudioContent(
|
|||||||
override val fileSize: Long?,
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
|
override val htmlCaption: String? = null,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
override val mediaSource: MediaSource,
|
override val mediaSource: MediaSource,
|
||||||
|
|||||||
+10
@@ -33,6 +33,9 @@ sealed interface TimelineItemEventContentWithAttachment :
|
|||||||
val fileSize: Long?
|
val fileSize: Long?
|
||||||
val caption: String?
|
val caption: String?
|
||||||
val formattedCaption: CharSequence?
|
val formattedCaption: CharSequence?
|
||||||
|
|
||||||
|
/** The caption as it was written, in HTML, so that editing it can start from the formatted text. */
|
||||||
|
val htmlCaption: String?
|
||||||
val mediaSource: MediaSource
|
val mediaSource: MediaSource
|
||||||
val mimeType: String
|
val mimeType: String
|
||||||
val formattedFileSize: String
|
val formattedFileSize: String
|
||||||
@@ -127,6 +130,13 @@ fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (thi
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun TimelineItemEventContent.htmlCaptionOrNull(): String? = when (this) {
|
||||||
|
is TimelineItemEventContentWithAttachment -> htmlCaption
|
||||||
|
is TimelineItemGalleryContent -> htmlCaption
|
||||||
|
is TimelineItemAttachmentsContent -> htmlCaption
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is TimelineItemAudioContent -> duration
|
is TimelineItemAudioContent -> duration
|
||||||
|
|||||||
+1
@@ -16,6 +16,7 @@ data class TimelineItemFileContent(
|
|||||||
override val fileSize: Long?,
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
|
override val htmlCaption: String? = null,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
override val mediaSource: MediaSource,
|
override val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
|
|||||||
+1
@@ -16,6 +16,7 @@ data class TimelineItemGalleryContent(
|
|||||||
val body: String,
|
val body: String,
|
||||||
val caption: String?,
|
val caption: String?,
|
||||||
val formattedCaption: CharSequence?,
|
val formattedCaption: CharSequence?,
|
||||||
|
val htmlCaption: String? = null,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
val items: ImmutableList<GalleryItem>,
|
val items: ImmutableList<GalleryItem>,
|
||||||
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
||||||
|
|||||||
+1
@@ -19,6 +19,7 @@ data class TimelineItemImageContent(
|
|||||||
override val fileSize: Long?,
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
|
override val htmlCaption: String? = null,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
override val mediaSource: MediaSource,
|
override val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
|
|||||||
+2
@@ -28,6 +28,7 @@ fun aTimelineItemImageContent(
|
|||||||
blurhash: String? = A_BLUR_HASH,
|
blurhash: String? = A_BLUR_HASH,
|
||||||
filename: String = "A picture.jpg",
|
filename: String = "A picture.jpg",
|
||||||
caption: String? = null,
|
caption: String? = null,
|
||||||
|
htmlCaption: String? = null,
|
||||||
width: Int? = null,
|
width: Int? = null,
|
||||||
height: Int? = 300,
|
height: Int? = 300,
|
||||||
) = TimelineItemImageContent(
|
) = TimelineItemImageContent(
|
||||||
@@ -35,6 +36,7 @@ fun aTimelineItemImageContent(
|
|||||||
fileSize = 4 * 1024 * 1024L,
|
fileSize = 4 * 1024 * 1024L,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
formattedCaption = null,
|
formattedCaption = null,
|
||||||
|
htmlCaption = htmlCaption,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|||||||
+1
@@ -15,6 +15,7 @@ data class TimelineItemStickerContent(
|
|||||||
override val fileSize: Long?,
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
|
override val htmlCaption: String? = null,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
override val mediaSource: MediaSource,
|
override val mediaSource: MediaSource,
|
||||||
val thumbnailSource: MediaSource?,
|
val thumbnailSource: MediaSource?,
|
||||||
|
|||||||
+1
@@ -16,6 +16,7 @@ data class TimelineItemVideoContent(
|
|||||||
override val fileSize: Long?,
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
|
override val htmlCaption: String? = null,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
override val mediaSource: MediaSource,
|
override val mediaSource: MediaSource,
|
||||||
|
|||||||
+1
@@ -19,6 +19,7 @@ data class TimelineItemVoiceContent(
|
|||||||
override val fileSize: Long?,
|
override val fileSize: Long?,
|
||||||
override val caption: String?,
|
override val caption: String?,
|
||||||
override val formattedCaption: CharSequence?,
|
override val formattedCaption: CharSequence?,
|
||||||
|
override val htmlCaption: String? = null,
|
||||||
override val isEdited: Boolean,
|
override val isEdited: Boolean,
|
||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
override val mediaSource: MediaSource,
|
override val mediaSource: MediaSource,
|
||||||
|
|||||||
+27
@@ -989,6 +989,33 @@ class MessagesPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action edit caption starts from the formatted caption`() = runTest {
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
content = aTimelineItemImageContent(
|
||||||
|
caption = "Hello world",
|
||||||
|
htmlCaption = "<b>Hello</b> world",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val composerRecorder = EventsRecorder<MessageComposerEvent>()
|
||||||
|
val presenter = createMessagesPresenter(
|
||||||
|
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder, showTextFormatting = true) },
|
||||||
|
)
|
||||||
|
presenter.testWithLifecycleOwner {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.EditCaption, messageEvent))
|
||||||
|
awaitItem()
|
||||||
|
composerRecorder.assertSingle(
|
||||||
|
MessageComposerEvent.SetMode(
|
||||||
|
composerMode = MessageComposerMode.EditCaption(
|
||||||
|
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
||||||
|
content = "<b>Hello</b> world",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - handle action add caption`() = runTest {
|
fun `present - handle action add caption`() = runTest {
|
||||||
val composerRecorder = EventsRecorder<MessageComposerEvent>()
|
val composerRecorder = EventsRecorder<MessageComposerEvent>()
|
||||||
|
|||||||
+2
-2
@@ -51,7 +51,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
@@ -69,6 +68,7 @@ import io.element.android.libraries.matrix.test.room.aRoomMember
|
|||||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
@@ -880,7 +880,7 @@ class TimelinePresenterTest {
|
|||||||
(0 until 3).map { index ->
|
(0 until 3).map { index ->
|
||||||
MatrixTimelineItem.Event(
|
MatrixTimelineItem.Event(
|
||||||
uniqueId = UniqueId("redacted_$index"),
|
uniqueId = UniqueId("redacted_$index"),
|
||||||
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent),
|
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = aRedactedContent()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
+79
@@ -12,15 +12,21 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
|
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -87,6 +93,42 @@ class TimelineItemsFactoryTest {
|
|||||||
).inOrder()
|
).inOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `redacted message keeps its thread info`() = runTest {
|
||||||
|
val factory = aTimelineItemsFactory(
|
||||||
|
config = TimelineItemsFactoryConfig(
|
||||||
|
computeReadReceipts = false,
|
||||||
|
computeReactions = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val items = listOf(
|
||||||
|
MatrixTimelineItem.Event(
|
||||||
|
uniqueId = UniqueId("event-0"),
|
||||||
|
event = anEventTimelineItem(
|
||||||
|
sender = A_USER_ID,
|
||||||
|
timestamp = 0L,
|
||||||
|
content = aRedactedContent(
|
||||||
|
threadInfo = EventThreadInfo.ThreadResponse(A_THREAD_ID),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var threadInfo: TimelineItemThreadInfo? = null
|
||||||
|
factory.timelineItems.test {
|
||||||
|
factory.replaceWith(
|
||||||
|
timelineItems = items,
|
||||||
|
roomMembers = emptyList(),
|
||||||
|
renderReadReceipts = false,
|
||||||
|
)
|
||||||
|
threadInfo = awaitItem()
|
||||||
|
.filterIsInstance<TimelineItem.Event>()
|
||||||
|
.firstOrNull()
|
||||||
|
?.threadInfo
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
assertThat(threadInfo).isEqualTo(TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a key verification request is not rendered`() = runTest {
|
fun `a key verification request is not rendered`() = runTest {
|
||||||
val factory = aTimelineItemsFactory(
|
val factory = aTimelineItemsFactory(
|
||||||
@@ -131,6 +173,43 @@ class TimelineItemsFactoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a deleted message keeps the summary of the thread it heads`() = runTest {
|
||||||
|
val factory = aTimelineItemsFactory(
|
||||||
|
config = TimelineItemsFactoryConfig(
|
||||||
|
computeReadReceipts = false,
|
||||||
|
computeReactions = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val items = listOf(
|
||||||
|
MatrixTimelineItem.Event(
|
||||||
|
uniqueId = UniqueId("event-0"),
|
||||||
|
event = anEventTimelineItem(
|
||||||
|
sender = A_USER_ID,
|
||||||
|
content = aRedactedContent(
|
||||||
|
threadInfo = EventThreadInfo.ThreadRoot(
|
||||||
|
summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
factory.timelineItems.test {
|
||||||
|
factory.replaceWith(
|
||||||
|
timelineItems = items,
|
||||||
|
roomMembers = emptyList(),
|
||||||
|
renderReadReceipts = false,
|
||||||
|
)
|
||||||
|
val threadInfo = awaitItem()
|
||||||
|
.filterIsInstance<TimelineItem.Event>()
|
||||||
|
.single()
|
||||||
|
.threadInfo
|
||||||
|
assertThat(threadInfo).isInstanceOf(TimelineItemThreadInfo.ThreadRoot::class.java)
|
||||||
|
assertThat((threadInfo as TimelineItemThreadInfo.ThreadRoot).summary.numberOfReplies).isEqualTo(3)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a sender the timeline knows nothing about is named from the room member list`() = runTest {
|
fun `a sender the timeline knows nothing about is named from the room member list`() = runTest {
|
||||||
val factory = aTimelineItemsFactory(
|
val factory = aTimelineItemsFactory(
|
||||||
|
|||||||
+2
@@ -297,6 +297,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
|
|||||||
fileSize = 555L,
|
fileSize = 555L,
|
||||||
caption = "body.mp4 caption",
|
caption = "body.mp4 caption",
|
||||||
formattedCaption = SpannedString("formatted"),
|
formattedCaption = SpannedString("formatted"),
|
||||||
|
htmlCaption = "formatted",
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
duration = 1.minutes,
|
duration = 1.minutes,
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
@@ -539,6 +540,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
|
|||||||
fileSize = 888L,
|
fileSize = 888L,
|
||||||
caption = "body.jpg caption",
|
caption = "body.jpg caption",
|
||||||
formattedCaption = SpannedString("formatted"),
|
formattedCaption = SpannedString("formatted"),
|
||||||
|
htmlCaption = "formatted",
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
mediaSource = MediaSource(url = "url", json = null),
|
mediaSource = MediaSource(url = "url", json = null),
|
||||||
thumbnailSource = MediaSource("url_thumbnail"),
|
thumbnailSource = MediaSource("url_thumbnail"),
|
||||||
|
|||||||
+19
@@ -15,11 +15,14 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
|||||||
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
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_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
@@ -190,6 +193,22 @@ class TimelineItemGrouperTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a deleted message that still heads a thread is left out of the removed messages group`() {
|
||||||
|
val threadRoot = aGroupableItem.copy(
|
||||||
|
id = UniqueId("r1"),
|
||||||
|
content = TimelineItemRedactedContent,
|
||||||
|
threadInfo = TimelineItemThreadInfo.ThreadRoot(
|
||||||
|
summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 2),
|
||||||
|
latestEventText = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent)
|
||||||
|
val r2 = aGroupableItem.copy(id = UniqueId("r2"), content = TimelineItemRedactedContent)
|
||||||
|
val result = sut.group(listOf(r0, threadRoot, r2))
|
||||||
|
assertThat(result).isEqualTo(listOf(r0, threadRoot, r2))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a run of fewer than three redacted events is left untouched`() {
|
fun `a run of fewer than three redacted events is left untouched`() {
|
||||||
val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent)
|
val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent)
|
||||||
|
|||||||
+2
-2
@@ -16,11 +16,11 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
|
||||||
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.AN_EVENT_ID_2
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.core.FakeSendHandle
|
import io.element.android.libraries.matrix.test.core.FakeSendHandle
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
|
||||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
@@ -88,7 +88,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
|
|||||||
sender = A_USER_ID,
|
sender = A_USER_ID,
|
||||||
senderProfile = ProfileDetails.Unavailable,
|
senderProfile = ProfileDetails.Unavailable,
|
||||||
timestamp = 9442,
|
timestamp = 9442,
|
||||||
content = RedactedContent,
|
content = aRedactedContent(),
|
||||||
origin = null,
|
origin = null,
|
||||||
timelineItemDebugInfoProvider = {
|
timelineItemDebugInfoProvider = {
|
||||||
TimelineItemDebugInfo(
|
TimelineItemDebugInfo(
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
|
implementation(projects.libraries.mediaviewer.api)
|
||||||
implementation(projects.libraries.mediapickers.api)
|
implementation(projects.libraries.mediapickers.api)
|
||||||
implementation(projects.libraries.mediaupload.api)
|
implementation(projects.libraries.mediaupload.api)
|
||||||
implementation(projects.libraries.permissions.api)
|
implementation(projects.libraries.permissions.api)
|
||||||
@@ -91,6 +92,7 @@ dependencies {
|
|||||||
implementation(libs.color.picker)
|
implementation(libs.color.picker)
|
||||||
implementation(libs.androidx.browser)
|
implementation(libs.androidx.browser)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.serialization.json)
|
||||||
api(projects.features.preferences.api)
|
api(projects.features.preferences.api)
|
||||||
implementation(libs.showkase)
|
implementation(libs.showkase)
|
||||||
|
|
||||||
@@ -108,6 +110,7 @@ dependencies {
|
|||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.libraries.pushstore.test)
|
testImplementation(projects.libraries.pushstore.test)
|
||||||
testImplementation(projects.libraries.roomselect.test)
|
testImplementation(projects.libraries.roomselect.test)
|
||||||
|
testImplementation(projects.libraries.mediaviewer.test)
|
||||||
testImplementation(projects.libraries.troubleshoot.test)
|
testImplementation(projects.libraries.troubleshoot.test)
|
||||||
testImplementation(projects.features.deactivation.test)
|
testImplementation(projects.features.deactivation.test)
|
||||||
testImplementation(projects.features.enterprise.test)
|
testImplementation(projects.features.enterprise.test)
|
||||||
|
|||||||
+30
@@ -40,10 +40,12 @@ import io.element.android.libraries.architecture.BaseFlowNode
|
|||||||
import io.element.android.libraries.architecture.appyx.canPop
|
import io.element.android.libraries.architecture.appyx.canPop
|
||||||
import io.element.android.libraries.architecture.callback
|
import io.element.android.libraries.architecture.callback
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
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.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
|
||||||
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
|
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
|
||||||
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
@@ -56,6 +58,7 @@ class PreferencesFlowNode(
|
|||||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||||
private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint,
|
private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint,
|
||||||
private val pushHistoryEntryPoint: PushHistoryEntryPoint,
|
private val pushHistoryEntryPoint: PushHistoryEntryPoint,
|
||||||
|
private val fileViewerEntryPoint: FileViewerEntryPoint,
|
||||||
private val logoutEntryPoint: LogoutEntryPoint,
|
private val logoutEntryPoint: LogoutEntryPoint,
|
||||||
private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint,
|
private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint,
|
||||||
private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint,
|
private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint,
|
||||||
@@ -95,6 +98,9 @@ class PreferencesFlowNode(
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
data object PushHistory : NavTarget
|
data object PushHistory : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class PushRules(val filename: String, val content: String) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object LockScreenSettings : NavTarget
|
data object LockScreenSettings : NavTarget
|
||||||
|
|
||||||
@@ -187,6 +193,10 @@ class PreferencesFlowNode(
|
|||||||
}
|
}
|
||||||
NavTarget.DeveloperSettings -> {
|
NavTarget.DeveloperSettings -> {
|
||||||
val developerSettingsCallback = object : DeveloperSettingsNode.Callback {
|
val developerSettingsCallback = object : DeveloperSettingsNode.Callback {
|
||||||
|
override fun navigateToPushRules(filename: String, content: String) {
|
||||||
|
backstack.push(NavTarget.PushRules(filename = filename, content = content))
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToPushHistory() {
|
override fun navigateToPushHistory() {
|
||||||
backstack.push(NavTarget.PushHistory)
|
backstack.push(NavTarget.PushHistory)
|
||||||
}
|
}
|
||||||
@@ -270,6 +280,26 @@ class PreferencesFlowNode(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
is NavTarget.PushRules -> {
|
||||||
|
fileViewerEntryPoint.createNode(
|
||||||
|
parentNode = this,
|
||||||
|
buildContext = buildContext,
|
||||||
|
params = FileViewerEntryPoint.Params(
|
||||||
|
filename = navTarget.filename,
|
||||||
|
mimeType = MimeTypes.Json,
|
||||||
|
content = navTarget.content,
|
||||||
|
),
|
||||||
|
callback = object : FileViewerEntryPoint.Callback {
|
||||||
|
override fun onDone() {
|
||||||
|
if (backstack.canPop()) {
|
||||||
|
backstack.pop()
|
||||||
|
} else {
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
is NavTarget.EditDefaultNotificationSetting -> {
|
is NavTarget.EditDefaultNotificationSetting -> {
|
||||||
val callback = object : EditDefaultNotificationSettingNode.Callback {
|
val callback = object : EditDefaultNotificationSettingNode.Callback {
|
||||||
override fun navigateToRoomNotificationSettings(roomId: RoomId) {
|
override fun navigateToRoomNotificationSettings(roomId: RoomId) {
|
||||||
|
|||||||
+2
@@ -17,4 +17,6 @@ sealed interface DeveloperSettingsEvent {
|
|||||||
data object VacuumStores : DeveloperSettingsEvent
|
data object VacuumStores : DeveloperSettingsEvent
|
||||||
data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : DeveloperSettingsEvent
|
data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : DeveloperSettingsEvent
|
||||||
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent
|
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent
|
||||||
|
data object OpenPushRules : DeveloperSettingsEvent
|
||||||
|
data object DismissPushRulesError : DeveloperSettingsEvent
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.preferences.impl.developer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation performed by the developer settings screen, when data has to be loaded first.
|
||||||
|
*/
|
||||||
|
fun interface DeveloperSettingsNavigator {
|
||||||
|
fun openPushRules(filename: String, content: String)
|
||||||
|
}
|
||||||
+9
-2
@@ -28,15 +28,22 @@ import io.element.android.libraries.di.SessionScope
|
|||||||
class DeveloperSettingsNode(
|
class DeveloperSettingsNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: DeveloperSettingsPresenter,
|
presenterFactory: DeveloperSettingsPresenter.Factory,
|
||||||
private val extraDeveloperOptionsRenderer: ExtraDeveloperOptionsRenderer,
|
private val extraDeveloperOptionsRenderer: ExtraDeveloperOptionsRenderer,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins),
|
||||||
|
DeveloperSettingsNavigator {
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
|
fun navigateToPushRules(filename: String, content: String)
|
||||||
fun navigateToPushHistory()
|
fun navigateToPushHistory()
|
||||||
fun onDone()
|
fun onDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
|
private val presenter = presenterFactory.create(navigator = this)
|
||||||
|
|
||||||
|
override fun openPushRules(filename: String, content: String) {
|
||||||
|
callback.navigateToPushRules(filename = filename, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
|||||||
+60
-2
@@ -17,7 +17,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.features.enterprise.api.EnterpriseService
|
import io.element.android.features.enterprise.api.EnterpriseService
|
||||||
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
|
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
|
||||||
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
|
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
|
||||||
@@ -30,17 +32,23 @@ import io.element.android.libraries.architecture.AsyncData
|
|||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
import io.element.android.libraries.core.data.ByteUnit
|
import io.element.android.libraries.core.data.ByteUnit
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
|
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
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.notificationsettings.NotificationSettingsService
|
||||||
import kotlinx.collections.immutable.ImmutableMap
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
@Inject
|
@AssistedInject
|
||||||
class DeveloperSettingsPresenter(
|
class DeveloperSettingsPresenter(
|
||||||
|
@Assisted private val navigator: DeveloperSettingsNavigator,
|
||||||
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
|
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
|
||||||
private val sessionId: SessionId,
|
private val sessionId: SessionId,
|
||||||
private val deviceId: DeviceId,
|
private val deviceId: DeviceId,
|
||||||
@@ -52,7 +60,13 @@ class DeveloperSettingsPresenter(
|
|||||||
private val fileSizeFormatter: FileSizeFormatter,
|
private val fileSizeFormatter: FileSizeFormatter,
|
||||||
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
|
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val notificationSettingsService: NotificationSettingsService,
|
||||||
) : Presenter<DeveloperSettingsState> {
|
) : Presenter<DeveloperSettingsState> {
|
||||||
|
@AssistedFactory
|
||||||
|
fun interface Factory {
|
||||||
|
fun create(navigator: DeveloperSettingsNavigator): DeveloperSettingsPresenter
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): DeveloperSettingsState {
|
override fun present(): DeveloperSettingsState {
|
||||||
val cacheSize = remember {
|
val cacheSize = remember {
|
||||||
@@ -67,6 +81,9 @@ class DeveloperSettingsPresenter(
|
|||||||
val markAllRoomsAsReadAction = remember {
|
val markAllRoomsAsReadAction = remember {
|
||||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||||
}
|
}
|
||||||
|
val pushRulesAction = remember {
|
||||||
|
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||||
|
}
|
||||||
var showColorPicker by remember {
|
var showColorPicker by remember {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
@@ -109,6 +126,10 @@ class DeveloperSettingsPresenter(
|
|||||||
DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation -> {
|
DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation -> {
|
||||||
markAllRoomsAsReadAction.value = AsyncAction.Uninitialized
|
markAllRoomsAsReadAction.value = AsyncAction.Uninitialized
|
||||||
}
|
}
|
||||||
|
DeveloperSettingsEvent.OpenPushRules -> coroutineScope.openPushRules(pushRulesAction)
|
||||||
|
DeveloperSettingsEvent.DismissPushRulesError -> {
|
||||||
|
pushRulesAction.value = AsyncAction.Uninitialized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +140,7 @@ class DeveloperSettingsPresenter(
|
|||||||
databaseSizes = databaseSizes.value,
|
databaseSizes = databaseSizes.value,
|
||||||
clearCacheAction = clearCacheAction.value,
|
clearCacheAction = clearCacheAction.value,
|
||||||
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
|
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
|
||||||
|
pushRulesAction = pushRulesAction.value,
|
||||||
isEnterpriseBuild = buildMeta.isEnterpriseBuild,
|
isEnterpriseBuild = buildMeta.isEnterpriseBuild,
|
||||||
showColorPicker = showColorPicker,
|
showColorPicker = showColorPicker,
|
||||||
deviceId = deviceId,
|
deviceId = deviceId,
|
||||||
@@ -164,4 +186,40 @@ class DeveloperSettingsPresenter(
|
|||||||
markAllRoomsAsRead().getOrThrow()
|
markAllRoomsAsRead().getOrThrow()
|
||||||
}.runCatchingUpdatingState(state = markAllRoomsAsReadAction)
|
}.runCatchingUpdatingState(state = markAllRoomsAsReadAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.openPushRules(pushRulesAction: MutableState<AsyncAction<Unit>>) = launch {
|
||||||
|
pushRulesAction.value = AsyncAction.Loading
|
||||||
|
notificationSettingsService.getRawPushRules()
|
||||||
|
.onSuccess { content ->
|
||||||
|
pushRulesAction.value = AsyncAction.Uninitialized
|
||||||
|
navigator.openPushRules(
|
||||||
|
filename = pushRulesFilename(),
|
||||||
|
content = content.orEmpty().prettyPrintJson(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
pushRulesAction.value = AsyncAction.Failure(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user id contains a colon, which is not a valid character for a file name on all the file systems,
|
||||||
|
* so replace it by an underscore.
|
||||||
|
*/
|
||||||
|
private fun pushRulesFilename() = "push_rules${sessionId.value.replace(':', '_')}.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
private val prettyPrintJson = Json {
|
||||||
|
prettyPrint = true
|
||||||
|
// Keep the indentation small, for a better rendering on mobile.
|
||||||
|
prettyPrintIndent = " "
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pretty print this json content, so that it is readable when rendered.
|
||||||
|
* Return the content as is if it is not valid json.
|
||||||
|
*/
|
||||||
|
private fun String.prettyPrintJson(): String = runCatchingExceptions {
|
||||||
|
prettyPrintJson.encodeToString(JsonElement.serializer(), prettyPrintJson.parseToJsonElement(this))
|
||||||
|
}.getOrDefault(this)
|
||||||
|
|||||||
+4
-1
@@ -20,10 +20,13 @@ data class DeveloperSettingsState(
|
|||||||
val databaseSizes: AsyncData<ImmutableMap<String, String>>,
|
val databaseSizes: AsyncData<ImmutableMap<String, String>>,
|
||||||
val clearCacheAction: AsyncAction<Unit>,
|
val clearCacheAction: AsyncAction<Unit>,
|
||||||
val markAllRoomsAsReadAction: AsyncAction<Unit>,
|
val markAllRoomsAsReadAction: AsyncAction<Unit>,
|
||||||
|
val pushRulesAction: AsyncAction<Unit>,
|
||||||
val isEnterpriseBuild: Boolean,
|
val isEnterpriseBuild: Boolean,
|
||||||
val showColorPicker: Boolean,
|
val showColorPicker: Boolean,
|
||||||
val deviceId: DeviceId,
|
val deviceId: DeviceId,
|
||||||
val eventSink: (DeveloperSettingsEvent) -> Unit
|
val eventSink: (DeveloperSettingsEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading
|
val showLoader = clearCacheAction is AsyncAction.Loading ||
|
||||||
|
markAllRoomsAsReadAction is AsyncAction.Loading ||
|
||||||
|
pushRulesAction is AsyncAction.Loading
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -32,6 +32,9 @@ open class DeveloperSettingsStatePreviewParam : PreviewParameterProvider<Develop
|
|||||||
// at io.mhssn.colorpicker.pickers.ClassicColorPickerKt$ClassicColorPicker$1$1.invokeSuspend(ClassicColorPicker.kt:53)
|
// at io.mhssn.colorpicker.pickers.ClassicColorPickerKt$ClassicColorPicker$1$1.invokeSuspend(ClassicColorPicker.kt:53)
|
||||||
showColorPicker = false,
|
showColorPicker = false,
|
||||||
),
|
),
|
||||||
|
aDeveloperSettingsState(
|
||||||
|
pushRulesAction = AsyncAction.Failure(Exception("A failure"))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ fun aDeveloperSettingsState(
|
|||||||
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
|
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
|
||||||
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
|
pushRulesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
isEnterpriseBuild: Boolean = false,
|
isEnterpriseBuild: Boolean = false,
|
||||||
showColorPicker: Boolean = false,
|
showColorPicker: Boolean = false,
|
||||||
deviceId: DeviceId = DeviceId("ILAKNDNASDLK"),
|
deviceId: DeviceId = DeviceId("ILAKNDNASDLK"),
|
||||||
@@ -49,6 +53,7 @@ fun aDeveloperSettingsState(
|
|||||||
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
|
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
|
||||||
clearCacheAction = clearCacheAction,
|
clearCacheAction = clearCacheAction,
|
||||||
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
|
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
|
||||||
|
pushRulesAction = pushRulesAction,
|
||||||
isEnterpriseBuild = isEnterpriseBuild,
|
isEnterpriseBuild = isEnterpriseBuild,
|
||||||
showColorPicker = showColorPicker,
|
showColorPicker = showColorPicker,
|
||||||
deviceId = deviceId,
|
deviceId = deviceId,
|
||||||
|
|||||||
+22
-2
@@ -23,8 +23,10 @@ import io.element.android.compound.theme.ElementTheme
|
|||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
|
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
|
||||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||||
@@ -60,6 +62,12 @@ fun DeveloperSettingsView(
|
|||||||
onDismiss = { state.eventSink(DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation) },
|
onDismiss = { state.eventSink(DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
(state.pushRulesAction as? AsyncAction.Failure)?.let { failure ->
|
||||||
|
ErrorDialog(
|
||||||
|
content = failure.error.message ?: stringResource(CommonStrings.error_unknown),
|
||||||
|
onSubmit = { state.eventSink(DeveloperSettingsEvent.DismissPushRulesError) },
|
||||||
|
)
|
||||||
|
}
|
||||||
BackHandler(
|
BackHandler(
|
||||||
enabled = !state.showLoader,
|
enabled = !state.showLoader,
|
||||||
onBack = onBackClick,
|
onBack = onBackClick,
|
||||||
@@ -79,7 +87,10 @@ fun DeveloperSettingsView(
|
|||||||
onOpenShowkase = onOpenShowkase,
|
onOpenShowkase = onOpenShowkase,
|
||||||
)
|
)
|
||||||
SessionCategory(deviceId = state.deviceId)
|
SessionCategory(deviceId = state.deviceId)
|
||||||
NotificationCategory(onPushHistoryClick)
|
NotificationCategory(
|
||||||
|
onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) },
|
||||||
|
onPushHistoryClick = onPushHistoryClick,
|
||||||
|
)
|
||||||
MarkAllRoomsAsReadCategory(state)
|
MarkAllRoomsAsReadCategory(state)
|
||||||
|
|
||||||
if (state.isEnterpriseBuild) {
|
if (state.isEnterpriseBuild) {
|
||||||
@@ -217,8 +228,17 @@ private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
|
private fun NotificationCategory(
|
||||||
|
onPushRulesClick: () -> Unit,
|
||||||
|
onPushHistoryClick: () -> Unit,
|
||||||
|
) {
|
||||||
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) {
|
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) {
|
||||||
|
ListItem(
|
||||||
|
content = {
|
||||||
|
Text("Push rules")
|
||||||
|
},
|
||||||
|
onClick = onPushRulesClick,
|
||||||
|
)
|
||||||
ListItem(
|
ListItem(
|
||||||
content = {
|
content = {
|
||||||
Text(stringResource(R.string.troubleshoot_notifications_entry_point_push_history_title))
|
Text(stringResource(R.string.troubleshoot_notifications_entry_point_push_history_title))
|
||||||
|
|||||||
+2
@@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint
|
|||||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||||
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.mediaviewer.test.FakeFileViewerEntryPoint
|
||||||
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
|
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
|
||||||
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
|
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
@@ -43,6 +44,7 @@ class DefaultPreferencesEntryPointTest {
|
|||||||
lockScreenEntryPoint = FakeLockScreenEntryPoint(),
|
lockScreenEntryPoint = FakeLockScreenEntryPoint(),
|
||||||
notificationTroubleShootEntryPoint = FakeNotificationTroubleShootEntryPoint(),
|
notificationTroubleShootEntryPoint = FakeNotificationTroubleShootEntryPoint(),
|
||||||
pushHistoryEntryPoint = FakePushHistoryEntryPoint(),
|
pushHistoryEntryPoint = FakePushHistoryEntryPoint(),
|
||||||
|
fileViewerEntryPoint = FakeFileViewerEntryPoint(),
|
||||||
logoutEntryPoint = FakeLogoutEntryPoint(),
|
logoutEntryPoint = FakeLogoutEntryPoint(),
|
||||||
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
|
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
|
||||||
accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(),
|
accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(),
|
||||||
|
|||||||
+84
@@ -28,10 +28,13 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
|
|||||||
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
|
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
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.notificationsettings.NotificationSettingsService
|
||||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
@@ -41,6 +44,19 @@ import kotlinx.coroutines.test.runTest
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
private const val A_PUSH_RULES_CONTENT = """{"global":{"override":[{"rule_id":".m.rule.master","enabled":false}]}}"""
|
||||||
|
private const val A_PUSH_RULES_CONTENT_PRETTY_PRINTED = """{
|
||||||
|
"global": {
|
||||||
|
"override": [
|
||||||
|
{
|
||||||
|
"rule_id": ".m.rule.master",
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
private val AN_EXCEPTION = Exception("A failure")
|
||||||
|
|
||||||
class DeveloperSettingsPresenterTest {
|
class DeveloperSettingsPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
@@ -170,7 +186,72 @@ class DeveloperSettingsPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - OpenPushRules event navigates to the push rules content`() = runTest {
|
||||||
|
val openPushRulesLambda = lambdaRecorder<String, String, Unit> { _, _ -> }
|
||||||
|
val presenter = createDeveloperSettingsPresenter(
|
||||||
|
navigator = DeveloperSettingsNavigator(openPushRulesLambda),
|
||||||
|
notificationSettingsService = FakeNotificationSettingsService(
|
||||||
|
getRawPushRulesResult = { Result.success(A_PUSH_RULES_CONTENT) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val state = awaitItem()
|
||||||
|
assertThat(state.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
|
state.eventSink(DeveloperSettingsEvent.OpenPushRules)
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
|
}
|
||||||
|
openPushRulesLambda.assertions().isCalledOnce().with(
|
||||||
|
value("push_rules@alice_server.org.json"),
|
||||||
|
value(A_PUSH_RULES_CONTENT_PRETTY_PRINTED),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - OpenPushRules event keeps the content as is when it is not valid json`() = runTest {
|
||||||
|
val openPushRulesLambda = lambdaRecorder<String, String, Unit> { _, _ -> }
|
||||||
|
val presenter = createDeveloperSettingsPresenter(
|
||||||
|
navigator = DeveloperSettingsNavigator(openPushRulesLambda),
|
||||||
|
notificationSettingsService = FakeNotificationSettingsService(
|
||||||
|
getRawPushRulesResult = { Result.success("not json") },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitItem().eventSink(DeveloperSettingsEvent.OpenPushRules)
|
||||||
|
skipItems(2)
|
||||||
|
openPushRulesLambda.assertions().isCalledOnce().with(
|
||||||
|
value("push_rules@alice_server.org.json"),
|
||||||
|
value("not json"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - OpenPushRules event failure can be dismissed`() = runTest {
|
||||||
|
val presenter = createDeveloperSettingsPresenter(
|
||||||
|
notificationSettingsService = FakeNotificationSettingsService(
|
||||||
|
getRawPushRulesResult = { Result.failure(AN_EXCEPTION) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val state = awaitItem()
|
||||||
|
state.eventSink(DeveloperSettingsEvent.OpenPushRules)
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||||
|
it.eventSink(DeveloperSettingsEvent.DismissPushRulesError)
|
||||||
|
}
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createDeveloperSettingsPresenter(
|
private fun createDeveloperSettingsPresenter(
|
||||||
|
navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() },
|
||||||
sessionId: SessionId = A_SESSION_ID,
|
sessionId: SessionId = A_SESSION_ID,
|
||||||
deviceId: DeviceId = A_DEVICE_ID,
|
deviceId: DeviceId = A_DEVICE_ID,
|
||||||
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
|
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
|
||||||
@@ -180,8 +261,10 @@ class DeveloperSettingsPresenterTest {
|
|||||||
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
|
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
|
||||||
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
|
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
|
||||||
buildMeta: BuildMeta = aBuildMeta(),
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
|
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
|
||||||
): DeveloperSettingsPresenter {
|
): DeveloperSettingsPresenter {
|
||||||
return DeveloperSettingsPresenter(
|
return DeveloperSettingsPresenter(
|
||||||
|
navigator = navigator,
|
||||||
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
|
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
deviceId = deviceId,
|
deviceId = deviceId,
|
||||||
@@ -193,6 +276,7 @@ class DeveloperSettingsPresenterTest {
|
|||||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
markAllRoomsAsRead = markAllRoomsAsRead,
|
markAllRoomsAsRead = markAllRoomsAsRead,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
|
notificationSettingsService = notificationSettingsService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -57,6 +57,19 @@ class DeveloperSettingsViewTest : RobolectricTest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Config(qualifiers = "h2400dp")
|
||||||
|
@Test
|
||||||
|
fun `clicking on push rules emits the expected event`() = runAndroidComposeUiTest {
|
||||||
|
val eventsRecorder = EventsRecorder<DeveloperSettingsEvent>()
|
||||||
|
setDeveloperSettingsView(
|
||||||
|
state = aDeveloperSettingsState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onNodeWithText("Push rules").performScrollTo().performClick()
|
||||||
|
eventsRecorder.assertSingle(DeveloperSettingsEvent.OpenPushRules)
|
||||||
|
}
|
||||||
|
|
||||||
@Config(qualifiers = "h2000dp")
|
@Config(qualifiers = "h2000dp")
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest {
|
fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest {
|
||||||
|
|||||||
+14
-3
@@ -23,9 +23,11 @@ import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.timeout
|
import kotlinx.coroutines.flow.timeout
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import com.freeletics.flowredux.dsl.State as MachineState
|
import com.freeletics.flowredux.dsl.State as MachineState
|
||||||
|
|
||||||
@@ -36,6 +38,11 @@ class OutgoingVerificationStateMachine(
|
|||||||
) : FlowReduxStateMachine<OutgoingVerificationStateMachine.State, OutgoingVerificationStateMachine.Event>(
|
) : FlowReduxStateMachine<OutgoingVerificationStateMachine.State, OutgoingVerificationStateMachine.Event>(
|
||||||
initialState = State.Initial,
|
initialState = State.Initial,
|
||||||
) {
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Lifetime the Matrix specification gives a verification request; past it the other session can no longer accept ours. */
|
||||||
|
private val VERIFICATION_REQUEST_TIMEOUT = 10.minutes
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
spec {
|
spec {
|
||||||
inState<State.Initial> {
|
inState<State.Initial> {
|
||||||
@@ -44,11 +51,15 @@ class OutgoingVerificationStateMachine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
inState<State.RequestingVerification> {
|
inState<State.RequestingVerification> {
|
||||||
onEnterEffect { event ->
|
onEnter { state ->
|
||||||
when (event.verificationRequest) {
|
val verificationRequest = state.snapshot.verificationRequest
|
||||||
|
when (verificationRequest) {
|
||||||
is VerificationRequest.Outgoing.CurrentSession -> sessionVerificationService.requestDeviceVerification()
|
is VerificationRequest.Outgoing.CurrentSession -> sessionVerificationService.requestDeviceVerification()
|
||||||
is VerificationRequest.Outgoing.User -> sessionVerificationService.requestUserVerification(event.verificationRequest.userId)
|
is VerificationRequest.Outgoing.User -> sessionVerificationService.requestUserVerification(verificationRequest.userId)
|
||||||
}
|
}
|
||||||
|
delay(VERIFICATION_REQUEST_TIMEOUT)
|
||||||
|
sessionVerificationService.cancelVerification()
|
||||||
|
state.override { State.Canceled.andLogStateChange() }
|
||||||
}
|
}
|
||||||
on<Event.DidAcceptVerificationRequest> { _, state ->
|
on<Event.DidAcceptVerificationRequest> { _, state ->
|
||||||
state.override { State.VerificationRequestAccepted.andLogStateChange() }
|
state.override { State.VerificationRequestAccepted.andLogStateChange() }
|
||||||
|
|||||||
+23
-1
@@ -31,10 +31,12 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
|
|||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
class OutgoingVerificationPresenterTest {
|
class OutgoingVerificationPresenterTest {
|
||||||
@@ -141,6 +143,26 @@ class OutgoingVerificationPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - A verification request the other session never accepts is canceled`() = runTest {
|
||||||
|
val cancelVerificationRecorder = lambdaRecorder<Unit> {}
|
||||||
|
val service = unverifiedSessionService(
|
||||||
|
requestDeviceVerificationLambda = { },
|
||||||
|
cancelVerificationLambda = cancelVerificationRecorder,
|
||||||
|
)
|
||||||
|
val presenter = createOutgoingVerificationPresenter(service)
|
||||||
|
presenter.test {
|
||||||
|
awaitItem().eventSink(OutgoingVerificationViewEvent.RequestVerification)
|
||||||
|
advanceTimeBy(1.minutes)
|
||||||
|
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
|
||||||
|
advanceTimeBy(9.minutes)
|
||||||
|
expectNoEvents()
|
||||||
|
advanceTimeBy(2.minutes)
|
||||||
|
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
|
||||||
|
cancelVerificationRecorder.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
|
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
|
||||||
val service = unverifiedSessionService(
|
val service = unverifiedSessionService(
|
||||||
@@ -363,7 +385,7 @@ class OutgoingVerificationPresenterTest {
|
|||||||
var state = awaitItem()
|
var state = awaitItem()
|
||||||
assertThat(state.step).isEqualTo(Step.Initial)
|
assertThat(state.step).isEqualTo(Step.Initial)
|
||||||
state.eventSink(OutgoingVerificationViewEvent.RequestVerification)
|
state.eventSink(OutgoingVerificationViewEvent.RequestVerification)
|
||||||
testScope.advanceUntilIdle()
|
testScope.advanceTimeBy(1.minutes)
|
||||||
// Await for other device response:
|
// Await for other device response:
|
||||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
|
|||||||
+13
-11
@@ -16,6 +16,7 @@ import android.provider.MediaStore
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.androidutils.file.saveWithUniqueFileName
|
||||||
import io.element.android.libraries.androidutils.system.toast
|
import io.element.android.libraries.androidutils.system.toast
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
@@ -25,6 +26,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
interface FileSave {
|
interface FileSave {
|
||||||
suspend fun save(
|
suspend fun save(
|
||||||
@@ -61,18 +63,18 @@ class DefaultFileSave(
|
|||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
private fun saveOnDiskUsingMediaStore(path: String) {
|
private fun saveOnDiskUsingMediaStore(path: String) {
|
||||||
val file = File(path)
|
val file = File(path)
|
||||||
val contentValues = ContentValues().apply {
|
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
|
|
||||||
put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream)
|
|
||||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
|
||||||
}
|
|
||||||
val resolver = context.contentResolver
|
val resolver = context.contentResolver
|
||||||
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
val outputUri = saveWithUniqueFileName(file.name) { displayName ->
|
||||||
if (outputUri != null) {
|
val contentValues = ContentValues().apply {
|
||||||
file.inputStream().use { input ->
|
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||||
resolver.openOutputStream(outputUri).use { output ->
|
put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream)
|
||||||
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
|
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||||
}
|
}
|
||||||
|
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||||
|
} ?: throw IOException("Unable to create the destination file")
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
resolver.openOutputStream(outputUri).use { output ->
|
||||||
|
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.viewfolder.impl.file
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.tests.testutils.fake.registerFakeMediaStoreContentProvider
|
||||||
|
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class DefaultFileSaveTest : RobolectricTest() {
|
||||||
|
@Test
|
||||||
|
fun `save inserts the file in the Downloads collection under its own name`() = runTest {
|
||||||
|
val mediaStore = registerFakeMediaStoreContentProvider()
|
||||||
|
createFileSave().save(aFileToSave().path)
|
||||||
|
assertThat(mediaStore.insertedDisplayNames).containsExactly("a-file.txt")
|
||||||
|
assertThat(mediaStore.savedContent("a-file.txt")).isEqualTo("Some content")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `save retries with a suffixed name when the name cannot be made unique`() = runTest {
|
||||||
|
val mediaStore = registerFakeMediaStoreContentProvider().apply {
|
||||||
|
conflictingDisplayNames = setOf("a-file.txt")
|
||||||
|
}
|
||||||
|
createFileSave().save(aFileToSave().path)
|
||||||
|
assertThat(mediaStore.insertedDisplayNames).hasSize(2)
|
||||||
|
assertThat(mediaStore.insertedDisplayNames.last()).matches("a-file_\\d+\\.txt")
|
||||||
|
assertThat(mediaStore.savedContent(mediaStore.insertedDisplayNames.last())).isEqualTo("Some content")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `save writes nothing when the destination cannot be created`() = runTest {
|
||||||
|
val mediaStore = registerFakeMediaStoreContentProvider().apply {
|
||||||
|
returnsNoUriOnInsert = true
|
||||||
|
}
|
||||||
|
createFileSave().save(aFileToSave().path)
|
||||||
|
assertThat(mediaStore.insertedDisplayNames).containsExactly("a-file.txt")
|
||||||
|
assertThat(mediaStore.savedFileNames()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aFileToSave(): File {
|
||||||
|
return File(RuntimeEnvironment.getApplication().cacheDir, "a-file.txt").apply {
|
||||||
|
writeText("Some content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createFileSave() = DefaultFileSave(
|
||||||
|
context = RuntimeEnvironment.getApplication(),
|
||||||
|
dispatchers = testCoroutineDispatchers(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin
|
|||||||
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
|
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
|
||||||
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
|
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
|
||||||
# https://firebase.google.com/docs/android/setup#available-libraries
|
# https://firebase.google.com/docs/android/setup#available-libraries
|
||||||
google_firebase_bom = "com.google.firebase:firebase-bom:34.17.0"
|
google_firebase_bom = "com.google.firebase:firebase-bom:34.18.0"
|
||||||
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
|
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
|
||||||
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
|
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
|
||||||
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
|
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
|
||||||
@@ -175,7 +175,7 @@ test_truth = "com.google.truth:truth:1.4.5"
|
|||||||
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22"
|
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22"
|
||||||
test_robolectric = "org.robolectric:robolectric:4.16.1"
|
test_robolectric = "org.robolectric:robolectric:4.16.1"
|
||||||
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
|
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
|
||||||
test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.2"
|
test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.3"
|
||||||
test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" }
|
test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" }
|
||||||
test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" }
|
test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" }
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
|
|||||||
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
|
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
|
||||||
# All new features should not be implemented in the pull request that upgrades the version, developers should
|
# All new features should not be implemented in the pull request that upgrades the version, developers should
|
||||||
# only fix API breaks and may add some TODOs.
|
# only fix API breaks and may add some TODOs.
|
||||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.19"
|
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.25"
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
|
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
|
||||||
@@ -208,7 +208,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose",
|
|||||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||||
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
||||||
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||||
sqlcipher = "net.zetetic:sqlcipher-android:4.17.0"
|
sqlcipher = "net.zetetic:sqlcipher-android:4.18.0"
|
||||||
sqlite = "androidx.sqlite:sqlite-ktx:2.7.0"
|
sqlite = "androidx.sqlite:sqlite-ktx:2.7.0"
|
||||||
unifiedpush = "org.unifiedpush.android:connector:3.3.3"
|
unifiedpush = "org.unifiedpush.android:connector:3.3.3"
|
||||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
||||||
@@ -222,7 +222,7 @@ 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"
|
||||||
zxing_cpp = "io.github.zxing-cpp:android:3.1.1"
|
zxing_cpp = "io.github.zxing-cpp:android:3.1.1"
|
||||||
google_zxing = "com.google.zxing:core:3.5.4"
|
google_zxing = "com.google.zxing:core:3.5.4"
|
||||||
google_guava = "com.google.guava:guava:33.7.0-android"
|
google_guava = "com.google.guava:guava:33.7.1-android"
|
||||||
|
|
||||||
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
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" }
|
||||||
|
|||||||
+26
@@ -42,6 +42,32 @@ fun File.safeRenameTo(dest: File) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns this file name with [suffix] inserted before its extension, i.e. `"image.png"` with the
|
||||||
|
* suffix `"_1"` gives `"image_1.png"`. A name without extension simply gets the suffix appended.
|
||||||
|
*/
|
||||||
|
fun String.withFileNameSuffix(suffix: String): String {
|
||||||
|
val extension = substringAfterLast('.', "")
|
||||||
|
return if (extension.isEmpty()) {
|
||||||
|
"$this$suffix"
|
||||||
|
} else {
|
||||||
|
"${substringBeforeLast('.')}$suffix.$extension"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> saveWithUniqueFileName(
|
||||||
|
fileName: String,
|
||||||
|
uniqueSuffix: () -> String = { "_${System.currentTimeMillis()}" },
|
||||||
|
saveAs: (String) -> T,
|
||||||
|
): T {
|
||||||
|
return try {
|
||||||
|
saveAs(fileName)
|
||||||
|
} catch (nameConflict: IllegalStateException) {
|
||||||
|
Timber.w(nameConflict, "Unable to build a unique file name, retrying with a suffixed one")
|
||||||
|
saveAs(fileName.withFileNameSuffix(uniqueSuffix()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File {
|
fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File {
|
||||||
val suffix = extension?.let { ".$extension" }
|
val suffix = extension?.let { ".$extension" }
|
||||||
return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
|
return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
|
||||||
|
|||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.file
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FileTest {
|
||||||
|
@Test
|
||||||
|
fun `withFileNameSuffix - inserts the suffix before the extension`() {
|
||||||
|
assertThat("image.png".withFileNameSuffix("_1")).isEqualTo("image_1.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withFileNameSuffix - appends the suffix when there is no extension`() {
|
||||||
|
assertThat("README".withFileNameSuffix("_1")).isEqualTo("README_1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withFileNameSuffix - only considers the last extension`() {
|
||||||
|
assertThat("archive.tar.gz".withFileNameSuffix("_1")).isEqualTo("archive.tar_1.gz")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withFileNameSuffix - handles a name starting with a dot`() {
|
||||||
|
assertThat(".hidden".withFileNameSuffix("_1")).isEqualTo("_1.hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveWithUniqueFileName - uses the original name when it works`() {
|
||||||
|
val usedNames = mutableListOf<String>()
|
||||||
|
|
||||||
|
val result = saveWithUniqueFileName(
|
||||||
|
fileName = "image.png",
|
||||||
|
uniqueSuffix = { "_1" },
|
||||||
|
) { name ->
|
||||||
|
usedNames.add(name)
|
||||||
|
"saved as $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo("saved as image.png")
|
||||||
|
assertThat(usedNames).containsExactly("image.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveWithUniqueFileName - retries with a suffixed name when the name cannot be made unique`() {
|
||||||
|
val usedNames = mutableListOf<String>()
|
||||||
|
|
||||||
|
val result = saveWithUniqueFileName(
|
||||||
|
fileName = "image.png",
|
||||||
|
uniqueSuffix = { "_1" },
|
||||||
|
) { name ->
|
||||||
|
usedNames.add(name)
|
||||||
|
if (name == "image.png") {
|
||||||
|
error("Failed to build unique file")
|
||||||
|
}
|
||||||
|
"saved as $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo("saved as image_1.png")
|
||||||
|
assertThat(usedNames).containsExactly("image.png", "image_1.png").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveWithUniqueFileName - does not retry more than once`() {
|
||||||
|
val usedNames = mutableListOf<String>()
|
||||||
|
|
||||||
|
val error = try {
|
||||||
|
saveWithUniqueFileName(
|
||||||
|
fileName = "image.png",
|
||||||
|
uniqueSuffix = { "_1" },
|
||||||
|
) { name ->
|
||||||
|
usedNames.add(name)
|
||||||
|
error("Failed to build unique file")
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} catch (nameConflict: IllegalStateException) {
|
||||||
|
nameConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(error).isInstanceOf(IllegalStateException::class.java)
|
||||||
|
assertThat(usedNames).containsExactly("image.png", "image_1.png").inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -10,6 +10,7 @@ package io.element.android.libraries.designsystem.atomic.atoms
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
@@ -20,11 +21,12 @@ fun RoomPreviewTitleAtom(
|
|||||||
title: String,
|
title: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
fontStyle: FontStyle? = null,
|
fontStyle: FontStyle? = null,
|
||||||
|
style: TextStyle = ElementTheme.typography.fontHeadingLgBold
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
text = title,
|
text = title,
|
||||||
style = ElementTheme.typography.fontHeadingLgBold,
|
style = style,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
fontStyle = fontStyle,
|
fontStyle = fontStyle,
|
||||||
color = ElementTheme.colors.textPrimary,
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.text
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the title of a screen, but adapt the style if it does not fit on a single line,
|
||||||
|
* so that a wrapped title does not look oversized.
|
||||||
|
*
|
||||||
|
* @param title the title to render.
|
||||||
|
* @param style the style to use to render the [title].
|
||||||
|
* @param twoLinesStyle the style to use instead of [style] when the [title] does not fit on a single line with [style],
|
||||||
|
* so that a wrapped title does not look oversized.
|
||||||
|
* @param modifier the [Modifier] to apply to this layout.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AdaptativeTitle(
|
||||||
|
title: String,
|
||||||
|
style: TextStyle,
|
||||||
|
twoLinesStyle: TextStyle,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// If style and twoLinesStyle are the same, we don't need to measure the text.
|
||||||
|
if (style == twoLinesStyle) {
|
||||||
|
Text(
|
||||||
|
modifier = modifier.semantics {
|
||||||
|
heading()
|
||||||
|
},
|
||||||
|
style = style,
|
||||||
|
maxLines = 2,
|
||||||
|
text = title,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
BoxWithConstraints(modifier = modifier) {
|
||||||
|
// Measure the title before rendering it, so that the style is picked in a single layout pass.
|
||||||
|
val fitsOnASingleLine = textMeasurer.measure(
|
||||||
|
text = title,
|
||||||
|
style = style,
|
||||||
|
constraints = constraints,
|
||||||
|
).lineCount == 1
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.semantics {
|
||||||
|
heading()
|
||||||
|
},
|
||||||
|
style = if (fitsOnASingleLine) style else twoLinesStyle,
|
||||||
|
maxLines = 2,
|
||||||
|
text = title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun AdaptativeTitlePreview() = ElementPreview {
|
||||||
|
@Composable
|
||||||
|
fun AdaptativeTitleItem(
|
||||||
|
text: String,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(180.dp)
|
||||||
|
.background(ElementTheme.colors.bgSubtlePrimary),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = ElementTheme.typography.fontBodyXsRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// Fits on a single line: the large style is kept.
|
||||||
|
AdaptativeTitleItem("Adaptative, 1 line:") {
|
||||||
|
AdaptativeTitle(
|
||||||
|
title = "Chats",
|
||||||
|
style = ElementTheme.typography.fontHeadingLgBold,
|
||||||
|
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Would be rendered on two lines with the large style: the smaller style is used instead.
|
||||||
|
AdaptativeTitleItem("Adaptative, 2 lines:") {
|
||||||
|
AdaptativeTitle(
|
||||||
|
title = "Tutte le conversazioni:",
|
||||||
|
style = ElementTheme.typography.fontHeadingLgBold,
|
||||||
|
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Would be rendered on two lines with the large style: the smaller style is used instead
|
||||||
|
// and it's finally rendered on a single line with the smaller style.
|
||||||
|
AdaptativeTitleItem("Adaptative, but 1 line:") {
|
||||||
|
AdaptativeTitle(
|
||||||
|
title = "Tous les chats",
|
||||||
|
style = ElementTheme.typography.fontHeadingLgBold,
|
||||||
|
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Short title, with small font, 1 line.
|
||||||
|
AdaptativeTitleItem("Always small, 1 line:") {
|
||||||
|
AdaptativeTitle(
|
||||||
|
title = "My space",
|
||||||
|
style = ElementTheme.typography.fontHeadingSmMedium,
|
||||||
|
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Space name, which can still be rendered on two lines.
|
||||||
|
AdaptativeTitleItem("Always small, 2 lines:") {
|
||||||
|
AdaptativeTitle(
|
||||||
|
title = "A very long space name",
|
||||||
|
style = ElementTheme.typography.fontHeadingSmMedium,
|
||||||
|
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-library")
|
id("io.element.android-library")
|
||||||
}
|
}
|
||||||
@@ -27,4 +29,6 @@ dependencies {
|
|||||||
implementation(libs.google.tink)
|
implementation(libs.google.tink)
|
||||||
|
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
|
|
||||||
|
testCommonDependencies(libs)
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-7
@@ -51,12 +51,28 @@ class SqlCipherDriverFactory(
|
|||||||
is ClientSecret.Passphrase -> secret.formattedAsString().hexToByteArray()
|
is ClientSecret.Passphrase -> secret.formattedAsString().hexToByteArray()
|
||||||
}
|
}
|
||||||
val factory = SupportOpenHelperFactory(key)
|
val factory = SupportOpenHelperFactory(key)
|
||||||
return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory, callback = object : AndroidSqliteDriver.Callback(
|
return AndroidSqliteDriver(
|
||||||
schema
|
schema = schema,
|
||||||
) {
|
context = context,
|
||||||
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
name = name,
|
||||||
onUpgradeCallback?.invoke(db, oldVersion, newVersion)
|
factory = factory,
|
||||||
}
|
callback = MigrationCallback(schema, onUpgradeCallback),
|
||||||
})
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [onUpgradeCallback] and then the schema migrations themselves.
|
||||||
|
*
|
||||||
|
* @param schema The SQLite DB schema, whose migrations are applied by [AndroidSqliteDriver.Callback.onUpgrade].
|
||||||
|
* @param onUpgradeCallback Called before the migrations run, so that the database key can be replaced first.
|
||||||
|
*/
|
||||||
|
internal class MigrationCallback(
|
||||||
|
schema: SqlSchema<QueryResult.Value<Unit>>,
|
||||||
|
private val onUpgradeCallback: ((driver: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) -> Unit)?,
|
||||||
|
) : AndroidSqliteDriver.Callback(schema) {
|
||||||
|
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
|
onUpgradeCallback?.invoke(db, oldVersion, newVersion)
|
||||||
|
super.onUpgrade(db, oldVersion, newVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.encrypteddb
|
||||||
|
|
||||||
|
import app.cash.sqldelight.db.AfterVersion
|
||||||
|
import app.cash.sqldelight.db.QueryResult
|
||||||
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
|
import app.cash.sqldelight.db.SqlSchema
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MigrationCallbackTest {
|
||||||
|
@Test
|
||||||
|
fun `onUpgrade migrates the schema`() {
|
||||||
|
val schema = RecordingSchema()
|
||||||
|
MigrationCallback(schema, onUpgradeCallback = null).onUpgrade(mockk(relaxed = true), 9, 11)
|
||||||
|
assertThat(schema.migrations).containsExactly(9L to 11L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `onUpgrade replaces the database key before migrating the schema`() {
|
||||||
|
val order = mutableListOf<String>()
|
||||||
|
val schema = RecordingSchema(onMigrate = { order.add("migrate") })
|
||||||
|
MigrationCallback(schema, onUpgradeCallback = { _, _, _ -> order.add("rekey") })
|
||||||
|
.onUpgrade(mockk(relaxed = true), 9, 11)
|
||||||
|
assertThat(order).containsExactly("rekey", "migrate").inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingSchema(
|
||||||
|
private val onMigrate: () -> Unit = {},
|
||||||
|
) : SqlSchema<QueryResult.Value<Unit>> {
|
||||||
|
val migrations = mutableListOf<Pair<Long, Long>>()
|
||||||
|
|
||||||
|
override val version = 11L
|
||||||
|
|
||||||
|
override fun create(driver: SqlDriver) = QueryResult.Unit
|
||||||
|
|
||||||
|
override fun migrate(
|
||||||
|
driver: SqlDriver,
|
||||||
|
oldVersion: Long,
|
||||||
|
newVersion: Long,
|
||||||
|
vararg callbacks: AfterVersion,
|
||||||
|
): QueryResult.Value<Unit> {
|
||||||
|
migrations.add(oldVersion to newVersion)
|
||||||
|
onMigrate()
|
||||||
|
return QueryResult.Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -56,7 +56,7 @@ class DefaultPinnedMessagesBannerFormatter(
|
|||||||
is PollContent -> {
|
is PollContent -> {
|
||||||
content.question.prefixWith(CommonStrings.a11y_poll)
|
content.question.prefixWith(CommonStrings.a11y_poll)
|
||||||
}
|
}
|
||||||
RedactedContent -> {
|
is RedactedContent -> {
|
||||||
sp.getString(CommonStrings.common_message_removed)
|
sp.getString(CommonStrings.common_message_removed)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ class DefaultRoomLatestEventFormatter(
|
|||||||
): CharSequence? {
|
): CharSequence? {
|
||||||
return when (content) {
|
return when (content) {
|
||||||
is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||||
RedactedContent -> {
|
is RedactedContent -> {
|
||||||
val message = sp.getString(CommonStrings.common_message_removed)
|
val message = sp.getString(CommonStrings.common_message_removed)
|
||||||
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter(
|
|||||||
is CallNotifyContent -> {
|
is CallNotifyContent -> {
|
||||||
sp.getString(CommonStrings.common_call_started)
|
sp.getString(CommonStrings.common_call_started)
|
||||||
}
|
}
|
||||||
RedactedContent,
|
is RedactedContent,
|
||||||
is LegacyCallInviteContent,
|
is LegacyCallInviteContent,
|
||||||
is StickerContent,
|
is StickerContent,
|
||||||
is PollContent,
|
is PollContent,
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
|
|||||||
fun `Redacted content`() {
|
fun `Redacted content`() {
|
||||||
val expected = "Message removed"
|
val expected = "Message removed"
|
||||||
val senderName = "Someone"
|
val senderName = "Someone"
|
||||||
val message = createRoomEvent(false, senderName, RedactedContent)
|
val message = createRoomEvent(false, senderName, RedactedContent(threadInfo = null))
|
||||||
val result = formatter.format(message)
|
val result = formatter.format(message)
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
|||||||
val expected = "Message removed"
|
val expected = "Message removed"
|
||||||
val senderName = "Someone"
|
val senderName = "Someone"
|
||||||
sequenceOf(false, true).forEach { isDm ->
|
sequenceOf(false, true).forEach { isDm ->
|
||||||
val message = createLatestEvent(false, senderName, RedactedContent)
|
val message = createLatestEvent(false, senderName, RedactedContent(threadInfo = null))
|
||||||
val result = formatter.format(message, isDm)
|
val result = formatter.format(message, isDm)
|
||||||
if (isDm) {
|
if (isDm) {
|
||||||
assertThat(result).isEqualTo(expected)
|
assertThat(result).isEqualTo(expected)
|
||||||
|
|||||||
+5
@@ -14,11 +14,16 @@ import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
|||||||
class FakeRoomLatestEventFormatter : RoomLatestEventFormatter {
|
class FakeRoomLatestEventFormatter : RoomLatestEventFormatter {
|
||||||
private var result: CharSequence? = null
|
private var result: CharSequence? = null
|
||||||
|
|
||||||
|
var lastIsDmRoom: Boolean? = null
|
||||||
|
private set
|
||||||
|
|
||||||
override fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? {
|
override fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? {
|
||||||
|
lastIsDmRoom = isDmRoom
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence? {
|
override fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence? {
|
||||||
|
lastIsDmRoom = isDmRoom
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -35,6 +35,7 @@ sealed interface StateEventType {
|
|||||||
data object RoomImagePack : StateEventType
|
data object RoomImagePack : StateEventType
|
||||||
data object RoomLanguage : StateEventType
|
data object RoomLanguage : StateEventType
|
||||||
data object RoomPolicy : StateEventType
|
data object RoomPolicy : StateEventType
|
||||||
|
data object RoomRetention : StateEventType
|
||||||
|
|
||||||
data class Custom(val type: String) : StateEventType
|
data class Custom(val type: String) : StateEventType
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -32,7 +32,9 @@ data class MessageContent(
|
|||||||
val type: MessageType,
|
val type: MessageType,
|
||||||
) : EventContent
|
) : EventContent
|
||||||
|
|
||||||
data object RedactedContent : EventContent
|
data class RedactedContent(
|
||||||
|
val threadInfo: EventThreadInfo?,
|
||||||
|
) : EventContent
|
||||||
|
|
||||||
data class StickerContent(
|
data class StickerContent(
|
||||||
val filename: String,
|
val filename: String,
|
||||||
|
|||||||
+1
@@ -51,6 +51,7 @@ data class EventTimelineItem(
|
|||||||
is PollContent -> content.threadInfo
|
is PollContent -> content.threadInfo
|
||||||
is StickerContent -> content.threadInfo
|
is StickerContent -> content.threadInfo
|
||||||
is UnableToDecryptContent -> content.threadInfo
|
is UnableToDecryptContent -> content.threadInfo
|
||||||
|
is RedactedContent -> content.threadInfo
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1
@@ -99,7 +99,6 @@ class RustMatrixClientFactory(
|
|||||||
isMessageSearchAvailable = isMessageSearchAvailable,
|
isMessageSearchAvailable = isMessageSearchAvailable,
|
||||||
)
|
)
|
||||||
.homeserverUrl(sessionData.homeserverUrl)
|
.homeserverUrl(sessionData.homeserverUrl)
|
||||||
.username(sessionData.userId)
|
|
||||||
.let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner }
|
.let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner }
|
||||||
.use { it.build() }
|
.use { it.build() }
|
||||||
|
|
||||||
|
|||||||
+2
@@ -39,6 +39,7 @@ fun StateEventType.map(): RustStateEventType = when (this) {
|
|||||||
StateEventType.RoomImagePack -> RustStateEventType.RoomImagePack
|
StateEventType.RoomImagePack -> RustStateEventType.RoomImagePack
|
||||||
StateEventType.RoomLanguage -> RustStateEventType.RoomLanguage
|
StateEventType.RoomLanguage -> RustStateEventType.RoomLanguage
|
||||||
StateEventType.RoomPolicy -> RustStateEventType.RoomPolicy
|
StateEventType.RoomPolicy -> RustStateEventType.RoomPolicy
|
||||||
|
StateEventType.RoomRetention -> RustStateEventType.RoomRetention
|
||||||
is StateEventType.Custom -> stateEventTypeFromString(type)
|
is StateEventType.Custom -> stateEventTypeFromString(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,5 +70,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
|
|||||||
RustStateEventType.RoomImagePack -> StateEventType.RoomImagePack
|
RustStateEventType.RoomImagePack -> StateEventType.RoomImagePack
|
||||||
RustStateEventType.RoomLanguage -> StateEventType.RoomLanguage
|
RustStateEventType.RoomLanguage -> StateEventType.RoomLanguage
|
||||||
RustStateEventType.RoomPolicy -> StateEventType.RoomPolicy
|
RustStateEventType.RoomPolicy -> StateEventType.RoomPolicy
|
||||||
|
RustStateEventType.RoomRetention -> StateEventType.RoomRetention
|
||||||
is RustStateEventType.Custom -> StateEventType.Custom(this.toString())
|
is RustStateEventType.Custom -> StateEventType.Custom(this.toString())
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@ class TimelineEventContentMapper(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MsgLikeKind.Redacted -> {
|
is MsgLikeKind.Redacted -> {
|
||||||
RedactedContent
|
RedactedContent(threadInfo = extractThreadInfo(it.content))
|
||||||
}
|
}
|
||||||
is MsgLikeKind.Poll -> {
|
is MsgLikeKind.Poll -> {
|
||||||
PollContent(
|
PollContent(
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ class FakeFfiClientBuilder(
|
|||||||
override fun setSessionDelegate(sessionDelegate: ClientSessionDelegate) = this
|
override fun setSessionDelegate(sessionDelegate: ClientSessionDelegate) = this
|
||||||
override fun slidingSyncVersionBuilder(versionBuilder: SlidingSyncVersionBuilder) = this
|
override fun slidingSyncVersionBuilder(versionBuilder: SlidingSyncVersionBuilder) = this
|
||||||
override fun userAgent(userAgent: String) = this
|
override fun userAgent(userAgent: String) = this
|
||||||
override fun username(username: String) = this
|
override fun serverNameFromUserId(userId: String): ClientBuilder = this
|
||||||
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
|
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
|
||||||
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
|
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
|
||||||
override fun sqliteStore(config: SqliteStoreBuilder): ClientBuilder = this
|
override fun sqliteStore(config: SqliteStoreBuilder): ClientBuilder = this
|
||||||
|
|||||||
+2
-1
@@ -30,6 +30,7 @@ fun aNotificationData(
|
|||||||
senderIsNameAmbiguous: Boolean = false,
|
senderIsNameAmbiguous: Boolean = false,
|
||||||
roomDisplayName: String? = A_ROOM_NAME,
|
roomDisplayName: String? = A_ROOM_NAME,
|
||||||
roomJoinRule: JoinRule? = null,
|
roomJoinRule: JoinRule? = null,
|
||||||
|
isNoisy: Boolean = false,
|
||||||
): NotificationData {
|
): NotificationData {
|
||||||
return NotificationData(
|
return NotificationData(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
@@ -45,7 +46,7 @@ fun aNotificationData(
|
|||||||
isDm = false,
|
isDm = false,
|
||||||
isSpace = isSpace,
|
isSpace = isSpace,
|
||||||
isEncrypted = false,
|
isEncrypted = false,
|
||||||
isNoisy = false,
|
isNoisy = isNoisy,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
content = content,
|
content = content,
|
||||||
hasMention = hasMention,
|
hasMention = hasMention,
|
||||||
|
|||||||
+5
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider
|
import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
@@ -137,6 +138,10 @@ fun aStickerContent(
|
|||||||
threadInfo = threadInfo,
|
threadInfo = threadInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun aRedactedContent(
|
||||||
|
threadInfo: EventThreadInfo? = null,
|
||||||
|
) = RedactedContent(threadInfo = threadInfo)
|
||||||
|
|
||||||
fun aTimelineItemDebugInfo(
|
fun aTimelineItemDebugInfo(
|
||||||
model: String = "Rust(Model())",
|
model: String = "Rust(Model())",
|
||||||
originalJson: String? = null,
|
originalJson: String? = null,
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ fun SpaceHeaderRootView(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(CommonStrings.screen_space_list_title),
|
text = stringResource(CommonStrings.screen_space_list_title),
|
||||||
style = ElementTheme.typography.fontHeadingLgBold,
|
style = ElementTheme.typography.fontHeadingMdBold,
|
||||||
color = ElementTheme.colors.textPrimary,
|
color = ElementTheme.colors.textPrimary,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-2
@@ -19,6 +19,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
|
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
|
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
|
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
|
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
|
||||||
@@ -68,11 +69,15 @@ fun SpaceHeaderView(
|
|||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
if (name != null) {
|
if (name != null) {
|
||||||
RoomPreviewTitleAtom(title = name)
|
RoomPreviewTitleAtom(
|
||||||
|
title = name,
|
||||||
|
style = ElementTheme.typography.fontHeadingMdBold,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
RoomPreviewTitleAtom(
|
RoomPreviewTitleAtom(
|
||||||
title = stringResource(id = CommonStrings.common_no_space_name),
|
title = stringResource(id = CommonStrings.common_no_space_name),
|
||||||
fontStyle = FontStyle.Italic
|
fontStyle = FontStyle.Italic,
|
||||||
|
style = ElementTheme.typography.fontHeadingMdBold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -118,7 +118,7 @@ class InReplyToDetailsDisambiguatedPreviewParam : InReplyToDetailsPreviewParam()
|
|||||||
class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() {
|
class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() {
|
||||||
override val values: Sequence<InReplyToDetails>
|
override val values: Sequence<InReplyToDetails>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
RedactedContent,
|
RedactedContent(threadInfo = null),
|
||||||
UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
|
UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
|
||||||
).map {
|
).map {
|
||||||
aInReplyToDetails(
|
aInReplyToDetails(
|
||||||
|
|||||||
+1
-1
@@ -656,7 +656,7 @@ class InReplyToMetadataKtTest : RobolectricTest() {
|
|||||||
fun `redacted content`() = runTest {
|
fun `redacted content`() = runTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
anInReplyToDetailsReady(
|
anInReplyToDetailsReady(
|
||||||
eventContent = RedactedContent
|
eventContent = RedactedContent(threadInfo = null)
|
||||||
).metadata(hideImage = false)
|
).metadata(hideImage = false)
|
||||||
}.test {
|
}.test {
|
||||||
awaitItem().let {
|
awaitItem().let {
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.api
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders content which is not a Matrix media as a file, the same way the media viewer does.
|
||||||
|
*
|
||||||
|
* The content is provided by the caller and written to a temporary file, so that it can be
|
||||||
|
* displayed, shared and saved on disk.
|
||||||
|
*/
|
||||||
|
interface FileViewerEntryPoint : FeatureEntryPoint {
|
||||||
|
fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
params: Params,
|
||||||
|
callback: Callback,
|
||||||
|
): Node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param filename the name of the file, used as the screen title and for the shared and saved files.
|
||||||
|
* @param mimeType the mime type of [content], it drives how the content is rendered.
|
||||||
|
* @param content the whole content of the file.
|
||||||
|
*/
|
||||||
|
data class Params(
|
||||||
|
val filename: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val content: String,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -79,7 +79,7 @@ class EventItemFactory(
|
|||||||
LegacyCallInviteContent,
|
LegacyCallInviteContent,
|
||||||
is PollContent,
|
is PollContent,
|
||||||
is ProfileChangeContent,
|
is ProfileChangeContent,
|
||||||
RedactedContent,
|
is RedactedContent,
|
||||||
is RoomMembershipContent,
|
is RoomMembershipContent,
|
||||||
is StateContent,
|
is StateContent,
|
||||||
is StickerContent,
|
is StickerContent,
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultFileViewerEntryPoint : FileViewerEntryPoint {
|
||||||
|
override fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
params: FileViewerEntryPoint.Params,
|
||||||
|
callback: FileViewerEntryPoint.Callback,
|
||||||
|
): Node {
|
||||||
|
return parentNode.createNode<FileViewerNode>(
|
||||||
|
buildContext = buildContext,
|
||||||
|
plugins = listOf(params, callback),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
sealed interface FileViewerEvent {
|
||||||
|
data object Share : FileViewerEvent
|
||||||
|
data object SaveOnDisk : FileViewerEvent
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
|
import io.element.android.libraries.architecture.callback
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class FileViewerNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: FileViewerPresenter.Factory,
|
||||||
|
private val textFileViewer: TextFileViewer,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
private val callback: FileViewerEntryPoint.Callback = callback()
|
||||||
|
private val inputs = inputs<FileViewerEntryPoint.Params>()
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(params = inputs)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
FileViewerView(
|
||||||
|
state = state,
|
||||||
|
textFileViewer = textFileViewer,
|
||||||
|
onBackClick = callback::onDone,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||||
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
|
||||||
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import io.element.android.libraries.androidutils.R as UtilsR
|
||||||
|
|
||||||
|
@AssistedInject
|
||||||
|
class FileViewerPresenter(
|
||||||
|
@Assisted private val params: FileViewerEntryPoint.Params,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
private val fileSizeFormatter: FileSizeFormatter,
|
||||||
|
private val fileExtensionExtractor: FileExtensionExtractor,
|
||||||
|
private val localMediaActions: LocalMediaActions,
|
||||||
|
) : Presenter<FileViewerState> {
|
||||||
|
@AssistedFactory
|
||||||
|
fun interface Factory {
|
||||||
|
fun create(params: FileViewerEntryPoint.Params): FileViewerPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a local snackbarDispatcher, because this screen can be rendered from any Node.
|
||||||
|
private val snackbarDispatcher = SnackbarDispatcher()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): FileViewerState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val localMedia = remember { mutableStateOf<AsyncData<LocalMedia>>(AsyncData.Uninitialized) }
|
||||||
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
|
|
||||||
|
localMediaActions.Configure()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
suspend { createLocalMedia() }.runCatchingUpdatingState(localMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvent(event: FileViewerEvent) {
|
||||||
|
when (event) {
|
||||||
|
FileViewerEvent.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
|
||||||
|
FileViewerEvent.Share -> coroutineScope.share(localMedia.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileViewerState(
|
||||||
|
filename = params.filename,
|
||||||
|
localMedia = localMedia.value,
|
||||||
|
snackbarMessage = snackbarMessage,
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the content to a temporary file, so that it can be rendered, shared and saved on disk.
|
||||||
|
* The file is always written to the same location, so it does not need to be cleaned up.
|
||||||
|
*/
|
||||||
|
private suspend fun createLocalMedia(): LocalMedia = withContext(coroutineDispatchers.io) {
|
||||||
|
val folder = File(context.cacheDir, "temp/fileviewer").apply { mkdirs() }
|
||||||
|
val file = File(folder, params.filename)
|
||||||
|
file.writeText(params.content)
|
||||||
|
LocalMedia(
|
||||||
|
uri = file.toUri(),
|
||||||
|
info = MediaInfo(
|
||||||
|
filename = params.filename,
|
||||||
|
caption = null,
|
||||||
|
mimeType = params.mimeType,
|
||||||
|
fileSize = file.length(),
|
||||||
|
formattedFileSize = fileSizeFormatter.format(file.length()),
|
||||||
|
fileExtension = fileExtensionExtractor.extractFromName(params.filename),
|
||||||
|
senderId = null,
|
||||||
|
senderName = null,
|
||||||
|
senderAvatar = null,
|
||||||
|
dateSent = null,
|
||||||
|
dateSentFull = null,
|
||||||
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
|
||||||
|
if (localMedia is AsyncData.Success) {
|
||||||
|
localMediaActions.saveOnDisk(localMedia.data)
|
||||||
|
.onSuccess {
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_file_saved_on_disk_android))
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(mediaActionsError(it)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.share(localMedia: AsyncData<LocalMedia>) = launch {
|
||||||
|
if (localMedia is AsyncData.Success) {
|
||||||
|
localMediaActions.share(localMedia.data)
|
||||||
|
.onFailure {
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(mediaActionsError(it)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mediaActionsError(throwable: Throwable): Int {
|
||||||
|
return if (throwable is ActivityNotFoundException) {
|
||||||
|
UtilsR.string.error_no_compatible_app_found
|
||||||
|
} else {
|
||||||
|
CommonStrings.error_unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
|
||||||
|
data class FileViewerState(
|
||||||
|
val filename: String,
|
||||||
|
val localMedia: AsyncData<LocalMedia>,
|
||||||
|
val snackbarMessage: SnackbarMessage?,
|
||||||
|
val eventSink: (FileViewerEvent) -> Unit,
|
||||||
|
)
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
|
||||||
|
private const val A_FILENAME = "push_rules@alice_server.org.json"
|
||||||
|
|
||||||
|
open class FileViewerStatePreviewParam : PreviewParameterProvider<FileViewerState> {
|
||||||
|
override val values: Sequence<FileViewerState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aFileViewerState(),
|
||||||
|
aFileViewerState(localMedia = AsyncData.Failure(Exception("A failure"))),
|
||||||
|
aFileViewerState(
|
||||||
|
localMedia = AsyncData.Success(
|
||||||
|
LocalMedia(
|
||||||
|
uri = Uri.EMPTY,
|
||||||
|
info = aJsonMediaInfo(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aFileViewerState(
|
||||||
|
filename: String = A_FILENAME,
|
||||||
|
localMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
|
||||||
|
eventSink: (FileViewerEvent) -> Unit = {},
|
||||||
|
) = FileViewerState(
|
||||||
|
filename = filename,
|
||||||
|
localMedia = localMedia,
|
||||||
|
snackbarMessage = null,
|
||||||
|
eventSink = eventSink,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aJsonMediaInfo(
|
||||||
|
filename: String = A_FILENAME,
|
||||||
|
) = MediaInfo(
|
||||||
|
filename = filename,
|
||||||
|
caption = null,
|
||||||
|
mimeType = MimeTypes.Json,
|
||||||
|
fileSize = 2 * 1024,
|
||||||
|
formattedFileSize = "2kB",
|
||||||
|
fileExtension = "json",
|
||||||
|
senderId = null,
|
||||||
|
senderName = null,
|
||||||
|
senderAvatar = null,
|
||||||
|
dateSent = null,
|
||||||
|
dateSentFull = null,
|
||||||
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
|
)
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileViewerView(
|
||||||
|
state: FileViewerState,
|
||||||
|
textFileViewer: TextFileViewer,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
FileViewerTopBar(
|
||||||
|
filename = state.filename,
|
||||||
|
actionsEnabled = state.localMedia.isSuccess(),
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
onShareClick = { state.eventSink(FileViewerEvent.Share) },
|
||||||
|
onSaveClick = { state.eventSink(FileViewerEvent.SaveOnDisk) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
) { padding ->
|
||||||
|
when (val localMedia = state.localMedia) {
|
||||||
|
AsyncData.Uninitialized,
|
||||||
|
is AsyncData.Loading -> AsyncLoading(
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
is AsyncData.Failure -> AsyncFailure(
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
throwable = localMedia.error,
|
||||||
|
onRetry = null,
|
||||||
|
)
|
||||||
|
is AsyncData.Success -> {
|
||||||
|
// Do not apply the padding, the content is rendered behind the translucent top bar,
|
||||||
|
// the same way the media viewer does.
|
||||||
|
LocalMediaView(
|
||||||
|
localMedia = localMedia.data,
|
||||||
|
bottomPaddingInPixels = 0,
|
||||||
|
audioFocus = null,
|
||||||
|
onClick = {},
|
||||||
|
onOpenWith = null,
|
||||||
|
textFileViewer = textFileViewer,
|
||||||
|
forPreview = false,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FileViewerTopBar(
|
||||||
|
filename: String,
|
||||||
|
actionsEnabled: Boolean,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
onShareClick: () -> Unit,
|
||||||
|
onSaveClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
titleStr = filename,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = bgCanvasWithTransparency,
|
||||||
|
),
|
||||||
|
navigationIcon = { BackButton(onClick = onBackClick) },
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = onShareClick,
|
||||||
|
enabled = actionsEnabled,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.ShareAndroid(),
|
||||||
|
contentDescription = stringResource(id = CommonStrings.action_share),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onSaveClick,
|
||||||
|
enabled = actionsEnabled,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.Download(),
|
||||||
|
contentDescription = stringResource(id = CommonStrings.action_download),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun FileViewerViewPreview(
|
||||||
|
@PreviewParameter(FileViewerStatePreviewParam::class) state: FileViewerState,
|
||||||
|
) = ElementPreview {
|
||||||
|
FileViewerView(
|
||||||
|
state = state,
|
||||||
|
textFileViewer = { _, _ -> },
|
||||||
|
onBackClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
+13
-11
@@ -32,6 +32,7 @@ import androidx.core.content.PermissionChecker
|
|||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.androidutils.file.saveWithUniqueFileName
|
||||||
import io.element.android.libraries.androidutils.system.startInstallFromSourceIntent
|
import io.element.android.libraries.androidutils.system.startInstallFromSourceIntent
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
@@ -44,6 +45,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
@@ -172,18 +174,18 @@ class AndroidLocalMediaActions(
|
|||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) {
|
private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) {
|
||||||
val contentValues = ContentValues().apply {
|
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.filename)
|
|
||||||
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
|
|
||||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
|
||||||
}
|
|
||||||
val resolver = context.contentResolver
|
val resolver = context.contentResolver
|
||||||
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
val outputUri = saveWithUniqueFileName(localMedia.info.filename) { displayName ->
|
||||||
if (outputUri != null) {
|
val contentValues = ContentValues().apply {
|
||||||
localMedia.openStream()?.use { input ->
|
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||||
resolver.openOutputStream(outputUri).use { output ->
|
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
|
||||||
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
|
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||||
}
|
}
|
||||||
|
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||||
|
} ?: throw IOException("Unable to create the destination file")
|
||||||
|
localMedia.openStream()?.use { input ->
|
||||||
|
resolver.openOutputStream(outputUri).use { output ->
|
||||||
|
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ fun LocalMediaView(
|
|||||||
forPreview = forPreview,
|
forPreview = forPreview,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
mimeType == MimeTypes.PlainText -> TextFileView(
|
mimeType == MimeTypes.PlainText || mimeType == MimeTypes.Json -> TextFileView(
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
textFileViewer = textFileViewer,
|
textFileViewer = textFileViewer,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
+2
-2
@@ -35,7 +35,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
@@ -49,6 +48,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.aPollContent
|
import io.element.android.libraries.matrix.test.timeline.aPollContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
|
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
||||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||||
@@ -80,7 +80,7 @@ class EventItemFactoryTest {
|
|||||||
LegacyCallInviteContent,
|
LegacyCallInviteContent,
|
||||||
aPollContent(),
|
aPollContent(),
|
||||||
aProfileChangeMessageContent(),
|
aProfileChangeMessageContent(),
|
||||||
RedactedContent,
|
aRedactedContent(),
|
||||||
aRoomMembershipContent(
|
aRoomMembershipContent(
|
||||||
userId = A_USER_ID,
|
userId = A_USER_ID,
|
||||||
),
|
),
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
|
||||||
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
|
||||||
|
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import io.element.android.tests.testutils.node.TestParentNode
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultFileViewerEntryPointTest {
|
||||||
|
@get:Rule
|
||||||
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test node builder`() = runTest {
|
||||||
|
val entryPoint = DefaultFileViewerEntryPoint()
|
||||||
|
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||||
|
FileViewerNode(
|
||||||
|
buildContext = buildContext,
|
||||||
|
plugins = plugins,
|
||||||
|
presenterFactory = { nodeParams ->
|
||||||
|
FileViewerPresenter(
|
||||||
|
params = nodeParams,
|
||||||
|
context = mockk<Context>(relaxed = true),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers(),
|
||||||
|
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
|
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||||
|
localMediaActions = FakeLocalMediaActions(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
textFileViewer = { _, _ -> lambdaError() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val params = FileViewerEntryPoint.Params(
|
||||||
|
filename = "a file.json",
|
||||||
|
mimeType = MimeTypes.Json,
|
||||||
|
content = "{}",
|
||||||
|
)
|
||||||
|
val callback = object : FileViewerEntryPoint.Callback {
|
||||||
|
override fun onDone() = lambdaError()
|
||||||
|
}
|
||||||
|
val result = entryPoint.createNode(
|
||||||
|
parentNode = parentNode,
|
||||||
|
buildContext = BuildContext.root(null),
|
||||||
|
params = params,
|
||||||
|
callback = callback,
|
||||||
|
)
|
||||||
|
assertThat(result).isInstanceOf(FileViewerNode::class.java)
|
||||||
|
assertThat(result.plugins).contains(params)
|
||||||
|
assertThat(result.plugins).contains(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
|
||||||
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
|
||||||
|
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import io.element.android.libraries.androidutils.R as UtilsR
|
||||||
|
|
||||||
|
private const val A_FILENAME = "push_rules@alice_server.org.json"
|
||||||
|
private const val A_CONTENT = """{"global":{"override":[]}}"""
|
||||||
|
|
||||||
|
class FileViewerPresenterTest : RobolectricTest() {
|
||||||
|
@Test
|
||||||
|
fun `present - writes the content to a file and exposes it as a local media`() = runTest {
|
||||||
|
val presenter = createFileViewerPresenter()
|
||||||
|
presenter.test {
|
||||||
|
assertThat(awaitItem().localMedia).isEqualTo(AsyncData.Uninitialized)
|
||||||
|
consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().also { state ->
|
||||||
|
assertThat(state.filename).isEqualTo(A_FILENAME)
|
||||||
|
val localMedia = state.localMedia.dataOrNull()!!
|
||||||
|
assertThat(localMedia.info.filename).isEqualTo(A_FILENAME)
|
||||||
|
assertThat(localMedia.info.mimeType).isEqualTo(MimeTypes.Json)
|
||||||
|
assertThat(localMedia.info.fileExtension).isEqualTo("json")
|
||||||
|
val file = localMedia.uri.toFile()
|
||||||
|
assertThat(file.name).isEqualTo(A_FILENAME)
|
||||||
|
assertThat(file.readText()).isEqualTo(A_CONTENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - SaveOnDisk event saves the file and posts a snackbar message`() = runTest {
|
||||||
|
val saveOnDiskResult = lambdaRecorder<LocalMedia, Result<Unit>> { Result.success(Unit) }
|
||||||
|
val presenter = createFileViewerPresenter(
|
||||||
|
localMediaActions = FakeLocalMediaActions(saveOnDiskResult = saveOnDiskResult),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().eventSink(FileViewerEvent.SaveOnDisk)
|
||||||
|
consumeItemsUntilPredicate { it.snackbarMessage != null }.last().also { state ->
|
||||||
|
assertThat(state.snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_file_saved_on_disk_android)
|
||||||
|
}
|
||||||
|
saveOnDiskResult.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Share event failure posts a snackbar message`() = runTest {
|
||||||
|
val shareResult = lambdaRecorder<LocalMedia, Result<Unit>> { Result.failure(ActivityNotFoundException()) }
|
||||||
|
val presenter = createFileViewerPresenter(
|
||||||
|
localMediaActions = FakeLocalMediaActions(shareResult = shareResult),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().eventSink(FileViewerEvent.Share)
|
||||||
|
consumeItemsUntilPredicate { it.snackbarMessage != null }.last().also { state ->
|
||||||
|
assertThat(state.snackbarMessage?.messageResId).isEqualTo(UtilsR.string.error_no_compatible_app_found)
|
||||||
|
}
|
||||||
|
shareResult.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createFileViewerPresenter(
|
||||||
|
params: FileViewerEntryPoint.Params = FileViewerEntryPoint.Params(
|
||||||
|
filename = A_FILENAME,
|
||||||
|
mimeType = MimeTypes.Json,
|
||||||
|
content = A_CONTENT,
|
||||||
|
),
|
||||||
|
localMediaActions: LocalMediaActions = FakeLocalMediaActions(),
|
||||||
|
) = FileViewerPresenter(
|
||||||
|
params = params,
|
||||||
|
context = RuntimeEnvironment.getApplication(),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers(),
|
||||||
|
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
|
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||||
|
localMediaActions = localMediaActions,
|
||||||
|
)
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalTestApi::class)
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.fileviewer
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.hasClickAction
|
||||||
|
import androidx.compose.ui.test.hasContentDescription
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
|
import io.element.android.tests.testutils.pressBack
|
||||||
|
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FileViewerViewTest : RobolectricTest() {
|
||||||
|
@Test
|
||||||
|
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
|
||||||
|
val eventsRecorder = EventsRecorder<FileViewerEvent>(expectEvents = false)
|
||||||
|
ensureCalledOnce {
|
||||||
|
setFileViewerView(
|
||||||
|
state = aFileViewerState(eventSink = eventsRecorder),
|
||||||
|
onBackClick = it,
|
||||||
|
)
|
||||||
|
pressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking on share emits the expected event`() = runAndroidComposeUiTest {
|
||||||
|
val eventsRecorder = EventsRecorder<FileViewerEvent>()
|
||||||
|
setFileViewerView(
|
||||||
|
state = aFileViewerState(
|
||||||
|
localMedia = AsyncData.Success(LocalMedia(Uri.EMPTY, aJsonMediaInfo())),
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
clickOnContentDescription(CommonStrings.action_share)
|
||||||
|
eventsRecorder.assertSingle(FileViewerEvent.Share)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking on download emits the expected event`() = runAndroidComposeUiTest {
|
||||||
|
val eventsRecorder = EventsRecorder<FileViewerEvent>()
|
||||||
|
setFileViewerView(
|
||||||
|
state = aFileViewerState(
|
||||||
|
localMedia = AsyncData.Success(LocalMedia(Uri.EMPTY, aJsonMediaInfo())),
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
clickOnContentDescription(CommonStrings.action_download)
|
||||||
|
eventsRecorder.assertSingle(FileViewerEvent.SaveOnDisk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidComposeUiTest<ComponentActivity>.clickOnContentDescription(@StringRes res: Int) {
|
||||||
|
val text = activity!!.getString(res)
|
||||||
|
onNode(hasContentDescription(text) and hasClickAction()).performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidComposeUiTest<ComponentActivity>.setFileViewerView(
|
||||||
|
state: FileViewerState,
|
||||||
|
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
FileViewerView(
|
||||||
|
state = state,
|
||||||
|
textFileViewer = { _, _ -> },
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -12,18 +12,22 @@ import android.net.Uri
|
|||||||
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.net.toUri
|
||||||
import app.cash.molecule.RecompositionMode
|
import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
||||||
|
import io.element.android.tests.testutils.fake.registerFakeMediaStoreContentProvider
|
||||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.robolectric.RuntimeEnvironment
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
class AndroidLocalMediaActionsTest : RobolectricTest() {
|
class AndroidLocalMediaActionsTest : RobolectricTest() {
|
||||||
@Test
|
@Test
|
||||||
@@ -62,6 +66,43 @@ class AndroidLocalMediaActionsTest : RobolectricTest() {
|
|||||||
assertThat(result.exceptionOrNull()).isNotNull()
|
assertThat(result.exceptionOrNull()).isNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test AndroidLocalMediaAction save on disk inserts the media under its own name`() = runTest {
|
||||||
|
val mediaStore = registerFakeMediaStoreContentProvider()
|
||||||
|
val sut = createAndroidLocalMediaActions()
|
||||||
|
val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri()))
|
||||||
|
assertThat(result.exceptionOrNull()).isNull()
|
||||||
|
assertThat(mediaStore.insertedDisplayNames).containsExactly("an image file.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test AndroidLocalMediaAction save on disk retries with a suffixed name when the name cannot be made unique`() = runTest {
|
||||||
|
val mediaStore = registerFakeMediaStoreContentProvider().apply {
|
||||||
|
conflictingDisplayNames = setOf("an image file.jpg")
|
||||||
|
}
|
||||||
|
val sut = createAndroidLocalMediaActions()
|
||||||
|
val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri()))
|
||||||
|
assertThat(result.exceptionOrNull()).isNull()
|
||||||
|
assertThat(mediaStore.insertedDisplayNames).hasSize(2)
|
||||||
|
assertThat(mediaStore.insertedDisplayNames.last()).matches("an image file_\\d+\\.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test AndroidLocalMediaAction save on disk fails when the destination cannot be created`() = runTest {
|
||||||
|
registerFakeMediaStoreContentProvider().apply {
|
||||||
|
returnsNoUriOnInsert = true
|
||||||
|
}
|
||||||
|
val sut = createAndroidLocalMediaActions()
|
||||||
|
val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri()))
|
||||||
|
assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aMediaFile(): File {
|
||||||
|
return File(RuntimeEnvironment.getApplication().cacheDir, "an image file.jpg").apply {
|
||||||
|
writeText("Some content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun TestScope.createAndroidLocalMediaActions() = AndroidLocalMediaActions(
|
private fun TestScope.createAndroidLocalMediaActions() = AndroidLocalMediaActions(
|
||||||
RuntimeEnvironment.getApplication(),
|
RuntimeEnvironment.getApplication(),
|
||||||
testCoroutineDispatchers(),
|
testCoroutineDispatchers(),
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations 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.mediaviewer.test
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
|
class FakeFileViewerEntryPoint : FileViewerEntryPoint {
|
||||||
|
override fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
params: FileViewerEntryPoint.Params,
|
||||||
|
callback: FileViewerEntryPoint.Callback,
|
||||||
|
): Node = lambdaError()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user