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/7397-keep-thread-after-redaction
# Conflicts: # features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt # features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt # features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt # libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+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
@@ -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(threadInfo = null)),
|
||||
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = aRedactedContent()),
|
||||
)
|
||||
}
|
||||
),
|
||||
|
||||
+39
-2
@@ -20,9 +20,10 @@ 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.RedactedContent
|
||||
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.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
|
||||
@@ -89,6 +90,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(
|
||||
@@ -146,7 +183,7 @@ class TimelineItemsFactoryTest {
|
||||
uniqueId = UniqueId("event-0"),
|
||||
event = anEventTimelineItem(
|
||||
sender = A_USER_ID,
|
||||
content = RedactedContent(
|
||||
content = aRedactedContent(
|
||||
threadInfo = EventThreadInfo.ThreadRoot(
|
||||
summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 3),
|
||||
),
|
||||
|
||||
+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"),
|
||||
|
||||
+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(threadInfo = null),
|
||||
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 {
|
||||
|
||||
+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(),
|
||||
)
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
|
||||
|
||||
+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,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+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(threadInfo = null),
|
||||
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()
|
||||
}
|
||||
@@ -45,7 +45,7 @@ private const val versionMonth = 8
|
||||
* Release number in the month. Value must be in [0,99].
|
||||
* Do not update this value. it is updated by the release script.
|
||||
*/
|
||||
private const val versionReleaseNumber = 2
|
||||
private const val versionReleaseNumber = 4
|
||||
|
||||
object Versions {
|
||||
/**
|
||||
|
||||
@@ -103,6 +103,7 @@ class KonsistClassNameTest {
|
||||
"FakeImageLoader",
|
||||
"FakeKeyStore",
|
||||
"FakeListenableFuture",
|
||||
"FakeMediaStoreContentProvider",
|
||||
"FakeParentNode",
|
||||
)
|
||||
.assertTrue {
|
||||
|
||||
@@ -133,6 +133,7 @@ class KonsistPreviewTest {
|
||||
"SecureBackupSetupViewChangePreview",
|
||||
"SelectedUserCannotRemovePreview",
|
||||
"SpaceMembersViewNoHeroesPreview",
|
||||
"SyncStateViewServerUnreachablePreview",
|
||||
"TextComposerAddCaptionPreview",
|
||||
"TextComposerCaptionPreview",
|
||||
"TextComposerEditCaptionPreview",
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.tests.testutils.fake
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.pm.ProviderInfo
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.robolectric.shadows.ShadowContentResolver
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Stands in for the MediaStore provider: it records the display names an insert is attempted with,
|
||||
* writes what is copied into it to a temporary directory, and can make any display name collide the
|
||||
* way MediaStore does once a directory already holds 32 files with the same name.
|
||||
*/
|
||||
class FakeMediaStoreContentProvider : ContentProvider() {
|
||||
private val directory: File = File.createTempFile("fake-media-store", null).apply {
|
||||
delete()
|
||||
mkdir()
|
||||
}
|
||||
|
||||
val insertedDisplayNames = mutableListOf<String>()
|
||||
|
||||
var conflictingDisplayNames: Set<String> = emptySet()
|
||||
|
||||
/** When true, an insert reports that it created nothing, the way a rejected insert does. */
|
||||
var returnsNoUriOnInsert: Boolean = false
|
||||
|
||||
fun savedFileNames(): List<String> = directory.list().orEmpty().toList()
|
||||
|
||||
fun savedContent(displayName: String): String = File(directory, displayName).readText()
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? {
|
||||
val displayName = values?.getAsString(MediaStore.MediaColumns.DISPLAY_NAME).orEmpty()
|
||||
insertedDisplayNames += displayName
|
||||
check(displayName !in conflictingDisplayNames) { "Failed to build unique file" }
|
||||
return if (returnsNoUriOnInsert) null else uri.buildUpon().appendPath(displayName).build()
|
||||
}
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
|
||||
val file = File(directory, uri.lastPathSegment.orEmpty())
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_READ_WRITE)
|
||||
}
|
||||
|
||||
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
||||
|
||||
override fun getType(uri: Uri): String? = null
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||
}
|
||||
|
||||
fun registerFakeMediaStoreContentProvider(): FakeMediaStoreContentProvider {
|
||||
return FakeMediaStoreContentProvider().apply {
|
||||
attachInfo(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
ProviderInfo().apply { authority = MediaStore.AUTHORITY }
|
||||
)
|
||||
ShadowContentResolver.registerProviderInternal(MediaStore.AUTHORITY, this)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user