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:
@@ -34,6 +34,13 @@
|
||||
"/^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
|
||||
"matchPackageNames": [
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
mkdir -p screenshots/en
|
||||
cp tests/uitests/src/test/snapshots/images/* screenshots/en
|
||||
- name: Deploy GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
|
||||
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./screenshots
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check membership
|
||||
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
|
||||
with:
|
||||
username: ${{ github.event.pull_request.user.login }}
|
||||
|
||||
@@ -7,7 +7,7 @@ appId: ${MAESTRO_APP_ID}
|
||||
- tapOn:
|
||||
text: ${MAESTRO_INVITEE1_MXID}
|
||||
index: 1
|
||||
- tapOn: "Send invite"
|
||||
- tapOn: "Continue"
|
||||
- takeScreenshot: build/maestro/330-createAndDeleteDM
|
||||
- tapOn: "maestroelement2"
|
||||
- scroll
|
||||
|
||||
@@ -24,8 +24,16 @@ appId: ${MAESTRO_APP_ID}
|
||||
text: ${MAESTRO_INVITEE2_MXID}
|
||||
index: 1
|
||||
- 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: "aRoomName"
|
||||
- scrollUntilVisible:
|
||||
direction: DOWN
|
||||
element:
|
||||
|
||||
+83
@@ -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
|
||||
=============================
|
||||
|
||||
|
||||
@@ -382,9 +382,13 @@ class LoggedInFlowNode(
|
||||
}
|
||||
is NavTarget.Room -> {
|
||||
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 {
|
||||
attachRoom(roomIdOrAlias = roomId.toRoomIdOrAlias(), serverNames = serverNames, clearBackstack = false)
|
||||
attachRoom(roomIdOrAlias = roomId.toRoomIdOrAlias(), serverNames = serverNames, clearBackstack = clearBackStack)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -82,7 +82,8 @@ class JoinedRoomLoadedFlowNode(
|
||||
plugins = plugins,
|
||||
), DependencyInjectionGraphOwner {
|
||||
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 navigateToGlobalNotificationSettings()
|
||||
fun navigateToDeveloperSettings()
|
||||
@@ -142,6 +143,10 @@ class JoinedRoomLoadedFlowNode(
|
||||
|
||||
private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {
|
||||
val callback = object : RoomDetailsEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
callback.onDone()
|
||||
}
|
||||
|
||||
override fun navigateToGlobalNotificationSettings() {
|
||||
callback.navigateToGlobalNotificationSettings()
|
||||
}
|
||||
@@ -150,7 +155,7 @@ class JoinedRoomLoadedFlowNode(
|
||||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
|
||||
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) {
|
||||
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>, clearBackStack: Boolean) {
|
||||
callback.navigateToRoom(roomId, serverNames)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
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 navigateToGlobalNotificationSettings() = lambdaError()
|
||||
override fun navigateToDeveloperSettings() = lambdaError()
|
||||
|
||||
+1
-1
Submodule enterprise updated: fb7e9287d9...6781da90aa
@@ -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
|
||||
+26
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
-88
@@ -17,9 +17,10 @@ import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
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.getValue
|
||||
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.preview.ElementPreview
|
||||
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.ui.strings.CommonStrings
|
||||
import timber.log.Timber
|
||||
@@ -64,94 +64,93 @@ internal fun CallScreenView(
|
||||
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun handleBack() {
|
||||
if (pipState.supportPip) {
|
||||
pipState.eventSink.invoke(PictureInPictureEvent.EnterPictureInPicture)
|
||||
} else {
|
||||
state.eventSink(CallScreenEvent.Hangup)
|
||||
var callWebView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
fun handleBack(fromNative: Boolean = false) {
|
||||
when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) {
|
||||
CallScreenBackPressAction.EnterPictureInPicture ->
|
||||
pipState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
|
||||
CallScreenBackPressAction.DispatchEscapeToWebView ->
|
||||
callWebView?.dispatchEscKeyEvent()
|
||||
null -> Timber.d("Back press with unsupported pip is a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
) { padding ->
|
||||
BackHandler {
|
||||
handleBack()
|
||||
BackHandler {
|
||||
handleBack(fromNative = true)
|
||||
}
|
||||
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) }
|
||||
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) }
|
||||
invalidAudioDeviceReason?.let {
|
||||
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
|
||||
invalidAudioDeviceReason = null
|
||||
}
|
||||
CallWebView(
|
||||
modifier = modifier.consumeWindowInsets(WindowInsets.systemBars).fillMaxSize(),
|
||||
url = state.urlState,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.fillMaxSize(),
|
||||
url = state.urlState,
|
||||
userAgent = state.userAgent,
|
||||
onPermissionsRequest = { request ->
|
||||
val androidPermissions = mapWebkitPermissions(request.resources)
|
||||
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
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,15 +247,16 @@ private fun WebView.setup(
|
||||
|
||||
private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun onBackPressed() = onBackPressed()
|
||||
},
|
||||
JavascriptBackHandlerBridge(callback = onBackPressed),
|
||||
"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
|
||||
@Composable
|
||||
internal fun CallScreenViewPreview(
|
||||
@@ -275,3 +275,12 @@ internal fun CallScreenViewPreview(
|
||||
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
|
||||
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
|
||||
}
|
||||
|
||||
internal class JavascriptBackHandlerBridge(
|
||||
private val callback: () -> Unit,
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun onBackPressed() {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -32,6 +32,9 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.core.content.IntentCompat
|
||||
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 dev.zacsweers.metro.Inject
|
||||
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.meta.BuildMeta
|
||||
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.preferences.api.store.AppPreferencesStore
|
||||
import timber.log.Timber
|
||||
@@ -111,6 +115,27 @@ class ElementCallActivity :
|
||||
val colors by remember(webViewTarget.value?.sessionId) {
|
||||
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
|
||||
}.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(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
featureFlagService = featureFlagService,
|
||||
|
||||
+3
-6
@@ -201,12 +201,9 @@ class WebViewAudioManager(
|
||||
return
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
proximitySensorMutex.withLock {
|
||||
if (proximitySensorWakeLock?.isHeld == true) {
|
||||
proximitySensorWakeLock?.release()
|
||||
}
|
||||
}
|
||||
// Since this should run when the call is no longer running, it should be OK to not use the mutex here
|
||||
if (proximitySensorWakeLock?.isHeld == true) {
|
||||
proximitySensorWakeLock?.release()
|
||||
}
|
||||
|
||||
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_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="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>
|
||||
|
||||
+96
@@ -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()
|
||||
}
|
||||
}
|
||||
+151
@@ -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_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_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_title">"Privat"</string>
|
||||
<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>
|
||||
<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_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_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_title">"Adresă"</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_placeholder">"Adăugați o descriere…"</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_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_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_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>
|
||||
|
||||
@@ -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_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_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_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>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_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_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_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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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_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_use_another_device">"Kasuta teist seadet"</string>
|
||||
<string name="screen_identity_confirmation_use_recovery_key">"Kasuta taastevõtit"</string>
|
||||
|
||||
+3
-2
@@ -83,8 +83,8 @@ class RoomListDataSource(
|
||||
|
||||
val loadingState = roomList.loadingState
|
||||
|
||||
fun launchIn(coroutineScope: CoroutineScope) {
|
||||
roomList
|
||||
fun launchIn(coroutineScope: CoroutineScope): Job {
|
||||
return roomList
|
||||
.summaries
|
||||
.onEach { roomSummaries ->
|
||||
replaceWith(roomSummaries)
|
||||
@@ -212,6 +212,7 @@ class RoomListDataSource(
|
||||
private suspend fun rebuildAllRoomSummaries() {
|
||||
lock.withLock {
|
||||
roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
|
||||
diffCacheUpdater.updateWith(roomSummaries)
|
||||
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_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_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_submit">"Seadista andmete taastamine"</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 taastevõti"</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_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_unread">"Marcați ca necitită"</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_title">"Verificați că sunteți dumneavoastră"</string>
|
||||
</resources>
|
||||
|
||||
+162
-1
@@ -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.dateformatter.test.FakeDateFormatter
|
||||
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.room.aRoomSummary
|
||||
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(
|
||||
roomListService: FakeRoomListService = FakeRoomListService(),
|
||||
roomListRoomSummaryFactory: RoomListRoomSummaryFactory = aRoomListRoomSummaryFactory(),
|
||||
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
||||
dateTimeObserver: FakeDateTimeObserver = FakeDateTimeObserver(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
) = RoomListDataSource(
|
||||
roomListService = roomListService,
|
||||
roomListRoomSummaryFactory = roomListRoomSummaryFactory,
|
||||
@@ -112,6 +273,6 @@ class RoomListDataSourceTest {
|
||||
notificationSettingsService = notificationSettingsService,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
dateTimeObserver = dateTimeObserver,
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
analyticsService = analyticsService,
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -11,4 +11,5 @@ package io.element.android.features.invitepeople.api
|
||||
interface InvitePeopleEvents {
|
||||
data object SendInvites : InvitePeopleEvents
|
||||
data object CloseSearch : InvitePeopleEvents
|
||||
data object ClearError : InvitePeopleEvents
|
||||
}
|
||||
|
||||
+2
@@ -9,10 +9,12 @@
|
||||
package io.element.android.features.invitepeople.api
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
interface InvitePeopleState {
|
||||
val canInvite: Boolean
|
||||
val isSearchActive: Boolean
|
||||
val sendInvitesAction: AsyncAction<Unit>
|
||||
val createRoomFromDmAction: AsyncAction<RoomId>
|
||||
val eventSink: (InvitePeopleEvents) -> Unit
|
||||
}
|
||||
|
||||
+4
@@ -10,6 +10,7 @@ package io.element.android.features.invitepeople.api
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
|
||||
override val values: Sequence<InvitePeopleState>
|
||||
@@ -25,6 +26,7 @@ private data class PreviewInvitePeopleState(
|
||||
override val canInvite: Boolean,
|
||||
override val isSearchActive: Boolean,
|
||||
override val sendInvitesAction: AsyncAction<Unit>,
|
||||
override val createRoomFromDmAction: AsyncAction<RoomId>,
|
||||
override val eventSink: (InvitePeopleEvents) -> Unit,
|
||||
) : InvitePeopleState
|
||||
|
||||
@@ -32,10 +34,12 @@ private fun aPreviewInvitePeopleState(
|
||||
canInvite: Boolean = false,
|
||||
isSearchActive: Boolean = false,
|
||||
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||
eventSink: (InvitePeopleEvents) -> Unit = {},
|
||||
) = PreviewInvitePeopleState(
|
||||
canInvite = canInvite,
|
||||
isSearchActive = isSearchActive,
|
||||
sendInvitesAction = sendInvitesAction,
|
||||
createRoomFromDmAction = createRoomFromDmAction,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ dependencies {
|
||||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.usersearch.api)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(projects.services.apperror.api)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
|
||||
+47
-1
@@ -38,12 +38,17 @@ import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
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.room.JoinedRoom
|
||||
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.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.roomdirectory.RoomVisibility
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.usersearch.api.UserRepository
|
||||
@@ -88,6 +93,7 @@ class DefaultInvitePeoplePresenter(
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
||||
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
val createRoomFromDmAction = remember { mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) }
|
||||
|
||||
val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
|
||||
if (roomMembers.value.isSuccess()) {
|
||||
@@ -208,7 +214,13 @@ class DefaultInvitePeoplePresenter(
|
||||
)
|
||||
} else {
|
||||
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
|
||||
queryState.clearText()
|
||||
}
|
||||
is InvitePeopleEvents.ClearError -> {
|
||||
sendInvitesAction.value = AsyncAction.Uninitialized
|
||||
createRoomFromDmAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +244,7 @@ class DefaultInvitePeoplePresenter(
|
||||
searchResults = searchResults.value,
|
||||
showSearchLoader = showSearchLoader.value,
|
||||
sendInvitesAction = sendInvitesAction.value,
|
||||
createRoomFromDmAction = createRoomFromDmAction.value,
|
||||
suggestions = suggestions,
|
||||
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")
|
||||
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
|
||||
value = if (value.contains(user)) {
|
||||
|
||||
+2
@@ -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.AsyncData
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@@ -26,6 +27,7 @@ data class DefaultInvitePeopleState(
|
||||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
override val isSearchActive: Boolean,
|
||||
override val sendInvitesAction: AsyncAction<Unit>,
|
||||
override val createRoomFromDmAction: AsyncAction<RoomId>,
|
||||
val suggestions: ImmutableList<InvitableUser>,
|
||||
override val eventSink: (InvitePeopleEvents) -> Unit
|
||||
) : InvitePeopleState
|
||||
|
||||
+3
@@ -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_JUSTIN
|
||||
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.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
@@ -119,6 +120,7 @@ private fun aDefaultInvitePeopleState(
|
||||
isSearchActive: Boolean = false,
|
||||
showSearchLoader: Boolean = false,
|
||||
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||
suggestions: List<InvitableUser> = aMatrixUserList()
|
||||
.take(5)
|
||||
.map { user -> anInvitableUser(matrixUser = user, isSelected = user in selectedUsers) },
|
||||
@@ -132,6 +134,7 @@ private fun aDefaultInvitePeopleState(
|
||||
isSearchActive = isSearchActive,
|
||||
showSearchLoader = showSearchLoader,
|
||||
sendInvitesAction = sendInvitesAction,
|
||||
createRoomFromDmAction = createRoomFromDmAction,
|
||||
suggestions = suggestions.toImmutableList(),
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
+6
-2
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
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.model.getAvatarData
|
||||
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.utils.strings.simplePluralStringResource
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -102,7 +106,7 @@ private fun InvitePeopleContentView(
|
||||
}
|
||||
|
||||
InvitePeopleSearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.imePadding().fillMaxWidth(),
|
||||
queryState = state.searchQuery,
|
||||
showLoader = state.showSearchLoader,
|
||||
selectedUsers = state.selectedUsers,
|
||||
@@ -298,7 +302,7 @@ private fun InvitePeopleConfirmModal(
|
||||
text = stringResource(CommonStrings.action_remove),
|
||||
onClick = onRemove,
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Close()),
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f).testTag(TestTags.confirmInviteUnknown),
|
||||
)
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_invite),
|
||||
|
||||
@@ -2,4 +2,8 @@
|
||||
<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_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>
|
||||
|
||||
@@ -2,4 +2,8 @@
|
||||
<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_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>
|
||||
|
||||
+48
@@ -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(
|
||||
users: List<MatrixUser>,
|
||||
isSearching: Boolean = false
|
||||
|
||||
+2
-4
@@ -609,6 +609,7 @@ private fun JoinRoomTopBar(
|
||||
val roundedCornerShape = RoundedCornerShape(8.dp)
|
||||
val titleModifier = Modifier
|
||||
.clip(roundedCornerShape)
|
||||
.semantics { heading() }
|
||||
if (contentState.name != null) {
|
||||
Row(
|
||||
modifier = titleModifier,
|
||||
@@ -621,10 +622,7 @@ private fun JoinRoomTopBar(
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.semantics {
|
||||
heading()
|
||||
},
|
||||
.padding(horizontal = 8.dp),
|
||||
text = contentState.name,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
maxLines = 1,
|
||||
|
||||
+8
-2
@@ -144,8 +144,14 @@ class LinkNewDeviceFlowNode(
|
||||
navigateToError(linkMobileStep.errorType)
|
||||
}
|
||||
is LinkMobileStep.QrReady -> {
|
||||
// The QrCode is ready, navigate to its display
|
||||
backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data))
|
||||
// The QrCode is ready, navigate to its display, if not already there
|
||||
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 -> {
|
||||
backstack.replace(NavTarget.MobileEnterNumber)
|
||||
|
||||
+12
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
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.LinkMobileStep
|
||||
import io.element.android.libraries.matrix.api.logs.LoggerTags
|
||||
@@ -65,4 +66,15 @@ class LinkNewMobileHandler(
|
||||
linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
fun rotateQrCode() {
|
||||
createAndStartNewHandler()
|
||||
}
|
||||
|
||||
fun onTooManyRotation() {
|
||||
reset()
|
||||
sessionScope.launch {
|
||||
linkMobileStepFlow.emit(LinkMobileStep.Error(ErrorType.Expired("Too many QR code rotations")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -25,6 +25,7 @@ import io.element.android.libraries.di.SessionScope
|
||||
class ShowQrCodeNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
showQrCodePresenterFactory: ShowQrCodePresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
class Inputs(
|
||||
val data: String,
|
||||
@@ -36,11 +37,15 @@ class ShowQrCodeNode(
|
||||
|
||||
private val inputs: Inputs = inputs<Inputs>()
|
||||
private val callback: Callback = callback()
|
||||
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
|
||||
initialData = inputs.data,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = showQrCodePresenter.present()
|
||||
ShowQrCodeView(
|
||||
data = inputs.data,
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = callback::navigateBack,
|
||||
)
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+14
@@ -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>,
|
||||
)
|
||||
+27
@@ -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,
|
||||
)
|
||||
+45
-8
@@ -9,6 +9,12 @@
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -21,6 +27,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
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.PreviewsDayNight
|
||||
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.qrcode.QrCodeImage
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -38,9 +46,10 @@ import kotlinx.collections.immutable.persistentListOf
|
||||
* QrCode display screen:
|
||||
* https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617
|
||||
*/
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun ShowQrCodeView(
|
||||
data: String,
|
||||
state: ShowQrCodeState,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -55,11 +64,17 @@ fun ShowQrCodeView(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
QrCodeImage(
|
||||
data = data,
|
||||
modifier = Modifier
|
||||
.size(220.dp)
|
||||
)
|
||||
AnimatedContent(
|
||||
modifier = Modifier.size(220.dp),
|
||||
targetState = state.data.dataOrNull(),
|
||||
transitionSpec = {
|
||||
fadeIn().togetherWith(fadeOut())
|
||||
}
|
||||
) { data ->
|
||||
QrCodeOrLoading(
|
||||
data = data,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
NumberedListOrganism(
|
||||
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
|
||||
@Composable
|
||||
internal fun ShowQrCodeViewPreview() = ElementPreview {
|
||||
internal fun ShowQrCodeViewPreview(
|
||||
@PreviewParameter(ShowQrCodeStateProvider::class) state: ShowQrCodeState,
|
||||
) = ElementPreview {
|
||||
ShowQrCodeView(
|
||||
data = "DATA",
|
||||
state = state,
|
||||
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_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_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_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>
|
||||
@@ -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_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_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_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.
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ class ShowQrCodeViewTest {
|
||||
) {
|
||||
setContent {
|
||||
ShowQrCodeView(
|
||||
data = "DATA",
|
||||
state = aShowQrCodeState(),
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-7
@@ -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.meta.BuildMeta
|
||||
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.room.CreateTimelineParams
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
@@ -66,7 +64,6 @@ class ShareLocationPresenter(
|
||||
private val messageComposerContext: MessageComposerContext,
|
||||
private val locationActions: LocationActions,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val client: MatrixClient,
|
||||
private val durationFormatter: DurationFormatter,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
@@ -83,9 +80,6 @@ class ShareLocationPresenter(
|
||||
override fun present(): ShareLocationState {
|
||||
val permissionsState: PermissionsState = permissionsPresenter.present()
|
||||
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 } }
|
||||
var dialogState: ShareLocationState.Dialog by remember {
|
||||
mutableStateOf(ShareLocationState.Dialog.None)
|
||||
@@ -171,7 +165,7 @@ class ShareLocationPresenter(
|
||||
dialogState = dialogState,
|
||||
trackUserLocation = trackUserPosition,
|
||||
hasLocationPermission = permissionsState.isAnyGranted,
|
||||
canShareLiveLocation = isLiveLocationSharingEnabled && timelineMode.canShareLiveLocation(),
|
||||
canShareLiveLocation = timelineMode.canShareLiveLocation(),
|
||||
appName = appName,
|
||||
startLiveLocationAction = startLiveLocationAction.value,
|
||||
eventSink = ::handleEvent,
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<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_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>
|
||||
|
||||
@@ -1,4 +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">"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_missing_permissions">"Sul pole õigust jagada selles jututoas oma asukohta reaalajas"</string>
|
||||
</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"?>
|
||||
<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_missing_permissions">"你无权在此房内共享实时位置。"</string>
|
||||
</resources>
|
||||
|
||||
-2
@@ -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.messages.test.FakeMessageComposerContext
|
||||
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.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
@@ -50,7 +49,6 @@ class DefaultShareLocationEntryPointTest {
|
||||
messageComposerContext = FakeMessageComposerContext(),
|
||||
locationActions = FakeLocationActions(),
|
||||
buildMeta = aBuildMeta(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
client = FakeMatrixClient(),
|
||||
durationFormatter = FakeDurationFormatter(),
|
||||
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
|
||||
+1
-20
@@ -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.messages.test.FakeMessageComposerContext
|
||||
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.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
@@ -77,7 +75,6 @@ class ShareLocationPresenterTest {
|
||||
private val fakeMessageComposerContext = FakeMessageComposerContext()
|
||||
private val fakeLocationActions = FakeLocationActions()
|
||||
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
|
||||
private val fakeFeatureFlagService = FakeFeatureFlagService()
|
||||
private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
|
||||
|
||||
private val durationFormatter = FakeDurationFormatter()
|
||||
@@ -96,7 +93,6 @@ class ShareLocationPresenterTest {
|
||||
messageComposerContext = fakeMessageComposerContext,
|
||||
locationActions = locationActions,
|
||||
buildMeta = fakeBuildMeta,
|
||||
featureFlagService = fakeFeatureFlagService,
|
||||
client = fakeMatrixClient,
|
||||
durationFormatter = durationFormatter,
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
@@ -658,21 +654,7 @@ class ShareLocationPresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canShareLiveLocation is false when the feature is disabled`() = 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)
|
||||
fun `canShareLiveLocation is true in live timeline`() = runTest {
|
||||
val shareLocationPresenter = createShareLocationPresenter(
|
||||
timelineMode = Timeline.Mode.Live,
|
||||
)
|
||||
@@ -685,7 +667,6 @@ class ShareLocationPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `canShareLiveLocation is false in thread timeline`() = runTest {
|
||||
fakeFeatureFlagService.setFeatureEnabled(FeatureFlags.LiveLocationSharing, true)
|
||||
val shareLocationPresenter = createShareLocationPresenter(
|
||||
timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
|
||||
)
|
||||
|
||||
+2
-1
@@ -43,7 +43,7 @@ class DefaultLockScreenService(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val sessionObserver: SessionObserver,
|
||||
private val appForegroundStateService: AppForegroundStateService,
|
||||
biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
||||
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
|
||||
) : LockScreenService {
|
||||
private val _lockState = MutableStateFlow<LockScreenLockState>(LockScreenLockState.Unlocked)
|
||||
override val lockState: StateFlow<LockScreenLockState> = _lockState
|
||||
@@ -81,6 +81,7 @@ class DefaultLockScreenService(
|
||||
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
|
||||
if (wasLastSession) {
|
||||
pinCodeManager.deletePinCode()
|
||||
biometricAuthenticatorManager.disable()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+5
@@ -24,6 +24,11 @@ interface BiometricAuthenticatorManager {
|
||||
fun addCallback(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.
|
||||
*/
|
||||
|
||||
+6
-4
@@ -80,10 +80,7 @@ class DefaultBiometricAuthenticatorManager(
|
||||
|
||||
private val internalCallback = object : DefaultBiometricUnlockCallback() {
|
||||
override fun onBiometricSetupError() {
|
||||
coroutineScope.launch {
|
||||
lockScreenStore.setIsBiometricUnlockAllowed(false)
|
||||
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
|
||||
}
|
||||
coroutineScope.launch { disable() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +117,11 @@ class DefaultBiometricAuthenticatorManager(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun disable() {
|
||||
lockScreenStore.setIsBiometricUnlockAllowed(false)
|
||||
secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberBiometricAuthenticator(
|
||||
isAvailable: Boolean,
|
||||
|
||||
+15
@@ -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.SecretKeyRepository
|
||||
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
|
||||
|
||||
internal const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
|
||||
@@ -29,6 +33,8 @@ class DefaultPinCodeManager(
|
||||
) : PinCodeManager {
|
||||
private val callbacks = CopyOnWriteArrayList<PinCodeManager.Callback>()
|
||||
|
||||
private val migrationMutex = Mutex()
|
||||
|
||||
override fun addCallback(callback: PinCodeManager.Callback) {
|
||||
callbacks.add(callback)
|
||||
}
|
||||
@@ -39,6 +45,15 @@ class DefaultPinCodeManager(
|
||||
|
||||
override fun hasPinCode(): Flow<Boolean> {
|
||||
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? {
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ class LockScreenSettingsPresenter(
|
||||
if (showRemovePinConfirmation) {
|
||||
showRemovePinConfirmation = false
|
||||
pinCodeManager.deletePinCode()
|
||||
biometricAuthenticatorManager.disable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,5 @@ Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvaka
|
||||
</plurals>
|
||||
<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_signout_in_progress_dialog_content">"Logime välja…"</string>
|
||||
<string name="screen_signout_in_progress_dialog_content">"Eemaldan seadet…"</string>
|
||||
</resources>
|
||||
|
||||
+5
@@ -15,6 +15,7 @@ class FakeBiometricAuthenticatorManager(
|
||||
override var isDeviceSecured: Boolean = true,
|
||||
override var hasAvailableAuthenticator: Boolean = false,
|
||||
private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
|
||||
private val disableLambda: suspend () -> Unit = { },
|
||||
) : BiometricAuthenticatorManager {
|
||||
override fun addCallback(callback: BiometricAuthenticator.Callback) {
|
||||
// no-op
|
||||
@@ -37,4 +38,8 @@ class FakeBiometricAuthenticatorManager(
|
||||
createBiometricAuthenticator()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disable() {
|
||||
disableLambda()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ dependencies {
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.designsystem)
|
||||
@@ -81,7 +80,6 @@ dependencies {
|
||||
testImplementation(projects.features.login.test)
|
||||
testImplementation(projects.features.enterprise.test)
|
||||
testImplementation(projects.features.preferences.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.oauth.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
|
||||
-12
@@ -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.uri.ensureProtocol
|
||||
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.HomeServerLoginCompatibilityChecker
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
@@ -71,7 +69,6 @@ class DefaultElementClassicConnection(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val matrixAuthenticationService: MatrixAuthenticationService,
|
||||
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : ElementClassicConnection {
|
||||
// Messenger for communicating with the service.
|
||||
private var messenger: Messenger? = null
|
||||
@@ -119,10 +116,6 @@ class DefaultElementClassicConnection(
|
||||
override fun start() {
|
||||
Timber.tag(loggerTag.value).d("start()")
|
||||
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
|
||||
// class name because there is no reason to be able to let other
|
||||
// applications replace our component.
|
||||
@@ -158,11 +151,6 @@ class DefaultElementClassicConnection(
|
||||
override fun requestSession() {
|
||||
Timber.tag(loggerTag.value).d("requestSession()")
|
||||
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
|
||||
if (finalMessenger == null) {
|
||||
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_title">"Vyberte váš server"</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_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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<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_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_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>
|
||||
@@ -39,7 +39,13 @@
|
||||
<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_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_checking_account">"Kontrollin kasutajakontot"</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_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_title">"Selectați serverul dumneavoastra"</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_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>
|
||||
@@ -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_title">"Bine ați revenit!"</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 > 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_checking_account">"Se verifică contul…"</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_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_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_subtitle">"Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate."</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_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_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_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.
|
||||
|
||||
-25
@@ -15,9 +15,6 @@ import androidx.core.graphics.createBitmap
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.HomeServerLoginCompatibilityChecker
|
||||
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
|
||||
fun `when an error is received, an error is emitted`() = runTest {
|
||||
val connection = createDefaultElementClassicConnection(
|
||||
@@ -514,17 +496,10 @@ class DefaultElementClassicConnectionTest {
|
||||
homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
|
||||
checkResult = { Result.success(true) }
|
||||
),
|
||||
isFeatureEnabled: Boolean = true,
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(
|
||||
FeatureFlags.SignInWithClassic.key to isFeatureEnabled,
|
||||
)
|
||||
),
|
||||
) = DefaultElementClassicConnection(
|
||||
serviceBinder = serviceBinder,
|
||||
coroutineScope = coroutineScope,
|
||||
matrixAuthenticationService = matrixAuthenticationService,
|
||||
homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker,
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_title">"Eemalda see seade"</string>
|
||||
<string name="screen_signout_in_progress_dialog_content">"Logime välja…"</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_title">"Sa oled varukoopiate tegemise välja lülitanud"</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_in_progress_dialog_content">"Eemaldan seadet…"</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 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 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_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_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_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>
|
||||
|
||||
+8
-1
@@ -16,5 +16,12 @@ import kotlinx.parcelize.Parcelize
|
||||
@Immutable
|
||||
sealed interface Attachment : Parcelable {
|
||||
@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
|
||||
}
|
||||
|
||||
+44
-5
@@ -23,6 +23,8 @@ import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
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.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.safeDelete
|
||||
import io.element.android.libraries.androidutils.hash.hash
|
||||
@@ -61,6 +63,7 @@ class AttachmentsPreviewPresenter(
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
|
||||
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
@@ -96,7 +99,10 @@ class AttachmentsPreviewPresenter(
|
||||
|
||||
val mediaAttachment = attachment as Attachment.Media
|
||||
val mediaOptimizationSelectorPresenter = remember {
|
||||
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
|
||||
mediaOptimizationSelectorPresenterFactory.create(
|
||||
localMedia = mediaAttachment.localMedia,
|
||||
sendAsFile = mediaAttachment.sendAsFile,
|
||||
)
|
||||
}
|
||||
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
|
||||
|
||||
@@ -104,14 +110,25 @@ class AttachmentsPreviewPresenter(
|
||||
|
||||
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
|
||||
// to prepare it for sending. This is done to avoid blocking the UI thread when the
|
||||
// user clicks on the send button.
|
||||
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
|
||||
preprocessMediaJob = preProcessAttachment(
|
||||
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && preprocessMediaJob == null) {
|
||||
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,
|
||||
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||
mediaOptimizationConfig = config,
|
||||
displayProgress = false,
|
||||
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(
|
||||
attachment: Attachment,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
|
||||
+19
-19
@@ -37,9 +37,11 @@ import kotlin.math.roundToLong
|
||||
@AssistedInject
|
||||
class DefaultMediaOptimizationSelectorPresenter(
|
||||
@Assisted private val localMedia: LocalMedia,
|
||||
@Assisted private val sendAsFile: Boolean,
|
||||
private val maxUploadSizeProvider: MaxUploadSizeProvider,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
|
||||
mediaExtractorFactory: VideoMetadataExtractor.Factory,
|
||||
) : MediaOptimizationSelectorPresenter {
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@@ -47,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||
interface Factory : MediaOptimizationSelectorPresenter.Factory {
|
||||
override fun create(
|
||||
localMedia: LocalMedia,
|
||||
sendAsFile: Boolean,
|
||||
): DefaultMediaOptimizationSelectorPresenter
|
||||
}
|
||||
|
||||
@@ -55,7 +58,9 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||
@Composable
|
||||
override fun present(): MediaOptimizationSelectorState {
|
||||
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) }
|
||||
@@ -123,12 +128,23 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) }
|
||||
|
||||
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()
|
||||
selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages)
|
||||
// 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
|
||||
selectedVideoOptimizationPreset = findBestVideoPreset(
|
||||
defaultVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
|
||||
selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
|
||||
expectedVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
|
||||
videoSizeEstimations = videoSizeEstimations,
|
||||
)
|
||||
}
|
||||
@@ -176,20 +192,4 @@ class DefaultMediaOptimizationSelectorPresenter(
|
||||
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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSe
|
||||
interface Factory {
|
||||
fun create(
|
||||
localMedia: LocalMedia,
|
||||
sendAsFile: Boolean,
|
||||
): MediaOptimizationSelectorPresenter
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -30,7 +30,6 @@ interface VideoMetadataExtractor : AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedInject
|
||||
class DefaultVideoMetadataExtractor(
|
||||
@ApplicationContext private val context: Context,
|
||||
|
||||
+4
-3
@@ -179,7 +179,7 @@ class MessageComposerPresenter(
|
||||
handlePickedMedia(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 ->
|
||||
handlePickedMedia(uri, MimeTypes.Jpeg)
|
||||
@@ -571,7 +571,7 @@ class MessageComposerPresenter(
|
||||
notificationConversationService.onSendMessage(
|
||||
sessionId = room.sessionId,
|
||||
roomId = roomInfo.id,
|
||||
roomName = roomInfo.name ?: roomInfo.id.value,
|
||||
roomName = roomInfo.name,
|
||||
roomIsDirect = roomInfo.isDm,
|
||||
roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl,
|
||||
)
|
||||
@@ -605,6 +605,7 @@ class MessageComposerPresenter(
|
||||
private fun handlePickedMedia(
|
||||
uri: Uri?,
|
||||
mimeType: String? = null,
|
||||
sendAsFile: Boolean = false,
|
||||
) {
|
||||
uri ?: return
|
||||
val localMedia = localMediaFactory.createFromUri(
|
||||
@@ -613,7 +614,7 @@ class MessageComposerPresenter(
|
||||
name = null,
|
||||
formattedFileSize = null
|
||||
)
|
||||
val mediaAttachment = Attachment.Media(localMedia)
|
||||
val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
|
||||
|
||||
|
||||
-5
@@ -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.timeline.Timeline
|
||||
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.isTalkbackActive
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -88,7 +87,6 @@ class ThreadedMessagesNode(
|
||||
private val presenterFactory: MessagesPresenter.Factory,
|
||||
private val actionListPresenterFactory: ActionListPresenter.Factory,
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
|
||||
@@ -157,9 +155,6 @@ class ThreadedMessagesNode(
|
||||
onStop = {
|
||||
appNavigationStateService.onLeavingThread(id)
|
||||
},
|
||||
onDestroy = {
|
||||
mediaPlayer.close()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -31,6 +31,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
@@ -79,7 +82,18 @@ fun ThreadsListView(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
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(
|
||||
avatarData = AvatarData(
|
||||
id = state.roomId.value,
|
||||
|
||||
+68
-42
@@ -8,6 +8,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -22,17 +23,19 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
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.aTimelineRoomInfo
|
||||
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.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.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -42,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun TimelineItemCallNotifyView(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
event: TimelineItem.Event,
|
||||
content: TimelineItemRtcNotificationContent,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
@@ -62,37 +66,22 @@ internal fun TimelineItemCallNotifyView(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = event.senderAvatar,
|
||||
avatarType = AvatarType.User,
|
||||
Icon(
|
||||
modifier = Modifier.size(20.sp.toDp()),
|
||||
imageVector = getIcon(timelineRoomInfo, content),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = event.safeSenderName,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
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(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = stringResource(getTextRes(timelineRoomInfo, content)),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = event.sentTime,
|
||||
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
|
||||
@Composable
|
||||
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
listOf(
|
||||
TimelineItemRtcNotificationContent(CallIntent.AUDIO),
|
||||
TimelineItemRtcNotificationContent(CallIntent.VIDEO),
|
||||
).forEach { content ->
|
||||
TimelineItemCallNotifyView(
|
||||
event = aTimelineItemEvent(content = content),
|
||||
content = content,
|
||||
onLongClick = {},
|
||||
)
|
||||
Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
listOf(false, true).forEach { isDm ->
|
||||
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
|
||||
listOf(
|
||||
RtcNotificationState.Started,
|
||||
RtcNotificationState.Declined(byMe = false),
|
||||
RtcNotificationState.Declined(byMe = true),
|
||||
).forEach { state ->
|
||||
val content = TimelineItemRtcNotificationContent(callIntent, state)
|
||||
TimelineItemCallNotifyView(
|
||||
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
|
||||
event = aTimelineItemEvent(content = content),
|
||||
content = content,
|
||||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -125,6 +125,7 @@ internal fun TimelineItemRow(
|
||||
is TimelineItemRtcNotificationContent -> {
|
||||
TimelineItemCallNotifyView(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
event = timelineItem,
|
||||
content = timelineItem.content,
|
||||
onLongClick = onLongClick,
|
||||
|
||||
+7
-1
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.TimelineItemLegacyCallInviteContent
|
||||
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 UnableToDecryptContent -> utdFactory.create(itemContent)
|
||||
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 LiveLocationContent -> {
|
||||
|
||||
+14
-2
@@ -9,7 +9,19 @@
|
||||
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.timeline.item.event.EventType
|
||||
|
||||
class TimelineItemRtcNotificationContent(val callIntent: CallIntent) : TimelineItemEventContent {
|
||||
override val type: String = "org.matrix.msc4075.rtc.notification"
|
||||
// State of the call, for now only isDeclined but in the future could be missed, active.
|
||||
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
|
||||
}
|
||||
|
||||
+3
-5
@@ -80,7 +80,8 @@ internal fun MessagesViewTopBar(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(roundedCornerShape)
|
||||
.clickable { onRoomDetailsClick() },
|
||||
.clickable { onRoomDetailsClick() }
|
||||
.semantics { heading() },
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -158,10 +159,7 @@ private fun RoomAvatarAndNameRow(
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.semantics {
|
||||
heading()
|
||||
},
|
||||
.padding(start = 8.dp),
|
||||
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
fontStyle = FontStyle.Italic.takeIf { roomName == null },
|
||||
|
||||
+16
-7
@@ -17,8 +17,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.semantics
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -58,7 +59,18 @@ internal fun ThreadTopBar(
|
||||
BackButton(onClick = onBackClick)
|
||||
},
|
||||
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(
|
||||
avatarData = roomAvatarData,
|
||||
avatarType = AvatarType.Room(
|
||||
@@ -69,17 +81,14 @@ internal fun ThreadTopBar(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.semantics {
|
||||
heading()
|
||||
},
|
||||
.padding(horizontal = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.common_thread),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
)
|
||||
Text(
|
||||
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
|
||||
text = name,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
fontStyle = FontStyle.Italic.takeIf { roomName == null },
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
|
||||
+11
-1
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
|
||||
|
||||
import android.content.Context
|
||||
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.TimelineItemEncryptedContent
|
||||
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 TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
|
||||
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
|
||||
.toSafeLength()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_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>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<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_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_text_formatting">"Tekstivorming"</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_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_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_gallery">"Zdjęcia i filmy"</string>
|
||||
<string name="screen_room_attachment_source_location">"Udostępnij lokalizację"</string>
|
||||
|
||||
+2
-1
@@ -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.timeline.aTimelineItemEvent
|
||||
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.TimelineItemRtcNotificationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
@@ -1169,7 +1170,7 @@ class ActionListPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
val messageEvent = aMessageEvent(
|
||||
isMine = true,
|
||||
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO),
|
||||
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO, state = RtcNotificationState.Started),
|
||||
)
|
||||
initialState.eventSink.invoke(
|
||||
ActionListEvent.ComputeForMessage(
|
||||
|
||||
+82
-1
@@ -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.SendActionState
|
||||
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.fixtures.aMediaAttachment
|
||||
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.mediaviewer.api.aVideoMediaInfo
|
||||
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.test.viewer.aLocalMedia
|
||||
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(
|
||||
localMedia: LocalMedia = aLocalMedia(
|
||||
uri = mockMediaUrl,
|
||||
),
|
||||
sendAsFile: Boolean = false,
|
||||
room: JoinedRoom = FakeJoinedRoom(),
|
||||
timelineMode: Timeline.Mode = Timeline.Mode.Live,
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||
@@ -573,9 +652,10 @@ class AttachmentsPreviewPresenterTest {
|
||||
}
|
||||
),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
|
||||
): AttachmentsPreviewPresenter {
|
||||
return AttachmentsPreviewPresenter(
|
||||
attachment = aMediaAttachment(localMedia),
|
||||
attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile),
|
||||
onDoneListener = onDoneListener,
|
||||
mediaSenderFactory = MediaSenderFactory { timelineMode ->
|
||||
DefaultMediaSender(
|
||||
@@ -592,6 +672,7 @@ class AttachmentsPreviewPresenterTest {
|
||||
sessionCoroutineScope = this,
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
|
||||
videoCompressionPresetSelector = videoCompressionPresetSelector,
|
||||
timelineMode = timelineMode,
|
||||
inReplyToEventId = null,
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
|
||||
+69
@@ -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(
|
||||
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
|
||||
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
|
||||
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
|
||||
sendAsFile: Boolean = false,
|
||||
): DefaultMediaOptimizationSelectorPresenter {
|
||||
return DefaultMediaOptimizationSelectorPresenter(
|
||||
localMedia = localMedia,
|
||||
sendAsFile = sendAsFile,
|
||||
maxUploadSizeProvider = maxUploadSizeProvider,
|
||||
featureFlagService = featureFlagService,
|
||||
mediaExtractorFactory = mediaExtractorFactory,
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
videoCompressionPresetSelector = videoCompressionPresetSelector,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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.libraries.mediaviewer.api.local.LocalMedia
|
||||
|
||||
fun aMediaAttachment(localMedia: LocalMedia) = Attachment.Media(
|
||||
fun aMediaAttachment(localMedia: LocalMedia, sendAsFile: Boolean = false) = Attachment.Media(
|
||||
localMedia = localMedia,
|
||||
sendAsFile = sendAsFile,
|
||||
)
|
||||
|
||||
+106
@@ -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")!!,
|
||||
)
|
||||
}
|
||||
)
|
||||
+1
-1
@@ -26,7 +26,7 @@ class FakeMediaOptimizationSelectorPresenterFactory(
|
||||
)
|
||||
}
|
||||
) : MediaOptimizationSelectorPresenter.Factory {
|
||||
override fun create(localMedia: LocalMedia): MediaOptimizationSelectorPresenter {
|
||||
override fun create(localMedia: LocalMedia, sendAsFile: Boolean): MediaOptimizationSelectorPresenter {
|
||||
return fakePresenter
|
||||
}
|
||||
}
|
||||
|
||||
+1
-13
@@ -25,11 +25,8 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
@@ -56,17 +53,8 @@ class AdvancedSettingsPresenter(
|
||||
appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed)
|
||||
}.collectAsState(initial = Theme.System)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveLocationMinimumDistanceUpdate by produceState<Int?>(null) {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
|
||||
.flatMapLatest { isEnabled ->
|
||||
if (isEnabled) {
|
||||
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
|
||||
} else {
|
||||
emptyFlow()
|
||||
}
|
||||
}
|
||||
.collect { value = it }
|
||||
appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow().collect { value = it }
|
||||
}
|
||||
|
||||
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
|
||||
|
||||
+5
-5
@@ -11,9 +11,9 @@ package io.element.android.features.preferences.impl.advanced
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
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.preferences.api.store.VideoCompressionPreset
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class AdvancedSettingsState(
|
||||
@@ -44,24 +44,24 @@ enum class ThemeOption : DropdownOption {
|
||||
System {
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
override fun getText(): String = stringResource(CommonStrings.common_system)
|
||||
override fun getText(): String = stringResource(R.string.theme_system)
|
||||
},
|
||||
|
||||
Light {
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
override fun getText(): String = stringResource(CommonStrings.common_light)
|
||||
override fun getText(): String = stringResource(R.string.theme_light)
|
||||
},
|
||||
|
||||
Dark {
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
override fun getText(): String = stringResource(CommonStrings.common_dark)
|
||||
override fun getText(): String = stringResource(R.string.theme_dark)
|
||||
},
|
||||
|
||||
Black {
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
override fun getText(): String = stringResource(CommonStrings.common_black)
|
||||
override fun getText(): String = stringResource(R.string.theme_black)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -11,7 +11,6 @@ package io.element.android.features.preferences.impl.tasks
|
||||
import android.content.Context
|
||||
import coil3.SingletonImageLoader
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Provider
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.preferences.impl.DefaultCacheService
|
||||
import io.element.android.libraries.cachestore.api.CacheStore
|
||||
@@ -34,7 +33,7 @@ class DefaultClearCacheUseCase(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val defaultCacheService: DefaultCacheService,
|
||||
private val okHttpClient: Provider<OkHttpClient>,
|
||||
private val okHttpClient: () -> OkHttpClient,
|
||||
private val pushService: PushService,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
private val activeRoomsHolder: ActiveRoomsHolder,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user