Merge remote-tracking branch 'upstream/develop' into feature/scroll-to-unread-messages

Resolved conflicts:
- libraries/matrix/impl/.../fakes/FakeFfiSpaceRoomList.kt: took upstream's
  direct return for rooms() — the simulateLongTask wrapper is for IO-style
  operations like paginate/reset, not a quick read.
- libraries/ui-strings/src/main/res/values/temporary.xml: kept the file with
  only the live strings (action_mark_as_read, a11y_jump_to_unread_messages).
  Dropped common_black (upstream moved theme strings to module-local R.string)
  and a11y_jump_to_unread_messages_count (badge count was removed earlier).
- RoomInfo.kt and its fixtures/tests: took upstream's version since both
  branches independently added fullyReadEventId via the same SDK bump
  (matrix-rust-components-kotlin 26.05.20).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jenna Vassar
2026-05-21 11:26:14 -07:00
369 changed files with 4556 additions and 2075 deletions
+7
View File
@@ -34,6 +34,13 @@
"/^org.jetbrains.kotlinx:kotlinx-datetime/", "/^org.jetbrains.kotlinx:kotlinx-datetime/",
], ],
}, },
{
// Keep Guava on the Android variant and ignore jre-only upgrades.
"matchPackageNames": [
"com.google.guava:guava",
],
"allowedVersions": "/-android$/",
},
{ {
// Limit PostHog Android upgrade to one PR per month, the first day of the month // Limit PostHog Android upgrade to one PR per month, the first day of the month
"matchPackageNames": [ "matchPackageNames": [
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
mkdir -p screenshots/en mkdir -p screenshots/en
cp tests/uitests/src/test/snapshots/images/* screenshots/en cp tests/uitests/src/test/snapshots/images/* screenshots/en
- name: Deploy GitHub Pages - name: Deploy GitHub Pages
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./screenshots publish_dir: ./screenshots
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
steps: steps:
- name: Check membership - name: Check membership
if: github.event.pull_request.user.login != 'renovate[bot]' if: github.event.pull_request.user.login != 'renovate[bot]'
uses: tspascoal/get-user-teams-membership@b1480b119326dde04ceffbeccd98e41892539c74 # v4.0.0 uses: tspascoal/get-user-teams-membership@818140d631d5f29f26b151afbe4179f87d9ceb5e # v4.0.1
id: teams id: teams
with: with:
username: ${{ github.event.pull_request.user.login }} username: ${{ github.event.pull_request.user.login }}
@@ -7,7 +7,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn: - tapOn:
text: ${MAESTRO_INVITEE1_MXID} text: ${MAESTRO_INVITEE1_MXID}
index: 1 index: 1
- tapOn: "Send invite" - tapOn: "Continue"
- takeScreenshot: build/maestro/330-createAndDeleteDM - takeScreenshot: build/maestro/330-createAndDeleteDM
- tapOn: "maestroelement2" - tapOn: "maestroelement2"
- scroll - scroll
@@ -24,8 +24,16 @@ appId: ${MAESTRO_APP_ID}
text: ${MAESTRO_INVITEE2_MXID} text: ${MAESTRO_INVITEE2_MXID}
index: 1 index: 1
- tapOn: "Invite" - tapOn: "Invite"
- runFlow:
when:
visible: 'Invite new contact to this room?'
commands:
- tapOn:
id: "confirm_invite_unknown"
# Close the keyboard if it's still open
- tapOn: "Back"
# Go back to the room details screen
- tapOn: "Back" - tapOn: "Back"
- tapOn: "aRoomName"
- scrollUntilVisible: - scrollUntilVisible:
direction: DOWN direction: DOWN
element: element:
+83
View File
@@ -1,3 +1,86 @@
Changes in Element X v26.05.2
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.05.2 -->
## What's Changed
### ✨ Features
* Remove SignInWithClassic FeatureFlag to enable the feature. by @bmarty in https://github.com/element-hq/element-x-android/pull/6698
* Create a new room when inviting people in a DM by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6756
* Remove LiveLocationSharing feature flag by @ganfra in https://github.com/element-hq/element-x-android/pull/6811
### 🙌 Improvements
* Disable biometric unlock when we disable pin code unlock by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6781
### 🐛 Bugfixes
* Fix room list duplicate-detection telemetry crashing before it can report by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/6791
* Only load full media on media viewer when it's the visible item by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6794
* Attempt to fix room list item duplicates at midnight by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6793
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6798
### 🧱 Build
* Fix Maestro again after changes to the invite flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6796
* Renovate: Keep Guava on the Android variant and ignore jre-only upgrades by @bmarty in https://github.com/element-hq/element-x-android/pull/6776
### Dependency upgrades
* Update dependency androidx.compose:compose-bom to v2026.05.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6784
* Update dependency io.sentry:sentry-android to v8.41.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6787
* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6790
* Update camera to v1.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6783
* Update dependency androidx.webkit:webkit to v1.16.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6786
* Update dependency com.google.firebase:firebase-bom to v34.13.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6789
* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.18 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6805
### Others
* Add MIDI playback by @cizra in https://github.com/element-hq/element-x-android/pull/6770
* Show error message when using "Sign in with QR code" with a QR from a device that is also not signed in by @hughns in https://github.com/element-hq/element-x-android/pull/6802
## New Contributors
* @jennaharris7 made their first contribution in https://github.com/element-hq/element-x-android/pull/6791
* @cizra made their first contribution in https://github.com/element-hq/element-x-android/pull/6770
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.05.1...v26.05.2
Changes in Element X v26.05.1
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.05.1 -->
## What's Changed
### ✨ Features
* Make Element Call screen work edge-to-edge by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6634
### 🙌 Improvements
* Stop removing the `logs` dir when clearing cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6765
* Adapt to new DM definition changes in the SDK by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6748
* feat: Update call started timeline item + declined support by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6649
### 🐛 Bugfixes
* Improve pin code UX by @bmarty in https://github.com/element-hq/element-x-android/pull/6744
* Use just the other user's avatar for DM details by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6738
* Improve `FetchPushForegroundService`'s reliability by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6757
* Prevent user from starting Live Location Sharing in thread by @bmarty in https://github.com/element-hq/element-x-android/pull/6767
* Fix media playback from the timeline broken when exiting a thread by @bmarty in https://github.com/element-hq/element-x-android/pull/6771
* Pin code: remove the key if there is no pin code by @bmarty in https://github.com/element-hq/element-x-android/pull/6780
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6761
### 🚧 In development 🚧
* Feature : share live location by @ganfra in https://github.com/element-hq/element-x-android/pull/6741
### Dependency upgrades
* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6746
* Update actions/add-to-project action to v2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6758
* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6759
* Update dependency io.element.android:element-call-embedded to v0.19.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6766
* Update metro to v1 (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6720
* Update tspascoal/get-user-teams-membership action to v4.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6750
* Update plugin sonarqube to v7.3.0.8198 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6743
* Update plugin dependencycheck to v12.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6760
* Update dependency com.google.guava:guava to v33.6.0-android by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6646
* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6779
### Others
* Render media captions formatting in the media viewer by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6729
* Reduce FeatureFlag `Knock` effect on room creation and room edition forms by @bmarty in https://github.com/element-hq/element-x-android/pull/6768
* Use the right analytics span as a parent in `checkNetworkConnection` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6751
* Add missing strings `theme.black` by @bmarty in https://github.com/element-hq/element-x-android/pull/6772
* Map back button in web view to esc (revive fixed version of: https://github.com/element-hq/element-x-android/pull/6724) by @toger5 in https://github.com/element-hq/element-x-android/pull/6725
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.05.0...v26.05.1
Changes in Element X v26.05.0 Changes in Element X v26.05.0
============================= =============================
@@ -382,9 +382,13 @@ class LoggedInFlowNode(
} }
is NavTarget.Room -> { is NavTarget.Room -> {
val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback { val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback {
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) { override fun onDone() {
backstack.pop()
}
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean) {
lifecycleScope.launch { lifecycleScope.launch {
attachRoom(roomIdOrAlias = roomId.toRoomIdOrAlias(), serverNames = serverNames, clearBackstack = false) attachRoom(roomIdOrAlias = roomId.toRoomIdOrAlias(), serverNames = serverNames, clearBackstack = clearBackStack)
} }
} }
@@ -82,7 +82,8 @@ class JoinedRoomLoadedFlowNode(
plugins = plugins, plugins = plugins,
), DependencyInjectionGraphOwner { ), DependencyInjectionGraphOwner {
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId, serverNames: List<String>) fun onDone()
fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean = false)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun navigateToGlobalNotificationSettings() fun navigateToGlobalNotificationSettings()
fun navigateToDeveloperSettings() fun navigateToDeveloperSettings()
@@ -142,6 +143,10 @@ class JoinedRoomLoadedFlowNode(
private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node { private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {
val callback = object : RoomDetailsEntryPoint.Callback { val callback = object : RoomDetailsEntryPoint.Callback {
override fun onDone() {
callback.onDone()
}
override fun navigateToGlobalNotificationSettings() { override fun navigateToGlobalNotificationSettings() {
callback.navigateToGlobalNotificationSettings() callback.navigateToGlobalNotificationSettings()
} }
@@ -150,7 +155,7 @@ class JoinedRoomLoadedFlowNode(
callback.navigateToDeveloperSettings() callback.navigateToDeveloperSettings()
} }
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) { override fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean) {
callback.navigateToRoom(roomId, serverNames) callback.navigateToRoom(roomId, serverNames)
} }
@@ -13,7 +13,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback { class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback {
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError() override fun onDone() = lambdaError()
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun navigateToGlobalNotificationSettings() = lambdaError() override fun navigateToGlobalNotificationSettings() = lambdaError()
override fun navigateToDeveloperSettings() = lambdaError() override fun navigateToDeveloperSettings() = lambdaError()
@@ -0,0 +1,2 @@
Main changes in this version: improvements in Element Call, room knocking and room directory are now available, improvements on DMs.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -0,0 +1,26 @@
/*
* 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.call.impl.ui
internal sealed interface CallScreenBackPressAction {
data object DispatchEscapeToWebView : CallScreenBackPressAction
data object EnterPictureInPicture : CallScreenBackPressAction
}
internal object CallScreenBackPressPolicy {
fun resolve(
supportPip: Boolean,
hasWebView: Boolean,
fromNative: Boolean,
): CallScreenBackPressAction? {
return when {
hasWebView && fromNative -> CallScreenBackPressAction.DispatchEscapeToWebView
hasWebView && supportPip -> CallScreenBackPressAction.EnterPictureInPicture
else -> null
}
}
}
@@ -17,9 +17,10 @@ import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -45,7 +46,6 @@ import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber import timber.log.Timber
@@ -64,94 +64,93 @@ internal fun CallScreenView(
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit, requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun handleBack() { var callWebView by remember { mutableStateOf<WebView?>(null) }
if (pipState.supportPip) {
pipState.eventSink.invoke(PictureInPictureEvent.EnterPictureInPicture) fun handleBack(fromNative: Boolean = false) {
} else { when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) {
state.eventSink(CallScreenEvent.Hangup) CallScreenBackPressAction.EnterPictureInPicture ->
pipState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
CallScreenBackPressAction.DispatchEscapeToWebView ->
callWebView?.dispatchEscKeyEvent()
null -> Timber.d("Back press with unsupported pip is a no-op")
} }
} }
Scaffold( BackHandler {
modifier = modifier, handleBack(fromNative = true)
) { padding -> }
BackHandler { if (state.webViewError != null) {
handleBack() ErrorDialog(
content = buildString {
append(stringResource(CommonStrings.error_unknown))
state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
},
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
)
} else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(null) }
invalidAudioDeviceReason?.let {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
invalidAudioDeviceReason = null
}
} }
if (state.webViewError != null) {
ErrorDialog(
content = buildString {
append(stringResource(CommonStrings.error_unknown))
state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
},
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
)
} else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(null) } CallWebView(
invalidAudioDeviceReason?.let { modifier = modifier.consumeWindowInsets(WindowInsets.systemBars).fillMaxSize(),
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) { url = state.urlState,
invalidAudioDeviceReason = null userAgent = state.userAgent,
} onPermissionsRequest = { request ->
val androidPermissions = mapWebkitPermissions(request.resources)
val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback)
},
onConsoleMessage = onConsoleMessage,
onCreateWebView = { webView ->
callWebView = webView
webView.addBackHandler(onBackPressed = ::handleBack)
val interceptor = WebViewWidgetMessageInterceptor(
webView = webView,
onUrlLoaded = { url ->
webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null)
if (webViewAudioManager?.isInCallMode?.get() == false) {
Timber.d("URL $url is loaded, starting in-call audio mode")
webViewAudioManager?.onCallStarted()
} else {
Timber.d("Can't start in-call audio mode since the app is already in it.")
}
},
onError = { state.eventSink(CallScreenEvent.OnWebViewError(it)) },
)
webViewAudioManager = WebViewAudioManager(
webView = webView,
coroutineScope = coroutineScope,
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
)
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
},
onDestroyWebView = {
callWebView = null
// Reset audio mode
webViewAudioManager?.onCallStopped()
} }
)
CallWebView( when (state.urlState) {
modifier = Modifier AsyncData.Uninitialized,
.padding(padding) is AsyncData.Loading ->
.consumeWindowInsets(padding) ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
.fillMaxSize(), is AsyncData.Failure -> {
url = state.urlState, Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
userAgent = state.userAgent, ErrorDialog(
onPermissionsRequest = { request -> content = state.urlState.error.message.orEmpty(),
val androidPermissions = mapWebkitPermissions(request.resources) onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
val callback: RequestPermissionCallback = { request.grant(it) } )
requestPermissions(androidPermissions.toTypedArray(), callback)
},
onConsoleMessage = onConsoleMessage,
onCreateWebView = { webView ->
webView.addBackHandler(onBackPressed = ::handleBack)
val interceptor = WebViewWidgetMessageInterceptor(
webView = webView,
onUrlLoaded = { url ->
webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null)
if (webViewAudioManager?.isInCallMode?.get() == false) {
Timber.d("URL $url is loaded, starting in-call audio mode")
webViewAudioManager?.onCallStarted()
} else {
Timber.d("Can't start in-call audio mode since the app is already in it.")
}
},
onError = { state.eventSink(CallScreenEvent.OnWebViewError(it)) },
)
webViewAudioManager = WebViewAudioManager(
webView = webView,
coroutineScope = coroutineScope,
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
)
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
},
onDestroyWebView = {
// Reset audio mode
webViewAudioManager?.onCallStopped()
}
)
when (state.urlState) {
AsyncData.Uninitialized,
is AsyncData.Loading ->
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
is AsyncData.Failure -> {
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
ErrorDialog(
content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
)
}
is AsyncData.Success -> Unit
} }
is AsyncData.Success -> Unit
} }
} }
} }
@@ -248,15 +247,16 @@ private fun WebView.setup(
private fun WebView.addBackHandler(onBackPressed: () -> Unit) { private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
addJavascriptInterface( addJavascriptInterface(
object { JavascriptBackHandlerBridge(callback = onBackPressed),
@Suppress("unused")
@JavascriptInterface
fun onBackPressed() = onBackPressed()
},
"backHandler" "backHandler"
) )
} }
private fun WebView.dispatchEscKeyEvent() {
dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_ESCAPE))
dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_ESCAPE))
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun CallScreenViewPreview( internal fun CallScreenViewPreview(
@@ -275,3 +275,12 @@ internal fun CallScreenViewPreview(
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview { internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {} InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
} }
internal class JavascriptBackHandlerBridge(
private val callback: () -> Unit,
) {
@JavascriptInterface
fun onBackPressed() {
callback()
}
}
@@ -32,6 +32,9 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.core.app.PictureInPictureModeChangedInfo import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
@@ -52,6 +55,7 @@ import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSize
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import timber.log.Timber import timber.log.Timber
@@ -111,6 +115,27 @@ class ElementCallActivity :
val colors by remember(webViewTarget.value?.sessionId) { val colors by remember(webViewTarget.value?.sessionId) {
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId) enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
}.collectAsState(SemanticColorsLightDark.default) }.collectAsState(SemanticColorsLightDark.default)
// When the height is compact, hide the system bars by default to maximize the space for the call, using immersive mode
val hasCompactHeight = hasCompactHeightWindowSize()
DisposableEffect(hasCompactHeight, pipState.isInPictureInPicture) {
if (hasCompactHeight && !pipState.isInPictureInPicture) {
val window = this@ElementCallActivity.window ?: return@DisposableEffect onDispose {}
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
val systemBarInsets = WindowInsetsCompat.Type.systemBars()
insetsController.hide(systemBarInsets)
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
insetsController.show(systemBarInsets)
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
}
} else {
onDispose {}
}
}
ElementThemeApp( ElementThemeApp(
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
@@ -201,12 +201,9 @@ class WebViewAudioManager(
return return
} }
coroutineScope.launch { // Since this should run when the call is no longer running, it should be OK to not use the mutex here
proximitySensorMutex.withLock { if (proximitySensorWakeLock?.isHeld == true) {
if (proximitySensorWakeLock?.isHeld == true) { proximitySensorWakeLock?.release()
proximitySensorWakeLock?.release()
}
}
} }
audioManager.mode = AudioManager.MODE_NORMAL audioManager.mode = AudioManager.MODE_NORMAL
@@ -4,5 +4,5 @@
<string name="call_foreground_service_message_android">"Kõne juurde naasmiseks klõpsa"</string> <string name="call_foreground_service_message_android">"Kõne juurde naasmiseks klõpsa"</string>
<string name="call_foreground_service_title_android">"☎️ Kõne on pooleli"</string> <string name="call_foreground_service_title_android">"☎️ Kõne on pooleli"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ei võimalda selles Androidi versioonis Bluetoothi heliseadmete kasutamist. Palun vali mõni muu heliseade."</string> <string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ei võimalda selles Androidi versioonis Bluetoothi heliseadmete kasutamist. Palun vali mõni muu heliseade."</string>
<string name="screen_incoming_call_subtitle_android">"Sissetulev Element Calli kõne"</string> <string name="screen_incoming_call_subtitle_android">"Saabuv Element Calli kõne"</string>
</resources> </resources>
@@ -0,0 +1,96 @@
/*
* 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.call.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.ui.CallScreenBackPressAction
import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy
import org.junit.Test
class CallScreenBackPressPolicyTest {
@Test
fun `resolve returns dispatch escape when a web view is available and native button is pressed`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = true,
fromNative = true,
)
assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
}
@Test
fun `resolve dispatch escape when there is a web view and pip is supported on native button press`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = true,
fromNative = true,
)
assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
}
@Test
fun `resolve returns hangup when there is no web view and pip is not supported from native button`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = false,
fromNative = true,
)
assertThat(result).isNull()
}
@Test
fun `resolve returns hangup when there is no web view even though pip is supported from native button`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = false,
fromNative = true,
)
assertThat(result).isNull()
}
@Test
fun `resolve goes to pip if its not from native but from the webview`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = true,
fromNative = false,
)
assertThat(result).isEqualTo(CallScreenBackPressAction.EnterPictureInPicture)
}
@Test
fun `resolve hangs up if its not from native but from the webview and pip is not supported`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = true,
fromNative = false,
)
assertThat(result).isNull()
}
@Test
fun `invalid cases (event comes from webview but there is now webview) all result in hangup`() {
val withPipSupport = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = false,
fromNative = false,
)
assertThat(withPipSupport).isNull()
val withOutPipSupport = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = false,
fromNative = false,
)
assertThat(withOutPipSupport).isNull()
}
}
@@ -0,0 +1,151 @@
/*
* 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.call.ui
import android.view.KeyEvent
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenState
import io.element.android.features.call.impl.ui.CallScreenView
import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge
import io.element.android.features.call.impl.ui.aCallScreenState
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.pressBackKey
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.annotation.Resetter
import org.robolectric.shadows.ShadowWebView
@OptIn(ExperimentalTestApi::class)
@RunWith(AndroidJUnit4::class)
class CallScreenViewTest {
@Test
fun `pressing back key triggers hangup when no web view is available and pip is unsupported`() = runAndroidComposeUiTest {
val callEvents = EventsRecorder<CallScreenEvent>()
setCallScreenView(
state = aCallScreenState(eventSink = callEvents),
useInspectionMode = true,
)
pressBackKey()
callEvents.assertEmpty()
}
@Config(shadows = [RecordingShadowWebView::class])
@Test
fun `pressing back key dispatches escape key events to web view when pip is unsupported`() = runAndroidComposeUiTest {
setCallScreenView(
state = aCallScreenState(),
useInspectionMode = false,
pipState = aPictureInPictureState(supportPip = false),
)
pressBackKey()
val dispatchedEvents = RecordingShadowWebView.dispatchedEvents
assertEquals(2, dispatchedEvents.size)
assertEquals(KeyEvent.ACTION_DOWN, dispatchedEvents[0].action)
assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[0].keyCode)
assertEquals(KeyEvent.ACTION_UP, dispatchedEvents[1].action)
assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[1].keyCode)
}
@Config(shadows = [RecordingShadowWebView::class])
@Test
fun `web view javascript back handler emits pip event when pip is supported`() = runAndroidComposeUiTest {
val pipEvents = EventsRecorder<PictureInPictureEvent>()
setCallScreenView(
state = aCallScreenState(),
useInspectionMode = false,
pipState = aPictureInPictureState(
supportPip = true,
eventSink = pipEvents,
),
)
runOnIdle {
RecordingShadowWebView.invokeJavascriptBackHandler()
}
pipEvents.assertSize(2)
pipEvents.assertTrue(0) { it is PictureInPictureEvent.SetPipController }
pipEvents.assertTrue(1) { it is PictureInPictureEvent.EnterPictureInPicture }
}
}
@OptIn(ExperimentalTestApi::class)
private fun <A : ComponentActivity> AndroidComposeUiTest<A>.setCallScreenView(
state: CallScreenState,
useInspectionMode: Boolean,
pipState: PictureInPictureState = aPictureInPictureState(supportPip = false),
) {
setContent {
// Inspection mode disables AndroidView creation; keep it configurable per test.
CompositionLocalProvider(LocalInspectionMode provides useInspectionMode) {
CallScreenView(
state = state,
pipState = pipState,
onConsoleMessage = {},
requestPermissions = { _, _ -> },
)
}
}
}
@Implements(WebView::class)
internal class RecordingShadowWebView : ShadowWebView() {
companion object {
val dispatchedEvents = mutableListOf<KeyEvent>()
private var backHandlerJavascriptInterface: JavascriptBackHandlerBridge? = null
@Resetter
@JvmStatic
@Suppress("unused")
fun resetRecordedEvents() {
dispatchedEvents.clear()
backHandlerJavascriptInterface = null
}
fun invokeJavascriptBackHandler() {
val backHandler = checkNotNull(backHandlerJavascriptInterface) { "Expected backHandler JavaScript interface to be registered" }
backHandler.onBackPressed()
}
}
@Implementation
protected override fun addJavascriptInterface(`object`: Any, name: String) {
super.addJavascriptInterface(`object`, name)
if (name == "backHandler") {
backHandlerJavascriptInterface = `object` as? JavascriptBackHandlerBridge
}
}
@Implementation
@Suppress("unused")
fun dispatchKeyEvent(event: KeyEvent): Boolean {
dispatchedEvents += KeyEvent(event)
return false
}
}
@@ -3,14 +3,34 @@
<string name="screen_create_room_action_create_room">"Cameră nouă"</string> <string name="screen_create_room_action_create_room">"Cameră nouă"</string>
<string name="screen_create_room_add_people_title">"Invitați prieteni"</string> <string name="screen_create_room_add_people_title">"Invitați prieteni"</string>
<string name="screen_create_room_error_creating_room">"A apărut o eroare la crearea camerei"</string> <string name="screen_create_room_error_creating_room">"A apărut o eroare la crearea camerei"</string>
<string name="screen_create_room_error_creating_space">"Spațiul nu a putut fi creat din cauza unei erori necunoscute. Încercați din nou mai târziu."</string>
<string name="screen_create_room_name_placeholder">"Adăugați un nume…"</string>
<string name="screen_create_room_new_room_title">"Cameră nouă"</string>
<string name="screen_create_room_new_space_title">"Spațiu nou"</string>
<string name="screen_create_room_private_option_description">"Doar persoanele invitate se pot alătura."</string> <string name="screen_create_room_private_option_description">"Doar persoanele invitate se pot alătura."</string>
<string name="screen_create_room_private_option_title">"Privat"</string>
<string name="screen_create_room_public_option_description">"Oricine poate găsi această cameră. <string name="screen_create_room_public_option_description">"Oricine poate găsi această cameră.
Puteți modifica acest lucru oricând în setări."</string> Puteți modifica acest lucru oricând în setări."</string>
<string name="screen_create_room_public_option_short_description">"Oricine se poate alătura."</string>
<string name="screen_create_room_public_option_title">"Public"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte cererea"</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte cererea"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Permite solicitarea de alăturare"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Permite solicitarea de alăturare"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Oricine din %1$s se poate alătura, dar oricine altcineva trebuie să solicite acces."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Solicitați să vă alăturați"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Doar persoanele invitate se pot alătura."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privat"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Oricine se poate alătura acestei camere"</string> <string name="screen_create_room_room_access_section_public_option_description">"Oricine se poate alătura acestei camere"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Public"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Oricine din %1$s se poate alătura."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
<string name="screen_create_room_room_access_section_title">"Cine are acces"</string>
<string name="screen_create_room_room_address_section_footer">"Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră."</string> <string name="screen_create_room_room_address_section_footer">"Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră."</string>
<string name="screen_create_room_room_address_section_title">"Adresă"</string> <string name="screen_create_room_room_address_section_title">"Adresă"</string>
<string name="screen_create_room_room_visibility_section_title">"Vizibilitatea camerei"</string> <string name="screen_create_room_room_visibility_section_title">"Vizibilitatea camerei"</string>
<string name="screen_create_room_space_selection_no_space_description">"(nicun spațiu)"</string>
<string name="screen_create_room_space_selection_no_space_option">"Nu adăugați la un spațiu"</string>
<string name="screen_create_room_space_selection_no_space_title">"Niciun spațiu selectat"</string>
<string name="screen_create_room_space_selection_sheet_title">"Adăugați la spațiu"</string>
<string name="screen_create_room_topic_label">"Subiect (opțional)"</string> <string name="screen_create_room_topic_label">"Subiect (opțional)"</string>
<string name="screen_create_room_topic_placeholder">"Adăugați o descriere…"</string>
</resources> </resources>
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Potvrďte prosím, že chcete svůj účet deaktivovat. Tuto akci nelze vrátit zpět."</string> <string name="screen_deactivate_account_confirmation_dialog_content">"Potvrďte prosím, že chcete smazat svůj účet. Tuto akci nelze vrátit zpět."</string>
<string name="screen_deactivate_account_delete_all_messages">"Smazat všechny mé zprávy"</string> <string name="screen_deactivate_account_delete_all_messages">"Smazat všechny mé zprávy"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Upozornění: Budoucí uživatelé mohou vidět neúplné konverzace."</string> <string name="screen_deactivate_account_delete_all_messages_notice">"Upozornění: Budoucí uživatelé mohou vidět neúplné konverzace."</string>
<string name="screen_deactivate_account_description">"Deaktivace vašeho účtu je %1$s, což způsobí:"</string> <string name="screen_deactivate_account_description">"Smazání účtu je %1$s, dojde k:"</string>
<string name="screen_deactivate_account_description_bold_part">"nezvratná"</string> <string name="screen_deactivate_account_description_bold_part">"nezvratná"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s váš účet (nemůžete se znovu přihlásit a vaše ID nelze znovu použít)."</string> <string name="screen_deactivate_account_list_item_1">"%1$s váš účet (nemůžete se znovu přihlásit a vaše ID nelze znovu použít)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Trvale zakázat"</string> <string name="screen_deactivate_account_list_item_1_bold_part">"Trvale zakázat"</string>
<string name="screen_deactivate_account_list_item_2">"Odebere vás ze všech chatovacích místností."</string> <string name="screen_deactivate_account_list_item_2">"Odebere vás ze všech chatovacích místností."</string>
<string name="screen_deactivate_account_list_item_3">"Odstraní informace o vašem účtu z našeho serveru identit."</string> <string name="screen_deactivate_account_list_item_3">"Odstraní informace o vašem účtu z našeho serveru identit."</string>
<string name="screen_deactivate_account_list_item_4">"Vaše zprávy budou stále viditelné registrovaným uživatelům, ale nebudou dostupné novým ani neregistrovaným uživatelům, pokud se rozhodnete je smazat."</string> <string name="screen_deactivate_account_list_item_4">"Vaše zprávy budou stále viditelné registrovaným uživatelům, ale nebudou dostupné novým ani neregistrovaným uživatelům, pokud se rozhodnete je smazat."</string>
<string name="screen_deactivate_account_title">"Deaktivovat účet"</string> <string name="screen_deactivate_account_title">"Smazat účet"</string>
</resources> </resources>
@@ -3,7 +3,7 @@
<string name="screen_deactivate_account_confirmation_dialog_content">"Palun kinnita uuesti, et soovid kustutada oma kasutajakonto. Seda tegevust ei saa tagasi pöörata."</string> <string name="screen_deactivate_account_confirmation_dialog_content">"Palun kinnita uuesti, et soovid kustutada oma kasutajakonto. Seda tegevust ei saa tagasi pöörata."</string>
<string name="screen_deactivate_account_delete_all_messages">"Kustuta kõik minu sõnumid"</string> <string name="screen_deactivate_account_delete_all_messages">"Kustuta kõik minu sõnumid"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Hoiatus: tulevased kasutajad võivad näha poolikuid vestlusi."</string> <string name="screen_deactivate_account_delete_all_messages_notice">"Hoiatus: tulevased kasutajad võivad näha poolikuid vestlusi."</string>
<string name="screen_deactivate_account_description">"Sinu konto kasutusest eemaldamine on %1$s ja sellega:"</string> <string name="screen_deactivate_account_description">"Sinu konto kustutamine on %1$s ja sellega:"</string>
<string name="screen_deactivate_account_description_bold_part">"pöördumatu"</string> <string name="screen_deactivate_account_description_bold_part">"pöördumatu"</string>
<string name="screen_deactivate_account_list_item_1">"Sinu kasutajakonto %1$s (sa ei saa enam sellega võrku logida ning kasutajatunnust ei saa enam pruukida)."</string> <string name="screen_deactivate_account_list_item_1">"Sinu kasutajakonto %1$s (sa ei saa enam sellega võrku logida ning kasutajatunnust ei saa enam pruukida)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"jäädavalt eemaldatakse kasutusest"</string> <string name="screen_deactivate_account_list_item_1_bold_part">"jäädavalt eemaldatakse kasutusest"</string>
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Vă rugăm să confirmați că doriți să vă dezactivați contul. Această acțiune nu poate fi anulată."</string> <string name="screen_deactivate_account_confirmation_dialog_content">"Vă rugăm să confirmați că doriți să vă ștergeți contul. Această acțiune nu poate fi anulată."</string>
<string name="screen_deactivate_account_delete_all_messages">"Ștergeți toate mesajele mele"</string> <string name="screen_deactivate_account_delete_all_messages">"Ștergeți toate mesajele mele"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Avertisment: este posibil ca viitorii utilizatori să vadă conversații incomplete."</string> <string name="screen_deactivate_account_delete_all_messages_notice">"Avertisment: este posibil ca viitorii utilizatori să vadă conversații incomplete."</string>
<string name="screen_deactivate_account_description">"Dezactivarea contului dumneavoastră este %1$s, acesta va:"</string> <string name="screen_deactivate_account_description">"Ștergerea contului dumneavoastră este %1$s, acesta va:"</string>
<string name="screen_deactivate_account_description_bold_part">"ireversibilă"</string> <string name="screen_deactivate_account_description_bold_part">"ireversibilă"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s contul dumneavoastră (nu vă puteți conecta din nou, iar ID-ul dvs. nu poate fi reutilizat)."</string> <string name="screen_deactivate_account_list_item_1">"%1$s contul dumneavoastră (nu vă puteți conecta din nou, iar ID-ul dvs. nu poate fi reutilizat)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Dezactivați permanent"</string> <string name="screen_deactivate_account_list_item_1_bold_part">"Dezactivați permanent"</string>
<string name="screen_deactivate_account_list_item_2">"Îndepărta din toate camerele de chat."</string> <string name="screen_deactivate_account_list_item_2">"Îndepărta din toate camerele de chat."</string>
<string name="screen_deactivate_account_list_item_3">"Șterge informațiile contului dumneavoastră de pe serverul nostru de identitate."</string> <string name="screen_deactivate_account_list_item_3">"Șterge informațiile contului dumneavoastră de pe serverul nostru de identitate."</string>
<string name="screen_deactivate_account_list_item_4">"Mesajele dumneavoastră vor fi în continuare vizibile pentru utilizatorii înregistrați, dar nu vor fi disponibile pentru utilizatorii noi sau neînregistrați dacă alegeți să le ștergeți."</string> <string name="screen_deactivate_account_list_item_4">"Mesajele dumneavoastră vor fi în continuare vizibile pentru utilizatorii înregistrați, dar nu vor fi disponibile pentru utilizatorii noi sau neînregistrați dacă alegeți să le ștergeți."</string>
<string name="screen_deactivate_account_title">"Dezactivați contul"</string> <string name="screen_deactivate_account_title">"Ștergeți contul"</string>
</resources> </resources>
@@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Kas kinnitamine pole võimalik?"</string> <string name="screen_identity_confirmation_cannot_confirm">"Kas kinnitamine pole võimalik?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Loo uus taastevõti"</string> <string name="screen_identity_confirmation_create_new_recovery_key">"Loo uus taastevõti"</string>
<string name="screen_identity_confirmation_subtitle">"Krüptitud sõnumivahetuse tagamiseks verifitseeri see seade."</string> <string name="screen_identity_confirmation_subtitle">"Turvalise sõnumside seadistamiseks vali verifitseerimise viis."</string>
<string name="screen_identity_confirmation_title">"Kinnita oma digitaalne identiteet"</string> <string name="screen_identity_confirmation_title">"Kinnita oma digitaalne identiteet"</string>
<string name="screen_identity_confirmation_use_another_device">"Kasuta teist seadet"</string> <string name="screen_identity_confirmation_use_another_device">"Kasuta teist seadet"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Kasuta taastevõtit"</string> <string name="screen_identity_confirmation_use_recovery_key">"Kasuta taastevõtit"</string>
@@ -83,8 +83,8 @@ class RoomListDataSource(
val loadingState = roomList.loadingState val loadingState = roomList.loadingState
fun launchIn(coroutineScope: CoroutineScope) { fun launchIn(coroutineScope: CoroutineScope): Job {
roomList return roomList
.summaries .summaries
.onEach { roomSummaries -> .onEach { roomSummaries ->
replaceWith(roomSummaries) replaceWith(roomSummaries)
@@ -212,6 +212,7 @@ class RoomListDataSource(
private suspend fun rebuildAllRoomSummaries() { private suspend fun rebuildAllRoomSummaries() {
lock.withLock { lock.withLock {
roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries -> roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
diffCacheUpdater.updateWith(roomSummaries)
buildAndEmitAllRooms(roomSummaries, useCache = false) buildAndEmitAllRooms(roomSummaries, useCache = false)
} }
} }
@@ -5,8 +5,8 @@
<string name="banner_battery_optimization_title_android">"Sa ei näe kõiki teavitusi?"</string> <string name="banner_battery_optimization_title_android">"Sa ei näe kõiki teavitusi?"</string>
<string name="banner_new_sound_message">"Sinu nutiseadme teavituste heli on uuenenud - see on nüüd selgem, kiirem ja vähem häiriv."</string> <string name="banner_new_sound_message">"Sinu nutiseadme teavituste heli on uuenenud - see on nüüd selgem, kiirem ja vähem häiriv."</string>
<string name="banner_new_sound_title">"Oleme sinu helisid värskendanud"</string> <string name="banner_new_sound_title">"Oleme sinu helisid värskendanud"</string>
<string name="banner_set_up_recovery_content">"Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele."</string> <string name="banner_set_up_recovery_content">"Sinu vestlused on automaatselt varundatud kasutades läbivat krüptimist. Kui peaksid kaotama ligipääsu kõikidele oma seadmetele, siis selle varukoopia taastamiseks ja oma digitaalse identiteedi säilitamiseks, on vaja taastevõtit."</string>
<string name="banner_set_up_recovery_submit">"Seadista andmete taastamine"</string> <string name="banner_set_up_recovery_submit">"Seadista taastevõti"</string>
<string name="banner_set_up_recovery_title">"Varunda oma vestlused"</string> <string name="banner_set_up_recovery_title">"Varunda oma vestlused"</string>
<string name="confirm_recovery_key_banner_message">"Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti."</string> <string name="confirm_recovery_key_banner_message">"Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Sisesta oma taastevõti"</string> <string name="confirm_recovery_key_banner_primary_button_title">"Sisesta oma taastevõti"</string>
@@ -50,6 +50,7 @@ Nu aveți mesaje necitite!"</string>
<string name="screen_roomlist_mark_as_read">"Marcați ca citită"</string> <string name="screen_roomlist_mark_as_read">"Marcați ca citită"</string>
<string name="screen_roomlist_mark_as_unread">"Marcați ca necitită"</string> <string name="screen_roomlist_mark_as_unread">"Marcați ca necitită"</string>
<string name="screen_roomlist_tombstoned_room_description">"Această cameră a fost modernizată."</string> <string name="screen_roomlist_tombstoned_room_description">"Această cameră a fost modernizată."</string>
<string name="screen_roomlist_your_spaces">"Spațiile dumneavoastră"</string>
<string name="session_verification_banner_message">"Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate."</string> <string name="session_verification_banner_message">"Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate."</string>
<string name="session_verification_banner_title">"Verificați că sunteți dumneavoastră"</string> <string name="session_verification_banner_title">"Verificați că sunteți dumneavoastră"</string>
</resources> </resources>
@@ -14,6 +14,9 @@ import io.element.android.features.home.impl.FakeDateTimeObserver
import io.element.android.libraries.androidutils.system.DateTimeObserver import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
@@ -100,11 +103,169 @@ class RoomListDataSourceTest {
} }
} }
/**
* Tracking issue #4182: rooms duplicated in the room list around midnight.
*
* If the SDK ever leaks a list containing the same roomId twice (the suspected cause of #4182),
* the UI mapper's `distinctBy` safety net in [RoomListDataSource.buildAndEmitAllRooms] must
* remove the duplicate AND `analyticsService.trackError` must fire so the team can root-cause
* it via Sentry.
*/
@Test
fun `when SDK summaries source contains duplicate roomIds, UI layer dedupes and reports trackError`() = runTest {
val analyticsService = FakeAnalyticsService()
val duplicatedSummaries = listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(roomId = A_ROOM_ID_2),
)
val roomList = FakeDynamicRoomList(summaries = MutableStateFlow(duplicatedSummaries))
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
).apply {
postState(RoomListService.State.Running)
}
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
analyticsService = analyticsService,
)
roomListDataSource.roomSummariesFlow.test {
roomListDataSource.launchIn(backgroundScope)
val list = awaitItem()
assertThat(list.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
assertThat(analyticsService.trackedErrors).hasSize(1)
}
}
/**
* Tracking issue #4182.
*
* Targeted scenario: a `DateChanged` tick fires after an initial SDK emit, then a follow-up
* SDK emit lands (mimicking "midnight, then a new message arrives"). Even though the diffCache
* is bypassed during the rebuild (`useCache = false`), the final state must contain each
* roomId exactly once and trackError must not fire on a happy path.
*/
@Test
fun `interleaved date change and SDK update with overlapping content does not produce duplicates`() = runTest {
val analyticsService = FakeAnalyticsService()
val summariesFlow = MutableStateFlow(
listOf(
aRoomSummary(roomId = A_ROOM_ID),
aRoomSummary(roomId = A_ROOM_ID_2),
)
)
val roomList = FakeDynamicRoomList(summaries = summariesFlow)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
).apply {
postState(RoomListService.State.Running)
}
val dateTimeObserver = FakeDateTimeObserver()
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
dateTimeObserver = dateTimeObserver,
analyticsService = analyticsService,
)
roomListDataSource.roomSummariesFlow.test {
roomListDataSource.launchIn(backgroundScope)
val initial = awaitItem()
assertThat(initial.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
// Midnight ticks while the cache holds [A_ROOM_ID, A_ROOM_ID_2]
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
val afterMidnight = awaitItem()
assertThat(afterMidnight.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
// A new message bumps A_ROOM_ID — different unread count makes the StateFlow see this
// as a new value
summariesFlow.value = listOf(
aRoomSummary(roomId = A_ROOM_ID, numUnreadMessages = 1),
aRoomSummary(roomId = A_ROOM_ID_2),
)
val afterMessage = awaitItem()
assertThat(afterMessage.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
assertThat(afterMessage.map { it.roomId }.toSet()).hasSize(afterMessage.size)
// Second midnight rebuild after the new message
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
val afterSecondMidnight = awaitItem()
assertThat(afterSecondMidnight.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
assertThat(afterSecondMidnight.map { it.roomId }.toSet()).hasSize(afterSecondMidnight.size)
assertThat(analyticsService.trackedErrors).isEmpty()
}
}
@Test
fun `regression test for race with DateTimeObserver and new items`() = runTest {
val roomList = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))))
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
).apply {
postState(RoomListService.State.Running)
}
val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today"
val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult })
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
),
dateTimeObserver = dateTimeObserver,
)
roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes
val job = roomListDataSource.launchIn(backgroundScope)
// Get the initial room list
val initialRoomList = awaitItem()
assertThat(initialRoomList).hasSize(2)
assertThat(initialRoomList[0].roomId).isEqualTo(A_ROOM_ID)
assertThat(initialRoomList[0].timestamp).isEqualTo(dateFormatterResult)
assertThat(initialRoomList[1].roomId).isEqualTo(A_ROOM_ID_2)
assertThat(initialRoomList[1].timestamp).isEqualTo(dateFormatterResult)
// Stop processing room list updates so we can force a race condition with the date time observer updates
job.cancel()
// Trigger a date change and a new item at the same time
dateFormatterResult = "Yesterday"
roomList.summaries.tryEmit(listOf(aRoomSummary(roomId = A_ROOM_ID), aRoomSummary(roomId = A_ROOM_ID_3), aRoomSummary(roomId = A_ROOM_ID_2)))
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
// The race condition would have caused the cache indices to be corrupted and only 2 items would be emitted
val rebuiltRoomList = awaitItem()
assertThat(rebuiltRoomList).hasSize(3)
assertThat(rebuiltRoomList[0].roomId).isEqualTo(A_ROOM_ID)
assertThat(rebuiltRoomList[0].timestamp).isEqualTo(dateFormatterResult)
assertThat(rebuiltRoomList[1].roomId).isEqualTo(A_ROOM_ID_3)
assertThat(rebuiltRoomList[1].timestamp).isEqualTo(dateFormatterResult)
assertThat(rebuiltRoomList[2].roomId).isEqualTo(A_ROOM_ID_2)
assertThat(rebuiltRoomList[2].timestamp).isEqualTo(dateFormatterResult)
// Restart processing room list updates
roomListDataSource.launchIn(backgroundScope)
// Check there is a new list and it's not the same as the previous one
val newRoomList = awaitItem()
assertThat(newRoomList).hasSize(3)
assertThat(newRoomList[0].roomId).isEqualTo(A_ROOM_ID)
assertThat(newRoomList[0].timestamp).isEqualTo(dateFormatterResult)
assertThat(newRoomList[1].roomId).isEqualTo(A_ROOM_ID_3)
assertThat(newRoomList[1].timestamp).isEqualTo(dateFormatterResult)
assertThat(newRoomList[2].roomId).isEqualTo(A_ROOM_ID_2)
assertThat(newRoomList[2].timestamp).isEqualTo(dateFormatterResult)
}
}
private fun TestScope.createRoomListDataSource( private fun TestScope.createRoomListDataSource(
roomListService: FakeRoomListService = FakeRoomListService(), roomListService: FakeRoomListService = FakeRoomListService(),
roomListRoomSummaryFactory: RoomListRoomSummaryFactory = aRoomListRoomSummaryFactory(), roomListRoomSummaryFactory: RoomListRoomSummaryFactory = aRoomListRoomSummaryFactory(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
dateTimeObserver: FakeDateTimeObserver = FakeDateTimeObserver(), dateTimeObserver: FakeDateTimeObserver = FakeDateTimeObserver(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = RoomListDataSource( ) = RoomListDataSource(
roomListService = roomListService, roomListService = roomListService,
roomListRoomSummaryFactory = roomListRoomSummaryFactory, roomListRoomSummaryFactory = roomListRoomSummaryFactory,
@@ -112,6 +273,6 @@ class RoomListDataSourceTest {
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
dateTimeObserver = dateTimeObserver, dateTimeObserver = dateTimeObserver,
analyticsService = FakeAnalyticsService(), analyticsService = analyticsService,
) )
} }
@@ -11,4 +11,5 @@ package io.element.android.features.invitepeople.api
interface InvitePeopleEvents { interface InvitePeopleEvents {
data object SendInvites : InvitePeopleEvents data object SendInvites : InvitePeopleEvents
data object CloseSearch : InvitePeopleEvents data object CloseSearch : InvitePeopleEvents
data object ClearError : InvitePeopleEvents
} }
@@ -9,10 +9,12 @@
package io.element.android.features.invitepeople.api package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
interface InvitePeopleState { interface InvitePeopleState {
val canInvite: Boolean val canInvite: Boolean
val isSearchActive: Boolean val isSearchActive: Boolean
val sendInvitesAction: AsyncAction<Unit> val sendInvitesAction: AsyncAction<Unit>
val createRoomFromDmAction: AsyncAction<RoomId>
val eventSink: (InvitePeopleEvents) -> Unit val eventSink: (InvitePeopleEvents) -> Unit
} }
@@ -10,6 +10,7 @@ package io.element.android.features.invitepeople.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> { class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
override val values: Sequence<InvitePeopleState> override val values: Sequence<InvitePeopleState>
@@ -25,6 +26,7 @@ private data class PreviewInvitePeopleState(
override val canInvite: Boolean, override val canInvite: Boolean,
override val isSearchActive: Boolean, override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>, override val sendInvitesAction: AsyncAction<Unit>,
override val createRoomFromDmAction: AsyncAction<RoomId>,
override val eventSink: (InvitePeopleEvents) -> Unit, override val eventSink: (InvitePeopleEvents) -> Unit,
) : InvitePeopleState ) : InvitePeopleState
@@ -32,10 +34,12 @@ private fun aPreviewInvitePeopleState(
canInvite: Boolean = false, canInvite: Boolean = false,
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized, sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (InvitePeopleEvents) -> Unit = {}, eventSink: (InvitePeopleEvents) -> Unit = {},
) = PreviewInvitePeopleState( ) = PreviewInvitePeopleState(
canInvite = canInvite, canInvite = canInvite,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
sendInvitesAction = sendInvitesAction, sendInvitesAction = sendInvitesAction,
createRoomFromDmAction = createRoomFromDmAction,
eventSink = eventSink eventSink = eventSink
) )
@@ -36,6 +36,7 @@ dependencies {
implementation(projects.libraries.uiUtils) implementation(projects.libraries.uiUtils)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.usersearch.api) implementation(projects.libraries.usersearch.api)
implementation(projects.libraries.testtags)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.services.apperror.api) implementation(projects.services.apperror.api)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
@@ -38,12 +38,17 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.filterMembers import io.element.android.libraries.matrix.api.room.filterMembers
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.usersearch.api.UserRepository import io.element.android.libraries.usersearch.api.UserRepository
@@ -88,6 +93,7 @@ class DefaultInvitePeoplePresenter(
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) } val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) } val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val createRoomFromDmAction = remember { mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) }
val recentDirectRooms by produceState(emptyList(), roomMembers.value) { val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
if (roomMembers.value.isSuccess()) { if (roomMembers.value.isSuccess()) {
@@ -208,7 +214,13 @@ class DefaultInvitePeoplePresenter(
) )
} else { } else {
room.dataOrNull()?.let { room.dataOrNull()?.let {
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction) sessionCoroutineScope.launch {
if (it.isDm()) {
createRoomFromDm(it, selectedUsers.value, createRoomFromDmAction)
} else {
sendInvites(it, selectedUsers.value, sendInvitesAction)
}
}
} }
} }
} }
@@ -216,6 +228,10 @@ class DefaultInvitePeoplePresenter(
searchActive = false searchActive = false
queryState.clearText() queryState.clearText()
} }
is InvitePeopleEvents.ClearError -> {
sendInvitesAction.value = AsyncAction.Uninitialized
createRoomFromDmAction.value = AsyncAction.Uninitialized
}
} }
} }
@@ -228,6 +244,7 @@ class DefaultInvitePeoplePresenter(
searchResults = searchResults.value, searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value, showSearchLoader = showSearchLoader.value,
sendInvitesAction = sendInvitesAction.value, sendInvitesAction = sendInvitesAction.value,
createRoomFromDmAction = createRoomFromDmAction.value,
suggestions = suggestions, suggestions = suggestions,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
@@ -254,6 +271,35 @@ class DefaultInvitePeoplePresenter(
} }
} }
private fun CoroutineScope.createRoomFromDm(
currentRoom: JoinedRoom,
selectedUsers: List<MatrixUser>,
createRoomFromDmAction: MutableState<AsyncAction<RoomId>>,
) = launch {
createRoomFromDmAction.runUpdatingState {
val currentUsers = currentRoom.getMembers(limit = 100).getOrNull().orEmpty()
.filter { it.membership.isActive() }
val invitees = (currentUsers.map { it.userId } + selectedUsers.map { it.userId })
.filter { it != matrixClient.sessionId }
.distinct()
matrixClient.createRoom(
CreateRoomParameters(
name = null,
topic = null,
isEncrypted = true,
isDirect = false,
visibility = RoomVisibility.Private,
preset = RoomPreset.PRIVATE_CHAT,
invite = invitees,
avatar = null,
joinRuleOverride = JoinRule.Invite,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
isSpace = false,
)
)
}
}
@JvmName("toggleUserInSelectedUsers") @JvmName("toggleUserInSelectedUsers")
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) { private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
value = if (value.contains(user)) { value = if (value.contains(user)) {
@@ -14,6 +14,7 @@ import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -26,6 +27,7 @@ data class DefaultInvitePeopleState(
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
override val isSearchActive: Boolean, override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>, override val sendInvitesAction: AsyncAction<Unit>,
override val createRoomFromDmAction: AsyncAction<RoomId>,
val suggestions: ImmutableList<InvitableUser>, val suggestions: ImmutableList<InvitableUser>,
override val eventSink: (InvitePeopleEvents) -> Unit override val eventSink: (InvitePeopleEvents) -> Unit
) : InvitePeopleState ) : InvitePeopleState
@@ -18,6 +18,7 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_CAROL
import io.element.android.libraries.designsystem.preview.USER_NAME_EVE import io.element.android.libraries.designsystem.preview.USER_NAME_EVE
import io.element.android.libraries.designsystem.preview.USER_NAME_JUSTIN import io.element.android.libraries.designsystem.preview.USER_NAME_JUSTIN
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@@ -119,6 +120,7 @@ private fun aDefaultInvitePeopleState(
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
showSearchLoader: Boolean = false, showSearchLoader: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized, sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
suggestions: List<InvitableUser> = aMatrixUserList() suggestions: List<InvitableUser> = aMatrixUserList()
.take(5) .take(5)
.map { user -> anInvitableUser(matrixUser = user, isSelected = user in selectedUsers) }, .map { user -> anInvitableUser(matrixUser = user, isSelected = user in selectedUsers) },
@@ -132,6 +134,7 @@ private fun aDefaultInvitePeopleState(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader, showSearchLoader = showSearchLoader,
sendInvitesAction = sendInvitesAction, sendInvitesAction = sendInvitesAction,
createRoomFromDmAction = createRoomFromDmAction,
suggestions = suggestions.toImmutableList(), suggestions = suggestions.toImmutableList(),
eventSink = {}, eventSink = {},
) )
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -23,6 +24,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -54,6 +56,8 @@ import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.strings.simplePluralStringResource import io.element.android.libraries.ui.utils.strings.simplePluralStringResource
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -102,7 +106,7 @@ private fun InvitePeopleContentView(
} }
InvitePeopleSearchBar( InvitePeopleSearchBar(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.imePadding().fillMaxWidth(),
queryState = state.searchQuery, queryState = state.searchQuery,
showLoader = state.showSearchLoader, showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers, selectedUsers = state.selectedUsers,
@@ -298,7 +302,7 @@ private fun InvitePeopleConfirmModal(
text = stringResource(CommonStrings.action_remove), text = stringResource(CommonStrings.action_remove),
onClick = onRemove, onClick = onRemove,
leadingIcon = IconSource.Vector(CompoundIcons.Close()), leadingIcon = IconSource.Vector(CompoundIcons.Close()),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f).testTag(TestTags.confirmInviteUnknown),
) )
Button( Button(
text = stringResource(CommonStrings.action_invite), text = stringResource(CommonStrings.action_invite),
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Sa juba oled jututoa liige"</string> <string name="screen_invite_users_already_a_member">"Sa juba oled jututoa liige"</string>
<string name="screen_invite_users_already_invited">"Sa juba oled kutse saanud"</string> <string name="screen_invite_users_already_invited">"Sa juba oled kutse saanud"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Sul pole hetkel nende kontaktidega ühtegi vestlust. Enne jätkamist kinnita neile siia jututuppa kutse saatmine."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Sul pole hetkel selle kontaktiga ühtegi vestlust. Enne jätkamist kinnita talle siia jututuppa kutse saatmine."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Kas kutsud uued kontaktid siia jututuppa?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Kas kutsud uue kontakti siia jututuppa?"</string>
</resources> </resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Deja membru"</string> <string name="screen_invite_users_already_a_member">"Deja membru"</string>
<string name="screen_invite_users_already_invited">"Deja invitat"</string> <string name="screen_invite_users_already_invited">"Deja invitat"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"În prezent, nu aveți nicio conversație cu aceste contacte. Confirmați invitarea lor în această cameră înainte de a continua."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"În prezent, nu aveți nicio conversație cu acest contact. Confirmați invitarea acestuia în cameră înainte de a continua."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Invitați contactele noi în această cameră?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Invitați contactul nou în această cameră?"</string>
</resources> </resources>
@@ -831,6 +831,54 @@ internal class DefaultInvitePeoplePresenterTest {
} }
} }
@Test
fun `present - inviting someone to a DM creates a new room`() = runTest {
val alice = aMatrixUser("@alice:example.com")
val matrixClient = FakeMatrixClient(
encryptionService = FakeEncryptionService(
getUserIdentityResult = lambdaRecorder { userId: UserId ->
Result.success(IdentityState.Pinned)
}
)
)
val presenter = createDefaultInvitePeoplePresenter(
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClient = matrixClient,
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
initialRoomInfo = aRoomInfo(isDm = true),
getMembersResult = { Result.success(listOf(aRoomMember(userId = alice.userId, membership = RoomMembershipState.JOIN))) },
)
)
)
presenter.test {
val initialState = awaitItem()
skipItems(1)
// We want to add a new user to a DM
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice))
// And we send the invites
initialState.eventSink(InvitePeopleEvents.SendInvites)
skipItems(1)
awaitItemAsDefault().run {
assertThat(canInvite).isTrue()
assertThat(sendInvitesAction.isUninitialized()).isTrue()
// Inviting to a DM should trigger the creation of a new room
assertThat(createRoomFromDmAction.isLoading()).isTrue()
}
awaitItemAsDefault().run {
assertThat(sendInvitesAction.isUninitialized()).isTrue()
// Once the room is created, the action should be successful
assertThat(createRoomFromDmAction.isSuccess()).isTrue()
}
}
}
private suspend fun FakeUserRepository.emitStateWithUsers( private suspend fun FakeUserRepository.emitStateWithUsers(
users: List<MatrixUser>, users: List<MatrixUser>,
isSearching: Boolean = false isSearching: Boolean = false
@@ -609,6 +609,7 @@ private fun JoinRoomTopBar(
val roundedCornerShape = RoundedCornerShape(8.dp) val roundedCornerShape = RoundedCornerShape(8.dp)
val titleModifier = Modifier val titleModifier = Modifier
.clip(roundedCornerShape) .clip(roundedCornerShape)
.semantics { heading() }
if (contentState.name != null) { if (contentState.name != null) {
Row( Row(
modifier = titleModifier, modifier = titleModifier,
@@ -621,10 +622,7 @@ private fun JoinRoomTopBar(
) )
Text( Text(
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp),
.semantics {
heading()
},
text = contentState.name, text = contentState.name,
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1, maxLines = 1,
@@ -144,8 +144,14 @@ class LinkNewDeviceFlowNode(
navigateToError(linkMobileStep.errorType) navigateToError(linkMobileStep.errorType)
} }
is LinkMobileStep.QrReady -> { is LinkMobileStep.QrReady -> {
// The QrCode is ready, navigate to its display // The QrCode is ready, navigate to its display, if not already there
backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data)) val navTarget = backstack.elements.value.last().key.navTarget
if (navTarget !is NavTarget.MobileShowQrCode) {
backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data))
}
}
LinkMobileStep.QrRotating -> {
// This step is handled in ShowQrCodePresenter
} }
is LinkMobileStep.QrScanned -> { is LinkMobileStep.QrScanned -> {
backstack.replace(NavTarget.MobileEnterNumber) backstack.replace(NavTarget.MobileEnterNumber)
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.libraries.matrix.api.logs.LoggerTags import io.element.android.libraries.matrix.api.logs.LoggerTags
@@ -65,4 +66,15 @@ class LinkNewMobileHandler(
linkMobileStepFlow.emit(LinkMobileStep.Uninitialized) linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
} }
} }
fun rotateQrCode() {
createAndStartNewHandler()
}
fun onTooManyRotation() {
reset()
sessionScope.launch {
linkMobileStepFlow.emit(LinkMobileStep.Error(ErrorType.Expired("Too many QR code rotations")))
}
}
} }
@@ -25,6 +25,7 @@ import io.element.android.libraries.di.SessionScope
class ShowQrCodeNode( class ShowQrCodeNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
showQrCodePresenterFactory: ShowQrCodePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
class Inputs( class Inputs(
val data: String, val data: String,
@@ -36,11 +37,15 @@ class ShowQrCodeNode(
private val inputs: Inputs = inputs<Inputs>() private val inputs: Inputs = inputs<Inputs>()
private val callback: Callback = callback() private val callback: Callback = callback()
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
initialData = inputs.data,
)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = showQrCodePresenter.present()
ShowQrCodeView( ShowQrCodeView(
data = inputs.data, state = state,
modifier = modifier, modifier = modifier,
onBackClick = callback::navigateBack, onBackClick = callback::navigateBack,
) )
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.linknewdevice.impl.screens.qrcode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject
class ShowQrCodePresenter(
@Assisted private val initialData: String,
private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter<ShowQrCodeState> {
@AssistedFactory
interface Factory {
fun create(initialData: String): ShowQrCodePresenter
}
private var loadingJob: Job? = null
@Composable
override fun present(): ShowQrCodeState {
var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) }
val state by produceState(
initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData),
)
) {
linkNewMobileHandler.stepFlow.collect { step ->
when (step) {
is LinkMobileStep.QrReady -> {
loadingJob?.cancel()
value = ShowQrCodeState(
data = AsyncData.Success(step.data),
)
}
is LinkMobileStep.QrRotating -> {
if (qrCodeRotationCounter-- > 0) {
Timber.tag(tag.value).d("Rotating QrCode")
linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch {
delay(1000)
value = ShowQrCodeState(
data = AsyncData.Loading(),
)
}
} else {
Timber.tag(tag.value).w("Max QR code rotation reached, not rotating anymore")
linkNewMobileHandler.onTooManyRotation()
}
}
else -> Unit
}
}
}
return state
}
companion object {
const val MAX_QR_CODE_ROTATION = 10
}
}
@@ -0,0 +1,14 @@
/*
* 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.linknewdevice.impl.screens.qrcode
import io.element.android.libraries.architecture.AsyncData
data class ShowQrCodeState(
val data: AsyncData<String>,
)
@@ -0,0 +1,27 @@
/*
* 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.linknewdevice.impl.screens.qrcode
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
class ShowQrCodeStateProvider : PreviewParameterProvider<ShowQrCodeState> {
override val values: Sequence<ShowQrCodeState>
get() = sequenceOf(
aShowQrCodeState(),
aShowQrCodeState(
data = AsyncData.Loading(),
),
)
}
internal fun aShowQrCodeState(
data: AsyncData<String> = AsyncData.Success("DATA"),
) = ShowQrCodeState(
data = data,
)
@@ -9,6 +9,12 @@
package io.element.android.features.linknewdevice.impl.screens.qrcode package io.element.android.features.linknewdevice.impl.screens.qrcode
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -21,6 +27,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.linknewdevice.impl.R import io.element.android.features.linknewdevice.impl.R
@@ -30,6 +37,7 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.qrcode.QrCodeImage import io.element.android.libraries.qrcode.QrCodeImage
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -38,9 +46,10 @@ import kotlinx.collections.immutable.persistentListOf
* QrCode display screen: * QrCode display screen:
* https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617 * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617
*/ */
@OptIn(ExperimentalAnimationApi::class)
@Composable @Composable
fun ShowQrCodeView( fun ShowQrCodeView(
data: String, state: ShowQrCodeState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -55,11 +64,17 @@ fun ShowQrCodeView(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
QrCodeImage( AnimatedContent(
data = data, modifier = Modifier.size(220.dp),
modifier = Modifier targetState = state.data.dataOrNull(),
.size(220.dp) transitionSpec = {
) fadeIn().togetherWith(fadeOut())
}
) { data ->
QrCodeOrLoading(
data = data,
)
}
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
NumberedListOrganism( NumberedListOrganism(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -79,11 +94,33 @@ fun ShowQrCodeView(
} }
} }
@Composable
private fun QrCodeOrLoading(
data: String?,
modifier: Modifier = Modifier,
) {
if (data == null) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
QrCodeImage(
modifier = modifier,
data = data,
)
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ShowQrCodeViewPreview() = ElementPreview { internal fun ShowQrCodeViewPreview(
@PreviewParameter(ShowQrCodeStateProvider::class) state: ShowQrCodeState,
) = ElementPreview {
ShowQrCodeView( ShowQrCodeView(
data = "DATA", state = state,
onBackClick = { }, onBackClick = { },
) )
} }
@@ -26,6 +26,7 @@
<string name="screen_link_new_device_root_loading_qr_code">"Se încarcă codul QR…"</string> <string name="screen_link_new_device_root_loading_qr_code">"Se încarcă codul QR…"</string>
<string name="screen_link_new_device_root_mobile_device">"Dispozitiv mobil"</string> <string name="screen_link_new_device_root_mobile_device">"Dispozitiv mobil"</string>
<string name="screen_link_new_device_root_title">"Ce tip de dispozitiv doriți să conectați?"</string> <string name="screen_link_new_device_root_title">"Ce tip de dispozitiv doriți să conectați?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Încercați din nou și asigurați-vă că ați introdus corect codul de 2 cifre. Dacă numerele tot nu se potrivesc, contactați furnizorul contului."</string>
<string name="screen_link_new_device_wrong_number_title">"Numerele nu se potrivesc"</string> <string name="screen_link_new_device_wrong_number_title">"Numerele nu se potrivesc"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Și acum?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"Și acum?"</string>
@@ -39,6 +40,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Cererea de autentificare a fost anulată"</string> <string name="screen_qr_code_login_error_cancelled_title">"Cererea de autentificare a fost anulată"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Autentificarea a fost refuzată pe celălalt dispozitiv."</string> <string name="screen_qr_code_login_error_declined_subtitle">"Autentificarea a fost refuzată pe celălalt dispozitiv."</string>
<string name="screen_qr_code_login_error_declined_title">"Autentificarea a fost refuzată"</string> <string name="screen_qr_code_login_error_declined_title">"Autentificarea a fost refuzată"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Nu trebuie să faceți nimic altceva."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Celălalt dispozitiv este deja conectat"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Autentificarea a expirat. Vă rugăm să încercați din nou."</string> <string name="screen_qr_code_login_error_expired_subtitle">"Autentificarea a expirat. Vă rugăm să încercați din nou."</string>
<string name="screen_qr_code_login_error_expired_title">"Autentificarea nu a fost finalizată la timp"</string> <string name="screen_qr_code_login_error_expired_title">"Autentificarea nu a fost finalizată la timp"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR. <string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR.
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.linknewdevice.impl.screens.qrcode
import com.google.common.truth.Truth.assertThat
import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ShowQrCodePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
createPresenter().test {
val initialState = awaitItem()
assertThat(initialState.data.dataOrNull()).isEqualTo("DATA")
}
}
@Test
fun `present - when handler emits QrRotating, the presenter requests to rotate the QrCode`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val createLinkMobileHandlerResult = lambdaRecorder<Result<LinkMobileHandler>> {
Result.success(linkMobileHandler)
}
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = createLinkMobileHandlerResult,
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
).test {
awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrRotating
)
runCurrent()
val finalState = awaitItem()
assertThat(finalState.data.isLoading()).isTrue()
createLinkMobileHandlerResult.assertions().isCalledExactly(2)
}
}
@Test
fun `present - when handler emits QrRotating, the presenter requests to rotate the QrCode and the code is rotated`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) },
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
).test {
awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrRotating
)
runCurrent()
linkMobileHandler.emitStep(
LinkMobileStep.QrReady("DATA2")
)
val finalState = awaitItem()
assertThat(finalState.data.dataOrNull()).isEqualTo("DATA2")
}
}
private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
) = ShowQrCodePresenter(
initialData = "DATA",
linkNewMobileHandler = linkNewMobileHandler,
)
}
@@ -37,7 +37,7 @@ class ShowQrCodeViewTest {
) { ) {
setContent { setContent {
ShowQrCodeView( ShowQrCodeView(
data = "DATA", state = aShowQrCodeState(),
onBackClick = onBackClick, onBackClick = onBackClick,
) )
} }
@@ -40,8 +40,6 @@ import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DurationFormatter import io.element.android.libraries.dateformatter.api.DurationFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -66,7 +64,6 @@ class ShareLocationPresenter(
private val messageComposerContext: MessageComposerContext, private val messageComposerContext: MessageComposerContext,
private val locationActions: LocationActions, private val locationActions: LocationActions,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService,
private val client: MatrixClient, private val client: MatrixClient,
private val durationFormatter: DurationFormatter, private val durationFormatter: DurationFormatter,
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
@@ -83,9 +80,6 @@ class ShareLocationPresenter(
override fun present(): ShareLocationState { override fun present(): ShareLocationState {
val permissionsState: PermissionsState = permissionsPresenter.present() val permissionsState: PermissionsState = permissionsPresenter.present()
var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted && locationActions.isLocationEnabled()) } var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted && locationActions.isLocationEnabled()) }
val isLiveLocationSharingEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
}.collectAsState(false)
val appName by remember { derivedStateOf { buildMeta.applicationName } } val appName by remember { derivedStateOf { buildMeta.applicationName } }
var dialogState: ShareLocationState.Dialog by remember { var dialogState: ShareLocationState.Dialog by remember {
mutableStateOf(ShareLocationState.Dialog.None) mutableStateOf(ShareLocationState.Dialog.None)
@@ -171,7 +165,7 @@ class ShareLocationPresenter(
dialogState = dialogState, dialogState = dialogState,
trackUserLocation = trackUserPosition, trackUserLocation = trackUserPosition,
hasLocationPermission = permissionsState.isAnyGranted, hasLocationPermission = permissionsState.isAnyGranted,
canShareLiveLocation = isLiveLocationSharingEnabled && timelineMode.canShareLiveLocation(), canShareLiveLocation = timelineMode.canShareLiveLocation(),
appName = appName, appName = appName,
startLiveLocationAction = startLiveLocationAction.value, startLiveLocationAction = startLiveLocationAction.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Vaše historie aktuální polohy bude uložena v místnosti a bude viditelná pro členy i po skončení relace."</string> <string name="screen_share_location_live_location_disclaimer_title">"Vaše historie aktuální polohy bude uložena v místnosti a bude viditelná pro členy i po skončení relace."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Zvolte, jak dlouho chcete sdílet svou aktuální polohu."</string> <string name="screen_share_location_live_location_duration_picker_title">"Zvolte, jak dlouho chcete sdílet svou aktuální polohu."</string>
<string name="screen_share_location_live_location_missing_permissions">"Nemáte oprávnění sdílet svou aktuální polohu v této místnosti."</string>
</resources> </resources>
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Sinu reaalajas jagatud asukoha ajalugu salvestub siin jututoas ja see on liikmetele nähtav ka pärast jagamissessiooni lõppu."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Vali, kui kaua tahad oma reaalajas jagada."</string> <string name="screen_share_location_live_location_duration_picker_title">"Vali, kui kaua tahad oma reaalajas jagada."</string>
<string name="screen_share_location_live_location_missing_permissions">"Sul pole õigust jagada selles jututoas oma asukohta reaalajas"</string>
</resources> </resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Istoricul locațiilor dumneavoastră va fi stocat în cameră și va fi vizibil pentru membri după încheierea sesiunii."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Alegeți cât timp doriți să vă partajați locația în timp real."</string>
<string name="screen_share_location_live_location_missing_permissions">"Nu aveți permisiunea de a vă partaja locația în această cameră."</string>
</resources>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"你实时位置历史将存储在房间中,并于会话结束后对其他成员可见。"</string> <string name="screen_share_location_live_location_disclaimer_title">"你的实时位置历史将存储在房间中,并于会话结束后对其他成员可见。"</string>
<string name="screen_share_location_live_location_duration_picker_title">"选择共享实时位置的时长。"</string> <string name="screen_share_location_live_location_duration_picker_title">"选择共享实时位置的时长。"</string>
<string name="screen_share_location_live_location_missing_permissions">"你无权在此房内共享实时位置。"</string> <string name="screen_share_location_live_location_missing_permissions">"你无权在此房内共享实时位置。"</string>
</resources> </resources>
@@ -17,7 +17,6 @@ import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
@@ -50,7 +49,6 @@ class DefaultShareLocationEntryPointTest {
messageComposerContext = FakeMessageComposerContext(), messageComposerContext = FakeMessageComposerContext(),
locationActions = FakeLocationActions(), locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
featureFlagService = FakeFeatureFlagService(),
client = FakeMatrixClient(), client = FakeMatrixClient(),
durationFormatter = FakeDurationFormatter(), durationFormatter = FakeDurationFormatter(),
liveLocationShareManager = FakeActiveLiveLocationShareManager(), liveLocationShareManager = FakeActiveLiveLocationShareManager(),
@@ -29,8 +29,6 @@ import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@@ -77,7 +75,6 @@ class ShareLocationPresenterTest {
private val fakeMessageComposerContext = FakeMessageComposerContext() private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions() private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val fakeFeatureFlagService = FakeFeatureFlagService()
private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID) private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
private val durationFormatter = FakeDurationFormatter() private val durationFormatter = FakeDurationFormatter()
@@ -96,7 +93,6 @@ class ShareLocationPresenterTest {
messageComposerContext = fakeMessageComposerContext, messageComposerContext = fakeMessageComposerContext,
locationActions = locationActions, locationActions = locationActions,
buildMeta = fakeBuildMeta, buildMeta = fakeBuildMeta,
featureFlagService = fakeFeatureFlagService,
client = fakeMatrixClient, client = fakeMatrixClient,
durationFormatter = durationFormatter, durationFormatter = durationFormatter,
liveLocationShareManager = liveLocationShareManager, liveLocationShareManager = liveLocationShareManager,
@@ -658,21 +654,7 @@ class ShareLocationPresenterTest {
} }
@Test @Test
fun `canShareLiveLocation is false when the feature is disabled`() = runTest { fun `canShareLiveLocation is true in live timeline`() = runTest {
fakeFeatureFlagService.setFeatureEnabled(FeatureFlags.LiveLocationSharing, false)
val shareLocationPresenter = createShareLocationPresenter(
timelineMode = Timeline.Mode.Live,
)
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.canShareLiveLocation).isFalse()
}
}
@Test
fun `canShareLiveLocation is true when the feature is enabled`() = runTest {
fakeFeatureFlagService.setFeatureEnabled(FeatureFlags.LiveLocationSharing, true)
val shareLocationPresenter = createShareLocationPresenter( val shareLocationPresenter = createShareLocationPresenter(
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
) )
@@ -685,7 +667,6 @@ class ShareLocationPresenterTest {
@Test @Test
fun `canShareLiveLocation is false in thread timeline`() = runTest { fun `canShareLiveLocation is false in thread timeline`() = runTest {
fakeFeatureFlagService.setFeatureEnabled(FeatureFlags.LiveLocationSharing, true)
val shareLocationPresenter = createShareLocationPresenter( val shareLocationPresenter = createShareLocationPresenter(
timelineMode = Timeline.Mode.Thread(A_THREAD_ID), timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
) )
@@ -43,7 +43,7 @@ class DefaultLockScreenService(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver, private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
biometricAuthenticatorManager: BiometricAuthenticatorManager, private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
) : LockScreenService { ) : LockScreenService {
private val _lockState = MutableStateFlow<LockScreenLockState>(LockScreenLockState.Unlocked) private val _lockState = MutableStateFlow<LockScreenLockState>(LockScreenLockState.Unlocked)
override val lockState: StateFlow<LockScreenLockState> = _lockState override val lockState: StateFlow<LockScreenLockState> = _lockState
@@ -81,6 +81,7 @@ class DefaultLockScreenService(
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
if (wasLastSession) { if (wasLastSession) {
pinCodeManager.deletePinCode() pinCodeManager.deletePinCode()
biometricAuthenticatorManager.disable()
} }
} }
}) })
@@ -24,6 +24,11 @@ interface BiometricAuthenticatorManager {
fun addCallback(callback: BiometricAuthenticator.Callback) fun addCallback(callback: BiometricAuthenticator.Callback)
fun removeCallback(callback: BiometricAuthenticator.Callback) fun removeCallback(callback: BiometricAuthenticator.Callback)
/**
* Disable using the biometric unlock feature and remove any data associated with it.
*/
suspend fun disable()
/** /**
* Remember a biometric authenticator ready for unlocking the app. * Remember a biometric authenticator ready for unlocking the app.
*/ */
@@ -80,10 +80,7 @@ class DefaultBiometricAuthenticatorManager(
private val internalCallback = object : DefaultBiometricUnlockCallback() { private val internalCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricSetupError() { override fun onBiometricSetupError() {
coroutineScope.launch { coroutineScope.launch { disable() }
lockScreenStore.setIsBiometricUnlockAllowed(false)
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
}
} }
} }
@@ -120,6 +117,11 @@ class DefaultBiometricAuthenticatorManager(
) )
} }
override suspend fun disable() {
lockScreenStore.setIsBiometricUnlockAllowed(false)
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
}
@Composable @Composable
private fun rememberBiometricAuthenticator( private fun rememberBiometricAuthenticator(
isAvailable: Boolean, isAvailable: Boolean,
@@ -16,6 +16,10 @@ import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.EncryptionResult import io.element.android.libraries.cryptography.api.EncryptionResult
import io.element.android.libraries.cryptography.api.SecretKeyRepository import io.element.android.libraries.cryptography.api.SecretKeyRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
internal const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE" internal const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
@@ -29,6 +33,8 @@ class DefaultPinCodeManager(
) : PinCodeManager { ) : PinCodeManager {
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>() private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
private val migrationMutex = Mutex()
override fun addCallback(callback: PinCodeManager.Callback) { override fun addCallback(callback: PinCodeManager.Callback) {
callbacks.add(callback) callbacks.add(callback)
} }
@@ -39,6 +45,15 @@ class DefaultPinCodeManager(
override fun hasPinCode(): Flow<Boolean> { override fun hasPinCode(): Flow<Boolean> {
return secretKeyRepository.hasKey(SECRET_KEY_ALIAS) return secretKeyRepository.hasKey(SECRET_KEY_ALIAS)
.onStart {
migrationMutex.withLock {
val hasKey = secretKeyRepository.hasKey(SECRET_KEY_ALIAS).first()
if (hasKey && lockScreenStore.getEncryptedCode() == null) {
// Remove the key if there is no pin code
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
}
}
}
} }
override suspend fun getPinCodeSize(): Int? { override suspend fun getPinCodeSize(): Int? {
@@ -59,6 +59,7 @@ class LockScreenSettingsPresenter(
if (showRemovePinConfirmation) { if (showRemovePinConfirmation) {
showRemovePinConfirmation = false showRemovePinConfirmation = false
pinCodeManager.deletePinCode() pinCodeManager.deletePinCode()
biometricAuthenticatorManager.disable()
} }
} }
} }
@@ -34,5 +34,5 @@ Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvaka
</plurals> </plurals>
<string name="screen_app_lock_use_biometric_android">"Kasuta biomeetriat"</string> <string name="screen_app_lock_use_biometric_android">"Kasuta biomeetriat"</string>
<string name="screen_app_lock_use_pin_android">"Kasuta PIN-koodi"</string> <string name="screen_app_lock_use_pin_android">"Kasuta PIN-koodi"</string>
<string name="screen_signout_in_progress_dialog_content">"Logime välja…"</string> <string name="screen_signout_in_progress_dialog_content">"Eemaldan seadet…"</string>
</resources> </resources>
@@ -15,6 +15,7 @@ class FakeBiometricAuthenticatorManager(
override var isDeviceSecured: Boolean = true, override var isDeviceSecured: Boolean = true,
override var hasAvailableAuthenticator: Boolean = false, override var hasAvailableAuthenticator: Boolean = false,
private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() }, private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
private val disableLambda: suspend () -> Unit = { },
) : BiometricAuthenticatorManager { ) : BiometricAuthenticatorManager {
override fun addCallback(callback: BiometricAuthenticator.Callback) { override fun addCallback(callback: BiometricAuthenticator.Callback) {
// no-op // no-op
@@ -37,4 +38,8 @@ class FakeBiometricAuthenticatorManager(
createBiometricAuthenticator() createBiometricAuthenticator()
} }
} }
override suspend fun disable() {
disableLambda()
}
} }
-2
View File
@@ -60,7 +60,6 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
@@ -81,7 +80,6 @@ dependencies {
testImplementation(projects.features.login.test) testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test) testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.preferences.test) testImplementation(projects.features.preferences.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
@@ -28,8 +28,6 @@ import io.element.android.libraries.androidutils.service.ServiceBinder
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.auth.ElementClassicSession import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -71,7 +69,6 @@ class DefaultElementClassicConnection(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val matrixAuthenticationService: MatrixAuthenticationService, private val matrixAuthenticationService: MatrixAuthenticationService,
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
private val featureFlagService: FeatureFlagService,
) : ElementClassicConnection { ) : ElementClassicConnection {
// Messenger for communicating with the service. // Messenger for communicating with the service.
private var messenger: Messenger? = null private var messenger: Messenger? = null
@@ -119,10 +116,6 @@ class DefaultElementClassicConnection(
override fun start() { override fun start() {
Timber.tag(loggerTag.value).d("start()") Timber.tag(loggerTag.value).d("start()")
coroutineScope.launch { coroutineScope.launch {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SignInWithClassic)) {
Timber.tag(loggerTag.value).d("Login with Element Classic is disabled, not starting connection")
return@launch
}
// Establish a connection with the service. We use an explicit // Establish a connection with the service. We use an explicit
// class name because there is no reason to be able to let other // class name because there is no reason to be able to let other
// applications replace our component. // applications replace our component.
@@ -158,11 +151,6 @@ class DefaultElementClassicConnection(
override fun requestSession() { override fun requestSession() {
Timber.tag(loggerTag.value).d("requestSession()") Timber.tag(loggerTag.value).d("requestSession()")
coroutineScope.launch { coroutineScope.launch {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SignInWithClassic)) {
Timber.tag(loggerTag.value).d("Login with Element Classic is disabled")
emitState(ElementClassicConnectionState.Error("The feature is disabled"))
return@launch
}
val finalMessenger = messenger val finalMessenger = messenger
if (finalMessenger == null) { if (finalMessenger == null) {
Timber.tag(loggerTag.value).d("The messenger is null, can't request data") Timber.tag(loggerTag.value).d("The messenger is null, can't request data")
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Jaká je adresa vašeho serveru?"</string> <string name="screen_change_server_subtitle">"Jaká je adresa vašeho serveru?"</string>
<string name="screen_change_server_title">"Vyberte váš server"</string> <string name="screen_change_server_title">"Vyberte váš server"</string>
<string name="screen_create_account_title">"Vytvořit účet"</string> <string name="screen_create_account_title">"Vytvořit účet"</string>
<string name="screen_login_error_deactivated_account">"Tento účet byl deaktivován."</string> <string name="screen_login_error_deactivated_account">"Tento účet byl smazán."</string>
<string name="screen_login_error_invalid_credentials">"Nesprávné uživatelské jméno nebo heslo"</string> <string name="screen_login_error_invalid_credentials">"Nesprávné uživatelské jméno nebo heslo"</string>
<string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string> <string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Tento server je nakonfigurován tak, aby používal obnovovací tokeny. Ty nejsou podporovány při použití přihlašovacích údajů založených na hesle."</string> <string name="screen_login_error_refresh_tokens">"Tento server je nakonfigurován tak, aby používal obnovovací tokeny. Ty nejsou podporovány při použití přihlašovacích údajů založených na hesle."</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Mis on sinu koduserveri aadress?"</string> <string name="screen_change_server_subtitle">"Mis on sinu koduserveri aadress?"</string>
<string name="screen_change_server_title">"Vali oma server"</string> <string name="screen_change_server_title">"Vali oma server"</string>
<string name="screen_create_account_title">"Loo kasutajakonto"</string> <string name="screen_create_account_title">"Loo kasutajakonto"</string>
<string name="screen_login_error_deactivated_account">"Konto on kasutusest eemaldatud."</string> <string name="screen_login_error_deactivated_account">"See kasutajakonto on kustutatud."</string>
<string name="screen_login_error_invalid_credentials">"Vigane kasutajanimi ja/või salasõna"</string> <string name="screen_login_error_invalid_credentials">"Vigane kasutajanimi ja/või salasõna"</string>
<string name="screen_login_error_invalid_user_id">"See ei ole korrektne kasutajanimi. Õige vorming on: „@kasutaja:koduserver.ee“"</string> <string name="screen_login_error_invalid_user_id">"See ei ole korrektne kasutajanimi. Õige vorming on: „@kasutaja:koduserver.ee“"</string>
<string name="screen_login_error_refresh_tokens">"See server on seadistatud kasutama tunnusloa põhist sisselogimist. Salasõnaga sisselogimisel see võimalus aga ei ole toetatud."</string> <string name="screen_login_error_refresh_tokens">"See server on seadistatud kasutama tunnusloa põhist sisselogimist. Salasõnaga sisselogimisel see võimalus aga ei ole toetatud."</string>
@@ -39,7 +39,13 @@
<string name="screen_login_title_with_homeserver">"Logi sisse serverisse %1$s"</string> <string name="screen_login_title_with_homeserver">"Logi sisse serverisse %1$s"</string>
<string name="screen_missing_key_backup_open_element_classic">"Ava Element Classic"</string> <string name="screen_missing_key_backup_open_element_classic">"Ava Element Classic"</string>
<string name="screen_missing_key_backup_step_1">"Ava Element Classic oma seadmes"</string> <string name="screen_missing_key_backup_step_1">"Ava Element Classic oma seadmes"</string>
<string name="screen_missing_key_backup_step_2_android">"Ava „Seadistused“ → „Turvalisus ja privaatsus“"</string>
<string name="screen_missing_key_backup_step_3_android">"Krüptovõtmete halduses vali „Krüptitud sõnumite taastamine“"</string>
<string name="screen_missing_key_backup_step_4">"Võtmehoidla kasutuselevõtmiseks palun järgi juhendit"</string>
<string name="screen_missing_key_backup_step_5">"Tule tagasi rakendusse %1$s"</string>
<string name="screen_missing_key_backup_title">"Enne jätkamist rakenduses %1$s võta oma võtmehoidla kasutusele"</string>
<string name="screen_onboarding_app_version">"Versioon %1$s"</string> <string name="screen_onboarding_app_version">"Versioon %1$s"</string>
<string name="screen_onboarding_checking_account">"Kontrollin kasutajakontot"</string>
<string name="screen_onboarding_sign_in_manually">"Logi sisse käsitsi"</string> <string name="screen_onboarding_sign_in_manually">"Logi sisse käsitsi"</string>
<string name="screen_onboarding_sign_in_to">"Logi sisse serverisse %1$s"</string> <string name="screen_onboarding_sign_in_to">"Logi sisse serverisse %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Logi sisse QR-koodi alusel"</string> <string name="screen_onboarding_sign_in_with_qr_code">"Logi sisse QR-koodi alusel"</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Care este adresa serverului dumneavoastră?"</string> <string name="screen_change_server_subtitle">"Care este adresa serverului dumneavoastră?"</string>
<string name="screen_change_server_title">"Selectați serverul dumneavoastra"</string> <string name="screen_change_server_title">"Selectați serverul dumneavoastra"</string>
<string name="screen_create_account_title">"Creați un cont"</string> <string name="screen_create_account_title">"Creați un cont"</string>
<string name="screen_login_error_deactivated_account">"Acest cont a fost dezactivat."</string> <string name="screen_login_error_deactivated_account">"Acest cont a fost șters."</string>
<string name="screen_login_error_invalid_credentials">"Utilizator și/sau parolă incorecte"</string> <string name="screen_login_error_invalid_credentials">"Utilizator și/sau parolă incorecte"</string>
<string name="screen_login_error_invalid_user_id">"Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”"</string> <string name="screen_login_error_invalid_user_id">"Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”"</string>
<string name="screen_login_error_refresh_tokens">"Acest server este configurat pentru a utiliza token-uri de reîmprospătare. Acestea nu sunt acceptate atunci când utilizați autentificare bazată pe parolă."</string> <string name="screen_login_error_refresh_tokens">"Acest server este configurat pentru a utiliza token-uri de reîmprospătare. Acestea nu sunt acceptate atunci când utilizați autentificare bazată pe parolă."</string>
@@ -37,11 +37,20 @@
<string name="screen_login_subtitle">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string> <string name="screen_login_subtitle">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string>
<string name="screen_login_title">"Bine ați revenit!"</string> <string name="screen_login_title">"Bine ați revenit!"</string>
<string name="screen_login_title_with_homeserver">"Conectați-vă la %1$s"</string> <string name="screen_login_title_with_homeserver">"Conectați-vă la %1$s"</string>
<string name="screen_missing_key_backup_open_element_classic">"Deschideți Element Clasic"</string>
<string name="screen_missing_key_backup_step_1">"Deschideți Element Classic pe dispozitivul dumneavoastră"</string>
<string name="screen_missing_key_backup_step_2_android">"Accesați Setări &gt; Securitate și confidențialitate"</string>
<string name="screen_missing_key_backup_step_3_android">"În Gestionarea cheilor criptografice, selectați Recuperarea mesajelor criptate"</string>
<string name="screen_missing_key_backup_step_4">"Urmați instrucțiunile pentru a activa stocarea cheilor"</string>
<string name="screen_missing_key_backup_step_5">"Reveniți la %1$s"</string>
<string name="screen_missing_key_backup_title">"Activați stocarea cheilor înainte de a continua către %1$s"</string>
<string name="screen_onboarding_app_version">"Versiunea %1$s"</string> <string name="screen_onboarding_app_version">"Versiunea %1$s"</string>
<string name="screen_onboarding_checking_account">"Se verifică contul…"</string>
<string name="screen_onboarding_sign_in_manually">"Conectați-vă manual"</string> <string name="screen_onboarding_sign_in_manually">"Conectați-vă manual"</string>
<string name="screen_onboarding_sign_in_to">"Conectați-vă la %1$s"</string> <string name="screen_onboarding_sign_in_to">"Conectați-vă la %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Conectați-vă cu un cod QR"</string> <string name="screen_onboarding_sign_in_with_qr_code">"Conectați-vă cu un cod QR"</string>
<string name="screen_onboarding_sign_up">"Creați un cont"</string> <string name="screen_onboarding_sign_up">"Creați un cont"</string>
<string name="screen_onboarding_welcome_back">"Bine ați revenit"</string>
<string name="screen_onboarding_welcome_message">"Bine ați venit la cel mai rapid %1$s din toate timpurile. Supraalimentat pentru viteză și simplitate."</string> <string name="screen_onboarding_welcome_message">"Bine ați venit la cel mai rapid %1$s din toate timpurile. Supraalimentat pentru viteză și simplitate."</string>
<string name="screen_onboarding_welcome_subtitle">"Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate."</string> <string name="screen_onboarding_welcome_subtitle">"Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate."</string>
<string name="screen_onboarding_welcome_title">"Fii în Elementul tău"</string> <string name="screen_onboarding_welcome_title">"Fii în Elementul tău"</string>
@@ -60,6 +69,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Cererea de autentificare a fost anulată"</string> <string name="screen_qr_code_login_error_cancelled_title">"Cererea de autentificare a fost anulată"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Autentificarea a fost refuzată pe celălalt dispozitiv."</string> <string name="screen_qr_code_login_error_declined_subtitle">"Autentificarea a fost refuzată pe celălalt dispozitiv."</string>
<string name="screen_qr_code_login_error_declined_title">"Autentificarea a fost refuzată"</string> <string name="screen_qr_code_login_error_declined_title">"Autentificarea a fost refuzată"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Nu trebuie să faceți nimic altceva."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Celălalt dispozitiv este deja conectat"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Autentificarea a expirat. Vă rugăm să încercați din nou."</string> <string name="screen_qr_code_login_error_expired_subtitle">"Autentificarea a expirat. Vă rugăm să încercați din nou."</string>
<string name="screen_qr_code_login_error_expired_title">"Autentificarea nu a fost finalizată la timp"</string> <string name="screen_qr_code_login_error_expired_title">"Autentificarea nu a fost finalizată la timp"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR. <string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR.
@@ -15,9 +15,6 @@ import androidx.core.graphics.createBitmap
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.service.ServiceBinder import io.element.android.libraries.androidutils.service.ServiceBinder
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.auth.ElementClassicSession import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -112,21 +109,6 @@ class DefaultElementClassicConnectionTest {
} }
} }
@Test
fun `requestSession when the feature is disabled emits an error`() = runTest {
val connection = createDefaultElementClassicConnection(
matrixAuthenticationService = FakeMatrixAuthenticationService(
setElementClassicSessionResult = {},
),
isFeatureEnabled = false,
)
connection.stateFlow.test {
assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
connection.requestSession()
assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java)
}
}
@Test @Test
fun `when an error is received, an error is emitted`() = runTest { fun `when an error is received, an error is emitted`() = runTest {
val connection = createDefaultElementClassicConnection( val connection = createDefaultElementClassicConnection(
@@ -514,17 +496,10 @@ class DefaultElementClassicConnectionTest {
homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker( homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(true) } checkResult = { Result.success(true) }
), ),
isFeatureEnabled: Boolean = true,
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.SignInWithClassic.key to isFeatureEnabled,
)
),
) = DefaultElementClassicConnection( ) = DefaultElementClassicConnection(
serviceBinder = serviceBinder, serviceBinder = serviceBinder,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
matrixAuthenticationService = matrixAuthenticationService, matrixAuthenticationService = matrixAuthenticationService,
homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker, homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker,
featureFlagService = featureFlagService,
) )
} }
@@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Kas sa oled kindel, et soovid välja logida?"</string> <string name="screen_signout_confirmation_dialog_content">"Kas sa oled kindel, et soovid selle seadme eemaldada?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Eemalda see seade"</string> <string name="screen_signout_confirmation_dialog_submit">"Eemalda see seade"</string>
<string name="screen_signout_confirmation_dialog_title">"Eemalda see seade"</string> <string name="screen_signout_confirmation_dialog_title">"Eemalda see seade"</string>
<string name="screen_signout_in_progress_dialog_content">"Logime välja…"</string> <string name="screen_signout_in_progress_dialog_content">"Eemaldan seadet…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Oled oma viimasest seansist välja logimas. Kui logid nüüd välja, kaotad ligipääsu oma krüptitud sõnumitele."</string> <string name="screen_signout_key_backup_disabled_subtitle">"See on sinu ainus seade. Kui sa selle eemaldad, vajad taastamisvõtit, et kinnitada oma digitaalset identiteeti ja taastada järgmisel sisselogimisel oma krüptitud vestlused."</string>
<string name="screen_signout_key_backup_disabled_title">"Sa oled varukoopiate tegemise välja lülitanud"</string> <string name="screen_signout_key_backup_disabled_title">"Sa kaotad peagi juurdepääsu oma krüptitud vestlustele"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Kui su võrguühendus katkes, siis sinu krüptovõtmed oli parasjagu varundamisel. Loo võrguühendus uuesti, oota kuni krüptovõtmete varundamine lõppeb ja alles siis logi rakendusest välja."</string> <string name="screen_signout_key_backup_offline_subtitle">"Kui su võrguühendus katkes, siis sinu krüptovõtmed oli parasjagu varundamisel. Loo võrguühendus uuesti, oota kuni krüptovõtmete varundamine lõppeb ja alles siis eemalda see seade."</string>
<string name="screen_signout_key_backup_offline_title">"Sinu krüptovõtmed on veel varundamisel"</string> <string name="screen_signout_key_backup_offline_title">"Sinu krüptovõtmed on veel varundamisel"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Enne väljalogimist palun oota, et pooleliolev toiming lõppeb."</string> <string name="screen_signout_key_backup_ongoing_subtitle">"Enne selle seadme eemaldamist palun oota, et pooleliolev toiming lõppeb."</string>
<string name="screen_signout_key_backup_ongoing_title">"Sinu krüptovõtmed on veel varundamisel"</string> <string name="screen_signout_key_backup_ongoing_title">"Sinu krüptovõtmed on veel varundamisel"</string>
<string name="screen_signout_preference_item">"Eemalda see seade"</string> <string name="screen_signout_preference_item">"Eemalda see seade"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Sa oled logimas välja oma viimasest sessioonist. Kui teed seda nüüd, siis kaotad ligipääsu oma krüptitud sõnumitele."</string> <string name="screen_signout_recovery_disabled_subtitle">"See on sinu ainus seade. Kui sa selle eemaldad, vajad taastamisvõtit, et kinnitada oma digitaalset identiteeti ja taastada järgmisel sisselogimisel oma krüptitud vestlused."</string>
<string name="screen_signout_recovery_disabled_title">"Andmete taastamine on seadistamata"</string> <string name="screen_signout_recovery_disabled_title">"Andmete taastamine on seadistamata"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Sa oled logimas välja oma viimasest sessioonist. Kui teed seda nüüd, siis ilmselt kaotad ligipääsu oma krüptitud sõnumitele."</string> <string name="screen_signout_save_recovery_key_subtitle">"Sa oled logimas välja oma viimasest sessioonist. Kui teed seda nüüd, siis ilmselt kaotad ligipääsu oma krüptitud sõnumitele."</string>
<string name="screen_signout_save_recovery_key_title">"Kas sa oled oma taastevõtme salvestanud?"</string> <string name="screen_signout_save_recovery_key_title">"Enne selle seadme eemaldamist veendu, et sul on juurdepääs taastevõtmele"</string>
</resources> </resources>
@@ -16,5 +16,12 @@ import kotlinx.parcelize.Parcelize
@Immutable @Immutable
sealed interface Attachment : Parcelable { sealed interface Attachment : Parcelable {
@Parcelize @Parcelize
data class Media(val localMedia: LocalMedia) : Attachment data class Media(
val localMedia: LocalMedia,
// When true, the media was picked through the "Files" picker and should be
// uploaded without image recompression; videos still use the highest available
// / best-fit preset rather than an additional size-reduction optimization pass.
// See https://github.com/element-hq/element-x-android/issues/6365
val sendAsFile: Boolean = false,
) : Attachment
} }
@@ -23,6 +23,8 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.androidutils.hash.hash
@@ -61,6 +63,7 @@ class AttachmentsPreviewPresenter(
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter, private val temporaryUriDeleter: TemporaryUriDeleter,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
@@ -96,7 +99,10 @@ class AttachmentsPreviewPresenter(
val mediaAttachment = attachment as Attachment.Media val mediaAttachment = attachment as Attachment.Media
val mediaOptimizationSelectorPresenter = remember { val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia) mediaOptimizationSelectorPresenterFactory.create(
localMedia = mediaAttachment.localMedia,
sendAsFile = mediaAttachment.sendAsFile,
)
} }
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present()) val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
@@ -104,14 +110,25 @@ class AttachmentsPreviewPresenter(
var displayFileTooLargeError by remember { mutableStateOf(false) } var displayFileTooLargeError by remember { mutableStateOf(false) }
LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) { LaunchedEffect(
mediaOptimizationSelectorState.displayMediaSelectorViews,
mediaOptimizationSelectorState.videoSizeEstimations,
) {
// If the media optimization selector is not displayed, we can pre-process the media // If the media optimization selector is not displayed, we can pre-process the media
// to prepare it for sending. This is done to avoid blocking the UI thread when the // to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button. // user clicks on the send button.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) { if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && preprocessMediaJob == null) {
preprocessMediaJob = preProcessAttachment( if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
return@LaunchedEffect
}
val config = getAutoPreprocessMediaOptimizationConfig(
mediaAttachment = mediaAttachment,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
) ?: return@LaunchedEffect
preprocessMediaJob = coroutineScope.preProcessAttachment(
attachment = attachment, attachment = attachment,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), mediaOptimizationConfig = config,
displayProgress = false, displayProgress = false,
sendActionState = sendActionState, sendActionState = sendActionState,
) )
@@ -233,6 +250,28 @@ class AttachmentsPreviewPresenter(
) )
} }
private suspend fun getAutoPreprocessMediaOptimizationConfig(
mediaAttachment: Attachment.Media,
mediaOptimizationSelectorState: MediaOptimizationSelectorState,
): MediaOptimizationConfig? {
return if (mediaAttachment.sendAsFile) {
// If we're sending the media as a file, we can skip image compression and we should select the highest video compression preset that still fits
// the upload limit (if the estimations are available)
val videoCompressionPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations,
).dataOrNull() ?: VideoCompressionPreset.HIGH
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = videoCompressionPreset,
)
} else {
// Otherwise, we just rely on the user preferences for media optimization
mediaOptimizationConfigProvider.get()
}
}
private fun CoroutineScope.preProcessAttachment( private fun CoroutineScope.preProcessAttachment(
attachment: Attachment, attachment: Attachment,
mediaOptimizationConfig: MediaOptimizationConfig, mediaOptimizationConfig: MediaOptimizationConfig,
@@ -37,9 +37,11 @@ import kotlin.math.roundToLong
@AssistedInject @AssistedInject
class DefaultMediaOptimizationSelectorPresenter( class DefaultMediaOptimizationSelectorPresenter(
@Assisted private val localMedia: LocalMedia, @Assisted private val localMedia: LocalMedia,
@Assisted private val sendAsFile: Boolean,
private val maxUploadSizeProvider: MaxUploadSizeProvider, private val maxUploadSizeProvider: MaxUploadSizeProvider,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
mediaExtractorFactory: VideoMetadataExtractor.Factory, mediaExtractorFactory: VideoMetadataExtractor.Factory,
) : MediaOptimizationSelectorPresenter { ) : MediaOptimizationSelectorPresenter {
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@@ -47,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter(
interface Factory : MediaOptimizationSelectorPresenter.Factory { interface Factory : MediaOptimizationSelectorPresenter.Factory {
override fun create( override fun create(
localMedia: LocalMedia, localMedia: LocalMedia,
sendAsFile: Boolean,
): DefaultMediaOptimizationSelectorPresenter ): DefaultMediaOptimizationSelectorPresenter
} }
@@ -55,7 +58,9 @@ class DefaultMediaOptimizationSelectorPresenter(
@Composable @Composable
override fun present(): MediaOptimizationSelectorState { override fun present(): MediaOptimizationSelectorState {
val displayMediaSelectorViews by produceState<Boolean?>(null) { val displayMediaSelectorViews by produceState<Boolean?>(null) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) // When sending as a raw file, never show the optimization selector: images skip
// recompression, while videos use the highest available best-fit preset.
value = !sendAsFile && featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
} }
var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) } var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) }
@@ -123,12 +128,23 @@ class DefaultMediaOptimizationSelectorPresenter(
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) } var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) }
LaunchedEffect(videoSizeEstimations.dataOrNull()) { LaunchedEffect(videoSizeEstimations.dataOrNull()) {
if (sendAsFile) {
// Send-as-file path: pin to no image compression, and pick the highest-quality
// video preset that still fits the upload limit (we have no true "do not re-encode
// video" path in the pre-processor right now).
selectedImageOptimization = AsyncData.Success(false)
selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = videoSizeEstimations,
)
return@LaunchedEffect
}
val mediaOptimizationConfig = mediaOptimizationConfigProvider.get() val mediaOptimizationConfig = mediaOptimizationConfigProvider.get()
selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages) selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages)
// Find the best video preset based on the default preset and the video size estimations // Find the best video preset based on the default preset and the video size estimations
// Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes // Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes
selectedVideoOptimizationPreset = findBestVideoPreset( selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
defaultVideoPreset = mediaOptimizationConfig.videoCompressionPreset, expectedVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
videoSizeEstimations = videoSizeEstimations, videoSizeEstimations = videoSizeEstimations,
) )
} }
@@ -176,20 +192,4 @@ class DefaultMediaOptimizationSelectorPresenter(
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
private fun findBestVideoPreset(
defaultVideoPreset: VideoCompressionPreset,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
): AsyncData<VideoCompressionPreset> {
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
// This will find the best video preset that can be used to produce a video that can be uploaded
val bestEstimation = estimations.find { it.preset.ordinal >= defaultVideoPreset.ordinal && it.canUpload }?.preset
return if (bestEstimation != null) {
AsyncData.Success(bestEstimation)
} else {
AsyncData.Failure(
IllegalStateException("No suitable video preset found for default preset: $defaultVideoPreset")
)
}
}
} }
@@ -15,6 +15,7 @@ fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSe
interface Factory { interface Factory {
fun create( fun create(
localMedia: LocalMedia, localMedia: LocalMedia,
sendAsFile: Boolean,
): MediaOptimizationSelectorPresenter ): MediaOptimizationSelectorPresenter
} }
} }
@@ -0,0 +1,31 @@
/*
* 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.messages.impl.attachments.video
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
@Inject
class VideoCompressionPresetSelector {
fun selectBestVideoPreset(
expectedVideoPreset: VideoCompressionPreset,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
): AsyncData<VideoCompressionPreset> {
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
val bestEstimation = estimations.find { it.preset.ordinal >= expectedVideoPreset.ordinal && it.canUpload }?.preset
return if (bestEstimation != null) {
AsyncData.Success(bestEstimation)
} else {
AsyncData.Failure(
IllegalStateException("No suitable video preset found for expected preset: $expectedVideoPreset")
)
}
}
}
@@ -30,7 +30,6 @@ interface VideoMetadataExtractor : AutoCloseable {
} }
} }
@ContributesBinding(AppScope::class)
@AssistedInject @AssistedInject
class DefaultVideoMetadataExtractor( class DefaultVideoMetadataExtractor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@@ -179,7 +179,7 @@ class MessageComposerPresenter(
handlePickedMedia(uri, mimeType) handlePickedMedia(uri, mimeType)
} }
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream) handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
} }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
handlePickedMedia(uri, MimeTypes.Jpeg) handlePickedMedia(uri, MimeTypes.Jpeg)
@@ -571,7 +571,7 @@ class MessageComposerPresenter(
notificationConversationService.onSendMessage( notificationConversationService.onSendMessage(
sessionId = room.sessionId, sessionId = room.sessionId,
roomId = roomInfo.id, roomId = roomInfo.id,
roomName = roomInfo.name ?: roomInfo.id.value, roomName = roomInfo.name,
roomIsDirect = roomInfo.isDm, roomIsDirect = roomInfo.isDm,
roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl, roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl,
) )
@@ -605,6 +605,7 @@ class MessageComposerPresenter(
private fun handlePickedMedia( private fun handlePickedMedia(
uri: Uri?, uri: Uri?,
mimeType: String? = null, mimeType: String? = null,
sendAsFile: Boolean = false,
) { ) {
uri ?: return uri ?: return
val localMedia = localMediaFactory.createFromUri( val localMedia = localMediaFactory.createFromUri(
@@ -613,7 +614,7 @@ class MessageComposerPresenter(
name = null, name = null,
formattedFileSize = null formattedFileSize = null
) )
val mediaAttachment = Attachment.Media(localMedia) val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId) navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
@@ -67,7 +67,6 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@@ -88,7 +87,6 @@ class ThreadedMessagesNode(
private val presenterFactory: MessagesPresenter.Factory, private val presenterFactory: MessagesPresenter.Factory,
private val actionListPresenterFactory: ActionListPresenter.Factory, private val actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
@@ -157,9 +155,6 @@ class ThreadedMessagesNode(
onStop = { onStop = {
appNavigationStateService.onLeavingThread(id) appNavigationStateService.onLeavingThread(id)
}, },
onDestroy = {
mediaPlayer.close()
}
) )
} }
@@ -31,6 +31,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@@ -79,7 +82,18 @@ fun ThreadsListView(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { val description = stringResource(
CommonStrings.a11y_threads_in_room,
state.roomName,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clearAndSetSemantics {
heading()
contentDescription = description
},
) {
Avatar( Avatar(
avatarData = AvatarData( avatarData = AvatarData(
id = state.roomId.value, id = state.roomId.value,
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.annotation.StringRes
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -22,17 +23,19 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -42,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun TimelineItemCallNotifyView( internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event, event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent, content: TimelineItemRtcNotificationContent,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
@@ -62,37 +66,22 @@ internal fun TimelineItemCallNotifyView(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Avatar( Icon(
avatarData = event.senderAvatar, modifier = Modifier.size(20.sp.toDp()),
avatarType = AvatarType.User, imageVector = getIcon(timelineRoomInfo, content),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
) )
Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = event.safeSenderName, modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium, text = stringResource(getTextRes(timelineRoomInfo, content)),
maxLines = 1, style = ElementTheme.typography.fontBodyMdRegular,
overflow = TextOverflow.Ellipsis, color = ElementTheme.colors.textSecondary,
) maxLines = 1,
Row( overflow = TextOverflow.Ellipsis,
horizontalArrangement = Arrangement.spacedBy(4.dp), )
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector =
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Text(
text = stringResource(CommonStrings.common_call_started),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text( Text(
text = event.sentTime, text = event.sentTime,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
@@ -103,19 +92,56 @@ internal fun TimelineItemCallNotifyView(
} }
} }
@StringRes
private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): Int = if (timelineRoomInfo.isDm) {
when (content.state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
}
RtcNotificationState.Started -> CommonStrings.common_call_started
}
} else {
// In Rooms, do not show declined info.
CommonStrings.common_call_started
}
@Composable
private fun getIcon(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): ImageVector {
val showAsDeclined = timelineRoomInfo.isDm && content.state is RtcNotificationState.Declined
val icon = if (showAsDeclined) {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallDeclinedSolid() else CompoundIcons.VideoCallDeclinedSolid()
} else {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid()
}
return icon
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview { internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
listOf( listOf(false, true).forEach { isDm ->
TimelineItemRtcNotificationContent(CallIntent.AUDIO), listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
TimelineItemRtcNotificationContent(CallIntent.VIDEO), listOf(
).forEach { content -> RtcNotificationState.Started,
TimelineItemCallNotifyView( RtcNotificationState.Declined(byMe = false),
event = aTimelineItemEvent(content = content), RtcNotificationState.Declined(byMe = true),
content = content, ).forEach { state ->
onLongClick = {}, val content = TimelineItemRtcNotificationContent(callIntent, state)
) TimelineItemCallNotifyView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(content = content),
content = content,
onLongClick = {},
)
}
}
} }
} }
} }
@@ -125,6 +125,7 @@ internal fun TimelineItemRow(
is TimelineItemRtcNotificationContent -> { is TimelineItemRtcNotificationContent -> {
TimelineItemCallNotifyView( TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp), modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
timelineRoomInfo = timelineRoomInfo,
event = timelineItem, event = timelineItem,
content = timelineItem.content, content = timelineItem.content,
onLongClick = onLongClick, onLongClick = onLongClick,
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -104,7 +105,12 @@ class TimelineItemContentFactory(
is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent) is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent( is CallNotifyContent -> TimelineItemRtcNotificationContent(
itemContent.callIntent callIntent = itemContent.callIntent,
state = if (itemContent.declinedBy.isEmpty()) {
RtcNotificationState.Started
} else {
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
}
) )
is UnknownContent -> TimelineItemUnknownContent is UnknownContent -> TimelineItemUnknownContent
is LiveLocationContent -> { is LiveLocationContent -> {
@@ -9,7 +9,19 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
class TimelineItemRtcNotificationContent(val callIntent: CallIntent) : TimelineItemEventContent { // State of the call, for now only isDeclined but in the future could be missed, active.
override val type: String = "org.matrix.msc4075.rtc.notification" sealed interface RtcNotificationState {
/** Some users have declined, byMe indicates if the current user is one of them. */
data class Declined(val byMe: Boolean) : RtcNotificationState
object Started : RtcNotificationState
}
class TimelineItemRtcNotificationContent(
val callIntent: CallIntent,
val state: RtcNotificationState,
) : TimelineItemEventContent {
override val type: String = EventType.RTC_NOTIFICATION
} }
@@ -80,7 +80,8 @@ internal fun MessagesViewTopBar(
Row( Row(
modifier = Modifier modifier = Modifier
.clip(roundedCornerShape) .clip(roundedCornerShape)
.clickable { onRoomDetailsClick() }, .clickable { onRoomDetailsClick() }
.semantics { heading() },
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -158,10 +159,7 @@ private fun RoomAvatarAndNameRow(
) )
Text( Text(
modifier = Modifier modifier = Modifier
.padding(start = 8.dp) .padding(start = 8.dp),
.semantics {
heading()
},
text = roomName ?: stringResource(CommonStrings.common_no_room_name), text = roomName ?: stringResource(CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { roomName == null }, fontStyle = FontStyle.Italic.takeIf { roomName == null },
@@ -17,8 +17,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -58,7 +59,18 @@ internal fun ThreadTopBar(
BackButton(onClick = onBackClick) BackButton(onClick = onBackClick)
}, },
title = { title = {
Row(verticalAlignment = Alignment.CenterVertically) { val name = roomName ?: stringResource(CommonStrings.common_no_room_name)
val description = stringResource(
CommonStrings.a11y_thread_in_room,
name,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clearAndSetSemantics {
heading()
contentDescription = description
},
) {
Avatar( Avatar(
avatarData = roomAvatarData, avatarData = roomAvatarData,
avatarType = AvatarType.Room( avatarType = AvatarType.Room(
@@ -69,17 +81,14 @@ internal fun ThreadTopBar(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp),
.semantics {
heading()
},
) { ) {
Text( Text(
text = stringResource(CommonStrings.common_thread), text = stringResource(CommonStrings.common_thread),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
) )
Text( Text(
text = roomName ?: stringResource(CommonStrings.common_no_room_name), text = name,
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
fontStyle = FontStyle.Italic.takeIf { roomName == null }, fontStyle = FontStyle.Italic.takeIf { roomName == null },
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context import android.content.Context
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@@ -56,7 +57,16 @@ class DefaultMessageSummaryFormatter(
is TimelineItemFileContent -> context.getString(CommonStrings.common_file) is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio) is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call) is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started) is TimelineItemRtcNotificationContent -> when (content.state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) {
context.getString(CommonStrings.common_call_you_declined)
} else {
context.getString(CommonStrings.common_call_declined)
}
}
RtcNotificationState.Started -> context.getString(CommonStrings.common_call_started)
}
} }
// Truncate the message to a safe length to avoid crashes in Compose // Truncate the message to a safe length to avoid crashes in Compose
.toSafeLength() .toSafeLength()
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crypto_event_authenticity_mismatched_sender">"Odesílatel události se neshoduje s vlastníkem zařízení, které ji odeslalo."</string> <string name="crypto_event_authenticity_mismatched_sender">"Odesílatel události se neshoduje s vlastníkem zařízení, které ji odeslalo."</string>
<string name="crypto_event_authenticity_not_guaranteed">"Autenticitu této zašifrované zprávy nelze na tomto zařízení zaručit."</string> <string name="crypto_event_authenticity_not_guaranteed">"Pravost této šifrované zprávy nelze na tomto zařízení zaručit."</string>
<string name="crypto_event_authenticity_previously_verified">"Zašifrováno dříve ověřeným uživatelem."</string> <string name="crypto_event_authenticity_previously_verified">"Zašifrováno dříve ověřeným uživatelem."</string>
<string name="crypto_event_authenticity_sent_in_clear">"Není zašifrováno."</string> <string name="crypto_event_authenticity_sent_in_clear">"Není zašifrováno."</string>
<string name="crypto_event_authenticity_unknown_device">"Šifrováno neznámým nebo smazaným zařízením."</string> <string name="crypto_event_authenticity_unknown_device">"Šifrováno neznámým nebo smazaným zařízením."</string>
@@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Salvesta video"</string> <string name="screen_room_attachment_source_camera_video">"Salvesta video"</string>
<string name="screen_room_attachment_source_files">"Manus"</string> <string name="screen_room_attachment_source_files">"Manus"</string>
<string name="screen_room_attachment_source_gallery">"Fotode ja videote galerii"</string> <string name="screen_room_attachment_source_gallery">"Fotode ja videote galerii"</string>
<string name="screen_room_attachment_source_location">"Asukoht"</string> <string name="screen_room_attachment_source_location">"Jaga asukohta"</string>
<string name="screen_room_attachment_source_poll">"Küsitlus"</string> <string name="screen_room_attachment_source_poll">"Küsitlus"</string>
<string name="screen_room_attachment_text_formatting">"Tekstivorming"</string> <string name="screen_room_attachment_text_formatting">"Tekstivorming"</string>
<string name="screen_room_encrypted_history_banner">"Sõnumite ajalugu pole hetkel saadaval"</string> <string name="screen_room_encrypted_history_banner">"Sõnumite ajalugu pole hetkel saadaval"</string>
@@ -32,7 +32,7 @@
<string name="screen_report_content_hint">"Powód zgłoszenia treści"</string> <string name="screen_report_content_hint">"Powód zgłoszenia treści"</string>
<string name="screen_room_attachment_source_camera">"Kamera"</string> <string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Zrób zdjęcie"</string> <string name="screen_room_attachment_source_camera_photo">"Zrób zdjęcie"</string>
<string name="screen_room_attachment_source_camera_video">"Nagraj film"</string> <string name="screen_room_attachment_source_camera_video">"Nagraj wideo"</string>
<string name="screen_room_attachment_source_files">"Załącznik"</string> <string name="screen_room_attachment_source_files">"Załącznik"</string>
<string name="screen_room_attachment_source_gallery">"Zdjęcia i filmy"</string> <string name="screen_room_attachment_source_gallery">"Zdjęcia i filmy"</string>
<string name="screen_room_attachment_source_location">"Udostępnij lokalizację"</string> <string name="screen_room_attachment_source_location">"Udostępnij lokalizację"</string>
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@@ -1169,7 +1170,7 @@ class ActionListPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO), content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO, state = RtcNotificationState.Started),
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage( ActionListEvent.ComputeForMessage(
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.attachments.preview.Attachments
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
import io.element.android.features.messages.impl.attachments.preview.SendActionState import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.features.messages.impl.fixtures.aMediaAttachment import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory
@@ -45,6 +46,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfig
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
@@ -548,10 +550,87 @@ class AttachmentsPreviewPresenterTest {
} }
} }
@Test
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
// Even though the user has enabled "Optimize media quality" globally, picking the file
// through the Files picker (sendAsFile = true) must skip compression. Regression test
// for https://github.com/element-hq/element-x-android/issues/6365
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
sendAsFile = true,
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
config = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
)
),
)
presenter.test {
consumeItemsUntilPredicate { mediaPreProcessor.processCallCount > 0 }
assertThat(mediaPreProcessor.lastMediaOptimizationConfig).isEqualTo(
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.HIGH,
)
)
}
}
@Test
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
sendAsFile = true,
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
MediaOptimizationSelectorState(
maxUploadSize = AsyncData.Success(250_000_000L),
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 513_216_000L, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 228_096_000L, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 57_024_000L, canUpload = true),
)
),
isImageOptimizationEnabled = false,
selectedVideoPreset = VideoCompressionPreset.STANDARD,
displayMediaSelectorViews = false,
displayVideoPresetSelectorDialog = false,
eventSink = {},
)
},
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
config = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.LOW,
)
),
)
presenter.test {
consumeItemsUntilPredicate { mediaPreProcessor.processCallCount > 0 }
assertThat(mediaPreProcessor.lastMediaOptimizationConfig).isEqualTo(
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
)
)
}
}
private fun TestScope.createAttachmentsPreviewPresenter( private fun TestScope.createAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia( localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl, uri = mockMediaUrl,
), ),
sendAsFile: Boolean = false,
room: JoinedRoom = FakeJoinedRoom(), room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live, timelineMode: Timeline.Mode = Timeline.Mode.Live,
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
@@ -573,9 +652,10 @@ class AttachmentsPreviewPresenterTest {
} }
), ),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile),
onDoneListener = onDoneListener, onDoneListener = onDoneListener,
mediaSenderFactory = MediaSenderFactory { timelineMode -> mediaSenderFactory = MediaSenderFactory { timelineMode ->
DefaultMediaSender( DefaultMediaSender(
@@ -592,6 +672,7 @@ class AttachmentsPreviewPresenterTest {
sessionCoroutineScope = this, sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
videoCompressionPresetSelector = videoCompressionPresetSelector,
timelineMode = timelineMode, timelineMode = timelineMode,
inReplyToEventId = null, inReplyToEventId = null,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
@@ -210,19 +210,88 @@ class DefaultMediaOptimizationSelectorPresenterTest {
} }
} }
@Test
fun `present - sendAsFile hides selector views and disables image compression for images`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
// Even with the feature flag on, sendAsFile must hide the selector.
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
// And it must override the user's "optimize images" preference.
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
sendAsFile = true,
)
presenter.test {
// Initial loading state
skipItems(1)
awaitItem().run {
assertThat(displayMediaSelectorViews).isFalse()
assertThat(isImageOptimizationEnabled).isFalse()
}
}
}
@Test
fun `present - sendAsFile picks HIGH video preset when the video fits the upload limit`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
// Plenty of room: even HIGH preset will fit.
maxUploadSizeProvider = MaxUploadSizeProvider { Result.success(Long.MAX_VALUE) },
mediaExtractorFactory = FakeVideoMetadataExtractorFactory(
FakeVideoMetadataExtractor(
sizeResult = Result.success(Size(1920, 1080)),
duration = Result.success(10.minutes)
)
),
sendAsFile = true,
)
presenter.test {
// Initial loading state, then the one with size estimations loaded.
skipItems(1)
awaitItem().run {
assertThat(displayMediaSelectorViews).isFalse()
assertThat(selectedVideoPreset).isEqualTo(VideoCompressionPreset.HIGH)
}
}
}
@Test
fun `present - sendAsFile picks lower video preset when HIGH exceeds the upload limit`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
maxUploadSizeProvider = MaxUploadSizeProvider { Result.success(250_000_000L) },
mediaExtractorFactory = FakeVideoMetadataExtractorFactory(
FakeVideoMetadataExtractor(
sizeResult = Result.success(Size(1920, 1080)),
duration = Result.success(10.minutes)
)
),
sendAsFile = true,
)
presenter.test {
// Initial loading state, then the one with size estimations loaded.
skipItems(1)
awaitItem().run {
assertThat(displayMediaSelectorViews).isFalse()
assertThat(selectedVideoPreset).isEqualTo(VideoCompressionPreset.STANDARD)
}
}
}
private fun createDefaultMediaOptimizationSelectorPresenter( private fun createDefaultMediaOptimizationSelectorPresenter(
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) }, maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(), mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
sendAsFile: Boolean = false,
): DefaultMediaOptimizationSelectorPresenter { ): DefaultMediaOptimizationSelectorPresenter {
return DefaultMediaOptimizationSelectorPresenter( return DefaultMediaOptimizationSelectorPresenter(
localMedia = localMedia, localMedia = localMedia,
sendAsFile = sendAsFile,
maxUploadSizeProvider = maxUploadSizeProvider, maxUploadSizeProvider = maxUploadSizeProvider,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
mediaExtractorFactory = mediaExtractorFactory, mediaExtractorFactory = mediaExtractorFactory,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
videoCompressionPresetSelector = videoCompressionPresetSelector,
) )
} }
} }
@@ -0,0 +1,92 @@
/*
* 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.messages.impl.attachments.video
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class VideoCompressionPresetSelectorTest {
private val selector = VideoCompressionPresetSelector()
@Test
fun `selectBestVideoPreset - returns expected preset when it can upload`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = true),
)
)
)
assertThat(result.dataOrNull()).isEqualTo(VideoCompressionPreset.HIGH)
}
@Test
fun `selectBestVideoPreset - falls back to the highest fitting preset`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = true),
)
)
)
assertThat(result.dataOrNull()).isEqualTo(VideoCompressionPreset.STANDARD)
}
@Test
fun `selectBestVideoPreset - starts from the expected preset`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.STANDARD,
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = true),
)
)
)
assertThat(result.dataOrNull()).isEqualTo(VideoCompressionPreset.STANDARD)
}
@Test
fun `selectBestVideoPreset - returns failure when no preset can upload`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = false),
)
)
)
assertThat(result).isInstanceOf(AsyncData.Failure::class.java)
}
@Test
fun `selectBestVideoPreset - returns loading while estimations are missing`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = AsyncData.Loading(),
)
assertThat(result).isInstanceOf(AsyncData.Loading::class.java)
}
}
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.fixtures
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
fun aMediaAttachment(localMedia: LocalMedia) = Attachment.Media( fun aMediaAttachment(localMedia: LocalMedia, sendAsFile: Boolean = false) = Attachment.Media(
localMedia = localMedia, localMedia = localMedia,
sendAsFile = sendAsFile,
) )
@@ -0,0 +1,106 @@
/*
* 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.messages.impl.utils
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent.Mode
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.aProfileDetails
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
class DefaultMessageSummaryFormatterTest {
private val formatter = DefaultMessageSummaryFormatter(
RuntimeEnvironment.getApplication() as Context
)
@Test
@Config(qualifiers = "en")
fun `format call notification started`() {
val expected = formatter.format(
TimelineItemRtcNotificationContent(
callIntent = CallIntent.VIDEO,
state = RtcNotificationState.Started
)
)
assertThat(expected).isEqualTo("Call started")
}
@Test
@Config(qualifiers = "en")
fun `format call notification declined by me`() {
val expected = formatter.format(
TimelineItemRtcNotificationContent(
callIntent = CallIntent.VIDEO,
state = RtcNotificationState.Declined(byMe = true)
)
)
assertThat(expected).isEqualTo("You declined a call")
}
@Test
@Config(qualifiers = "en")
fun `format call notification declined`() {
val expected = formatter.format(
TimelineItemRtcNotificationContent(
callIntent = CallIntent.VIDEO,
state = RtcNotificationState.Declined(byMe = false)
)
)
assertThat(expected).isEqualTo("Call declined")
}
@Test
@Config(qualifiers = "en")
fun `format live location`() {
val expected = formatter.format(
aLocationContent(isLive = true)
)
assertThat(expected).isEqualTo("Shared live location")
}
@Test
@Config(qualifiers = "en")
fun `format static location`() {
val expected = formatter.format(
aLocationContent(isLive = false)
)
assertThat(expected).isEqualTo("Shared location")
}
}
private fun aLocationContent(isLive: Boolean) = TimelineItemLocationContent(
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
description = null,
assetType = null,
mode = if (isLive) {
Mode.Live(
lastKnownLocation = Location.fromGeoUri("geo:1,5"),
isActive = true,
endsAt = "",
endTimestamp = 0,
isOwnUser = true,
)
} else {
Mode.Static(
location = Location.fromGeoUri("geo:1,5")!!,
)
}
)
@@ -26,7 +26,7 @@ class FakeMediaOptimizationSelectorPresenterFactory(
) )
} }
) : MediaOptimizationSelectorPresenter.Factory { ) : MediaOptimizationSelectorPresenter.Factory {
override fun create(localMedia: LocalMedia): MediaOptimizationSelectorPresenter { override fun create(localMedia: LocalMedia, sendAsFile: Boolean): MediaOptimizationSelectorPresenter {
return fakePresenter return fakePresenter
} }
} }
@@ -25,11 +25,8 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @Inject
@@ -56,17 +53,8 @@ class AdvancedSettingsPresenter(
appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed) appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed)
}.collectAsState(initial = Theme.System) }.collectAsState(initial = Theme.System)
@OptIn(ExperimentalCoroutinesApi::class)
val liveLocationMinimumDistanceUpdate by produceState<Int?>(null) { val liveLocationMinimumDistanceUpdate by produceState<Int?>(null) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().collect { value = it }
.flatMapLatest { isEnabled ->
if (isEnabled) {
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
} else {
emptyFlow()
}
}
.collect { value = it }
} }
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
@@ -11,9 +11,9 @@ package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.preferences.DropdownOption import io.element.android.libraries.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class AdvancedSettingsState( data class AdvancedSettingsState(
@@ -44,24 +44,24 @@ enum class ThemeOption : DropdownOption {
System { System {
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_system) override fun getText(): String = stringResource(R.string.theme_system)
}, },
Light { Light {
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_light) override fun getText(): String = stringResource(R.string.theme_light)
}, },
Dark { Dark {
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_dark) override fun getText(): String = stringResource(R.string.theme_dark)
}, },
Black { Black {
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_black) override fun getText(): String = stringResource(R.string.theme_black)
} }
} }
@@ -11,7 +11,6 @@ package io.element.android.features.preferences.impl.tasks
import android.content.Context import android.content.Context
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Provider
import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.cachestore.api.CacheStore import io.element.android.libraries.cachestore.api.CacheStore
@@ -34,7 +33,7 @@ class DefaultClearCacheUseCase(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val defaultCacheService: DefaultCacheService, private val defaultCacheService: DefaultCacheService,
private val okHttpClient: Provider<OkHttpClient>, private val okHttpClient: () -> OkHttpClient,
private val pushService: PushService, private val pushService: PushService,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,

Some files were not shown because too many files have changed in this diff Show More