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