mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +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: |
|
||||
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:
|
||||
name: Create APKs (FDroid)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -50,9 +50,10 @@ PRs must meet these rules.
|
||||
- Title = changelog entry — descriptive, no "Fixes #…".
|
||||
- Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette).
|
||||
- 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.
|
||||
- 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
|
||||
=============================
|
||||
|
||||
|
||||
@@ -105,9 +105,18 @@ class LoggedInPresenter(
|
||||
}
|
||||
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
val showSyncSpinner by remember {
|
||||
val hasNetwork by networkMonitor.connectivity.collectAsState()
|
||||
val syncIndicatorState by remember {
|
||||
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) }
|
||||
@@ -181,7 +190,7 @@ class LoggedInPresenter(
|
||||
}
|
||||
|
||||
return LoggedInState(
|
||||
showSyncSpinner = showSyncSpinner,
|
||||
syncIndicatorState = syncIndicatorState,
|
||||
pusherRegistrationState = pusherRegistrationState.value,
|
||||
ignoreRegistrationError = ignoreRegistrationError,
|
||||
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
||||
|
||||
@@ -11,8 +11,14 @@ package io.element.android.appnav.loggedin
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
|
||||
enum class SyncIndicatorState {
|
||||
Hidden,
|
||||
Syncing,
|
||||
ServerUnreachable,
|
||||
}
|
||||
|
||||
data class LoggedInState(
|
||||
val showSyncSpinner: Boolean,
|
||||
val syncIndicatorState: SyncIndicatorState,
|
||||
val pusherRegistrationState: AsyncData<Unit>,
|
||||
val ignoreRegistrationError: Boolean,
|
||||
val forceNativeSlidingSyncMigration: Boolean,
|
||||
|
||||
+4
-3
@@ -17,7 +17,8 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider<LoggedInState> {
|
||||
override val values: Sequence<LoggedInState>
|
||||
get() = sequenceOf(
|
||||
aLoggedInState(),
|
||||
aLoggedInState(showSyncSpinner = true),
|
||||
aLoggedInState(syncIndicatorState = SyncIndicatorState.Syncing),
|
||||
aLoggedInState(syncIndicatorState = SyncIndicatorState.ServerUnreachable),
|
||||
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
|
||||
aLoggedInState(forceNativeSlidingSyncMigration = true),
|
||||
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
|
||||
@@ -26,13 +27,13 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider<LoggedInState> {
|
||||
}
|
||||
|
||||
fun aLoggedInState(
|
||||
showSyncSpinner: Boolean = false,
|
||||
syncIndicatorState: SyncIndicatorState = SyncIndicatorState.Hidden,
|
||||
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
|
||||
forceNativeSlidingSyncMigration: Boolean = false,
|
||||
appName: String = "Element X",
|
||||
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
|
||||
) = LoggedInState(
|
||||
showSyncSpinner = showSyncSpinner,
|
||||
syncIndicatorState = syncIndicatorState,
|
||||
pusherRegistrationState = pusherRegistrationState,
|
||||
ignoreRegistrationError = false,
|
||||
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
||||
|
||||
@@ -47,7 +47,7 @@ fun LoggedInView(
|
||||
) {
|
||||
SyncStateView(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
isVisible = state.showSyncSpinner,
|
||||
state = state.syncIndicatorState,
|
||||
)
|
||||
}
|
||||
when (state.pusherRegistrationState) {
|
||||
|
||||
@@ -25,18 +25,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun SyncStateView(
|
||||
isVisible: Boolean,
|
||||
state: SyncIndicatorState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
visible = state != SyncIndicatorState.Hidden,
|
||||
modifier = modifier,
|
||||
enter = fadeIn(spring(stiffness = 500F)),
|
||||
exit = fadeOut(spring(stiffness = 500F)),
|
||||
) {
|
||||
AsyncIndicator.Loading(
|
||||
text = stringResource(id = CommonStrings.common_syncing),
|
||||
)
|
||||
when (state) {
|
||||
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
|
||||
Box(modifier = Modifier.padding(24.dp)) {
|
||||
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 {
|
||||
createLoggedInPresenter().test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showSyncSpinner).isFalse()
|
||||
assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden)
|
||||
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
|
||||
assertThat(initialState.ignoreRegistrationError).isFalse()
|
||||
}
|
||||
@@ -97,11 +97,33 @@ class LoggedInPresenterTest {
|
||||
matrixClient = FakeMatrixClient(roomListService = roomListService),
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showSyncSpinner).isFalse()
|
||||
assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden)
|
||||
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
|
||||
consumeItemsUntilPredicate { it.showSyncSpinner }
|
||||
consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.Syncing }
|
||||
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) 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">
|
||||
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) Open the emulator Chrome browser app
|
||||
1) Go to the url `mitm.it`
|
||||
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">
|
||||
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) Build and run the Element X app.
|
||||
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
|
||||
|
||||
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:
|
||||
```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 \
|
||||
--overwrite
|
||||
```
|
||||
For instance:
|
||||
```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 \
|
||||
--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.LocalLayoutDirection
|
||||
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.Dp
|
||||
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.PreviewsDayNight
|
||||
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.DropdownMenuItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
@@ -141,15 +139,21 @@ fun HomeTopBar(
|
||||
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(
|
||||
@@ -179,7 +183,9 @@ fun HomeTopBar(
|
||||
TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) {
|
||||
RoomListFiltersView(
|
||||
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,
|
||||
) {
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||
}
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||
}
|
||||
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
|
||||
val avatarModifier = if (statusEmoji != null) {
|
||||
Modifier.eraseStatusEmojiBackground(
|
||||
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
|
||||
},
|
||||
}
|
||||
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
|
||||
val avatarModifier = if (statusEmoji != null) {
|
||||
Modifier.eraseStatusEmojiBackground(
|
||||
parentSize = AvatarSize.CurrentUserTopBar.dp,
|
||||
layoutDirection = LocalLayoutDirection.current,
|
||||
)
|
||||
if (statusEmoji != null) {
|
||||
StatusEmojiBadge(
|
||||
emoji = statusEmoji,
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
if (showAvatarIndicator) {
|
||||
RedIndicatorAtom(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
} 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) {
|
||||
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.matrix.api.room.CallIntentConsensus
|
||||
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.RoomSummary
|
||||
import io.element.android.libraries.matrix.ui.model.dmUserStatus
|
||||
@@ -47,7 +48,7 @@ class RoomListRoomSummaryFactory(
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
useRelative = true,
|
||||
),
|
||||
latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm),
|
||||
latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.hasOnlyTwoMembers()),
|
||||
avatarData = avatarData,
|
||||
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
|
||||
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 {
|
||||
return when (latestEvent) {
|
||||
is LatestEventValue.None -> {
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ private fun EmptySpaceHomeView(
|
||||
)
|
||||
Text(
|
||||
text = stringResource(CommonStrings.screen_space_list_empty_state_title),
|
||||
style = ElementTheme.typography.fontHeadingLgBold,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
+26
@@ -8,10 +8,36 @@
|
||||
|
||||
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.test.FakeDateFormatter
|
||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
||||
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(
|
||||
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
|
||||
|
||||
+3
@@ -115,6 +115,9 @@ fun ConfirmAccountProviderView(
|
||||
|
||||
HeaderFooterPage(
|
||||
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 = {
|
||||
IconTitleSubtitleMolecule(
|
||||
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.TimelineItemTextBasedContent
|
||||
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.voicemessages.composer.DefaultVoiceMessageComposerPresenter
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
@@ -377,7 +378,7 @@ class MessagesPresenter(
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
|
||||
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
|
||||
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
|
||||
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState, enableTextFormatting)
|
||||
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
|
||||
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
|
||||
TimelineItemAction.ReplyInThread -> {
|
||||
@@ -546,10 +547,15 @@ class MessagesPresenter(
|
||||
private suspend fun handleActionEditCaption(
|
||||
targetEvent: TimelineItem.Event,
|
||||
composerState: MessageComposerState,
|
||||
enableTextFormatting: Boolean,
|
||||
) {
|
||||
val composerMode = MessageComposerMode.EditCaption(
|
||||
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
||||
content = targetEvent.content.captionOrNull().orEmpty(),
|
||||
content = if (enableTextFormatting) {
|
||||
targetEvent.content.htmlCaptionOrNull() ?: targetEvent.content.captionOrNull()
|
||||
} else {
|
||||
targetEvent.content.captionOrNull()
|
||||
}.orEmpty(),
|
||||
)
|
||||
composerState.eventSink(
|
||||
MessageComposerEvent.SetMode(composerMode)
|
||||
|
||||
+8
@@ -108,6 +108,7 @@ class TimelineItemContentMessageFactory(
|
||||
fileSize = messageType.info?.size ?: 0,
|
||||
caption = messageType.caption?.trimEnd(),
|
||||
formattedCaption = formattedCaption,
|
||||
htmlCaption = messageType.formattedCaption?.body,
|
||||
isEdited = content.isEdited,
|
||||
mediaSource = messageType.source,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
@@ -132,6 +133,7 @@ class TimelineItemContentMessageFactory(
|
||||
fileSize = messageType.info?.size ?: 0,
|
||||
caption = messageType.caption?.trimEnd(),
|
||||
formattedCaption = formattedCaption,
|
||||
htmlCaption = messageType.formattedCaption?.body,
|
||||
isEdited = content.isEdited,
|
||||
mediaSource = messageType.source,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
@@ -174,6 +176,7 @@ class TimelineItemContentMessageFactory(
|
||||
fileSize = messageType.info?.size ?: 0,
|
||||
caption = messageType.caption?.trimEnd(),
|
||||
formattedCaption = formattedCaption,
|
||||
htmlCaption = messageType.formattedCaption?.body,
|
||||
isEdited = content.isEdited,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
mediaSource = messageType.source,
|
||||
@@ -198,6 +201,7 @@ class TimelineItemContentMessageFactory(
|
||||
fileSize = messageType.info?.size ?: 0,
|
||||
caption = messageType.caption?.trimEnd(),
|
||||
formattedCaption = formattedCaption,
|
||||
htmlCaption = messageType.formattedCaption?.body,
|
||||
isEdited = content.isEdited,
|
||||
mediaSource = messageType.source,
|
||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||
@@ -216,6 +220,7 @@ class TimelineItemContentMessageFactory(
|
||||
fileSize = messageType.info?.size ?: 0,
|
||||
caption = messageType.caption?.trimEnd(),
|
||||
formattedCaption = formattedCaption,
|
||||
htmlCaption = messageType.formattedCaption?.body,
|
||||
isEdited = content.isEdited,
|
||||
mediaSource = messageType.source,
|
||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||
@@ -235,6 +240,7 @@ class TimelineItemContentMessageFactory(
|
||||
fileSize = messageType.info?.size ?: 0,
|
||||
caption = messageType.caption?.trimEnd(),
|
||||
formattedCaption = formattedCaption,
|
||||
htmlCaption = messageType.formattedCaption?.body,
|
||||
isEdited = content.isEdited,
|
||||
thumbnailSource = messageType.info?.thumbnailSource,
|
||||
mediaSource = messageType.source,
|
||||
@@ -345,6 +351,7 @@ class TimelineItemContentMessageFactory(
|
||||
body = messageType.body,
|
||||
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
|
||||
formattedCaption = formattedCaption,
|
||||
htmlCaption = messageType.formatted?.body,
|
||||
isEdited = content.isEdited,
|
||||
items = galleryItems.toImmutableList(),
|
||||
)
|
||||
@@ -402,6 +409,7 @@ class TimelineItemContentMessageFactory(
|
||||
body = messageType.body,
|
||||
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
|
||||
formattedCaption = formattedCaption,
|
||||
htmlCaption = messageType.formatted?.body,
|
||||
isEdited = content.isEdited,
|
||||
attachments = attachments.toImmutableList(),
|
||||
)
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
|
||||
// Can be grouped
|
||||
is FailedToParseMessageLikeContent,
|
||||
is MessageContent,
|
||||
RedactedContent,
|
||||
is RedactedContent,
|
||||
is StickerContent,
|
||||
is PollContent,
|
||||
is UnableToDecryptContent,
|
||||
|
||||
+4
-2
@@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
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.libraries.di.RoomScope
|
||||
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
|
||||
* [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
|
||||
* (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
|
||||
* 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) {
|
||||
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)
|
||||
} else {
|
||||
flushRun()
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ data class TimelineItemAttachmentsContent(
|
||||
val body: String,
|
||||
val caption: String?,
|
||||
val formattedCaption: CharSequence?,
|
||||
val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val attachments: ImmutableList<AttachmentItem>,
|
||||
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ data class TimelineItemAudioContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val duration: Duration,
|
||||
override val mediaSource: MediaSource,
|
||||
|
||||
+10
@@ -33,6 +33,9 @@ sealed interface TimelineItemEventContentWithAttachment :
|
||||
val fileSize: Long?
|
||||
val caption: String?
|
||||
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 mimeType: String
|
||||
val formattedFileSize: String
|
||||
@@ -127,6 +130,13 @@ fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (thi
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun TimelineItemEventContent.htmlCaptionOrNull(): String? = when (this) {
|
||||
is TimelineItemEventContentWithAttachment -> htmlCaption
|
||||
is TimelineItemGalleryContent -> htmlCaption
|
||||
is TimelineItemAttachmentsContent -> htmlCaption
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||
return when (this) {
|
||||
is TimelineItemAudioContent -> duration
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ data class TimelineItemFileContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
override val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ data class TimelineItemGalleryContent(
|
||||
val body: String,
|
||||
val caption: String?,
|
||||
val formattedCaption: CharSequence?,
|
||||
val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val items: ImmutableList<GalleryItem>,
|
||||
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ data class TimelineItemImageContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
override val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ fun aTimelineItemImageContent(
|
||||
blurhash: String? = A_BLUR_HASH,
|
||||
filename: String = "A picture.jpg",
|
||||
caption: String? = null,
|
||||
htmlCaption: String? = null,
|
||||
width: Int? = null,
|
||||
height: Int? = 300,
|
||||
) = TimelineItemImageContent(
|
||||
@@ -35,6 +36,7 @@ fun aTimelineItemImageContent(
|
||||
fileSize = 4 * 1024 * 1024L,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
htmlCaption = htmlCaption,
|
||||
isEdited = false,
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ data class TimelineItemStickerContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
override val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ data class TimelineItemVideoContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val duration: Duration,
|
||||
override val mediaSource: MediaSource,
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ data class TimelineItemVoiceContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val duration: Duration,
|
||||
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
|
||||
fun `present - handle action add caption`() = runTest {
|
||||
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.ReactionSender
|
||||
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.virtual.VirtualTimelineItem
|
||||
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.timeline.FakeTimeline
|
||||
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.item.event.aRoomMembershipContent
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
@@ -880,7 +880,7 @@ class TimelinePresenterTest {
|
||||
(0 until 3).map { index ->
|
||||
MatrixTimelineItem.Event(
|
||||
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.timeline.model.TimelineItem
|
||||
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.libraries.architecture.AsyncData
|
||||
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.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.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_2
|
||||
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.aRedactedContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -87,6 +93,42 @@ class TimelineItemsFactoryTest {
|
||||
).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
|
||||
fun `a key verification request is not rendered`() = runTest {
|
||||
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
|
||||
fun `a sender the timeline knows nothing about is named from the room member list`() = runTest {
|
||||
val factory = aTimelineItemsFactory(
|
||||
|
||||
+2
@@ -297,6 +297,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
|
||||
fileSize = 555L,
|
||||
caption = "body.mp4 caption",
|
||||
formattedCaption = SpannedString("formatted"),
|
||||
htmlCaption = "formatted",
|
||||
isEdited = true,
|
||||
duration = 1.minutes,
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
@@ -539,6 +540,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
|
||||
fileSize = 888L,
|
||||
caption = "body.jpg caption",
|
||||
formattedCaption = SpannedString("formatted"),
|
||||
htmlCaption = "formatted",
|
||||
isEdited = true,
|
||||
mediaSource = MediaSource(url = "url", json = null),
|
||||
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.TimelineItem
|
||||
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.TimelineItemStateEventContent
|
||||
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.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.test.AN_EVENT_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
|
||||
fun `a run of fewer than three redacted events is left untouched`() {
|
||||
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.event.EventTimelineItem
|
||||
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_2
|
||||
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.timeline.aRedactedContent
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
@@ -88,7 +88,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
|
||||
sender = A_USER_ID,
|
||||
senderProfile = ProfileDetails.Unavailable,
|
||||
timestamp = 9442,
|
||||
content = RedactedContent,
|
||||
content = aRedactedContent(),
|
||||
origin = null,
|
||||
timelineItemDebugInfoProvider = {
|
||||
TimelineItemDebugInfo(
|
||||
|
||||
@@ -64,6 +64,7 @@ dependencies {
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.mediaviewer.api)
|
||||
implementation(projects.libraries.mediapickers.api)
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
@@ -91,6 +92,7 @@ dependencies {
|
||||
implementation(libs.color.picker)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.serialization.json)
|
||||
api(projects.features.preferences.api)
|
||||
implementation(libs.showkase)
|
||||
|
||||
@@ -108,6 +110,7 @@ dependencies {
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.libraries.pushstore.test)
|
||||
testImplementation(projects.libraries.roomselect.test)
|
||||
testImplementation(projects.libraries.mediaviewer.test)
|
||||
testImplementation(projects.libraries.troubleshoot.test)
|
||||
testImplementation(projects.features.deactivation.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.callback
|
||||
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.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
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.PushHistoryEntryPoint
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@@ -56,6 +58,7 @@ class PreferencesFlowNode(
|
||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||
private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint,
|
||||
private val pushHistoryEntryPoint: PushHistoryEntryPoint,
|
||||
private val fileViewerEntryPoint: FileViewerEntryPoint,
|
||||
private val logoutEntryPoint: LogoutEntryPoint,
|
||||
private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint,
|
||||
private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint,
|
||||
@@ -95,6 +98,9 @@ class PreferencesFlowNode(
|
||||
@Parcelize
|
||||
data object PushHistory : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class PushRules(val filename: String, val content: String) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object LockScreenSettings : NavTarget
|
||||
|
||||
@@ -187,6 +193,10 @@ class PreferencesFlowNode(
|
||||
}
|
||||
NavTarget.DeveloperSettings -> {
|
||||
val developerSettingsCallback = object : DeveloperSettingsNode.Callback {
|
||||
override fun navigateToPushRules(filename: String, content: String) {
|
||||
backstack.push(NavTarget.PushRules(filename = filename, content = content))
|
||||
}
|
||||
|
||||
override fun navigateToPushHistory() {
|
||||
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 -> {
|
||||
val callback = object : EditDefaultNotificationSettingNode.Callback {
|
||||
override fun navigateToRoomNotificationSettings(roomId: RoomId) {
|
||||
|
||||
+2
@@ -17,4 +17,6 @@ sealed interface DeveloperSettingsEvent {
|
||||
data object VacuumStores : DeveloperSettingsEvent
|
||||
data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : 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(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: DeveloperSettingsPresenter,
|
||||
presenterFactory: DeveloperSettingsPresenter.Factory,
|
||||
private val extraDeveloperOptionsRenderer: ExtraDeveloperOptionsRenderer,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
DeveloperSettingsNavigator {
|
||||
interface Callback : Plugin {
|
||||
fun navigateToPushRules(filename: String, content: String)
|
||||
fun navigateToPushHistory()
|
||||
fun onDone()
|
||||
}
|
||||
|
||||
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
|
||||
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.setValue
|
||||
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.preferences.impl.developer.appsettings.AppDeveloperSettingsState
|
||||
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.runCatchingUpdatingState
|
||||
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.matrix.api.analytics.GetDatabaseSizesUseCase
|
||||
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.notificationsettings.NotificationSettingsService
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Inject
|
||||
@AssistedInject
|
||||
class DeveloperSettingsPresenter(
|
||||
@Assisted private val navigator: DeveloperSettingsNavigator,
|
||||
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
|
||||
private val sessionId: SessionId,
|
||||
private val deviceId: DeviceId,
|
||||
@@ -52,7 +60,13 @@ class DeveloperSettingsPresenter(
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val notificationSettingsService: NotificationSettingsService,
|
||||
) : Presenter<DeveloperSettingsState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
fun create(navigator: DeveloperSettingsNavigator): DeveloperSettingsPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): DeveloperSettingsState {
|
||||
val cacheSize = remember {
|
||||
@@ -67,6 +81,9 @@ class DeveloperSettingsPresenter(
|
||||
val markAllRoomsAsReadAction = remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
val pushRulesAction = remember {
|
||||
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||
}
|
||||
var showColorPicker by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -109,6 +126,10 @@ class DeveloperSettingsPresenter(
|
||||
DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation -> {
|
||||
markAllRoomsAsReadAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
DeveloperSettingsEvent.OpenPushRules -> coroutineScope.openPushRules(pushRulesAction)
|
||||
DeveloperSettingsEvent.DismissPushRulesError -> {
|
||||
pushRulesAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +140,7 @@ class DeveloperSettingsPresenter(
|
||||
databaseSizes = databaseSizes.value,
|
||||
clearCacheAction = clearCacheAction.value,
|
||||
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
|
||||
pushRulesAction = pushRulesAction.value,
|
||||
isEnterpriseBuild = buildMeta.isEnterpriseBuild,
|
||||
showColorPicker = showColorPicker,
|
||||
deviceId = deviceId,
|
||||
@@ -164,4 +186,40 @@ class DeveloperSettingsPresenter(
|
||||
markAllRoomsAsRead().getOrThrow()
|
||||
}.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 clearCacheAction: AsyncAction<Unit>,
|
||||
val markAllRoomsAsReadAction: AsyncAction<Unit>,
|
||||
val pushRulesAction: AsyncAction<Unit>,
|
||||
val isEnterpriseBuild: Boolean,
|
||||
val showColorPicker: Boolean,
|
||||
val deviceId: DeviceId,
|
||||
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)
|
||||
showColorPicker = false,
|
||||
),
|
||||
aDeveloperSettingsState(
|
||||
pushRulesAction = AsyncAction.Failure(Exception("A failure"))
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +42,7 @@ fun aDeveloperSettingsState(
|
||||
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
|
||||
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
pushRulesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
isEnterpriseBuild: Boolean = false,
|
||||
showColorPicker: Boolean = false,
|
||||
deviceId: DeviceId = DeviceId("ILAKNDNASDLK"),
|
||||
@@ -49,6 +53,7 @@ fun aDeveloperSettingsState(
|
||||
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
|
||||
clearCacheAction = clearCacheAction,
|
||||
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
|
||||
pushRulesAction = pushRulesAction,
|
||||
isEnterpriseBuild = isEnterpriseBuild,
|
||||
showColorPicker = showColorPicker,
|
||||
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.developer.appsettings.AppDeveloperSettingsView
|
||||
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.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.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
@@ -60,6 +62,12 @@ fun DeveloperSettingsView(
|
||||
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(
|
||||
enabled = !state.showLoader,
|
||||
onBack = onBackClick,
|
||||
@@ -79,7 +87,10 @@ fun DeveloperSettingsView(
|
||||
onOpenShowkase = onOpenShowkase,
|
||||
)
|
||||
SessionCategory(deviceId = state.deviceId)
|
||||
NotificationCategory(onPushHistoryClick)
|
||||
NotificationCategory(
|
||||
onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) },
|
||||
onPushHistoryClick = onPushHistoryClick,
|
||||
)
|
||||
MarkAllRoomsAsReadCategory(state)
|
||||
|
||||
if (state.isEnterpriseBuild) {
|
||||
@@ -217,8 +228,17 @@ private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
|
||||
private fun NotificationCategory(
|
||||
onPushRulesClick: () -> Unit,
|
||||
onPushHistoryClick: () -> Unit,
|
||||
) {
|
||||
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) {
|
||||
ListItem(
|
||||
content = {
|
||||
Text("Push rules")
|
||||
},
|
||||
onClick = onPushRulesClick,
|
||||
)
|
||||
ListItem(
|
||||
content = {
|
||||
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.libraries.matrix.api.core.EventId
|
||||
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.FakePushHistoryEntryPoint
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
@@ -43,6 +44,7 @@ class DefaultPreferencesEntryPointTest {
|
||||
lockScreenEntryPoint = FakeLockScreenEntryPoint(),
|
||||
notificationTroubleShootEntryPoint = FakeNotificationTroubleShootEntryPoint(),
|
||||
pushHistoryEntryPoint = FakePushHistoryEntryPoint(),
|
||||
fileViewerEntryPoint = FakeFileViewerEntryPoint(),
|
||||
logoutEntryPoint = FakeLogoutEntryPoint(),
|
||||
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
|
||||
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.core.DeviceId
|
||||
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_SESSION_ID
|
||||
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.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
@@ -41,6 +44,19 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
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 {
|
||||
@get:Rule
|
||||
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(
|
||||
navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() },
|
||||
sessionId: SessionId = A_SESSION_ID,
|
||||
deviceId: DeviceId = A_DEVICE_ID,
|
||||
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
|
||||
@@ -180,8 +261,10 @@ class DeveloperSettingsPresenterTest {
|
||||
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
|
||||
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
|
||||
): DeveloperSettingsPresenter {
|
||||
return DeveloperSettingsPresenter(
|
||||
navigator = navigator,
|
||||
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
|
||||
sessionId = sessionId,
|
||||
deviceId = deviceId,
|
||||
@@ -193,6 +276,7 @@ class DeveloperSettingsPresenterTest {
|
||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||
markAllRoomsAsRead = markAllRoomsAsRead,
|
||||
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")
|
||||
@Test
|
||||
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.FlowPreview
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.timeout
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import com.freeletics.flowredux.dsl.State as MachineState
|
||||
|
||||
@@ -36,6 +38,11 @@ class OutgoingVerificationStateMachine(
|
||||
) : FlowReduxStateMachine<OutgoingVerificationStateMachine.State, OutgoingVerificationStateMachine.Event>(
|
||||
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 {
|
||||
spec {
|
||||
inState<State.Initial> {
|
||||
@@ -44,11 +51,15 @@ class OutgoingVerificationStateMachine(
|
||||
}
|
||||
}
|
||||
inState<State.RequestingVerification> {
|
||||
onEnterEffect { event ->
|
||||
when (event.verificationRequest) {
|
||||
onEnter { state ->
|
||||
val verificationRequest = state.snapshot.verificationRequest
|
||||
when (verificationRequest) {
|
||||
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 ->
|
||||
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 kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
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
|
||||
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
|
||||
val service = unverifiedSessionService(
|
||||
@@ -363,7 +385,7 @@ class OutgoingVerificationPresenterTest {
|
||||
var state = awaitItem()
|
||||
assertThat(state.step).isEqualTo(Step.Initial)
|
||||
state.eventSink(OutgoingVerificationViewEvent.RequestVerification)
|
||||
testScope.advanceUntilIdle()
|
||||
testScope.advanceTimeBy(1.minutes)
|
||||
// Await for other device response:
|
||||
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
|
||||
state = awaitItem()
|
||||
|
||||
+13
-11
@@ -16,6 +16,7 @@ import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import dev.zacsweers.metro.AppScope
|
||||
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.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
@@ -25,6 +26,7 @@ import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
|
||||
interface FileSave {
|
||||
suspend fun save(
|
||||
@@ -61,18 +63,18 @@ class DefaultFileSave(
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveOnDiskUsingMediaStore(path: String) {
|
||||
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 outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||
if (outputUri != null) {
|
||||
file.inputStream().use { input ->
|
||||
resolver.openOutputStream(outputUri).use { output ->
|
||||
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
|
||||
}
|
||||
val outputUri = saveWithUniqueFileName(file.name) { displayName ->
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream)
|
||||
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" }
|
||||
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
|
||||
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" }
|
||||
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" }
|
||||
@@ -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_robolectric = "org.robolectric:robolectric:4.16.1"
|
||||
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_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
|
||||
# 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.
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.19"
|
||||
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.25"
|
||||
|
||||
# Others
|
||||
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-jvm = { module = "app.cash.sqldelight:sqlite-driver", 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"
|
||||
unifiedpush = "org.unifiedpush.android:connector:3.3.3"
|
||||
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"
|
||||
zxing_cpp = "io.github.zxing-cpp:android:3.1.1"
|
||||
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_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 {
|
||||
val suffix = extension?.let { ".$extension" }
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
@@ -20,11 +21,12 @@ fun RoomPreviewTitleAtom(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fontStyle: FontStyle? = null,
|
||||
style: TextStyle = ElementTheme.typography.fontHeadingLgBold
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontHeadingLgBold,
|
||||
style = style,
|
||||
textAlign = TextAlign.Center,
|
||||
fontStyle = fontStyle,
|
||||
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.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import extension.testCommonDependencies
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
@@ -27,4 +29,6 @@ dependencies {
|
||||
implementation(libs.google.tink)
|
||||
|
||||
implementation(projects.libraries.androidutils)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
}
|
||||
|
||||
+23
-7
@@ -51,12 +51,28 @@ class SqlCipherDriverFactory(
|
||||
is ClientSecret.Passphrase -> secret.formattedAsString().hexToByteArray()
|
||||
}
|
||||
val factory = SupportOpenHelperFactory(key)
|
||||
return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory, callback = object : AndroidSqliteDriver.Callback(
|
||||
schema
|
||||
) {
|
||||
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
onUpgradeCallback?.invoke(db, oldVersion, newVersion)
|
||||
}
|
||||
})
|
||||
return AndroidSqliteDriver(
|
||||
schema = schema,
|
||||
context = context,
|
||||
name = name,
|
||||
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 -> {
|
||||
content.question.prefixWith(CommonStrings.a11y_poll)
|
||||
}
|
||||
RedactedContent -> {
|
||||
is RedactedContent -> {
|
||||
sp.getString(CommonStrings.common_message_removed)
|
||||
}
|
||||
else -> {
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ class DefaultRoomLatestEventFormatter(
|
||||
): CharSequence? {
|
||||
return when (content) {
|
||||
is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||
RedactedContent -> {
|
||||
is RedactedContent -> {
|
||||
val message = sp.getString(CommonStrings.common_message_removed)
|
||||
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter(
|
||||
is CallNotifyContent -> {
|
||||
sp.getString(CommonStrings.common_call_started)
|
||||
}
|
||||
RedactedContent,
|
||||
is RedactedContent,
|
||||
is LegacyCallInviteContent,
|
||||
is StickerContent,
|
||||
is PollContent,
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
|
||||
fun `Redacted content`() {
|
||||
val expected = "Message removed"
|
||||
val senderName = "Someone"
|
||||
val message = createRoomEvent(false, senderName, RedactedContent)
|
||||
val message = createRoomEvent(false, senderName, RedactedContent(threadInfo = null))
|
||||
val result = formatter.format(message)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
val expected = "Message removed"
|
||||
val senderName = "Someone"
|
||||
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)
|
||||
if (isDm) {
|
||||
assertThat(result).isEqualTo(expected)
|
||||
|
||||
+5
@@ -14,11 +14,16 @@ import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
||||
class FakeRoomLatestEventFormatter : RoomLatestEventFormatter {
|
||||
private var result: CharSequence? = null
|
||||
|
||||
var lastIsDmRoom: Boolean? = null
|
||||
private set
|
||||
|
||||
override fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? {
|
||||
lastIsDmRoom = isDmRoom
|
||||
return result
|
||||
}
|
||||
|
||||
override fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence? {
|
||||
lastIsDmRoom = isDmRoom
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ sealed interface StateEventType {
|
||||
data object RoomImagePack : StateEventType
|
||||
data object RoomLanguage : StateEventType
|
||||
data object RoomPolicy : StateEventType
|
||||
data object RoomRetention : StateEventType
|
||||
|
||||
data class Custom(val type: String) : StateEventType
|
||||
}
|
||||
|
||||
+3
-1
@@ -32,7 +32,9 @@ data class MessageContent(
|
||||
val type: MessageType,
|
||||
) : EventContent
|
||||
|
||||
data object RedactedContent : EventContent
|
||||
data class RedactedContent(
|
||||
val threadInfo: EventThreadInfo?,
|
||||
) : EventContent
|
||||
|
||||
data class StickerContent(
|
||||
val filename: String,
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ data class EventTimelineItem(
|
||||
is PollContent -> content.threadInfo
|
||||
is StickerContent -> content.threadInfo
|
||||
is UnableToDecryptContent -> content.threadInfo
|
||||
is RedactedContent -> content.threadInfo
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -99,7 +99,6 @@ class RustMatrixClientFactory(
|
||||
isMessageSearchAvailable = isMessageSearchAvailable,
|
||||
)
|
||||
.homeserverUrl(sessionData.homeserverUrl)
|
||||
.username(sessionData.userId)
|
||||
.let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner }
|
||||
.use { it.build() }
|
||||
|
||||
|
||||
+2
@@ -39,6 +39,7 @@ fun StateEventType.map(): RustStateEventType = when (this) {
|
||||
StateEventType.RoomImagePack -> RustStateEventType.RoomImagePack
|
||||
StateEventType.RoomLanguage -> RustStateEventType.RoomLanguage
|
||||
StateEventType.RoomPolicy -> RustStateEventType.RoomPolicy
|
||||
StateEventType.RoomRetention -> RustStateEventType.RoomRetention
|
||||
is StateEventType.Custom -> stateEventTypeFromString(type)
|
||||
}
|
||||
|
||||
@@ -69,5 +70,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
|
||||
RustStateEventType.RoomImagePack -> StateEventType.RoomImagePack
|
||||
RustStateEventType.RoomLanguage -> StateEventType.RoomLanguage
|
||||
RustStateEventType.RoomPolicy -> StateEventType.RoomPolicy
|
||||
RustStateEventType.RoomRetention -> StateEventType.RoomRetention
|
||||
is RustStateEventType.Custom -> StateEventType.Custom(this.toString())
|
||||
}
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ class TimelineEventContentMapper(
|
||||
)
|
||||
}
|
||||
is MsgLikeKind.Redacted -> {
|
||||
RedactedContent
|
||||
RedactedContent(threadInfo = extractThreadInfo(it.content))
|
||||
}
|
||||
is MsgLikeKind.Poll -> {
|
||||
PollContent(
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class FakeFfiClientBuilder(
|
||||
override fun setSessionDelegate(sessionDelegate: ClientSessionDelegate) = this
|
||||
override fun slidingSyncVersionBuilder(versionBuilder: SlidingSyncVersionBuilder) = 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 threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
|
||||
override fun sqliteStore(config: SqliteStoreBuilder): ClientBuilder = this
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ fun aNotificationData(
|
||||
senderIsNameAmbiguous: Boolean = false,
|
||||
roomDisplayName: String? = A_ROOM_NAME,
|
||||
roomJoinRule: JoinRule? = null,
|
||||
isNoisy: Boolean = false,
|
||||
): NotificationData {
|
||||
return NotificationData(
|
||||
sessionId = A_SESSION_ID,
|
||||
@@ -45,7 +46,7 @@ fun aNotificationData(
|
||||
isDm = false,
|
||||
isSpace = isSpace,
|
||||
isEncrypted = false,
|
||||
isNoisy = false,
|
||||
isNoisy = isNoisy,
|
||||
timestamp = timestamp,
|
||||
content = content,
|
||||
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.ProfileDetails
|
||||
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.StickerContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
@@ -137,6 +138,10 @@ fun aStickerContent(
|
||||
threadInfo = threadInfo,
|
||||
)
|
||||
|
||||
fun aRedactedContent(
|
||||
threadInfo: EventThreadInfo? = null,
|
||||
) = RedactedContent(threadInfo = threadInfo)
|
||||
|
||||
fun aTimelineItemDebugInfo(
|
||||
model: String = "Rust(Model())",
|
||||
originalJson: String? = null,
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ fun SpaceHeaderRootView(
|
||||
)
|
||||
Text(
|
||||
text = stringResource(CommonStrings.screen_space_list_title),
|
||||
style = ElementTheme.typography.fontHeadingLgBold,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
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.tooling.preview.datasource.LoremIpsum
|
||||
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.RoomPreviewDescriptionAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
|
||||
@@ -68,11 +69,15 @@ fun SpaceHeaderView(
|
||||
},
|
||||
title = {
|
||||
if (name != null) {
|
||||
RoomPreviewTitleAtom(title = name)
|
||||
RoomPreviewTitleAtom(
|
||||
title = name,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
)
|
||||
} else {
|
||||
RoomPreviewTitleAtom(
|
||||
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() {
|
||||
override val values: Sequence<InReplyToDetails>
|
||||
get() = sequenceOf(
|
||||
RedactedContent,
|
||||
RedactedContent(threadInfo = null),
|
||||
UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
|
||||
).map {
|
||||
aInReplyToDetails(
|
||||
|
||||
+1
-1
@@ -656,7 +656,7 @@ class InReplyToMetadataKtTest : RobolectricTest() {
|
||||
fun `redacted content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = RedactedContent
|
||||
eventContent = RedactedContent(threadInfo = null)
|
||||
).metadata(hideImage = false)
|
||||
}.test {
|
||||
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,
|
||||
is PollContent,
|
||||
is ProfileChangeContent,
|
||||
RedactedContent,
|
||||
is RedactedContent,
|
||||
is RoomMembershipContent,
|
||||
is StateContent,
|
||||
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 dev.zacsweers.metro.AppScope
|
||||
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.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
@@ -44,6 +45,7 @@ import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@@ -172,18 +174,18 @@ class AndroidLocalMediaActions(
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
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 outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||
if (outputUri != null) {
|
||||
localMedia.openStream()?.use { input ->
|
||||
resolver.openOutputStream(outputUri).use { output ->
|
||||
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
|
||||
}
|
||||
val outputUri = saveWithUniqueFileName(localMedia.info.filename) { displayName ->
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
|
||||
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,
|
||||
modifier = modifier,
|
||||
)
|
||||
mimeType == MimeTypes.PlainText -> TextFileView(
|
||||
mimeType == MimeTypes.PlainText || mimeType == MimeTypes.Json -> TextFileView(
|
||||
localMedia = localMedia,
|
||||
textFileViewer = textFileViewer,
|
||||
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.OtherMessageType
|
||||
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.StickerMessageType
|
||||
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.aPollContent
|
||||
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.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||
@@ -80,7 +80,7 @@ class EventItemFactoryTest {
|
||||
LegacyCallInviteContent,
|
||||
aPollContent(),
|
||||
aProfileChangeMessageContent(),
|
||||
RedactedContent,
|
||||
aRedactedContent(),
|
||||
aRoomMembershipContent(
|
||||
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.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.net.toUri
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
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.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class AndroidLocalMediaActionsTest : RobolectricTest() {
|
||||
@Test
|
||||
@@ -62,6 +66,43 @@ class AndroidLocalMediaActionsTest : RobolectricTest() {
|
||||
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(
|
||||
RuntimeEnvironment.getApplication(),
|
||||
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