Merge branch 'release/26.08.4'

This commit is contained in:
Benoit Marty
2026-08-27 14:21:20 +02:00
70 changed files with 1251 additions and 120 deletions
-38
View File
@@ -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
+64
View File
@@ -1,3 +1,67 @@
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,
@@ -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()
}
}
+53
View File
@@ -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.
+3 -3
View File
@@ -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
```
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -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 -> {
@@ -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" },
@@ -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)
@@ -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(),
)
@@ -91,7 +91,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
// Can be grouped
is FailedToParseMessageLikeContent,
is MessageContent,
RedactedContent,
is RedactedContent,
is StickerContent,
is PollContent,
is UnableToDecryptContent,
@@ -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 {
@@ -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,
@@ -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
@@ -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?,
@@ -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 {
@@ -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?,
@@ -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,
@@ -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?,
@@ -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,
@@ -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,
@@ -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>()
@@ -51,7 +51,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -69,6 +68,7 @@ import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@@ -880,7 +880,7 @@ class TimelinePresenterTest {
(0 until 3).map { index ->
MatrixTimelineItem.Event(
uniqueId = UniqueId("redacted_$index"),
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent),
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = aRedactedContent()),
)
}
),
@@ -12,12 +12,16 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
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
@@ -84,6 +88,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(
@@ -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"),
@@ -16,11 +16,11 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.core.FakeSendHandle
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -88,7 +88,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
sender = A_USER_ID,
senderProfile = ProfileDetails.Unavailable,
timestamp = 9442,
content = RedactedContent,
content = aRedactedContent(),
origin = null,
timelineItemDebugInfoProvider = {
TimelineItemDebugInfo(
@@ -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)
}
}
}
@@ -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(),
)
}
@@ -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() }
@@ -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()
}
}
@@ -56,7 +56,7 @@ class DefaultPinnedMessagesBannerFormatter(
is PollContent -> {
content.question.prefixWith(CommonStrings.a11y_poll)
}
RedactedContent -> {
is RedactedContent -> {
sp.getString(CommonStrings.common_message_removed)
}
else -> {
@@ -89,7 +89,7 @@ class DefaultRoomLatestEventFormatter(
): CharSequence? {
return when (content) {
is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
RedactedContent -> {
is RedactedContent -> {
val message = sp.getString(CommonStrings.common_message_removed)
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
@@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter(
is CallNotifyContent -> {
sp.getString(CommonStrings.common_call_started)
}
RedactedContent,
is RedactedContent,
is LegacyCallInviteContent,
is StickerContent,
is PollContent,
@@ -81,7 +81,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
fun `Redacted content`() {
val expected = "Message removed"
val senderName = "Someone"
val message = createRoomEvent(false, senderName, RedactedContent)
val message = createRoomEvent(false, senderName, RedactedContent(threadInfo = null))
val result = formatter.format(message)
assertThat(result).isEqualTo(expected)
}
@@ -85,7 +85,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
val expected = "Message removed"
val senderName = "Someone"
sequenceOf(false, true).forEach { isDm ->
val message = createLatestEvent(false, senderName, RedactedContent)
val message = createLatestEvent(false, senderName, RedactedContent(threadInfo = null))
val result = formatter.format(message, isDm)
if (isDm) {
assertThat(result).isEqualTo(expected)
@@ -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
}
@@ -32,7 +32,9 @@ data class MessageContent(
val type: MessageType,
) : EventContent
data object RedactedContent : EventContent
data class RedactedContent(
val threadInfo: EventThreadInfo?,
) : EventContent
data class StickerContent(
val filename: String,
@@ -51,6 +51,7 @@ data class EventTimelineItem(
is PollContent -> content.threadInfo
is StickerContent -> content.threadInfo
is UnableToDecryptContent -> content.threadInfo
is RedactedContent -> content.threadInfo
else -> null
}
@@ -81,7 +81,7 @@ class TimelineEventContentMapper(
)
}
is MsgLikeKind.Redacted -> {
RedactedContent
RedactedContent(threadInfo = extractThreadInfo(it.content))
}
is MsgLikeKind.Poll -> {
PollContent(
@@ -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,
@@ -118,7 +118,7 @@ class InReplyToDetailsDisambiguatedPreviewParam : InReplyToDetailsPreviewParam()
class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
RedactedContent,
RedactedContent(threadInfo = null),
UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
).map {
aInReplyToDetails(
@@ -656,7 +656,7 @@ class InReplyToMetadataKtTest : RobolectricTest() {
fun `redacted content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = RedactedContent
eventContent = RedactedContent(threadInfo = null)
).metadata(hideImage = false)
}.test {
awaitItem().let {
@@ -79,7 +79,7 @@ class EventItemFactory(
LegacyCallInviteContent,
is PollContent,
is ProfileChangeContent,
RedactedContent,
is RedactedContent,
is RoomMembershipContent,
is StateContent,
is StickerContent,
@@ -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)
}
}
}
@@ -35,7 +35,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@@ -49,6 +48,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.matrix.test.timeline.aStickerContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
@@ -80,7 +80,7 @@ class EventItemFactoryTest {
LegacyCallInviteContent,
aPollContent(),
aProfileChangeMessageContent(),
RedactedContent,
aRedactedContent(),
aRoomMembershipContent(
userId = A_USER_ID,
),
@@ -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(),
+1 -1
View File
@@ -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 = 3
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",
@@ -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)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package: name='io.element.android.x' versionCode='202608030' versionName='26.08.3' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17'
package: name='io.element.android.x' versionCode='202608040' versionName='26.08.4' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17'
sdkVersion:'24'
targetSdkVersion:'37'
uses-permission: name='android.permission.POST_NOTIFICATIONS'
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env bash
# 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.
# do not exit when any command fails (issue with git flow)
set +e
printf "\n================================================================================\n"
printf "| Welcome to the release script (V2)! |\n"
printf "================================================================================\n"
printf "Checking environment...\n"
envError=0
# Check that bundletool is installed
if ! command -v bundletool &> /dev/null
then
printf "Fatal: bundletool is not installed. You can install it running \`brew install bundletool\`\n"
envError=1
fi
# Check that the GitHub CLI is installed. It is expected to be already authenticated, see `gh auth login`.
if ! command -v gh &> /dev/null
then
printf "Fatal: gh (the GitHub CLI) is not installed. You can install it running \`brew install gh\`\n"
envError=1
fi
# Path of the key store (it's a file)
keyStorePath="${ELEMENT_X_KEYSTORE_PATH}"
if [[ -z "${keyStorePath}" ]]; then
printf "Fatal: ELEMENT_X_KEYSTORE_PATH is not defined in the environment.\n"
envError=1
fi
# Keystore password
keyStorePassword="${ELEMENT_X_KEYSTORE_PASSWORD}"
if [[ -z "${keyStorePassword}" ]]; then
printf "Fatal: ELEMENT_X_KEYSTORE_PASSWORD is not defined in the environment.\n"
envError=1
fi
# Key password
keyPassword="${ELEMENT_X_KEY_PASSWORD}"
if [[ -z "${keyPassword}" ]]; then
printf "Fatal: ELEMENT_X_KEY_PASSWORD is not defined in the environment.\n"
envError=1
fi
# Android home
androidHome="${ANDROID_HOME}"
if [[ -z "${androidHome}" ]]; then
printf "Fatal: ANDROID_HOME is not defined in the environment.\n"
envError=1
fi
# @elementbot:matrix.org matrix token / Not mandatory
elementBotToken="${ELEMENT_BOT_MATRIX_TOKEN}"
if [[ -z "${elementBotToken}" ]]; then
printf "Warning: ELEMENT_BOT_MATRIX_TOKEN is not defined in the environment.\n"
fi
if [ ${envError} == 1 ]; then
exit 1
fi
# Handle the result of a version check. ${checkError} must be set to 1 when a check has failed.
checkVersionResult() {
if [[ ${checkError} -ne 0 ]]; then
printf "\nThe check above has failed, this is not expected.\n"
read -r -p "Do you want to continue anyway (yes/no) default to no? " doContinue
doContinue=${doContinue:-no}
if [ "${doContinue}" != "yes" ]; then
exit 1
fi
else
printf "The versions are correct.\n"
fi
}
# Read minSdkVersion from file plugins/src/main/kotlin/Versions.kt
minSdkVersion=$(grep "MIN_SDK_FOSS =" ./plugins/src/main/kotlin/Versions.kt |cut -d '=' -f 2 |xargs)
# Read buildToolsVersion from file plugins/src/main/kotlin/Versions.kt
buildToolsVersion=$(grep "BUILD_TOOLS_VERSION =" ./plugins/src/main/kotlin/Versions.kt |cut -d '=' -f 2 |xargs)
buildToolsPath="${androidHome}/build-tools/${buildToolsVersion}"
if [[ ! -d ${buildToolsPath} ]]; then
printf "Fatal: %s folder not found, ensure that you have installed the SDK version %s.\n" "${buildToolsPath}" "${buildToolsVersion}"
exit 1
fi
# Check if git flow is enabled
gitFlowDevelop=$(git config gitflow.branch.develop)
if [[ ${gitFlowDevelop} != "" ]]
then
printf "Git flow is initialized\n"
else
printf "Git flow is not initialized. Initializing...\n"
./tools/gitflow/gitflow-init.sh
fi
printf "OK\n"
printf "\n================================================================================\n"
printf "Ensuring main and develop branches are up to date...\n"
git checkout main
git pull
git checkout develop
git pull
printf "\n================================================================================\n"
# Guessing version to propose a default version
versionsFile="./plugins/src/main/kotlin/Versions.kt"
# The version of the release must match the date of next monday, where the release is supposed to go live
# The command below gets the date of next monday
nextMondayDateCommand="date -v +1w -v -monday"
# Get release year on 2 digits
versionYearCandidate=$(${nextMondayDateCommand} +%y)
currentVersionMonth=$(grep "val versionMonth" ${versionsFile} | cut -d " " -f6)
# Get release month on 2 digits
versionMonthCandidate=$(${nextMondayDateCommand} +%m)
versionMonthCandidateNoLeadingZero=${versionMonthCandidate/#0/}
currentVersionReleaseNumber=$(grep "val versionReleaseNumber" ${versionsFile} | cut -d " " -f6)
# if the release month is the same as the current version, we increment the release number, else we reset it to 0
if [[ ${currentVersionMonth} -eq ${versionMonthCandidateNoLeadingZero} ]]; then
versionReleaseNumberCandidate=$((currentVersionReleaseNumber + 1))
else
versionReleaseNumberCandidate=0
fi
versionCandidate="${versionYearCandidate}.${versionMonthCandidate}.${versionReleaseNumberCandidate}"
read -r -p "Please enter the release version (example: ${versionCandidate}). Format must be 'YY.MM.x' or 'YY.MM.xy', with year and month matching next Monday. Just press enter if ${versionCandidate} is correct. " version
version=${version:-${versionCandidate}}
# extract year, month and release number for future use
versionYear=$(echo "${version}" | cut -d "." -f1)
versionMonth=$(echo "${version}" | cut -d "." -f2)
versionMonthNoLeadingZero=${versionMonth/#0/}
versionReleaseNumber=$(echo "${version}" | cut -d "." -f3)
printf "\n================================================================================\n"
printf "Starting the release %s\n" "${version}"
git flow release start "${version}"
# Note: in case the release is already started and the script is started again, checkout the release branch again.
ret=$?
if [[ $ret -ne 0 ]]; then
printf "Mmh, it seems that the release is already started. I'm displaying the changes now:\n"
git diff --stat "release/${version}" origin/main
printf "Do you want to continue the release using its contents?\n\n"
read -r -p "Continue (yes/no) default to yes? " doContinue
doContinue=${doContinue:-yes}
if [ "${doContinue}" == "no" ]; then
printf "OK, exiting, you can start the release again with the command 'git flow release start %s'\n" "${version}"
exit 1
fi
git checkout "release/${version}"
fi
# Ensure version is OK
versionsFileBak="${versionsFile}.bak"
cp ${versionsFile} ${versionsFileBak}
sed "s/private const val versionYear = .*/private const val versionYear = ${versionYear}/" ${versionsFileBak} > ${versionsFile}
sed "s/private const val versionMonth = .*/private const val versionMonth = ${versionMonthNoLeadingZero}/" ${versionsFile} > ${versionsFileBak}
sed "s/private const val versionReleaseNumber = .*/private const val versionReleaseNumber = ${versionReleaseNumber}/" ${versionsFileBak} > ${versionsFile}
rm ${versionsFileBak}
printf -v versionReleaseNumber2Digits "%02d" "${versionReleaseNumber}"
versionCode="20${versionYear}${versionMonth}${versionReleaseNumber2Digits}0"
# Update the file aaptDump.txt with the new version
aaptDumpFile="./tools/manifest/gplay/release/aaptDump.txt"
sed "s/versionCode='[0-9]*'/versionCode='${versionCode}'/" ${aaptDumpFile} > ${aaptDumpFile}.bak
sed "s/versionName='[0-9]*\.[0-9]*\.[0-9]*'/versionName='${version}'/" ${aaptDumpFile}.bak > ${aaptDumpFile}
rm ${aaptDumpFile}.bak
git commit -a -m "Setting version for the release ${version}"
printf "\n================================================================================\n"
printf "Creating fastlane file...\n"
fastlaneFile="${versionCode}.txt"
fastlanePathFile="./fastlane/metadata/android/en-US/changelogs/${fastlaneFile}"
printf "Main changes in this version: bug fixes and improvements.\nFull changelog: https://github.com/element-hq/element-x-android/releases" > "${fastlanePathFile}"
read -r -p "I have created the file ${fastlanePathFile}, please edit it and press enter to continue. "
git add "${fastlanePathFile}"
git commit -a -m "Adding fastlane file for version ${version}"
printf "\n================================================================================\n"
printf "OK, finishing the release...\n"
git flow release finish "${version}"
printf "\n================================================================================\n"
read -r -p "Done, push the branch 'main' and the new tag (yes/no) default to yes? " doPush
doPush=${doPush:-yes}
if [ "${doPush}" == "yes" ]; then
printf "Pushing branch 'main' and tag 'v%s'...\n" "${version}"
git push origin main
git push origin "v${version}"
else
printf "Not pushing, do not forget to push manually!\n"
fi
printf "\n================================================================================\n"
printf "Checking out develop...\n"
git checkout develop
printf "\n================================================================================\n"
printf "Downloading the artifacts...\n"
targetPath="./tmp/Element/${version}"
fdroidTargetPath="${targetPath}/fdroid"
gplayTargetPath="${targetPath}/gplay"
releaseCommit=$(git rev-parse --verify --quiet "v${version}^{commit}")
if [[ -z "${releaseCommit}" ]]; then
# Without a commit, `gh run list` would return the latest run of the workflow, which may be another one.
printf "Fatal: the tag v%s cannot be resolved.\n" "${version}"
exit 1
fi
printf "Looking for the run of the workflow release.yml for the commit %s...\n" "${releaseCommit}"
runId=""
# The run can take a few seconds to appear after the push, so retry for a couple of minutes.
for _ in $(seq 1 12); do
runId=$(gh run list --repo element-hq/element-x-android --workflow release.yml --commit "${releaseCommit}" --limit 1 --json databaseId -q '.[0].databaseId' 2> /dev/null)
if [[ -n "${runId}" ]]; then
break
fi
printf "No run found yet, waiting...\n"
sleep 10
done
if [[ -z "${runId}" ]]; then
printf "Fatal: no run of the workflow release.yml found for the commit %s.\n" "${releaseCommit}"
exit 1
fi
printf "Found the run https://github.com/element-hq/element-x-android/actions/runs/%s\n" "${runId}"
printf "Waiting for the run to complete...\n"
gh run watch "${runId}" --repo element-hq/element-x-android --compact --exit-status
ret=1
while [[ $ret -ne 0 ]]; do
gh run download "${runId}" --repo element-hq/element-x-android \
--dir "${gplayTargetPath}" \
--name elementx-app-gplay-bundle-unsigned
ret=$?
if [[ $ret -eq 0 ]]; then
gh run download "${runId}" --repo element-hq/element-x-android \
--dir "${fdroidTargetPath}" \
--name elementx-app-fdroid-apks-unsigned
ret=$?
fi
if [[ $ret -ne 0 ]]; then
read -r -p "Error while downloading the artifacts. You may want to fix the issue and retry. Retry (yes/no) default to yes? " doRetry
doRetry=${doRetry:-yes}
if [ "${doRetry}" == "no" ]; then
exit 1
fi
fi
done
printf "\n================================================================================\n"
printf "Signing the FDroid APKs...\n"
cp "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release.apk \
"${fdroidTargetPath}"/app-fdroid-arm64-v8a-release-signed.apk
"${buildToolsPath}"/apksigner sign \
-v \
--alignment-preserved true \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${fdroidTargetPath}"/app-fdroid-arm64-v8a-release-signed.apk
cp "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release.apk \
"${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release-signed.apk
"${buildToolsPath}"/apksigner sign \
-v \
--alignment-preserved true \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release-signed.apk
cp "${fdroidTargetPath}"/app-fdroid-x86-release.apk \
"${fdroidTargetPath}"/app-fdroid-x86-release-signed.apk
"${buildToolsPath}"/apksigner sign \
-v \
--alignment-preserved true \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${fdroidTargetPath}"/app-fdroid-x86-release-signed.apk
cp "${fdroidTargetPath}"/app-fdroid-x86_64-release.apk \
"${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk
"${buildToolsPath}"/apksigner sign \
-v \
--alignment-preserved true \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk
printf "\n================================================================================\n"
printf "Checking the signed APKs...\n"
checkError=0
# Each APK gets the version code of the app bundle, plus the code of its ABI.
# Must be kept in sync with the abiVersionCodes map in app/build.gradle.kts.
fdroidAbis="armeabi-v7a:1 arm64-v8a:2 x86:3 x86_64:4"
for abiEntry in ${fdroidAbis}; do
abi="${abiEntry%%:*}"
abiCode="${abiEntry##*:}"
expectedApkVersionCode=$((versionCode + abiCode))
apkFile="${fdroidTargetPath}/app-fdroid-${abi}-release-signed.apk"
apkBadging=$("${buildToolsPath}"/aapt dump badging "${apkFile}" | grep -m 1 "^package")
apkVersionCode=$(printf "%s" "${apkBadging}" | sed -n "s/.*versionCode='\([0-9]*\)'.*/\1/p")
apkVersionName=$(printf "%s" "${apkBadging}" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p")
printf "File app-fdroid-%s-release-signed.apk: version code %s, version name %s\n" "${abi}" "${apkVersionCode}" "${apkVersionName}"
if [[ "${apkVersionCode}" != "${expectedApkVersionCode}" ]]; then
printf "Warning: was expecting the version code %s, but got %s.\n" "${expectedApkVersionCode}" "${apkVersionCode}"
checkError=1
fi
if [[ "${apkVersionName}" != "${version}" ]]; then
printf "Warning: was expecting the version name %s, but got %s.\n" "${version}" "${apkVersionName}"
checkError=1
fi
done
checkVersionResult
printf "\n================================================================================\n"
printf "The APKs in %s have been signed!\n" "${fdroidTargetPath}"
unsignedBundlePath="${gplayTargetPath}/app-gplay-release.aab"
signedBundlePath="${gplayTargetPath}/app-gplay-release-signed.aab"
# The universal APK is downloaded manually from the GooglePlay console, and is named after the version code.
universalApkPath="${gplayTargetPath}/${versionCode}.apk"
printf "\n================================================================================\n"
printf "Signing file %s with build-tools version %s for min SDK version %s...\n" "${unsignedBundlePath}" "${buildToolsVersion}" "${minSdkVersion}"
cp "${unsignedBundlePath}" "${signedBundlePath}"
"${buildToolsPath}"/apksigner sign \
-v \
--ks "${keyStorePath}" \
--ks-pass pass:"${keyStorePassword}" \
--ks-key-alias elementx \
--key-pass pass:"${keyPassword}" \
--min-sdk-version "${minSdkVersion}" \
"${signedBundlePath}"
printf "\n================================================================================\n"
printf "Checking the signed app bundle...\n"
checkError=0
bundleVersionCode=$(bundletool dump manifest --bundle="${signedBundlePath}" --xpath=/manifest/@android:versionCode)
bundleVersionName=$(bundletool dump manifest --bundle="${signedBundlePath}" --xpath=/manifest/@android:versionName)
printf "File %s: version code %s, version name %s\n" "$(basename "${signedBundlePath}")" "${bundleVersionCode}" "${bundleVersionName}"
if [[ "${bundleVersionCode}" != "${versionCode}" ]]; then
printf "Warning: was expecting the version code %s, but got %s.\n" "${versionCode}" "${bundleVersionCode}"
checkError=1
fi
if [[ "${bundleVersionName}" != "${version}" ]]; then
printf "Warning: was expecting the version name %s, but got %s.\n" "${version}" "${bundleVersionName}"
checkError=1
fi
checkVersionResult
printf "\n================================================================================\n"
printf "The file %s has been signed and can be uploaded to the PlayStore!\n" "${signedBundlePath}"
printf "\n================================================================================\n"
read -r -p "Do you want to build the APKs from the app bundle? You need to do this step if you want to install the application to your device. (yes/no) default to no " doBuildApks
doBuildApks=${doBuildApks:-no}
if [ "${doBuildApks}" == "yes" ]; then
printf "Building apks...\n"
bundletool build-apks --bundle="${signedBundlePath}" --output="${gplayTargetPath}"/elementx.apks \
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
--overwrite
read -r -p "Do you want to install the application to your device? Make sure there is one (and only one!) connected device first. (yes/no) default to yes " doDeploy
doDeploy=${doDeploy:-yes}
if [ "${doDeploy}" == "yes" ]; then
printf "Installing apk for your device...\n"
bundletool install-apks --apks="${gplayTargetPath}"/elementx.apks
read -r -p "Please run the application on your phone to check that the upgrade went well. Press enter to continue. "
else
printf "APK will not be deployed!\n"
fi
else
printf "APKs will not be generated!\n"
fi
printf "\n================================================================================\n"
printf "Create the open testing release on GooglePlay.\n"
printf "On GooglePlay console, go the the open testing section and click on \"Create new release\" button, then:\n"
printf " - upload the file %s.\n" "${signedBundlePath}"
printf " - copy the release note from the fastlane file.\n"
printf " - download the universal APK, to be able to provide it to the GitHub release: click on the right arrow next to the \"App bundle\", then click on the \"Download\" tab, and download the \"Signed, universal APK\". Save it, without renaming it, to the folder %s. The expected final path is %s\n" "${gplayTargetPath}" "${universalApkPath}"
printf " - submit the release.\n"
read -r -p "Press enter to continue. "
printf "You can then go to \"Publishing overview\" and send the new release for a review by Google.\n"
read -r -p "Press enter to continue. "
printf "\n================================================================================\n"
printf "Creating the release on GitHub.\n"
releaseAssets=(
"${signedBundlePath}"
"${universalApkPath}"
"${fdroidTargetPath}/app-fdroid-arm64-v8a-release-signed.apk"
"${fdroidTargetPath}/app-fdroid-armeabi-v7a-release-signed.apk"
"${fdroidTargetPath}/app-fdroid-x86-release-signed.apk"
"${fdroidTargetPath}/app-fdroid-x86_64-release-signed.apk"
)
missingAsset=0
for releaseAsset in "${releaseAssets[@]}"; do
if [[ ! -f "${releaseAsset}" ]]; then
printf "Error: the file %s does not exist.\n" "${releaseAsset}"
missingAsset=1
fi
done
if [[ ${missingAsset} -ne 0 ]]; then
printf "Fatal: some files are missing, cannot create the GitHub release.\n"
exit 1
fi
printf "Creating the pre-release v%s and uploading the %d files, this can take a while...\n" "${version}" "${#releaseAssets[@]}"
gh release create "v${version}" \
--repo element-hq/element-x-android \
--title "Element X Android v${version}" \
--generate-notes \
--prerelease \
--verify-tag \
"${releaseAssets[@]}"
if [[ $? -ne 0 ]]; then
printf "Fatal: error while creating the GitHub release.\n"
exit 1
fi
printf "The pre-release has been created: https://github.com/element-hq/element-x-android/releases/tag/v%s\n" "${version}"
printf "Please check the generated release notes, and optionally reorder items and fix typos.\n"
read -r -p "Press enter to continue. "
printf "\n================================================================================\n"
printf "Update the project release notes:\n\n"
printf "Getting the release notes from GitHub...\n"
releaseNotes=$(gh release view "v${version}" --repo element-hq/element-x-android --json body -q .body)
if [[ $? -ne 0 || -z "${releaseNotes}" ]]; then
printf "Fatal: error while getting the release notes from GitHub.\n"
exit 1
fi
# GitHub returns the release notes with CRLF line endings, remove the CR.
releaseNotes="${releaseNotes//$'\r'/}"
changesTitle="Changes in Element X v${version}"
# Underline the title with as many '=' as there are characters in the title.
changesUnderline="${changesTitle//?/=}"
changesFile="./CHANGES.md"
changesFileBak="${changesFile}.bak"
mv "${changesFile}" "${changesFileBak}"
{
printf "%s\n%s\n\n" "${changesTitle}" "${changesUnderline}"
printf "%s\n\n" "${releaseNotes}"
cat "${changesFileBak}"
} > "${changesFile}"
rm "${changesFileBak}"
printf "The file CHANGES.md has been updated.\n"
read -r -p "Please check the change and press enter to commit it. "
printf "\n================================================================================\n"
printf "Committing...\n"
git commit -a -m "Changelog for version ${version}"
printf "\n================================================================================\n"
read -r -p "Done, push the branch 'develop' (yes/no) default to yes? (A rebase may be necessary in case develop got new commits) " doPush
doPush=${doPush:-yes}
if [ "${doPush}" == "yes" ]; then
printf "Pushing branch 'develop'...\n"
git push origin develop
else
printf "Not pushing, do not forget to push manually!\n"
fi
printf "\n================================================================================\n"
printf "Message for the Android internal room:\n\n"
message="@room Element X Android ${version} is ready to be tested. You can get it from https://github.com/element-hq/element-x-android/releases/tag/v${version}. You can install the universal APK. If you want to install the application from the app bundle, you can follow instructions [here](https://github.com/element-hq/element-x-android/blob/develop/docs/install_from_github_release.md). Please report any feedback. Thanks!"
printf "%s\n\n" "${message}"
if [[ -z "${elementBotToken}" ]]; then
read -r -p "ELEMENT_BOT_MATRIX_TOKEN is not defined in the environment. Cannot send the message for you. Please send it manually, and press enter to continue. "
else
read -r -p "Send this message to the room (yes/no) default to yes? " doSend
doSend=${doSend:-yes}
if [ "${doSend}" == "yes" ]; then
printf "Sending message...\n"
transactionId=$(openssl rand -hex 16)
# Element Android internal
matrixRoomId="!LiSLXinTDCsepePiYW:matrix.org"
curl -X PUT --data "{\"msgtype\":\"m.text\",\"body\":\"${message}\"}" -H "Authorization: Bearer ${elementBotToken}" https://matrix-client.matrix.org/_matrix/client/r0/rooms/${matrixRoomId}/send/m.room.message/\$local."${transactionId}"
else
printf "Message not sent, please send it manually!\n"
fi
fi
printf "\n================================================================================\n"
printf "Congratulation! Kudos for using this script! Have a nice day!\n"
printf "================================================================================\n"