mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 03:06:14 +00:00
Merge branch 'release/26.08.4'
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
|
||||
|
||||
+64
@@ -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,
|
||||
|
||||
+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
|
||||
+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 -> {
|
||||
|
||||
+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
-1
@@ -91,7 +91,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
|
||||
// Can be grouped
|
||||
is FailedToParseMessageLikeContent,
|
||||
is MessageContent,
|
||||
RedactedContent,
|
||||
is RedactedContent,
|
||||
is StickerContent,
|
||||
is PollContent,
|
||||
is UnableToDecryptContent,
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ data class TimelineItemAttachmentsContent(
|
||||
val body: String,
|
||||
val caption: String?,
|
||||
val formattedCaption: CharSequence?,
|
||||
val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val attachments: ImmutableList<AttachmentItem>,
|
||||
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ data class TimelineItemAudioContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val duration: Duration,
|
||||
override val mediaSource: MediaSource,
|
||||
|
||||
+10
@@ -33,6 +33,9 @@ sealed interface TimelineItemEventContentWithAttachment :
|
||||
val fileSize: Long?
|
||||
val caption: String?
|
||||
val formattedCaption: CharSequence?
|
||||
|
||||
/** The caption as it was written, in HTML, so that editing it can start from the formatted text. */
|
||||
val htmlCaption: String?
|
||||
val mediaSource: MediaSource
|
||||
val mimeType: String
|
||||
val formattedFileSize: String
|
||||
@@ -127,6 +130,13 @@ fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (thi
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun TimelineItemEventContent.htmlCaptionOrNull(): String? = when (this) {
|
||||
is TimelineItemEventContentWithAttachment -> htmlCaption
|
||||
is TimelineItemGalleryContent -> htmlCaption
|
||||
is TimelineItemAttachmentsContent -> htmlCaption
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
|
||||
return when (this) {
|
||||
is TimelineItemAudioContent -> duration
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ data class TimelineItemFileContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
override val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ data class TimelineItemGalleryContent(
|
||||
val body: String,
|
||||
val caption: String?,
|
||||
val formattedCaption: CharSequence?,
|
||||
val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val items: ImmutableList<GalleryItem>,
|
||||
) : TimelineItemEventContent, TimelineItemEventMutableContent {
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ data class TimelineItemImageContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
override val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ fun aTimelineItemImageContent(
|
||||
blurhash: String? = A_BLUR_HASH,
|
||||
filename: String = "A picture.jpg",
|
||||
caption: String? = null,
|
||||
htmlCaption: String? = null,
|
||||
width: Int? = null,
|
||||
height: Int? = 300,
|
||||
) = TimelineItemImageContent(
|
||||
@@ -35,6 +36,7 @@ fun aTimelineItemImageContent(
|
||||
fileSize = 4 * 1024 * 1024L,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
htmlCaption = htmlCaption,
|
||||
isEdited = false,
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ data class TimelineItemStickerContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
override val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ data class TimelineItemVideoContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val duration: Duration,
|
||||
override val mediaSource: MediaSource,
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ data class TimelineItemVoiceContent(
|
||||
override val fileSize: Long?,
|
||||
override val caption: String?,
|
||||
override val formattedCaption: CharSequence?,
|
||||
override val htmlCaption: String? = null,
|
||||
override val isEdited: Boolean,
|
||||
val duration: Duration,
|
||||
override val mediaSource: MediaSource,
|
||||
|
||||
+27
@@ -989,6 +989,33 @@ class MessagesPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action edit caption starts from the formatted caption`() = runTest {
|
||||
val messageEvent = aMessageEvent(
|
||||
content = aTimelineItemImageContent(
|
||||
caption = "Hello world",
|
||||
htmlCaption = "<b>Hello</b> world",
|
||||
)
|
||||
)
|
||||
val composerRecorder = EventsRecorder<MessageComposerEvent>()
|
||||
val presenter = createMessagesPresenter(
|
||||
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder, showTextFormatting = true) },
|
||||
)
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.EditCaption, messageEvent))
|
||||
awaitItem()
|
||||
composerRecorder.assertSingle(
|
||||
MessageComposerEvent.SetMode(
|
||||
composerMode = MessageComposerMode.EditCaption(
|
||||
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
||||
content = "<b>Hello</b> world",
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action add caption`() = runTest {
|
||||
val composerRecorder = EventsRecorder<MessageComposerEvent>()
|
||||
|
||||
+2
-2
@@ -51,7 +51,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
@@ -69,6 +68,7 @@ import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
@@ -880,7 +880,7 @@ class TimelinePresenterTest {
|
||||
(0 until 3).map { index ->
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = UniqueId("redacted_$index"),
|
||||
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent),
|
||||
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = aRedactedContent()),
|
||||
)
|
||||
}
|
||||
),
|
||||
|
||||
+40
@@ -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(
|
||||
|
||||
+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,
|
||||
content = aRedactedContent(),
|
||||
origin = null,
|
||||
timelineItemDebugInfoProvider = {
|
||||
TimelineItemDebugInfo(
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -56,7 +56,7 @@ class DefaultPinnedMessagesBannerFormatter(
|
||||
is PollContent -> {
|
||||
content.question.prefixWith(CommonStrings.a11y_poll)
|
||||
}
|
||||
RedactedContent -> {
|
||||
is RedactedContent -> {
|
||||
sp.getString(CommonStrings.common_message_removed)
|
||||
}
|
||||
else -> {
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ class DefaultRoomLatestEventFormatter(
|
||||
): CharSequence? {
|
||||
return when (content) {
|
||||
is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||
RedactedContent -> {
|
||||
is RedactedContent -> {
|
||||
val message = sp.getString(CommonStrings.common_message_removed)
|
||||
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter(
|
||||
is CallNotifyContent -> {
|
||||
sp.getString(CommonStrings.common_call_started)
|
||||
}
|
||||
RedactedContent,
|
||||
is RedactedContent,
|
||||
is LegacyCallInviteContent,
|
||||
is StickerContent,
|
||||
is PollContent,
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
|
||||
fun `Redacted content`() {
|
||||
val expected = "Message removed"
|
||||
val senderName = "Someone"
|
||||
val message = createRoomEvent(false, senderName, RedactedContent)
|
||||
val message = createRoomEvent(false, senderName, RedactedContent(threadInfo = null))
|
||||
val result = formatter.format(message)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
val expected = "Message removed"
|
||||
val senderName = "Someone"
|
||||
sequenceOf(false, true).forEach { isDm ->
|
||||
val message = createLatestEvent(false, senderName, RedactedContent)
|
||||
val message = createLatestEvent(false, senderName, RedactedContent(threadInfo = null))
|
||||
val result = formatter.format(message, isDm)
|
||||
if (isDm) {
|
||||
assertThat(result).isEqualTo(expected)
|
||||
|
||||
+5
@@ -14,11 +14,16 @@ import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
||||
class FakeRoomLatestEventFormatter : RoomLatestEventFormatter {
|
||||
private var result: CharSequence? = null
|
||||
|
||||
var lastIsDmRoom: Boolean? = null
|
||||
private set
|
||||
|
||||
override fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? {
|
||||
lastIsDmRoom = isDmRoom
|
||||
return result
|
||||
}
|
||||
|
||||
override fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence? {
|
||||
lastIsDmRoom = isDmRoom
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -32,7 +32,9 @@ data class MessageContent(
|
||||
val type: MessageType,
|
||||
) : EventContent
|
||||
|
||||
data object RedactedContent : EventContent
|
||||
data class RedactedContent(
|
||||
val threadInfo: EventThreadInfo?,
|
||||
) : EventContent
|
||||
|
||||
data class StickerContent(
|
||||
val filename: String,
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ data class EventTimelineItem(
|
||||
is PollContent -> content.threadInfo
|
||||
is StickerContent -> content.threadInfo
|
||||
is UnableToDecryptContent -> content.threadInfo
|
||||
is RedactedContent -> content.threadInfo
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ class TimelineEventContentMapper(
|
||||
)
|
||||
}
|
||||
is MsgLikeKind.Redacted -> {
|
||||
RedactedContent
|
||||
RedactedContent(threadInfo = extractThreadInfo(it.content))
|
||||
}
|
||||
is MsgLikeKind.Poll -> {
|
||||
PollContent(
|
||||
|
||||
+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
@@ -118,7 +118,7 @@ class InReplyToDetailsDisambiguatedPreviewParam : InReplyToDetailsPreviewParam()
|
||||
class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() {
|
||||
override val values: Sequence<InReplyToDetails>
|
||||
get() = sequenceOf(
|
||||
RedactedContent,
|
||||
RedactedContent(threadInfo = null),
|
||||
UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
|
||||
).map {
|
||||
aInReplyToDetails(
|
||||
|
||||
+1
-1
@@ -656,7 +656,7 @@ class InReplyToMetadataKtTest : RobolectricTest() {
|
||||
fun `redacted content`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
anInReplyToDetailsReady(
|
||||
eventContent = RedactedContent
|
||||
eventContent = RedactedContent(threadInfo = null)
|
||||
).metadata(hideImage = false)
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ class EventItemFactory(
|
||||
LegacyCallInviteContent,
|
||||
is PollContent,
|
||||
is ProfileChangeContent,
|
||||
RedactedContent,
|
||||
is RedactedContent,
|
||||
is RoomMembershipContent,
|
||||
is StateContent,
|
||||
is StickerContent,
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -35,7 +35,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
@@ -49,6 +48,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.aPollContent
|
||||
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
|
||||
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||
@@ -80,7 +80,7 @@ class EventItemFactoryTest {
|
||||
LegacyCallInviteContent,
|
||||
aPollContent(),
|
||||
aProfileChangeMessageContent(),
|
||||
RedactedContent,
|
||||
aRedactedContent(),
|
||||
aRoomMembershipContent(
|
||||
userId = A_USER_ID,
|
||||
),
|
||||
|
||||
+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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
+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.
@@ -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'
|
||||
|
||||
Executable
+541
@@ -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"
|
||||
Reference in New Issue
Block a user