diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml
index 2aeafa9fba..8835e94594 100644
--- a/.github/workflows/maestro.yml
+++ b/.github/workflows/maestro.yml
@@ -79,7 +79,7 @@ jobs:
uses: actions/download-artifact@v4
with:
name: elementx-apk-maestro
- - uses: mobile-dev-inc/action-maestro-cloud@v1.9.2
+ - uses: mobile-dev-inc/action-maestro-cloud@v1.9.4
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index d4b7accbaa..c224ad564b 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/CHANGES.md b/CHANGES.md
index a274a14643..6452a0c1bb 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,19 @@
+Changes in Element X v0.7.2 (2024-10-29)
+========================================
+
+## What's Changed
+### 🙌 Improvements
+* Add setting to compress image and video by @bmarty in https://github.com/element-hq/element-x-android/pull/3744
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3743
+### 🧱 Build
+* Release script improvement by @bmarty in https://github.com/element-hq/element-x-android/pull/3741
+### Dependency upgrades
+* Update dependency org.maplibre.gl:android-sdk to v11.5.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3720
+* Update dependency io.sentry:sentry-android to v7.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3726
+* Update dependencyAnalysis to v2.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3740
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.58 by @renovate in https://github.com/element-hq/element-x-android/pull/3749
+
Changes in Element X v0.7.1 (2024-10-25)
========================================
diff --git a/README.md b/README.md
index f838a0fe50..5ae9b0c2b4 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
# Element X Android
-Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/). This app is currently in a pre-alpha release stage with only basic functionalities.
+Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/).
The application is a total rewrite of [Element-Android](https://github.com/element-hq/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
@@ -71,7 +71,7 @@ We're doing this as a way to share code between platforms and while we've seen p
## Status
-This project is in work in progress. The app does not cover yet all functionalities we expect. The list of supported features can be found in [this issue](https://github.com/element-hq/element-x-android/issues/911).
+This project is in an early rollout and migration phase.
## Contributing
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 74e8abcad8..52fb01f067 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -48,9 +48,9 @@ android {
} else {
"io.element.android.x"
}
- targetSdk = Versions.targetSdk
- versionCode = Versions.versionCode
- versionName = Versions.versionName
+ targetSdk = Versions.TARGET_SDK
+ versionCode = Versions.VERSION_CODE
+ versionName = Versions.VERSION_NAME
// Keep abiFilter for the universalApk
ndk {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a53541a1a6..8bbbb8adfa 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,12 +10,11 @@
-
(
@@ -123,6 +129,12 @@ class LoggedInFlowNode @AssistedInject constructor(
matrixClient.roomMembershipObserver(),
)
+ private val verificationListener = object : SessionVerificationServiceListener {
+ override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
+ backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
+ }
+ }
+
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
@@ -131,6 +143,7 @@ class LoggedInFlowNode @AssistedInject constructor(
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope)
+ matrixClient.sessionVerificationService().setListener(verificationListener)
ftueService.state
.onEach { ftueState ->
@@ -152,6 +165,7 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving()
+ matrixClient.sessionVerificationService().setListener(null)
}
)
observeSyncStateAndNetworkStatus()
@@ -232,6 +246,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
+
+ @Parcelize
+ data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -260,7 +277,7 @@ class LoggedInFlowNode @AssistedInject constructor(
}
override fun onSetUpRecoveryClick() {
- backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.SetUpRecovery))
+ backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
}
override fun onSessionConfirmRecoveryKeyClick() {
@@ -432,6 +449,16 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback)
.build()
}
+ is NavTarget.IncomingVerificationRequest -> {
+ incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
+ .params(IncomingVerificationEntryPoint.Params(navTarget.data))
+ .callback(object : IncomingVerificationEntryPoint.Callback {
+ override fun onDone() {
+ backstack.pop()
+ }
+ })
+ .build()
+ }
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
index 168cbe8314..8cff242cb2 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
@@ -27,10 +27,18 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
-class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider {
+class MatrixClientsHolder @Inject constructor(
+ private val authenticationService: MatrixAuthenticationService,
+) : MatrixClientProvider {
private val sessionIdsToMatrixClient = ConcurrentHashMap()
private val restoreMutex = Mutex()
+ init {
+ authenticationService.listenToNewMatrixClients { matrixClient ->
+ sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
+ }
+ }
+
fun removeAll() {
sessionIdsToMatrixClient.clear()
}
diff --git a/appnav/src/main/res/values-nl/translations.xml b/appnav/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..e38f95863c
--- /dev/null
+++ b/appnav/src/main/res/values-nl/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Uitloggen & Upgraden"
+ "Je homeserver ondersteunt het oude protocol niet meer. Log uit en log opnieuw in om de app te blijven gebruiken."
+
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt
index 0058485aa5..390b095c40 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt
@@ -81,4 +81,17 @@ class MatrixClientsHolderTest {
matrixClientsHolder.restoreWithSavedState(savedStateMap)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}
+
+ @Test
+ fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
+ val fakeAuthenticationService = FakeMatrixAuthenticationService()
+ val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
+ assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
+
+ fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID))
+ val loginSucceeded = fakeAuthenticationService.login("user", "pass")
+
+ assertThat(loginSucceeded.isSuccess).isTrue()
+ assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNotNull()
+ }
}
diff --git a/build.gradle.kts b/build.gradle.kts
index a71b8a6312..56e29f87d0 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -49,7 +49,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
- detektPlugins("io.nlopez.compose.rules:detekt:0.4.16")
+ detektPlugins("io.nlopez.compose.rules:detekt:0.4.17")
}
// KtLint
diff --git a/fastlane/metadata/android/en-US/changelogs/40007030.txt b/fastlane/metadata/android/en-US/changelogs/40007030.txt
new file mode 100644
index 0000000000..120548b6e1
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40007030.txt
@@ -0,0 +1,2 @@
+Main changes in this version: TODO.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCall.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCall.kt
new file mode 100644
index 0000000000..387d4a98ad
--- /dev/null
+++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCall.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.call.api
+
+import io.element.android.libraries.matrix.api.core.RoomId
+
+/**
+ * Value for the local current call.
+ */
+sealed interface CurrentCall {
+ data object None : CurrentCall
+
+ data class RoomCall(
+ val roomId: RoomId,
+ ) : CurrentCall
+
+ data class ExternalUrl(
+ val url: String,
+ ) : CurrentCall
+}
diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt
new file mode 100644
index 0000000000..9cc61ab96d
--- /dev/null
+++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.call.api
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface CurrentCallService {
+ /**
+ * The current call state flow, which will be updated when the active call changes.
+ * This value reflect the local state of the call. It is not updated if the user answers
+ * a call from another session.
+ */
+ val currentCall: StateFlow
+}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
index ce9319d0cf..711b203f99 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
@@ -183,6 +183,7 @@ private fun WebView.setup(
allowFileAccess = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
+ @Suppress("DEPRECATION")
databaseEnabled = true
loadsImagesAutomatically = true
userAgentString = userAgent
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
index a685ef7b9a..0495f8ec7d 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
@@ -35,6 +35,7 @@ import androidx.core.content.IntentCompat
import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle
import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallType.ExternalUrl
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.pip.PictureInPictureEvents
@@ -44,11 +45,14 @@ import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import timber.log.Timber
import javax.inject.Inject
+private val loggerTag = LoggerTag("ElementCallActivity")
+
class ElementCallActivity :
AppCompatActivity(),
CallScreenNavigator,
@@ -132,7 +136,7 @@ class ElementCallActivity :
DisposableEffect(Unit) {
val listener = Runnable {
if (requestPermissionCallback != null) {
- Timber.w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
+ Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
} else {
pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
}
@@ -146,7 +150,7 @@ class ElementCallActivity :
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
- Timber.d("Exiting PiP mode: Hangup the call")
+ Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
eventSink?.invoke(CallScreenEvents.Hangup)
}
}
@@ -185,23 +189,23 @@ class ElementCallActivity :
private fun setCallType(intent: Intent?) {
val callType = intent?.let {
- IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
+ IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
+ ?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
}
- val intentUrl = intent?.dataString?.let(::parseUrl)
- when {
- // Re-opened the activity but we have no url to load or a cached one, finish the activity
- intent?.dataString == null && callType == null && webViewTarget.value == null -> finish()
- callType != null -> {
- webViewTarget.value = callType
- presenter = presenterFactory.create(callType, this)
- }
- intentUrl != null -> {
- val fallbackInputs = CallType.ExternalUrl(intentUrl)
- webViewTarget.value = fallbackInputs
- presenter = presenterFactory.create(fallbackInputs, this)
- }
- // Coming back from notification, do nothing
- else -> return
+ val currentCallType = webViewTarget.value
+ if (currentCallType == null && callType == null) {
+ Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
+ finish()
+ } else if (currentCallType == null) {
+ Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
+ webViewTarget.value = callType
+ presenter = presenterFactory.create(callType!!, this)
+ } else if (callType != currentCallType) {
+ Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
+ setIntent(intent)
+ recreate()
+ } else {
+ Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
index 45f1f2be63..d774a5133c 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
@@ -12,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.di.AppScope
@@ -82,6 +83,7 @@ class DefaultActiveCallManager @Inject constructor(
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
private val notificationManagerCompat: NotificationManagerCompat,
private val matrixClientProvider: MatrixClientProvider,
+ private val defaultCurrentCallService: DefaultCurrentCallService,
) : ActiveCallManager {
private var timedOutCallJob: Job? = null
@@ -89,6 +91,7 @@ class DefaultActiveCallManager @Inject constructor(
init {
observeRingingCall()
+ observeCurrentCall()
}
override fun registerIncomingCall(notificationData: CallNotificationData) {
@@ -209,6 +212,28 @@ class DefaultActiveCallManager @Inject constructor(
}
.launchIn(coroutineScope)
}
+
+ private fun observeCurrentCall() {
+ activeCall
+ .onEach { value ->
+ if (value == null) {
+ defaultCurrentCallService.onCallEnded()
+ } else {
+ when (value.callState) {
+ is CallState.Ringing -> {
+ // Nothing to do
+ }
+ is CallState.InCall -> {
+ when (val callType = value.callType) {
+ is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
+ is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
+ }
+ }
+ }
+ }
+ }
+ .launchIn(coroutineScope)
+ }
}
/**
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt
new file mode 100644
index 0000000000..a2a358483b
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.call.impl.utils
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.call.api.CurrentCall
+import io.element.android.features.call.api.CurrentCallService
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import kotlinx.coroutines.flow.MutableStateFlow
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class)
+class DefaultCurrentCallService @Inject constructor() : CurrentCallService {
+ override val currentCall = MutableStateFlow(CurrentCall.None)
+
+ fun onCallStarted(call: CurrentCall) {
+ currentCall.value = call
+ }
+
+ fun onCallEnded() {
+ currentCall.value = CurrentCall.None
+ }
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
index 45568f2d39..b9f6a524ae 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
@@ -15,6 +15,7 @@ import io.element.android.features.call.impl.notifications.RingingCallNotificati
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
+import io.element.android.features.call.impl.utils.DefaultCurrentCallService
import io.element.android.features.call.test.aCallNotificationData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -299,5 +300,6 @@ class DefaultActiveCallManagerTest {
),
notificationManagerCompat = notificationManagerCompat,
matrixClientProvider = matrixClientProvider,
+ defaultCurrentCallService = DefaultCurrentCallService(),
)
}
diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallService.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallService.kt
new file mode 100644
index 0000000000..b9efc70ece
--- /dev/null
+++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallService.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.call.test
+
+import io.element.android.features.call.api.CurrentCall
+import io.element.android.features.call.api.CurrentCallService
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FakeCurrentCallService(
+ override val currentCall: MutableStateFlow = MutableStateFlow(CurrentCall.None),
+) : CurrentCallService
diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts
index bcd3799c92..6ddae68a72 100644
--- a/features/createroom/impl/build.gradle.kts
+++ b/features/createroom/impl/build.gradle.kts
@@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
+ implementation(projects.libraries.featureflag.api)
api(projects.features.createroom.api)
testImplementation(libs.test.junit)
@@ -56,6 +57,7 @@ dependencies {
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.createroom.test)
+ testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt
index 731dc27d07..ab6e116598 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt
@@ -8,7 +8,7 @@
package io.element.android.features.createroom.impl
import android.net.Uri
-import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
+import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -18,5 +18,7 @@ data class CreateRoomConfig(
val topic: String? = null,
val avatarUri: Uri? = null,
val invites: ImmutableList = persistentListOf(),
- val privacy: RoomPrivacy = RoomPrivacy.Private,
-)
+ val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private,
+) {
+ val isValid = roomName.isNullOrEmpty().not() && roomVisibility.isValid()
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
index 5925ca7818..56ebe326dd 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
@@ -8,7 +8,12 @@
package io.element.android.features.createroom.impl
import android.net.Uri
-import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
+import io.element.android.features.createroom.impl.configureroom.RoomAccess
+import io.element.android.features.createroom.impl.configureroom.RoomAccessItem
+import io.element.android.features.createroom.impl.configureroom.RoomAddress
+import io.element.android.features.createroom.impl.configureroom.RoomAddressErrorState
+import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem
+import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.androidutils.file.safeDelete
@@ -17,6 +22,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.getAndUpdate
import java.io.File
import javax.inject.Inject
@@ -31,28 +37,89 @@ class CreateRoomDataStore @Inject constructor(
field = value
}
- fun getCreateRoomConfig(): Flow = combine(
+ val createRoomConfigWithInvites: Flow = combine(
selectedUserListDataStore.selectedUsers(),
createRoomConfigFlow,
) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList())
}
- fun setRoomName(roomName: String?) {
- createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(roomName = roomName?.takeIf { it.isNotEmpty() }))
+ fun setRoomName(roomName: String) {
+ createRoomConfigFlow.getAndUpdate { config ->
+ /*
+ val newVisibility = when (config.roomVisibility) {
+ is RoomVisibilityState.Public -> {
+ val roomAddress = config.roomVisibility.roomAddress
+ if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
+ config.roomVisibility.copy(
+ roomAddress = RoomAddress.AutoFilled(roomName),
+ )
+ } else {
+ config.roomVisibility
+ }
+ }
+ else -> config.roomVisibility
+ }
+ */
+ config.copy(
+ roomName = roomName.takeIf { it.isNotEmpty() },
+ )
+ }
}
- fun setTopic(topic: String?) {
- createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() }))
+ fun setTopic(topic: String) {
+ createRoomConfigFlow.getAndUpdate { config ->
+ config.copy(topic = topic.takeIf { it.isNotEmpty() })
+ }
}
fun setAvatarUri(uri: Uri?, cached: Boolean = false) {
cachedAvatarUri = uri.takeIf { cached }
- createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUri = uri))
+ createRoomConfigFlow.getAndUpdate { config ->
+ config.copy(avatarUri = uri)
+ }
+ }
+
+ fun setRoomVisibility(visibility: RoomVisibilityItem) {
+ createRoomConfigFlow.getAndUpdate { config ->
+ config.copy(
+ roomVisibility = when (visibility) {
+ RoomVisibilityItem.Private -> RoomVisibilityState.Private
+ RoomVisibilityItem.Public -> RoomVisibilityState.Public(
+ roomAddress = RoomAddress.AutoFilled(config.roomName.orEmpty()),
+ roomAddressErrorState = RoomAddressErrorState.None,
+ roomAccess = RoomAccess.Anyone,
+ )
+ }
+ )
+ }
}
- fun setPrivacy(privacy: RoomPrivacy) {
- createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy))
+ fun setRoomAddress(address: String) {
+ createRoomConfigFlow.getAndUpdate { config ->
+ config.copy(
+ roomVisibility = when (config.roomVisibility) {
+ is RoomVisibilityState.Public -> config.roomVisibility.copy(roomAddress = RoomAddress.Edited(address))
+ else -> config.roomVisibility
+ }
+ )
+ }
+ }
+
+ fun setRoomAccess(access: RoomAccessItem) {
+ createRoomConfigFlow.getAndUpdate { config ->
+ config.copy(
+ roomVisibility = when (config.roomVisibility) {
+ is RoomVisibilityState.Public -> {
+ when (access) {
+ RoomAccessItem.Anyone -> config.roomVisibility.copy(roomAccess = RoomAccess.Anyone)
+ RoomAccessItem.AskToJoin -> config.roomVisibility.copy(roomAccess = RoomAccess.Knocking)
+ }
+ }
+ else -> config.roomVisibility
+ }
+ )
+ }
}
fun clearCachedData() {
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt
deleted file mode 100644
index 302f6134e6..0000000000
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.createroom.impl.components
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.selection.selectable
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem
-import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.Icon
-import io.element.android.libraries.designsystem.theme.components.RadioButton
-import io.element.android.libraries.designsystem.theme.components.Text
-
-@Composable
-fun RoomPrivacyOption(
- roomPrivacyItem: RoomPrivacyItem,
- onOptionClick: (RoomPrivacyItem) -> Unit,
- modifier: Modifier = Modifier,
- isSelected: Boolean = false,
-) {
- Row(
- modifier
- .fillMaxWidth()
- .selectable(
- selected = isSelected,
- onClick = { onOptionClick(roomPrivacyItem) },
- role = Role.RadioButton,
- )
- .padding(8.dp),
- ) {
- Icon(
- modifier = Modifier.padding(horizontal = 8.dp),
- resourceId = roomPrivacyItem.icon,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.secondary,
- )
-
- Column(
- Modifier
- .weight(1f)
- .padding(horizontal = 8.dp)
- ) {
- Text(
- text = roomPrivacyItem.title,
- style = ElementTheme.typography.fontBodyLgRegular,
- color = MaterialTheme.colorScheme.primary,
- )
- Spacer(Modifier.size(3.dp))
- Text(
- text = roomPrivacyItem.description,
- style = ElementTheme.typography.fontBodySmRegular,
- color = MaterialTheme.colorScheme.tertiary,
- )
- }
-
- RadioButton(
- modifier = Modifier
- .align(Alignment.CenterVertically)
- .size(48.dp),
- selected = isSelected,
- // null recommended for accessibility with screenreaders
- onClick = null
- )
- }
-}
-
-@PreviewsDayNight
-@Composable
-internal fun RoomPrivacyOptionPreview() = ElementPreview {
- val aRoomPrivacyItem = roomPrivacyItems().first()
- Column {
- RoomPrivacyOption(
- roomPrivacyItem = aRoomPrivacyItem,
- onOptionClick = {},
- isSelected = true,
- )
- RoomPrivacyOption(
- roomPrivacyItem = aRoomPrivacyItem,
- onOptionClick = {},
- isSelected = false,
- )
- }
-}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt
index d6f647fe61..26fee055ee 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt
@@ -7,16 +7,17 @@
package io.element.android.features.createroom.impl.configureroom
-import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents
- data class RoomPrivacyChanged(val privacy: RoomPrivacy) : ConfigureRoomEvents
- data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
- data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
+ data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents
+ data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents
+ data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents
+ data class RemoveUserFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
+ data object CreateRoom : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data object CancelCreateRoom : ConfigureRoomEvents
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
index 09553d6160..bbefc987ef 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
@@ -24,6 +24,8 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.mimetype.MimeTypes
+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.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
@@ -38,6 +40,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import timber.log.Timber
import javax.inject.Inject
class ConfigureRoomPresenter @Inject constructor(
@@ -47,6 +50,7 @@ class ConfigureRoomPresenter @Inject constructor(
private val mediaPreProcessor: MediaPreProcessor,
private val analyticsService: AnalyticsService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
+ private val featureFlagService: FeatureFlagService,
) : Presenter {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@@ -54,7 +58,9 @@ class ConfigureRoomPresenter @Inject constructor(
@Composable
override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present()
- val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig())
+ val createRoomConfig = dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
+ val homeserverName = remember { matrixClient.userIdServerName() }
+ val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) },
@@ -92,9 +98,11 @@ class ConfigureRoomPresenter @Inject constructor(
when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
- is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy)
- is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
- is ConfigureRoomEvents.CreateRoom -> createRoom(event.config)
+ is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem)
+ is ConfigureRoomEvents.RemoveUserFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
+ is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
+ is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
+ is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig.value)
is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
@@ -113,10 +121,12 @@ class ConfigureRoomPresenter @Inject constructor(
}
return ConfigureRoomState(
+ isKnockFeatureEnabled = isKnockFeatureEnabled,
config = createRoomConfig.value,
avatarActions = avatarActions,
createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState,
+ homeserverName = homeserverName,
eventSink = ::handleEvents,
)
}
@@ -127,26 +137,50 @@ class ConfigureRoomPresenter @Inject constructor(
) = launch {
suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
- val params = CreateRoomParameters(
- name = config.roomName,
- topic = config.topic,
- isEncrypted = config.privacy == RoomPrivacy.Private,
- isDirect = false,
- visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
- preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
- invite = config.invites.map { it.userId },
- avatar = avatarUrl,
- )
- matrixClient.createRoom(params).getOrThrow()
- .also {
+ val params = if (config.roomVisibility is RoomVisibilityState.Public) {
+ CreateRoomParameters(
+ name = config.roomName,
+ topic = config.topic,
+ isEncrypted = false,
+ isDirect = false,
+ visibility = RoomVisibility.PUBLIC,
+ joinRuleOverride = config.roomVisibility.roomAccess.toJoinRule(),
+ preset = RoomPreset.PUBLIC_CHAT,
+ invite = config.invites.map { it.userId },
+ avatar = avatarUrl,
+ canonicalAlias = config.roomVisibility.roomAddress()
+ )
+ } else {
+ CreateRoomParameters(
+ name = config.roomName,
+ topic = config.topic,
+ isEncrypted = config.roomVisibility is RoomVisibilityState.Private,
+ isDirect = false,
+ visibility = RoomVisibility.PRIVATE,
+ preset = RoomPreset.PRIVATE_CHAT,
+ invite = config.invites.map { it.userId },
+ avatar = avatarUrl,
+ )
+ }
+ matrixClient.createRoom(params)
+ .onFailure { failure ->
+ Timber.e(failure, "Failed to create room")
+ }
+ .onSuccess {
dataStore.clearCachedData()
analyticsService.capture(CreatedRoom(isDM = false))
}
+ .getOrThrow()
}.runCatchingUpdatingState(createRoomAction)
}
private suspend fun uploadAvatar(avatarUri: Uri): String {
- val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
+ val preprocessed = mediaPreProcessor.process(
+ uri = avatarUri,
+ mimeType = MimeTypes.Jpeg,
+ deleteOriginal = false,
+ compressIfPossible = false,
+ ).getOrThrow()
val byteArray = preprocessed.file.readBytes()
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt
index 70a0a9b76d..8a2122c306 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt
@@ -15,11 +15,11 @@ import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState(
+ val isKnockFeatureEnabled: Boolean,
val config: CreateRoomConfig,
val avatarActions: ImmutableList,
val createRoomAction: AsyncAction,
val cameraPermissionState: PermissionsState,
+ val homeserverName: String,
val eventSink: (ConfigureRoomEvents) -> Unit
-) {
- val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()
-}
+)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
index ba49ffcbdd..80445fbff4 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
@@ -10,30 +10,59 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
-import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aConfigureRoomState(),
- aConfigureRoomState().copy(
+ aConfigureRoomState(
+ isKnockFeatureEnabled = false,
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldn’t be more than 3 lines",
invites = aMatrixUserList().toImmutableList(),
- privacy = RoomPrivacy.Public,
+ roomVisibility = RoomVisibilityState.Public(
+ roomAddress = RoomAddress.AutoFilled("Room 101"),
+ roomAccess = RoomAccess.Knocking,
+ roomAddressErrorState = RoomAddressErrorState.None,
+ ),
+ ),
+ ),
+ aConfigureRoomState(
+ config = CreateRoomConfig(
+ roomName = "Room 101",
+ topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldn’t be more than 3 lines",
+ invites = aMatrixUserList().toImmutableList(),
+ roomVisibility = RoomVisibilityState.Public(
+ roomAddress = RoomAddress.AutoFilled("Room 101"),
+ roomAccess = RoomAccess.Knocking,
+ roomAddressErrorState = RoomAddressErrorState.None,
+ ),
),
),
)
}
-fun aConfigureRoomState() = ConfigureRoomState(
- config = CreateRoomConfig(),
- avatarActions = persistentListOf(),
- createRoomAction = AsyncAction.Uninitialized,
- cameraPermissionState = aPermissionsState(showDialog = false),
- eventSink = { },
+fun aConfigureRoomState(
+ config: CreateRoomConfig = CreateRoomConfig(),
+ isKnockFeatureEnabled: Boolean = true,
+ avatarActions: List = emptyList(),
+ createRoomAction: AsyncAction = AsyncAction.Uninitialized,
+ cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
+ homeserverName: String = "matrix.org",
+ eventSink: (ConfigureRoomEvents) -> Unit = { },
+) = ConfigureRoomState(
+ config = config,
+ isKnockFeatureEnabled = isKnockFeatureEnabled,
+ avatarActions = avatarActions.toImmutableList(),
+ createRoomAction = createRoomAction,
+ cameraPermissionState = cameraPermissionState,
+ homeserverName = homeserverName,
+ eventSink = eventSink,
)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
index cecdf643fb..b0084f68f6 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
@@ -11,9 +11,11 @@ import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@@ -21,6 +23,7 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -33,18 +36,22 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.createroom.impl.R
-import io.element.android.features.createroom.impl.components.RoomPrivacyOption
+import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.ListItem
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.TextButton
+import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
@@ -72,11 +79,11 @@ fun ConfigureRoomView(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
ConfigureRoomToolbar(
- isNextActionEnabled = state.isCreateButtonEnabled,
+ isNextActionEnabled = state.config.isValid,
onBackClick = onBackClick,
onNextClick = {
focusManager.clearFocus()
- state.eventSink(ConfigureRoomEvents.CreateRoom(state.config))
+ state.eventSink(ConfigureRoomEvents.CreateRoom)
},
)
}
@@ -103,23 +110,42 @@ fun ConfigureRoomView(
)
if (state.config.invites.isNotEmpty()) {
SelectedUsersRowList(
- modifier = Modifier.padding(bottom = 16.dp),
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemove = {
focusManager.clearFocus()
- state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
+ state.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(it))
},
)
}
- RoomPrivacyOptions(
- modifier = Modifier.padding(bottom = 40.dp),
- selected = state.config.privacy,
+ RoomVisibilityOptions(
+ selected = when (state.config.roomVisibility) {
+ is RoomVisibilityState.Private -> RoomVisibilityItem.Private
+ is RoomVisibilityState.Public -> RoomVisibilityItem.Public
+ },
onOptionClick = {
focusManager.clearFocus()
- state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
+ state.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(it))
},
)
+ if (state.config.roomVisibility is RoomVisibilityState.Public && state.isKnockFeatureEnabled) {
+ RoomAccessOptions(
+ selected = when (state.config.roomVisibility.roomAccess) {
+ RoomAccess.Anyone -> RoomAccessItem.Anyone
+ RoomAccess.Knocking -> RoomAccessItem.AskToJoin
+ },
+ onOptionClick = {
+ focusManager.clearFocus()
+ state.eventSink(ConfigureRoomEvents.RoomAccessChanged(it))
+ },
+ )
+ RoomAddressField(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ address = state.config.roomVisibility.roomAddress,
+ homeserverName = state.homeserverName,
+ onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
+ )
+ }
}
}
@@ -139,7 +165,7 @@ fun ConfigureRoomView(
},
onSuccess = { onCreateRoomSuccess(it) },
errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) },
- onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
+ onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) },
onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
)
@@ -221,23 +247,123 @@ private fun RoomTopic(
}
@Composable
-private fun RoomPrivacyOptions(
- selected: RoomPrivacy?,
- onOptionClick: (RoomPrivacyItem) -> Unit,
+private fun ConfigureRoomOptions(
+ title: String,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(
+ modifier = modifier.selectableGroup()
+ ) {
+ Text(
+ text = title,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ color = ElementTheme.colors.textPrimary,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ content()
+ }
+}
+
+@Composable
+private fun RoomVisibilityOptions(
+ selected: RoomVisibilityItem,
+ onOptionClick: (RoomVisibilityItem) -> Unit,
modifier: Modifier = Modifier,
) {
- val items = roomPrivacyItems()
- Column(modifier = modifier.selectableGroup()) {
- items.forEach { item ->
- RoomPrivacyOption(
- roomPrivacyItem = item,
- isSelected = selected == item.privacy,
- onOptionClick = onOptionClick,
+ ConfigureRoomOptions(
+ title = stringResource(R.string.screen_create_room_room_visibility_section_title),
+ modifier = modifier,
+ ) {
+ RoomVisibilityItem.entries.forEach { item ->
+ val isSelected = item == selected
+ ListItem(
+ leadingContent = ListItemContent.Custom {
+ RoundedIconAtom(
+ size = RoundedIconAtomSize.Big,
+ resourceId = item.icon,
+ tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
+ )
+ },
+ headlineContent = { Text(text = stringResource(item.title)) },
+ supportingContent = { Text(text = stringResource(item.description)) },
+ trailingContent = ListItemContent.RadioButton(selected = isSelected),
+ onClick = { onOptionClick(item) },
)
}
}
}
+@Composable
+private fun RoomAccessOptions(
+ selected: RoomAccessItem,
+ onOptionClick: (RoomAccessItem) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ConfigureRoomOptions(
+ title = stringResource(R.string.screen_create_room_room_access_section_header),
+ modifier = modifier,
+ ) {
+ RoomAccessItem.entries.forEach { item ->
+ ListItem(
+ headlineContent = { Text(text = stringResource(item.title)) },
+ supportingContent = { Text(text = stringResource(item.description)) },
+ trailingContent = ListItemContent.RadioButton(selected = item == selected),
+ onClick = { onOptionClick(item) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun RoomAddressField(
+ address: RoomAddress,
+ homeserverName: String,
+ onAddressChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = MaterialTheme.colorScheme.primary,
+ text = stringResource(R.string.screen_create_room_room_address_section_title),
+ )
+
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = address.value,
+ leadingIcon = {
+ Text(
+ text = "#",
+ style = ElementTheme.typography.fontBodyLgMedium,
+ color = ElementTheme.colors.textSecondary,
+ )
+ },
+ trailingIcon = {
+ Text(
+ text = homeserverName,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ color = ElementTheme.colors.textSecondary,
+ modifier = Modifier.padding(end = 16.dp)
+ )
+ },
+ supportingText = {
+ Text(
+ text = stringResource(R.string.screen_create_room_room_address_section_footer),
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ },
+ onValueChange = onAddressChange,
+ singleLine = true,
+ )
+ }
+}
+
@PreviewsDayNight
@Composable
internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview {
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt
new file mode 100644
index 0000000000..fc99079c22
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.createroom.impl.configureroom
+
+import io.element.android.libraries.matrix.api.createroom.JoinRuleOverride
+
+enum class RoomAccess {
+ Anyone,
+ Knocking
+}
+
+fun RoomAccess.toJoinRule(): JoinRuleOverride {
+ return when (this) {
+ RoomAccess.Anyone -> JoinRuleOverride.None
+ RoomAccess.Knocking -> JoinRuleOverride.Knock
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt
new file mode 100644
index 0000000000..ce1e249396
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.createroom.impl.configureroom
+
+import androidx.annotation.StringRes
+import io.element.android.features.createroom.impl.R
+
+enum class RoomAccessItem(
+ @StringRes val title: Int,
+ @StringRes val description: Int
+) {
+ Anyone(
+ title = R.string.screen_create_room_room_access_section_anyone_option_title,
+ description = R.string.screen_create_room_room_access_section_anyone_option_description,
+ ),
+ AskToJoin(
+ title = R.string.screen_create_room_room_access_section_knocking_option_title,
+ description = R.string.screen_create_room_room_access_section_knocking_option_description,
+ ),
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAddress.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAddress.kt
new file mode 100644
index 0000000000..5c10cfb7b5
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAddress.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.createroom.impl.configureroom
+
+sealed class RoomAddress(open val value: String) {
+ data class AutoFilled(override val value: String) : RoomAddress(value)
+ data class Edited(override val value: String) : RoomAddress(value)
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAddressErrorState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAddressErrorState.kt
new file mode 100644
index 0000000000..f2cadfd6bb
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAddressErrorState.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.createroom.impl.configureroom
+
+/**
+ * Represents the error state of a room address.
+ */
+sealed interface RoomAddressErrorState {
+ data object InvalidCharacters : RoomAddressErrorState
+ data object AlreadyExists : RoomAddressErrorState
+ data object None : RoomAddressErrorState
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt
deleted file mode 100644
index d376b84681..0000000000
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.createroom.impl.configureroom
-
-enum class RoomPrivacy {
- Private,
- Public,
-}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt
deleted file mode 100644
index a0b7e4cc05..0000000000
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.createroom.impl.configureroom
-
-import androidx.annotation.DrawableRes
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import io.element.android.features.createroom.impl.R
-import io.element.android.libraries.designsystem.icons.CompoundDrawables
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.toImmutableList
-
-data class RoomPrivacyItem(
- val privacy: RoomPrivacy,
- @DrawableRes val icon: Int,
- val title: String,
- val description: String,
-)
-
-@Composable
-fun roomPrivacyItems(): ImmutableList {
- return RoomPrivacy.entries
- .map {
- when (it) {
- RoomPrivacy.Private -> RoomPrivacyItem(
- privacy = it,
- icon = CompoundDrawables.ic_compound_lock_solid,
- title = stringResource(R.string.screen_create_room_private_option_title),
- description = stringResource(R.string.screen_create_room_private_option_description),
- )
- RoomPrivacy.Public -> RoomPrivacyItem(
- privacy = it,
- icon = CompoundDrawables.ic_compound_public,
- title = stringResource(R.string.screen_create_room_public_option_title),
- description = stringResource(R.string.screen_create_room_public_option_description),
- )
- }
- }
- .toImmutableList()
-}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt
new file mode 100644
index 0000000000..12909cdd5e
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.createroom.impl.configureroom
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import io.element.android.features.createroom.impl.R
+import io.element.android.libraries.designsystem.icons.CompoundDrawables
+
+enum class RoomVisibilityItem(
+ @DrawableRes val icon: Int,
+ @StringRes val title: Int,
+ @StringRes val description: Int
+) {
+ Private(
+ icon = CompoundDrawables.ic_compound_lock,
+ title = R.string.screen_create_room_private_option_title,
+ description = R.string.screen_create_room_private_option_description,
+ ),
+ Public(
+ icon = CompoundDrawables.ic_compound_public,
+ title = R.string.screen_create_room_public_option_title,
+ description = R.string.screen_create_room_public_option_description,
+ )
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt
new file mode 100644
index 0000000000..cc5af7836e
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.createroom.impl.configureroom
+
+import java.util.Optional
+
+sealed interface RoomVisibilityState {
+ data object Private : RoomVisibilityState
+
+ data class Public(
+ val roomAddress: RoomAddress,
+ val roomAddressErrorState: RoomAddressErrorState,
+ val roomAccess: RoomAccess,
+ ) : RoomVisibilityState
+
+ fun roomAddress(): Optional {
+ return when (this) {
+ is Private -> Optional.empty()
+ is Public -> Optional.of(roomAddress.value)
+ }
+ }
+
+ fun isValid(): Boolean {
+ return when (this) {
+ is Private -> true
+ is Public -> roomAddressErrorState is RoomAddressErrorState.None && roomAddress.value.isNotEmpty()
+ }
+ }
+}
diff --git a/features/createroom/impl/src/main/res/values-be/translations.xml b/features/createroom/impl/src/main/res/values-be/translations.xml
index 2415fe6bb6..861fca4d05 100644
--- a/features/createroom/impl/src/main/res/values-be/translations.xml
+++ b/features/createroom/impl/src/main/res/values-be/translations.xml
@@ -7,6 +7,9 @@
"Прыватны пакой (толькі па запрашэнні)"
"Паведамленні не зашыфраваны, і кожны можа іх прачытаць. Вы можаце ўключыць шыфраванне пазней."
"Публічны пакой (для ўсіх)"
+ "Хто заўгодна"
+ "Доступ у пакой"
+ "Папрасіце далучыцца"
"Назва пакоя"
"Стварыце пакой"
"Тэма (неабавязкова)"
diff --git a/features/createroom/impl/src/main/res/values-cs/translations.xml b/features/createroom/impl/src/main/res/values-cs/translations.xml
index 13ed58be07..697120201d 100644
--- a/features/createroom/impl/src/main/res/values-cs/translations.xml
+++ b/features/createroom/impl/src/main/res/values-cs/translations.xml
@@ -8,7 +8,15 @@
"Tuto místnost může najít kdokoli.
To můžete kdykoli změnit v nastavení místnosti."
"Veřejná místnost"
+ "Do této místnosti může vstoupit kdokoli"
+ "Kdokoliv"
+ "Přístup do místnosti"
+ "Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout"
+ "Požádat o připojení"
+ "Aby byla tato místnost viditelná v adresáři veřejných místností, budete potřebovat adresu místnosti."
+ "Adresa místnosti"
"Název místnosti"
+ "Viditelnost místnosti"
"Vytvořit místnost"
"Téma (nepovinné)"
"Při pokusu o zahájení chatu došlo k chybě"
diff --git a/features/createroom/impl/src/main/res/values-el/translations.xml b/features/createroom/impl/src/main/res/values-el/translations.xml
index 82eda078f4..72e1d997db 100644
--- a/features/createroom/impl/src/main/res/values-el/translations.xml
+++ b/features/createroom/impl/src/main/res/values-el/translations.xml
@@ -8,6 +8,11 @@
"Ο καθένας μπορεί να βρει αυτό το δωμάτιο.
Μπορείς να το αλλάξεις ανά πάσα στιγμή στις ρυθμίσεις δωματίου."
"Δημόσιο δωμάτιο"
+ "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτό το δωμάτιο"
+ "Οποιοσδήποτε"
+ "Πρόσβαση Δωματίου"
+ "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στο δωμάτιο, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχθεί το αίτημα"
+ "Αίτημα συμμετοχής"
"Όνομα δωματίου"
"Δημιούργησε ένα δωμάτιο"
"Θέμα (προαιρετικό)"
diff --git a/features/createroom/impl/src/main/res/values-et/translations.xml b/features/createroom/impl/src/main/res/values-et/translations.xml
index 043c228dea..67db887f2c 100644
--- a/features/createroom/impl/src/main/res/values-et/translations.xml
+++ b/features/createroom/impl/src/main/res/values-et/translations.xml
@@ -8,7 +8,15 @@
"Kõik saavad seda jututuba leida.
Sa võid seda jututoa seadistustest alati muuta."
"Avalik jututuba"
+ "Kõik võivad selle jututoaga liituda"
+ "Kõik"
+ "Ligipääs jututoale"
+ "Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama"
+ "Küsi võimalust liitumiseks"
+ "Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi."
+ "Jututoa aadress"
"Jututoa nimi"
+ "Jututoa nähtavus"
"Loo jututuba"
"Teema (kui soovid lisada)"
"Vestluse alustamisel tekkis viga"
diff --git a/features/createroom/impl/src/main/res/values-fr/translations.xml b/features/createroom/impl/src/main/res/values-fr/translations.xml
index 51d335571b..af40d1f9fe 100644
--- a/features/createroom/impl/src/main/res/values-fr/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fr/translations.xml
@@ -3,11 +3,20 @@
"Nouveau salon"
"Inviter des amis"
"Une erreur s’est produite lors de la création du salon"
- "Les messages dans ce salon sont chiffrés. Le chiffrement ne pourra pas être désactivé par la suite."
- "Salon privé (sur invitation seulement)"
- "Les messages ne sont pas chiffrés et n’importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."
- "Salon public (tout le monde)"
+ "Seules les personnes invitées peuvent accéder à ce salon. Tous les messages sont chiffrés de bout en bout."
+ "Salon privé"
+ "N’importe qui peut trouver ce salon.
+Vous pouvez modifier cela à tout moment dans les paramètres du salon."
+ "Salon public"
+ "Tout le monde peut rejoindre ce salon"
+ "Tout le monde"
+ "Accès au salon"
+ "Tout le monde peut demander à rejoindre le salon, mais un administrateur ou un modérateur devra accepter la demande"
+ "Demander à rejoindre"
+ "Pour que ce salon soit visible dans le répertoire des salons publics, vous aurez besoin d’une adresse de salon."
+ "Adresse du salon"
"Nom du salon"
+ "Visibilité du salon"
"Créer un salon"
"Sujet (facultatif)"
"Une erreur s’est produite lors de la tentative de création de la discussion"
diff --git a/features/createroom/impl/src/main/res/values-hu/translations.xml b/features/createroom/impl/src/main/res/values-hu/translations.xml
index e00de669a6..ee935c8c60 100644
--- a/features/createroom/impl/src/main/res/values-hu/translations.xml
+++ b/features/createroom/impl/src/main/res/values-hu/translations.xml
@@ -3,11 +3,20 @@
"Új szoba"
"Ismerősök meghívása"
"Hiba történt a szoba létrehozásakor"
- "A szobában lévő üzenetek titkosítottak. A titkosítást utólag nem lehet kikapcsolni."
- "Privát szoba (csak meghívással)"
- "Az üzenetek nincsenek titkosítva, és bárki elolvashatja őket. A titkosítást később is engedélyezheti."
- "Nyilvános szoba (bárki)"
+ "Csak a meghívottak léphetnek be ebbe a szobába. Az összes üzenet végpontok közti titkosítással van védve."
+ "Privát szoba"
+ "Bárki megtalálhatja ezt a szobát.
+Ezt bármikor módosíthatja a szobabeállításokban."
+ "Nyilvános szoba"
+ "Bárki csatlakozhat ehhez a szobához"
+ "Bárki"
+ "Szobahozzáférés"
+ "Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést"
+ "Csatlakozás kérése"
+ "Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét."
+ "Szoba címe"
"Szoba neve"
+ "Szoba láthatósága"
"Szoba létrehozása"
"Téma (nem kötelező)"
"Hiba történt a csevegés indításakor"
diff --git a/features/createroom/impl/src/main/res/values-in/translations.xml b/features/createroom/impl/src/main/res/values-in/translations.xml
index a9f795983e..b1c7aeef1c 100644
--- a/features/createroom/impl/src/main/res/values-in/translations.xml
+++ b/features/createroom/impl/src/main/res/values-in/translations.xml
@@ -8,7 +8,15 @@
"Siapa pun dapat mencari ruangan ini.
Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."
"Ruangan publik"
+ "Siapa pun dapat bergabung dengan ruangan ini"
+ "Siapa pun"
+ "Akses Ruangan"
+ "Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut"
+ "Minta untuk bergabung"
+ "Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan."
+ "Alamat ruangan"
"Nama ruangan"
+ "Keterlihatan ruangan"
"Buat ruangan"
"Topik (opsional)"
"Terjadi kesalahan saat mencoba memulai obrolan"
diff --git a/features/createroom/impl/src/main/res/values-ka/translations.xml b/features/createroom/impl/src/main/res/values-ka/translations.xml
index 45a6c79d33..45e87809b2 100644
--- a/features/createroom/impl/src/main/res/values-ka/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ka/translations.xml
@@ -4,9 +4,10 @@
"ხალხის მოწვევა"
"ოთახის შექმნისას შეცდომა მოხდა"
"ამ ოთახში შეტყობინებები დაშიფრულია. შემდგომ დაშიფვრის გამორთვა შეუძლებელია."
- "კერძო ოთახი (მხოლოდ მოწვევა)"
- "შეტყობინებები არ არის დაშიფრული და ყველას შეუძლია მათი წაკითხვა. შეგიძლიათ ჩართოთ დაშიფვრა მოგვიანებით."
- "საჯარო ოთახი (ნებისმიერი)"
+ "კერძო ოთახი"
+ "ყველას ამ ოთახის მოძებნა შეუძლია.
+თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში."
+ "საჯარო ოთახი"
"ოთახის სახელი"
"ოთახის შექმნა"
"თემა (სურვილისამებრ)"
diff --git a/features/createroom/impl/src/main/res/values-nl/translations.xml b/features/createroom/impl/src/main/res/values-nl/translations.xml
index 2cdb3202a5..c1f0fd92f5 100644
--- a/features/createroom/impl/src/main/res/values-nl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-nl/translations.xml
@@ -4,9 +4,15 @@
"Mensen uitnodigen"
"Er is een fout opgetreden bij het aanmaken van de kamer"
"Berichten in deze kamer zijn versleuteld. Versleuteling kan achteraf niet worden uitgeschakeld."
- "Privé kamer (alleen op uitnodiging)"
- "Berichten zijn niet versleuteld en iedereen kan ze lezen. Je kunt versleuteling later inschakelen."
- "Openbare kamer (iedereen)"
+ "Privé kamer"
+ "Iedereen kan deze kamer vinden.
+Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen."
+ "Openbare kamer"
+ "Iedereen kan toetreden tot deze kamer"
+ "Iedereen"
+ "Toegang tot de kamer"
+ "Iedereen kan vragen om toe te treden tot de kamer, maar een beheerder of moderator moet het verzoek accepteren"
+ "Vraag om toe te treden"
"Naam van de kamer"
"Creëer een kamer"
"Onderwerp (optioneel)"
diff --git a/features/createroom/impl/src/main/res/values-pl/translations.xml b/features/createroom/impl/src/main/res/values-pl/translations.xml
index e0b19a0c49..7902fb4c3c 100644
--- a/features/createroom/impl/src/main/res/values-pl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pl/translations.xml
@@ -3,11 +3,20 @@
"Nowy pokój"
"Zaproś znajomych"
"Wystąpił błąd w trakcie tworzenia pokoju"
- "Wiadomości w tym pokoju są szyfrowane. Szyfrowania nie można później wyłączyć."
- "Pokój prywatny (tylko zaproszenie)"
- "Wiadomości nie są szyfrowane i każdy może je odczytać. Możesz aktywować szyfrowanie później."
- "Pokój publiczny (wszyscy)"
+ "Tylko zaproszone osoby mogą dołączyć do tego pokoju. Wszystkie wiadomości są szyfrowane end-to-end."
+ "Pokój prywatny"
+ "Każdy może znaleźć ten pokój.
+Możesz to zmienić w ustawieniach pokoju."
+ "Pokój publiczny"
+ "Każdy może dołączyć do tego pokoju"
+ "Wszyscy"
+ "Dostęp do pokoju"
+ "Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić prośbę"
+ "Poproś o dołączenie"
+ "Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, będziesz potrzebował adres pokoju."
+ "Adres pokoju"
"Nazwa pokoju"
+ "Widoczność pomieszczenia"
"Utwórz pokój"
"Temat (opcjonalnie)"
"Wystąpił błąd podczas próby rozpoczęcia czatu"
diff --git a/features/createroom/impl/src/main/res/values-pt/translations.xml b/features/createroom/impl/src/main/res/values-pt/translations.xml
index 398be62190..60ee753b13 100644
--- a/features/createroom/impl/src/main/res/values-pt/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pt/translations.xml
@@ -3,11 +3,20 @@
"Nova sala"
"Convidar pessoas"
"Ocorreu um erro ao criar a sala"
- "As mensagens serão cifradas. Uma vez ativada, não é possível desativar a cifragem."
- "Sala privada (entrada apenas por convite)"
- "As mensagens não serão cifradas e qualquer um as poderá ler. É possível ativar a cifragem posteriormente."
- "Sala pública (entrada livre)"
+ "Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são encriptadas ponta a ponta."
+ "Sala privada"
+ "Qualquer um pode encontrar esta sala.
+Pode alterar esta opção nas definições da sala."
+ "Sala pública"
+ "Qualquer pessoa pode entrar nesta sala"
+ "Qualquer pessoa"
+ "Acesso à sala"
+ "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou um moderador terá de aceitar o pedido"
+ "Pedir para participar"
+ "Para que esta sala seja visível no diretório público de salas, precisas de um endereço de sala."
+ "Endereço da sala"
"Nome da sala"
+ "Visibilidade da sala"
"Criar uma sala"
"Descrição (opcional)"
"Ocorreu um erro ao tentar iniciar uma conversa"
diff --git a/features/createroom/impl/src/main/res/values-ru/translations.xml b/features/createroom/impl/src/main/res/values-ru/translations.xml
index 39df1f0ef5..b21bcb68ac 100644
--- a/features/createroom/impl/src/main/res/values-ru/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ru/translations.xml
@@ -8,7 +8,15 @@
"Любой желающий может найти эту комнату.
Вы можете изменить это в любое время в настройках комнаты."
"Общедоступная комната"
+ "Любой желающий может присоединиться к этой комнате"
+ "Любой"
+ "Доступ в комнату"
+ "Любой желающий может подать заявку на присоединение к комнате, но администратор или модератор должен будет принять запрос."
+ "Попросить присоединиться"
+ "Чтобы эта комната была видна в каталоге общедоступных, вам необходим ее адрес"
+ "Адрес комнаты"
"Название комнаты"
+ "Видимость комнаты"
"Создать комнату"
"Тема (необязательно)"
"Произошла ошибка при запуске чата"
diff --git a/features/createroom/impl/src/main/res/values-sk/translations.xml b/features/createroom/impl/src/main/res/values-sk/translations.xml
index 12a94590c8..d2d742239f 100644
--- a/features/createroom/impl/src/main/res/values-sk/translations.xml
+++ b/features/createroom/impl/src/main/res/values-sk/translations.xml
@@ -8,7 +8,15 @@
"Túto miestnosť môže nájsť ktokoľvek.
Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."
"Verejná miestnosť"
+ "Do tejto miestnosti sa môže pripojiť ktokoľvek"
+ "Ktokoľvek"
+ "Prístup do miestnosti"
+ "Ktokoľvek môže požiadať o pripojenie sa k miestnosti, ale administrátor alebo moderátor bude musieť žiadosť schváliť"
+ "Požiadať o pripojenie"
+ "Aby bola táto miestnosť viditeľná v adresári verejných miestností, budete potrebovať adresu miestnosti."
+ "Adresa miestnosti"
"Názov miestnosti"
+ "Viditeľnosť miestnosti"
"Vytvoriť miestnosť"
"Téma (voliteľné)"
"Pri pokuse o spustenie konverzácie sa vyskytla chyba"
diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml
index e5256d928b..6ed5510ce0 100644
--- a/features/createroom/impl/src/main/res/values/localazy.xml
+++ b/features/createroom/impl/src/main/res/values/localazy.xml
@@ -8,7 +8,15 @@
"Anyone can find this room.
You can change this anytime in room settings."
"Public room"
+ "Anyone can join this room"
+ "Anyone"
+ "Room Access"
+ "Anyone can ask to join the room but an administrator or a moderator will have to accept the request"
+ "Ask to join"
+ "In order for this room to be visible in the public room directory, you will need a room address."
+ "Room address"
"Room name"
+ "Room visibility"
"Create a room"
"Topic (optional)"
"An error occurred when trying to start a chat"
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt
index 9294f95b78..fefb09c241 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt
@@ -8,15 +8,16 @@
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
+import app.cash.turbine.TurbineTestContext
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_MESSAGE
@@ -25,13 +26,18 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
+import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
+import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.test
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -56,33 +62,8 @@ class ConfigureRoomPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
- private lateinit var presenter: ConfigureRoomPresenter
- private lateinit var userListDataStore: UserListDataStore
- private lateinit var createRoomDataStore: CreateRoomDataStore
- private lateinit var fakeMatrixClient: FakeMatrixClient
- private lateinit var fakePickerProvider: FakePickerProvider
- private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
- private lateinit var fakeAnalyticsService: FakeAnalyticsService
- private lateinit var fakePermissionsPresenter: FakePermissionsPresenter
-
@Before
fun setup() {
- fakeMatrixClient = FakeMatrixClient()
- userListDataStore = UserListDataStore()
- createRoomDataStore = CreateRoomDataStore(userListDataStore)
- fakePickerProvider = FakePickerProvider()
- fakeMediaPreProcessor = FakeMediaPreProcessor()
- fakeAnalyticsService = FakeAnalyticsService()
- fakePermissionsPresenter = FakePermissionsPresenter()
- presenter = ConfigureRoomPresenter(
- dataStore = createRoomDataStore,
- matrixClient = fakeMatrixClient,
- mediaPickerProvider = fakePickerProvider,
- mediaPreProcessor = fakeMediaPreProcessor,
- analyticsService = fakeAnalyticsService,
- permissionsPresenterFactory = FakePermissionsPresenterFactory(fakePermissionsPresenter),
- )
-
mockkStatic(File::readBytes)
every { any().readBytes() } returns byteArrayOf()
}
@@ -94,50 +75,56 @@ class ConfigureRoomPresenterTest {
@Test
fun `present - initial state`() = runTest {
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
+ val presenter = createConfigureRoomPresenter()
+ presenter.test {
+ val initialState = initialState()
assertThat(initialState.config).isEqualTo(CreateRoomConfig())
assertThat(initialState.config.roomName).isNull()
assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull()
- assertThat(initialState.config.privacy).isEqualTo(RoomPrivacy.Private)
+ assertThat(initialState.config.roomVisibility).isEqualTo(RoomVisibilityState.Private)
+ assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(initialState.homeserverName).isEqualTo("matrix.org")
}
}
@Test
fun `present - create room button is enabled only if the required fields are completed`() = runTest {
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
+ val presenter = createConfigureRoomPresenter()
+ presenter.test {
+ val initialState = initialState()
var config = initialState.config
- assertThat(initialState.isCreateButtonEnabled).isFalse()
+ assertThat(initialState.config.isValid).isFalse()
// Room name not empty
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
var newState: ConfigureRoomState = awaitItem()
config = config.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(config)
- assertThat(newState.isCreateButtonEnabled).isTrue()
+ assertThat(newState.config.isValid).isTrue()
// Clear room name
newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
newState = awaitItem()
config = config.copy(roomName = null)
assertThat(newState.config).isEqualTo(config)
- assertThat(newState.isCreateButtonEnabled).isFalse()
+ assertThat(newState.config.isValid).isFalse()
}
}
@Test
fun `present - state is updated when fields are changed`() = runTest {
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
+ val userListDataStore = UserListDataStore()
+ val pickerProvider = FakePickerProvider()
+ val permissionsPresenter = FakePermissionsPresenter()
+ val presenter = createConfigureRoomPresenter(
+ createRoomDataStore = CreateRoomDataStore(userListDataStore),
+ pickerProvider = pickerProvider,
+ permissionsPresenter = permissionsPresenter,
+ )
+ presenter.test {
+ val initialState = initialState()
var expectedConfig = CreateRoomConfig()
assertThat(initialState.config).isEqualTo(expectedConfig)
@@ -165,22 +152,22 @@ class ConfigureRoomPresenterTest {
// Room avatar
// Pick avatar
- fakePickerProvider.givenResult(null)
+ pickerProvider.givenResult(null)
// From gallery
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
- fakePickerProvider.givenResult(uriFromGallery)
+ pickerProvider.givenResult(uriFromGallery)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
assertThat(newState.config).isEqualTo(expectedConfig)
// From camera
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
- fakePickerProvider.givenResult(uriFromCamera)
+ pickerProvider.givenResult(uriFromCamera)
assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
assertThat(newState.cameraPermissionState.showDialog).isTrue()
- fakePermissionsPresenter.setPermissionGranted()
+ permissionsPresenter.setPermissionGranted()
newState = awaitItem()
assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
newState = awaitItem()
@@ -188,7 +175,7 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig)
// Do it again, no permission is requested
val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
- fakePickerProvider.givenResult(uriFromCamera2)
+ pickerProvider.givenResult(uriFromCamera2)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
@@ -200,13 +187,19 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy
- newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Public))
+ newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
newState = awaitItem()
- expectedConfig = expectedConfig.copy(privacy = RoomPrivacy.Public)
+ expectedConfig = expectedConfig.copy(
+ roomVisibility = RoomVisibilityState.Public(
+ roomAddress = RoomAddress.AutoFilled(expectedConfig.roomName ?: ""),
+ roomAddressErrorState = RoomAddressErrorState.None,
+ roomAccess = RoomAccess.Anyone,
+ )
+ )
assertThat(newState.config).isEqualTo(expectedConfig)
// Remove user
- newState.eventSink(ConfigureRoomEvents.RemoveFromSelection(selectedUser1))
+ newState.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(selectedUser1))
newState = awaitItem()
expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList())
assertThat(newState.config).isEqualTo(expectedConfig)
@@ -215,15 +208,17 @@ class ConfigureRoomPresenterTest {
@Test
fun `present - trigger create room action`() = runTest {
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
+ val matrixClient = createMatrixClient()
+ val presenter = createConfigureRoomPresenter(
+ matrixClient = matrixClient
+ )
+ presenter.test {
+ val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
- fakeMatrixClient.givenCreateRoomResult(createRoomResult)
+ matrixClient.givenCreateRoomResult(createRoomResult)
- initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@@ -233,18 +228,22 @@ class ConfigureRoomPresenterTest {
@Test
fun `present - record analytics when creating room`() = runTest {
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
+ val matrixClient = createMatrixClient()
+ val analyticsService = FakeAnalyticsService()
+ val presenter = createConfigureRoomPresenter(
+ matrixClient = matrixClient,
+ analyticsService = analyticsService
+ )
+ presenter.test {
+ val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
- fakeMatrixClient.givenCreateRoomResult(createRoomResult)
+ matrixClient.givenCreateRoomResult(createRoomResult)
- initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
skipItems(2)
- val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance().firstOrNull()
+ val analyticsEvent = analyticsService.capturedEvents.filterIsInstance().firstOrNull()
assertThat(analyticsEvent).isNotNull()
assertThat(analyticsEvent?.isDM).isFalse()
}
@@ -252,23 +251,31 @@ class ConfigureRoomPresenterTest {
@Test
fun `present - trigger create room with upload error and retry`() = runTest {
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- skipItems(1)
+ val matrixClient = createMatrixClient()
+ val analyticsService = FakeAnalyticsService()
+ val mediaPreProcessor = FakeMediaPreProcessor()
+ val createRoomDataStore = CreateRoomDataStore(UserListDataStore())
+ val presenter = createConfigureRoomPresenter(
+ createRoomDataStore = createRoomDataStore,
+ mediaPreProcessor = mediaPreProcessor,
+ matrixClient = matrixClient,
+ analyticsService = analyticsService
+ )
+ presenter.test {
+ val initialState = initialState()
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
- fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
- fakeMatrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
+ skipItems(1)
+ mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
+ matrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
- val initialState = awaitItem()
- initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
- assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance()).isEmpty()
+ assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty()
- fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
- stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
+ stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@@ -277,23 +284,25 @@ class ConfigureRoomPresenterTest {
@Test
fun `present - trigger retry and cancel actions`() = runTest {
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
+ val fakeMatrixClient = createMatrixClient()
+ val presenter = createConfigureRoomPresenter(
+ matrixClient = fakeMatrixClient
+ )
+ presenter.test {
+ val initialState = initialState()
val createRoomResult = Result.failure(A_THROWABLE)
fakeMatrixClient.givenCreateRoomResult(createRoomResult)
// Create
- initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
// Retry
- stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterRetry = awaitItem()
@@ -305,4 +314,33 @@ class ConfigureRoomPresenterTest {
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
+
+ private suspend fun TurbineTestContext.initialState(): ConfigureRoomState {
+ skipItems(1)
+ return awaitItem()
+ }
+
+ private fun createMatrixClient() = FakeMatrixClient(
+ userIdServerNameLambda = { "matrix.org" },
+ )
+
+ private fun createConfigureRoomPresenter(
+ createRoomDataStore: CreateRoomDataStore = CreateRoomDataStore(UserListDataStore()),
+ matrixClient: MatrixClient = createMatrixClient(),
+ pickerProvider: PickerProvider = FakePickerProvider(),
+ mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
+ analyticsService: AnalyticsService = FakeAnalyticsService(),
+ permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
+ isKnockFeatureEnabled: Boolean = true,
+ ) = ConfigureRoomPresenter(
+ dataStore = createRoomDataStore,
+ matrixClient = matrixClient,
+ mediaPickerProvider = pickerProvider,
+ mediaPreProcessor = mediaPreProcessor,
+ analyticsService = analyticsService,
+ permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
+ featureFlagService = FakeFeatureFlagService(
+ mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
+ )
+ )
}
diff --git a/features/deactivation/impl/src/main/res/values-nl/translations.xml b/features/deactivation/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 0000000000..5f7cf78847
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Bevestig dat je je account wilt sluiten. Deze actie kan niet ongedaan worden gemaakt."
+ "Verwijder al mijn berichten"
+ "Waarschuwing: Toekomstige gebruikers kunnen onvolledige gesprekken te zien krijgen."
+ "Je account sluiten is %1$s, het zal:"
+ "onomkeerbaar"
+ "Je account %1$s (je kunt niet opnieuw inloggen en je ID kan niet opnieuw worden gebruikt)"
+ "permanent uitschakelen"
+ "Je verwijderen uit alle chatrooms."
+ "Je accountgegevens verwijderen van onze identiteitsserver."
+ "Je berichten zijn nog steeds zichtbaar voor geregistreerde gebruikers, maar niet beschikbaar voor nieuwe of niet-geregistreerde gebruikers als je ervoor kiest ze te verwijderen."
+ "Account sluiten"
+
diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts
index a4a0ef8f71..1937a4b0fc 100644
--- a/features/ftue/impl/build.gradle.kts
+++ b/features/ftue/impl/build.gradle.kts
@@ -45,6 +45,7 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
+ testImplementation(projects.services.analytics.noop)
testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
index 16473e62f5..f84a0d2b87 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
@@ -8,7 +8,10 @@
package io.element.android.features.ftue.impl
import android.os.Parcelable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
@@ -31,12 +34,14 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -80,14 +85,17 @@ class FtueFlowNode @AssistedInject constructor(
super.onBuilt()
lifecycle.subscribe(onCreate = {
- lifecycleScope.launch { moveToNextStep() }
+ moveToNextStepIfNeeded()
})
analyticsService.didAskUserConsent()
.distinctUntilChanged()
- .onEach {
- lifecycleScope.launch { moveToNextStep() }
- }
+ .onEach { moveToNextStepIfNeeded() }
+ .launchIn(lifecycleScope)
+
+ ftueState.isVerificationStatusKnown
+ .filter { it }
+ .onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope)
}
@@ -99,7 +107,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() {
- lifecycleScope.launch { moveToNextStep() }
+ moveToNextStepIfNeeded()
}
}
createNode(buildContext, listOf(callback))
@@ -107,7 +115,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
- lifecycleScope.launch { moveToNextStep() }
+ moveToNextStepIfNeeded()
}
}
createNode(buildContext, listOf(callback))
@@ -118,7 +126,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.LockScreenSetup -> {
val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() {
- lifecycleScope.launch { moveToNextStep() }
+ moveToNextStepIfNeeded()
}
}
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
@@ -128,8 +136,11 @@ class FtueFlowNode @AssistedInject constructor(
}
}
- private fun moveToNextStep() = lifecycleScope.launch {
+ private fun moveToNextStepIfNeeded() = lifecycleScope.launch {
when (ftueState.getNextStep()) {
+ FtueStep.WaitingForInitialState -> {
+ backstack.newRoot(NavTarget.Placeholder)
+ }
FtueStep.SessionVerification -> {
backstack.newRoot(NavTarget.SessionVerification)
}
@@ -155,7 +166,14 @@ class FtueFlowNode @AssistedInject constructor(
class PlaceholderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- ) : Node(buildContext, plugins = plugins)
+ ) : Node(buildContext, plugins = plugins) {
+ @Composable
+ override fun View(modifier: Modifier) {
+ Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+ }
}
private class NoOpBackstackHandlerStrategy : BaseBackPressHandlerStrategy() {
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
index fd4f5bb8b1..924259b9d2 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
@@ -15,6 +15,7 @@ import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@@ -23,21 +24,17 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.timeout
-import kotlinx.coroutines.runBlocking
-import timber.log.Timber
import javax.inject.Inject
-import kotlin.time.Duration.Companion.seconds
@ContributesBinding(SessionScope::class)
+@SingleIn(SessionScope::class)
class DefaultFtueService @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
@@ -49,6 +46,14 @@ class DefaultFtueService @Inject constructor(
) : FtueService {
override val state = MutableStateFlow(FtueState.Unknown)
+ /**
+ * This flow emits true when the FTUE flow is ready to be displayed.
+ * In this case, the FTUE flow is ready when the session verification status is known.
+ */
+ val isVerificationStatusKnown = sessionVerificationService.sessionVerifiedStatus
+ .map { it != SessionVerifiedStatus.Unknown }
+ .distinctUntilChanged()
+
override suspend fun reset() {
analyticsService.reset()
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
@@ -69,7 +74,12 @@ class DefaultFtueService @Inject constructor(
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
- null -> if (isSessionNotVerified()) {
+ null -> if (!isSessionVerificationStateReady()) {
+ FtueStep.WaitingForInitialState
+ } else {
+ getNextStep(FtueStep.WaitingForInitialState)
+ }
+ FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
FtueStep.SessionVerification
} else {
getNextStep(FtueStep.SessionVerification)
@@ -89,34 +99,18 @@ class DefaultFtueService @Inject constructor(
} else {
getNextStep(FtueStep.AnalyticsOptIn)
}
- FtueStep.AnalyticsOptIn -> {
- updateState()
- null
- }
+ FtueStep.AnalyticsOptIn -> null
}
- private suspend fun isAnyStepIncomplete(): Boolean {
- return listOf Boolean>(
- { isSessionNotVerified() },
- { shouldAskNotificationPermissions() },
- { needsAnalyticsOptIn() },
- { shouldDisplayLockscreenSetup() },
- ).any { it() }
+ private fun isSessionVerificationStateReady(): Boolean {
+ return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
}
- @OptIn(FlowPreview::class)
private suspend fun isSessionNotVerified(): Boolean {
- // Wait for the first known (or ready) verification status
- val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus
- .filter { it != SessionVerifiedStatus.Unknown }
- // This is not ideal, but there are some very rare cases when reading the flow seems to get stuck
- .timeout(5.seconds)
- .catch {
- Timber.e(it, "Failed to get session verification status, assume it's not verified")
- emit(SessionVerifiedStatus.NotVerified)
- }
- .first()
- return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !canSkipVerification()
+ // Wait until the session verification status is known
+ isVerificationStatusKnown.filter { it }.first()
+
+ return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
}
private suspend fun canSkipVerification(): Boolean {
@@ -130,7 +124,7 @@ class DefaultFtueService @Inject constructor(
private suspend fun shouldAskNotificationPermissions(): Boolean {
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS
- val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
+ val isPermissionDenied = permissionStateProvider.isPermissionDenied(permission).first()
val isPermissionGranted = permissionStateProvider.isPermissionGranted(permission)
!isPermissionGranted && !isPermissionDenied
} else {
@@ -144,14 +138,17 @@ class DefaultFtueService @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal suspend fun updateState() {
+ val nextStep = getNextStep()
state.value = when {
- isAnyStepIncomplete() -> FtueState.Incomplete
- else -> FtueState.Complete
+ // Final state, there aren't any more next steps
+ nextStep == null -> FtueState.Complete
+ else -> FtueState.Incomplete
}
}
}
sealed interface FtueStep {
+ data object WaitingForInitialState : FtueStep
data object SessionVerification : FtueStep
data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
index 10189c3c67..ca0222398c 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt
@@ -23,6 +23,7 @@ import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.analytics.noop.NoopAnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -35,7 +36,7 @@ class DefaultFtueServiceTest {
@Test
fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply {
- givenVerifiedStatus(SessionVerifiedStatus.Unknown)
+ emitVerifiedStatus(SessionVerifiedStatus.Unknown)
}
val service = createDefaultFtueService(
sessionVerificationService = sessionVerificationService,
@@ -46,7 +47,7 @@ class DefaultFtueServiceTest {
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
// Verification state is known, we should display the flow if any check is false
- sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
+ sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
assertThat(awaitItem()).isEqualTo(FtueState.Incomplete)
}
}
@@ -64,7 +65,7 @@ class DefaultFtueServiceTest {
lockScreenService = lockScreenService,
)
- sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
+ sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
@@ -73,10 +74,31 @@ class DefaultFtueServiceTest {
assertThat(service.state.value).isEqualTo(FtueState.Complete)
}
+ @Test
+ fun `given all checks being true with no analytics, FtueState is Complete`() = runTest {
+ val analyticsService = NoopAnalyticsService()
+ val sessionVerificationService = FakeSessionVerificationService()
+ val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
+ val lockScreenService = FakeLockScreenService()
+ val service = createDefaultFtueService(
+ sessionVerificationService = sessionVerificationService,
+ analyticsService = analyticsService,
+ permissionStateProvider = permissionStateProvider,
+ lockScreenService = lockScreenService,
+ )
+
+ sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
+ permissionStateProvider.setPermissionGranted()
+ lockScreenService.setIsPinSetup(true)
+ service.updateState()
+
+ assertThat(service.state.value).isEqualTo(FtueState.Complete)
+ }
+
@Test
fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply {
- givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
+ emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
}
val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
@@ -91,7 +113,7 @@ class DefaultFtueServiceTest {
// Session verification
steps.add(service.getNextStep(steps.lastOrNull()))
- sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
+ sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
// Notifications opt in
steps.add(service.getNextStep(steps.lastOrNull()))
@@ -132,7 +154,7 @@ class DefaultFtueServiceTest {
)
// Skip first 3 steps
- sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
+ sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
@@ -155,7 +177,7 @@ class DefaultFtueServiceTest {
lockScreenService = lockScreenService,
)
- sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
+ sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
lockScreenService.setIsPinSetup(true)
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
diff --git a/features/joinroom/impl/src/main/res/values-fr/translations.xml b/features/joinroom/impl/src/main/res/values-fr/translations.xml
index 9dc20cdab0..5e89edb1fa 100644
--- a/features/joinroom/impl/src/main/res/values-fr/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-fr/translations.xml
@@ -1,6 +1,9 @@
"Annuler la demande"
+ "Oui, annuler"
+ "Êtes-vous sûr de vouloir annuler votre demande d’accès à ce salon?"
+ "Annuler la demande d’adhésion"
"Rejoindre"
"Demander à joindre"
"Message (facultatif)"
diff --git a/features/joinroom/impl/src/main/res/values-nl/translations.xml b/features/joinroom/impl/src/main/res/values-nl/translations.xml
index d778ef8640..c64b8917d3 100644
--- a/features/joinroom/impl/src/main/res/values-nl/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-nl/translations.xml
@@ -2,6 +2,8 @@
"Toetreden tot de kamer"
"Klop om deel te nemen"
+ "Bericht (optioneel)"
+ "Verzoek om toe te treden verzonden"
"%1$s ondersteunt nog geen spaces. Je kunt spaces benaderen via de webbrowser."
"Spaces worden nog niet ondersteund"
"Klik op de knop hieronder en een kamerbeheerder wordt op de hoogte gebracht. Na goedkeuring kun je deelnemen aan het gesprek."
diff --git a/features/joinroom/impl/src/main/res/values-pl/translations.xml b/features/joinroom/impl/src/main/res/values-pl/translations.xml
index 585169fa80..758d8d5615 100644
--- a/features/joinroom/impl/src/main/res/values-pl/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-pl/translations.xml
@@ -1,7 +1,14 @@
+ "Anuluj prośbę"
+ "Tak, anuluj"
+ "Czy na pewno chcesz anulować prośbę o dołączenie do tego pokoju?"
+ "Anuluj prośbę o dołączenie"
"Dołącz do pokoju"
- "Zapukaj, by dołączyć"
+ "Wyślij prośbę o dołączenie"
+ "Wiadomość (opcjonalne)"
+ "Otrzymasz zaproszenie dołączenia do pokoju, jeśli prośba zostanie zaakceptowana."
+ "Wysłano prośbę o dołączenie"
"%1$s jeszcze nie obsługuje przestrzeni. Uzyskaj dostęp do przestrzeni w wersji web."
"Przestrzenie nie są jeszcze obsługiwane"
"Kliknij przycisk poniżej, aby powiadomić administratora pokoju. Po zatwierdzeniu będziesz mógł dołączyć do rozmowy."
diff --git a/features/joinroom/impl/src/main/res/values-pt/translations.xml b/features/joinroom/impl/src/main/res/values-pt/translations.xml
index 81e2c4bd1c..93eeaf3bca 100644
--- a/features/joinroom/impl/src/main/res/values-pt/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-pt/translations.xml
@@ -1,6 +1,9 @@
"Cancelar pedido"
+ "Sim, cancelar"
+ "Tens a certeza de que queres cancelar o teu pedido de entrada nesta sala?"
+ "Cancela o pedido de adesão"
"Entrar na sala"
"Bater à porta"
"Mensagem (opcional)"
diff --git a/features/login/impl/src/main/res/values-hu/translations.xml b/features/login/impl/src/main/res/values-hu/translations.xml
index 024871d5b3..d278d3a15a 100644
--- a/features/login/impl/src/main/res/values-hu/translations.xml
+++ b/features/login/impl/src/main/res/values-hu/translations.xml
@@ -40,15 +40,15 @@
"A kapcsolat nem biztonságos"
"A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén."
"Adja meg az alábbi számot a másik eszközén"
- "Jelentkezzen be a másik eszközére, majd próbálja újra, vagy használjon egy másik eszközt, amelyre már bejelentkezett."
+ "Jelentkezzen be másik eszközére, majd próbálkozzon újra, vagy használjon egy másik, már bejelentkezett eszközt."
"Más eszköz nincs bejelentkezve"
- "A bejelentkezés megszakadt a másik eszközön."
+ "A bejelentkezést megszakították a másik eszközön."
"Bejelentkezési kérés törölve"
- "A bejelentkezés el lett utasítva a másik eszközön."
+ "A bejelentkezést elutasították a másik eszközön."
"A bejelentkezés elutasítva"
"A bejelentkezés lejárt. Próbálja újra."
"A bejelentkezés nem fejeződött be időben"
- "A másik eszköz nem támogatja a %s QR-kóddal történő bejelentkezést.
+ "A másik eszköz nem támogatja QR-kóddal történő bejelentkezést az %sbe.
Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik eszközzel."
"A QR-kód nem támogatott"
diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml
index 749406da04..0ddc32c212 100644
--- a/features/login/impl/src/main/res/values-in/translations.xml
+++ b/features/login/impl/src/main/res/values-in/translations.xml
@@ -21,6 +21,7 @@
"Anda hanya dapat terhubung ke server yang ada yang mendukung sinkronisasi geser. Admin homeserver Anda perlu mengaturnya. %1$s"
"Apa alamat server Anda?"
"Pilih server Anda"
+ "Buat akun"
"Akun ini telah dinonaktifkan."
"Nama pengguna dan/atau kata sandi salah"
"Ini bukan pengenal pengguna yang valid. Format yang diharapkan: \'@pengguna:homeserver.org\'"
@@ -59,6 +60,7 @@ Coba masuk secara manual, atau pindai kode QR dengan perangkat lain."
"Pilih %1$s"
"“Tautkan perangkat baru”"
"Pindai kode QR dengan perangkat ini"
+ "Hanya tersedia jika penyedia akun Anda mendukungnya."
"Buka %1$s di perangkat lain untuk mendapatkan kode QR"
"Gunakan kode QR yang ditampilkan di perangkat lain."
"Coba lagi"
diff --git a/features/login/impl/src/main/res/values-nl/translations.xml b/features/login/impl/src/main/res/values-nl/translations.xml
index aa4622ad28..4097408d55 100644
--- a/features/login/impl/src/main/res/values-nl/translations.xml
+++ b/features/login/impl/src/main/res/values-nl/translations.xml
@@ -21,7 +21,8 @@
"Je kunt alleen verbinding maken met een bestaande server die sliding sync ondersteunt. De beheerder van de homeserver moet dit configureren. %1$s"
"Wat is het adres van je server?"
"Selecteer je server"
- "Dit account is gedeactiveerd."
+ "Account aanmaken"
+ "Dit account is gesloten."
"Onjuiste gebruikersnaam en/of wachtwoord"
"Dit is geen geldige gebruikers-ID. Verwacht formaat: \'@user:homeserver.org\'"
"Deze server is geconfigureerd om verversingstokens te gebruiken. Deze worden niet ondersteund bij inloggen met een wachtwoord."
@@ -59,6 +60,7 @@ Probeer handmatig in te loggen, of scan de QR code met een ander apparaat.""Selecteer %1$s"
"“Nieuw apparaat koppelen”"
"Scan de QR-code met dit apparaat"
+ "Alleen beschikbaar als je accountprovider dit ondersteunt."
"Open %1$s op een ander apparaat om de QR-code te krijgen"
"Gebruik de QR-code die op het andere apparaat wordt weergegeven."
"Probeer het opnieuw"
diff --git a/features/login/impl/src/main/res/values-pl/translations.xml b/features/login/impl/src/main/res/values-pl/translations.xml
index 93a9f82c1b..fc0ad2789c 100644
--- a/features/login/impl/src/main/res/values-pl/translations.xml
+++ b/features/login/impl/src/main/res/values-pl/translations.xml
@@ -60,6 +60,7 @@ Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu.""Wybierz %1$s"
"“Powiąż nowe urządzenie”"
"Zeskanuj kod QR za pomocą tego urządzenia"
+ "Dostępne tylko wtedy, gdy Twój dostawca konta obsługuje tę funkcję."
"Otwórz %1$s na innym urządzeniu, aby uzyskać kod QR"
"Użyj kodu QR widocznego na drugim urządzeniu."
"Spróbuj ponownie"
diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index 892c69ea2c..824e8c1692 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -29,6 +29,7 @@ dependencies {
implementation(projects.features.call.api)
implementation(projects.features.location.api)
implementation(projects.features.poll.api)
+ implementation(projects.features.roomcall.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index b215fc49fd..7840c307c7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -50,6 +50,7 @@ import io.element.android.features.messages.impl.timeline.protection.TimelinePro
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
+import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -75,7 +76,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
-import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
@@ -98,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
private val reactionSummaryPresenter: Presenter,
private val readReceiptBottomSheetPresenter: Presenter,
private val pinnedMessagesBannerPresenter: Presenter,
+ private val roomCallStatePresenter: Presenter,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers,
@@ -133,6 +134,7 @@ class MessagesPresenter @AssistedInject constructor(
val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
+ val roomCallState = roomCallStatePresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
@@ -152,8 +154,6 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false)
}
- val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
-
LaunchedEffect(Unit) {
// Remove the unread flag on entering but don't send read receipts
// as those will be handled by the timeline.
@@ -204,12 +204,6 @@ class MessagesPresenter @AssistedInject constructor(
}
}
- val callState = when {
- !canJoinCall -> RoomCallState.DISABLED
- roomInfo?.hasRoomCall == true -> RoomCallState.ONGOING
- else -> RoomCallState.ENABLED
- }
-
return MessagesState(
roomId = room.roomId,
roomName = roomName,
@@ -232,7 +226,7 @@ class MessagesPresenter @AssistedInject constructor(
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
enableVoiceMessages = enableVoiceMessages,
appName = buildMeta.applicationName,
- callState = callState,
+ roomCallState = roomCallState,
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = { handleEvents(it) }
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
index 2dc43030a4..a643784f1d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
@@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
+import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -46,14 +47,8 @@ data class MessagesState(
val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean,
- val callState: RoomCallState,
+ val roomCallState: RoomCallState,
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val eventSink: (MessagesEvents) -> Unit
)
-
-enum class RoomCallState {
- ENABLED,
- ONGOING,
- DISABLED
-}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
index c8a8ee6f1f..e8dc5329f4 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
@@ -33,13 +33,15 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
+import io.element.android.features.roomcall.api.RoomCallState
+import io.element.android.features.roomcall.api.aStandByCallState
+import io.element.android.features.roomcall.api.anOngoingCallState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.textcomposer.aRichTextEditorState
import io.element.android.libraries.textcomposer.model.MessageComposerMode
-import io.element.android.libraries.textcomposer.model.TextEditorState
+import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@@ -71,7 +73,7 @@ open class MessagesStateProvider : PreviewParameterProvider {
),
),
aMessagesState(
- callState = RoomCallState.ONGOING,
+ roomCallState = anOngoingCallState(),
),
aMessagesState(
enableVoiceMessages = true,
@@ -81,7 +83,7 @@ open class MessagesStateProvider : PreviewParameterProvider {
),
),
aMessagesState(
- callState = RoomCallState.DISABLED,
+ roomCallState = aStandByCallState(canStartCall = false),
),
aMessagesState(
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
@@ -97,7 +99,7 @@ fun aMessagesState(
roomAvatar: AsyncData = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
userEventPermissions: UserEventPermissions = aUserEventPermissions(),
composerState: MessageComposerState = aMessageComposerState(
- textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
+ textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true),
isFullScreen = false,
mode = MessageComposerMode.Normal,
),
@@ -116,7 +118,7 @@ fun aMessagesState(
hasNetworkConnection: Boolean = true,
showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true,
- callState: RoomCallState = RoomCallState.ENABLED,
+ roomCallState: RoomCallState = aStandByCallState(),
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
@@ -140,7 +142,7 @@ fun aMessagesState(
showReinvitePrompt = showReinvitePrompt,
enableTextFormatting = true,
enableVoiceMessages = enableVoiceMessages,
- callState = callState,
+ roomCallState = roomCallState,
appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = eventSink,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index bd8ae98b2f..e5d73fbed6 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
-import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@@ -69,7 +68,7 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView
-import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem
+import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
@@ -81,6 +80,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
+import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog
@@ -93,8 +93,6 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
-import io.element.android.libraries.designsystem.theme.components.Icon
-import io.element.android.libraries.designsystem.theme.components.IconButton
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.TopAppBar
@@ -190,7 +188,7 @@ fun MessagesView(
roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(),
heroes = state.heroes,
- callState = state.callState,
+ roomCallState = state.roomCallState,
onBackClick = {
// Since the textfield is now based on an Android view, this is no longer done automatically.
// We need to hide the keyboard when navigating out of this screen.
@@ -479,7 +477,7 @@ private fun MessagesViewTopBar(
roomName: String?,
roomAvatar: AvatarData?,
heroes: ImmutableList,
- callState: RoomCallState,
+ roomCallState: RoomCallState,
onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit,
onBackClick: () -> Unit,
@@ -509,9 +507,8 @@ private fun MessagesViewTopBar(
},
actions = {
CallMenuItem(
- isCallOngoing = callState == RoomCallState.ONGOING,
- onClick = onJoinCallClick,
- enabled = callState != RoomCallState.DISABLED
+ roomCallState = roomCallState,
+ onJoinCallClick = onJoinCallClick,
)
Spacer(Modifier.width(8.dp))
},
@@ -519,24 +516,6 @@ private fun MessagesViewTopBar(
)
}
-@Composable
-private fun CallMenuItem(
- isCallOngoing: Boolean,
- enabled: Boolean = true,
- onClick: () -> Unit,
-) {
- if (isCallOngoing) {
- JoinCallMenuItem(onJoinCallClick = onClick)
- } else {
- IconButton(onClick = onClick, enabled = enabled) {
- Icon(
- imageVector = CompoundIcons.VideoCallSolid(),
- contentDescription = stringResource(CommonStrings.a11y_start_call),
- )
- }
- }
-}
-
@Composable
private fun RoomAvatarAndNameRow(
roomName: String,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt
index 28cc95eaf0..742e78e8e0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt
@@ -12,5 +12,6 @@ import androidx.compose.runtime.Immutable
@Immutable
sealed interface AttachmentsPreviewEvents {
data object SendAttachment : AttachmentsPreviewEvents
+ data object Cancel : AttachmentsPreviewEvents
data object ClearSendState : AttachmentsPreviewEvents
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt
index a435821197..2417d8346e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt
@@ -31,7 +31,14 @@ class AttachmentsPreviewNode @AssistedInject constructor(
private val inputs: Inputs = inputs()
- private val presenter = presenterFactory.create(inputs.attachment)
+ private val onDoneListener = OnDoneListener {
+ navigateUp()
+ }
+
+ private val presenter = presenterFactory.create(
+ attachment = inputs.attachment,
+ onDoneListener = onDoneListener,
+ )
@Composable
override fun View(modifier: Modifier) {
@@ -39,7 +46,6 @@ class AttachmentsPreviewNode @AssistedInject constructor(
val state = presenter.present()
AttachmentsPreviewView(
state = state,
- onDismiss = this::navigateUp,
modifier = modifier
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
index 42468d66cf..bc3e121070 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
@@ -9,16 +9,22 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
+import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.ProgressCallback
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.mediaupload.api.MediaSender
+import io.element.android.libraries.textcomposer.model.TextEditorState
+import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -29,11 +35,17 @@ import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment,
+ @Assisted private val onDoneListener: OnDoneListener,
private val mediaSender: MediaSender,
+ private val permalinkBuilder: PermalinkBuilder,
+ private val temporaryUriDeleter: TemporaryUriDeleter,
) : Presenter {
@AssistedFactory
interface Factory {
- fun create(attachment: Attachment): AttachmentsPreviewPresenter
+ fun create(
+ attachment: Attachment,
+ onDoneListener: OnDoneListener,
+ ): AttachmentsPreviewPresenter
}
@Composable
@@ -44,11 +56,27 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mutableStateOf(SendActionState.Idle)
}
+ val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
+ val textEditorState by rememberUpdatedState(
+ TextEditorState.Markdown(markdownTextEditorState)
+ )
+
val ongoingSendAttachmentJob = remember { mutableStateOf(null) }
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) {
- AttachmentsPreviewEvents.SendAttachment -> ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(attachment, sendActionState)
+ is AttachmentsPreviewEvents.SendAttachment -> {
+ val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
+ .takeIf { it.isNotEmpty() }
+ ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(
+ attachment = attachment,
+ caption = caption,
+ sendActionState = sendActionState,
+ )
+ }
+ AttachmentsPreviewEvents.Cancel -> {
+ coroutineScope.cancel(attachment)
+ }
AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let {
it.cancel()
@@ -62,26 +90,42 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
return AttachmentsPreviewState(
attachment = attachment,
sendActionState = sendActionState.value,
+ textEditorState = textEditorState,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.sendAttachment(
attachment: Attachment,
+ caption: String?,
sendActionState: MutableState,
) = launch {
when (attachment) {
is Attachment.Media -> {
sendMedia(
mediaAttachment = attachment,
+ caption = caption,
sendActionState = sendActionState,
)
}
}
}
+ private fun CoroutineScope.cancel(
+ attachment: Attachment,
+ ) = launch {
+ // Delete the temporary file
+ when (attachment) {
+ is Attachment.Media -> {
+ temporaryUriDeleter.delete(attachment.localMedia.uri)
+ }
+ }
+ onDoneListener()
+ }
+
private suspend fun sendMedia(
mediaAttachment: Attachment.Media,
+ caption: String?,
sendActionState: MutableState,
) = runCatching {
val context = coroutineContext
@@ -96,11 +140,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaSender.sendMedia(
uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType,
+ caption = caption,
progressCallback = progressCallback
).getOrThrow()
}.fold(
onSuccess = {
- sendActionState.value = SendActionState.Done
+ onDoneListener()
},
onFailure = { error ->
Timber.e(error, "Failed to send attachment")
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt
index fc446d60a8..5ffe9364ff 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt
@@ -9,12 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
+import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
+import io.element.android.libraries.textcomposer.model.TextEditorState
data class AttachmentsPreviewState(
val attachment: Attachment,
val sendActionState: SendActionState,
+ val textEditorState: TextEditorState,
val eventSink: (AttachmentsPreviewEvents) -> Unit
-)
+) {
+ val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let {
+ it.isMimeTypeImage() || it.isMimeTypeVideo()
+ }.orFalse()
+}
@Immutable
sealed interface SendActionState {
@@ -27,5 +36,4 @@ sealed interface SendActionState {
}
data class Failure(val error: Throwable) : SendActionState
- data object Done : SendActionState
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
index 718f9cfbe4..78f3ffc81a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
@@ -12,13 +12,19 @@ import androidx.core.net.toUri
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.MediaInfo
+import io.element.android.libraries.mediaviewer.api.local.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo
+import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
+import io.element.android.libraries.textcomposer.model.TextEditorState
+import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
open class AttachmentsPreviewStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
anAttachmentsPreviewState(),
+ anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
+ anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
@@ -27,11 +33,13 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider Unit,
modifier: Modifier = Modifier,
) {
fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
}
+ fun postCancel() {
+ state.eventSink(AttachmentsPreviewEvents.Cancel)
+ }
+
fun postClearSendState() {
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
}
- if (state.sendActionState is SendActionState.Done) {
- val latestOnDismiss by rememberUpdatedState(onDismiss)
- LaunchedEffect(state.sendActionState) {
- latestOnDismiss()
- }
+ BackHandler(enabled = state.sendActionState !is SendActionState.Sending) {
+ postCancel()
}
- Scaffold(modifier) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ navigationIcon = {
+ BackButton(
+ imageVector = CompoundIcons.Close(),
+ onClick = ::postCancel,
+ )
+ },
+ title = {},
+ )
+ }
+ ) {
AttachmentPreviewContent(
- attachment = state.attachment,
+ state = state,
onSendClick = ::postSendAttachment,
- onDismiss = onDismiss
)
}
AttachmentSendStateView(
@@ -106,21 +123,19 @@ private fun AttachmentSendStateView(
@Composable
private fun AttachmentPreviewContent(
- attachment: Attachment,
+ state: AttachmentsPreviewState,
onSendClick: () -> Unit,
- onDismiss: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding(),
- contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
- when (attachment) {
+ when (val attachment = state.attachment) {
is Attachment.Media -> {
val localMediaViewState = rememberLocalMediaViewState(
zoomableState = rememberZoomableState(
@@ -137,27 +152,46 @@ private fun AttachmentPreviewContent(
}
}
AttachmentsPreviewBottomActions(
- onCancelClick = onDismiss,
+ state = state,
onSendClick = onSendClick,
modifier = Modifier
.fillMaxWidth()
- .background(Color.Black.copy(alpha = 0.7f))
- .padding(horizontal = 24.dp)
- .defaultMinSize(minHeight = 80.dp)
+ .background(ElementTheme.colors.bgCanvasDefault)
+ .height(IntrinsicSize.Min)
+ .align(Alignment.BottomCenter)
+ .imePadding(),
)
}
}
@Composable
private fun AttachmentsPreviewBottomActions(
- onCancelClick: () -> Unit,
+ state: AttachmentsPreviewState,
onSendClick: () -> Unit,
modifier: Modifier = Modifier
) {
- ButtonRowMolecule(modifier = modifier) {
- TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClick)
- TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClick)
- }
+ TextComposer(
+ modifier = modifier,
+ state = state.textEditorState,
+ voiceMessageState = VoiceMessageState.Idle,
+ composerMode = MessageComposerMode.Attachment(state.allowCaption),
+ onRequestFocus = {},
+ onSendMessage = onSendClick,
+ showTextFormatting = false,
+ onResetComposerMode = {},
+ onAddAttachment = {},
+ onDismissTextFormatting = {},
+ enableVoiceMessages = false,
+ onVoiceRecorderEvent = {},
+ onVoicePlayerEvent = {},
+ onSendVoiceMessage = {},
+ onDeleteVoiceMessage = {},
+ onReceiveSuggestion = {},
+ resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
+ onError = {},
+ onTyping = {},
+ onSelectRichContent = {},
+ )
}
// Only preview in dark, dark theme is forced on the Node.
@@ -166,6 +200,5 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView(
state = state,
- onDismiss = {},
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/OnDoneListener.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/OnDoneListener.kt
new file mode 100644
index 0000000000..2e53ab2c50
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/OnDoneListener.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.attachments.preview
+
+fun interface OnDoneListener {
+ operator fun invoke()
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt
index 16bc39fac8..3b56fc9993 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt
@@ -15,8 +15,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom
@@ -41,7 +39,6 @@ import javax.inject.Inject
class IdentityChangeStatePresenter @Inject constructor(
private val room: MatrixRoom,
private val encryptionService: EncryptionService,
- private val featureFlagService: FeatureFlagService,
) : Presenter {
@Composable
override fun present(): IdentityChangeState {
@@ -64,11 +61,7 @@ class IdentityChangeStatePresenter @Inject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
private fun ProduceStateScope>.observeRoomMemberIdentityStateChange() {
- featureFlagService.isFeatureEnabledFlow(FeatureFlags.IdentityPinningViolationNotifications)
- .filter { it }
- .flatMapLatest {
- room.syncUpdateFlow
- }
+ room.syncUpdateFlow
.filter {
// Room cannot become unencrypted, so we can just apply a filter here.
room.isEncrypted
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt
index 1b57c52d23..c34f072c6d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt
@@ -14,8 +14,7 @@ import io.element.android.features.messages.impl.aMessagesState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
-import io.element.android.libraries.textcomposer.model.TextEditorState
+import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
@PreviewsDayNight
@Composable
@@ -25,11 +24,9 @@ internal fun MessagesViewWithIdentityChangePreview(
MessagesView(
state = aMessagesState(
composerState = aMessageComposerState(
- textEditorState = TextEditorState.Markdown(
- state = MarkdownTextEditorState(
- initialText = "",
- initialFocus = false,
- )
+ textEditorState = aTextEditorStateMarkdown(
+ initialText = "",
+ initialFocus = false,
)
),
identityChangeState = identityChangeState,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index cce78d601e..6101f48c1f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -436,6 +436,7 @@ class MessageComposerPresenter @Inject constructor(
// Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) {
+ is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> room.sendMessage(
body = message.markdown,
htmlBody = message.html,
@@ -605,6 +606,7 @@ class MessageComposerPresenter @Inject constructor(
): ComposerDraft? {
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false)
val draftType = when (val mode = messageComposerContext.composerMode) {
+ is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> ComposerDraftType.NewMessage
is MessageComposerMode.Edit -> {
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index 2730872072..a36102bc7d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -8,10 +8,10 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.textcomposer.aRichTextEditorState
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
+import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -24,7 +24,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider,
private val typingNotificationPresenter: Presenter,
+ private val roomCallStatePresenter: Presenter,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -229,14 +230,15 @@ class TimelinePresenter @AssistedInject constructor(
}
val typingNotificationState = typingNotificationPresenter.present()
- val timelineRoomInfo by remember(typingNotificationState) {
+ val roomCallState = roomCallStatePresenter.present()
+ val timelineRoomInfo by remember(typingNotificationState, roomCallState) {
derivedStateOf {
TimelineRoomInfo(
name = room.displayName,
isDm = room.isDm,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
- isCallOngoing = roomInfo?.hasRoomCall.orFalse(),
+ roomCallState = roomCallState,
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
typingNotificationState = typingNotificationState,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
index bfb357b579..aad5b7e354 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
@@ -12,6 +12,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.typing.TypingNotificationState
+import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@@ -73,7 +74,7 @@ data class TimelineRoomInfo(
val name: String?,
val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToSendReaction: Boolean,
- val isCallOngoing: Boolean,
+ val roomCallState: RoomCallState,
val pinnedEventIds: List,
val typingNotificationState: TypingNotificationState,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
index 6c790d7b1d..4d7677560e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
@@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
+import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
@@ -249,7 +250,7 @@ internal fun aTimelineRoomInfo(
name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = true,
- isCallOngoing = false,
+ roomCallState = aStandByCallState(),
pinnedEventIds = pinnedEventIds,
typingNotificationState = typingNotificationState,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt
new file mode 100644
index 0000000000..2284ffa50c
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.timeline.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.roomcall.api.RoomCallState
+import io.element.android.features.roomcall.api.RoomCallStateProvider
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+internal fun CallMenuItem(
+ roomCallState: RoomCallState,
+ onJoinCallClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ when (roomCallState) {
+ is RoomCallState.StandBy -> {
+ StandByCallMenuItem(
+ roomCallState = roomCallState,
+ onJoinCallClick = onJoinCallClick,
+ modifier = modifier,
+ )
+ }
+ is RoomCallState.OnGoing -> {
+ OnGoingCallMenuItem(
+ roomCallState = roomCallState,
+ onJoinCallClick = onJoinCallClick,
+ modifier = modifier,
+ )
+ }
+ }
+}
+
+@Composable
+private fun StandByCallMenuItem(
+ roomCallState: RoomCallState.StandBy,
+ onJoinCallClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ IconButton(
+ modifier = modifier,
+ onClick = onJoinCallClick,
+ enabled = roomCallState.canStartCall,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.VideoCallSolid(),
+ contentDescription = stringResource(CommonStrings.a11y_start_call),
+ )
+ }
+}
+
+@Composable
+private fun OnGoingCallMenuItem(
+ roomCallState: RoomCallState.OnGoing,
+ onJoinCallClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (!roomCallState.isUserLocallyInTheCall) {
+ Button(
+ onClick = onJoinCallClick,
+ colors = ButtonDefaults.buttonColors(
+ contentColor = ElementTheme.colors.bgCanvasDefault,
+ containerColor = ElementTheme.colors.iconAccentTertiary
+ ),
+ contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
+ modifier = modifier.heightIn(min = 36.dp),
+ enabled = roomCallState.canJoinCall,
+ ) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = CompoundIcons.VideoCallSolid(),
+ contentDescription = null
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = stringResource(CommonStrings.action_join),
+ style = ElementTheme.typography.fontBodyMdMedium
+ )
+ Spacer(Modifier.width(8.dp))
+ }
+ } else {
+ // Else user is already in the call, hide the button.
+ Box(modifier)
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun CallMenuItemPreview(
+ @PreviewParameter(RoomCallStateProvider::class) roomCallState: RoomCallState
+) = ElementPreview {
+ CallMenuItem(
+ roomCallState = roomCallState,
+ onJoinCallClick = {}
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ContentPadding.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ContentPadding.kt
new file mode 100644
index 0000000000..5e7bdf5623
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ContentPadding.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.timeline.components
+
+enum class ContentPadding {
+ Textual,
+ Media,
+ CaptionedMedia
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/JoinCallMenuItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/JoinCallMenuItem.kt
deleted file mode 100644
index 80611ccd7d..0000000000
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/JoinCallMenuItem.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.messages.impl.timeline.components
-
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.libraries.designsystem.theme.components.Icon
-import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.ui.strings.CommonStrings
-
-@Composable
-internal fun JoinCallMenuItem(
- onJoinCallClick: () -> Unit,
-) {
- Button(
- onClick = onJoinCallClick,
- colors = ButtonDefaults.buttonColors(
- contentColor = ElementTheme.colors.bgCanvasDefault,
- containerColor = ElementTheme.colors.iconAccentTertiary
- ),
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
- modifier = Modifier.heightIn(min = 36.dp),
- ) {
- Icon(
- modifier = Modifier.size(20.dp),
- imageVector = CompoundIcons.VideoCallSolid(),
- contentDescription = null
- )
- Spacer(Modifier.width(8.dp))
- Text(
- text = stringResource(CommonStrings.action_join),
- style = ElementTheme.typography.fontBodyMdMedium
- )
- Spacer(Modifier.width(8.dp))
- }
-}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt
index d64430e087..ca499336f9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt
@@ -31,6 +31,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
+import io.element.android.features.roomcall.api.RoomCallState
+import io.element.android.features.roomcall.api.RoomCallStateProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -41,7 +43,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun TimelineItemCallNotifyView(
event: TimelineItem.Event,
- isCallOngoing: Boolean,
+ roomCallState: RoomCallState,
onLongClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier
@@ -82,8 +84,11 @@ internal fun TimelineItemCallNotifyView(
)
}
}
- if (isCallOngoing) {
- JoinCallMenuItem(onJoinCallClick)
+ if (roomCallState is RoomCallState.OnGoing) {
+ CallMenuItem(
+ roomCallState = roomCallState,
+ onJoinCallClick = onJoinCallClick,
+ )
} else {
Text(
text = event.sentTime,
@@ -101,18 +106,14 @@ internal fun TimelineItemCallNotifyView(
internal fun TimelineItemCallNotifyViewPreview() {
ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
- TimelineItemCallNotifyView(
- event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
- isCallOngoing = true,
- onLongClick = {},
- onJoinCallClick = {},
- )
- TimelineItemCallNotifyView(
- event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
- isCallOngoing = false,
- onLongClick = {},
- onJoinCallClick = {},
- )
+ RoomCallStateProvider().values.forEach { roomCallState ->
+ TimelineItemCallNotifyView(
+ event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
+ roomCallState = roomCallState,
+ onLongClick = {},
+ onJoinCallClick = {},
+ )
+ }
}
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index d52cc9a360..2f0165cd7b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -325,7 +325,12 @@ private fun TimelineItemEventRowContent(
MessageEventBubble(
modifier = Modifier
.constrainAs(message) {
- top.linkTo(sender.bottom, margin = NEGATIVE_MARGIN_FOR_BUBBLE)
+ val topMargin = if (bubbleState.cutTopStart) {
+ NEGATIVE_MARGIN_FOR_BUBBLE
+ } else {
+ 0.dp
+ }
+ top.linkTo(sender.bottom, margin = topMargin)
if (event.isMine) {
end.linkTo(parent.end, margin = 16.dp)
} else {
@@ -522,32 +527,33 @@ private fun MessageEventBubbleContent(
fun CommonLayout(
timestampPosition: TimestampPosition,
showThreadDecoration: Boolean,
+ paddingBehaviour: ContentPadding,
inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier,
canShrinkContent: Boolean = false,
) {
- val timestampLayoutModifier: Modifier
- val contentModifier: Modifier
- when {
- inReplyToDetails != null -> {
- if (timestampPosition == TimestampPosition.Overlay) {
- timestampLayoutModifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
- contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
+ val timestampLayoutModifier =
+ if (inReplyToDetails != null && timestampPosition == TimestampPosition.Overlay) {
+ Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
+ } else {
+ Modifier
+ }
+
+ val topPadding = if (inReplyToDetails != null) 0.dp else 8.dp
+ val contentModifier = when (paddingBehaviour) {
+ ContentPadding.Textual ->
+ Modifier.padding(start = 12.dp, end = 12.dp, top = topPadding, bottom = 8.dp)
+ ContentPadding.Media -> {
+ if (inReplyToDetails == null) {
+ Modifier
} else {
- contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
- timestampLayoutModifier = Modifier
+ Modifier.clip(RoundedCornerShape(10.dp))
}
}
- timestampPosition != TimestampPosition.Overlay -> {
- timestampLayoutModifier = Modifier
- contentModifier = Modifier
- .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
- }
- else -> {
- timestampLayoutModifier = Modifier
- contentModifier = Modifier
- }
+ ContentPadding.CaptionedMedia ->
+ Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
}
+
val threadDecoration = @Composable {
if (showThreadDecoration) {
ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp))
@@ -601,9 +607,17 @@ private fun MessageEventBubbleContent(
is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default
}
+ val paddingBehaviour = when (event.content) {
+ is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
+ is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
+ is TimelineItemStickerContent,
+ is TimelineItemLocationContent -> ContentPadding.Media
+ else -> ContentPadding.Textual
+ }
CommonLayout(
showThreadDecoration = event.isThreaded,
timestampPosition = timestampPosition,
+ paddingBehaviour = paddingBehaviour,
inReplyToDetails = event.inReplyTo,
canShrinkContent = event.content is TimelineItemVoiceContent,
modifier = bubbleModifier.semantics(mergeDescendants = true) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
index c429e9cc83..fcdd8610d9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt
@@ -50,7 +50,9 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
isMine = it,
timelineItemReactions = aTimelineItemReactions(count = 0),
content = aTimelineItemImageContent(
- aspectRatio = 2.5f
+ aspectRatio = 2.5f,
+ filename = "image.jpg",
+ caption = "A reply with an image.",
),
inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
index c7c1cb5350..13c247645b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
@@ -105,7 +105,7 @@ internal fun TimelineItemRow(
TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
event = timelineItem,
- isCallOngoing = timelineRoomInfo.isCallOngoing,
+ roomCallState = timelineRoomInfo.roomCallState,
onLongClick = onLongClick,
onJoinCallClick = onJoinCallClick,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
index fcc24b791e..63bbac7f6f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt
@@ -51,7 +51,6 @@ import io.element.android.libraries.designsystem.components.blurhash.blurHashBac
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
-import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
@@ -69,9 +68,7 @@ fun TimelineItemImageView(
modifier = modifier.semantics { contentDescription = description },
) {
val containerModifier = if (content.showCaption) {
- Modifier
- .padding(top = 6.dp)
- .clip(RoundedCornerShape(6.dp))
+ Modifier.clip(RoundedCornerShape(10.dp))
} else {
Modifier
}
@@ -88,13 +85,7 @@ fun TimelineItemImageView(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
- model = MediaRequestData(
- source = content.preferredMediaSource,
- kind = MediaRequestData.Kind.File(
- fileName = content.filename,
- mimeType = content.mimeType,
- ),
- ),
+ model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
contentDescription = description,
@@ -119,6 +110,7 @@ fun TimelineItemImageView(
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText(
modifier = Modifier
+ .padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
index e743338ccf..64e6d00d71 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt
@@ -57,6 +57,8 @@ import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
+import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
+import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
@@ -70,14 +72,14 @@ fun TimelineItemVideoView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
- val description = stringResource(CommonStrings.common_image)
+ val description = stringResource(CommonStrings.common_video)
Column(
modifier = modifier.semantics { contentDescription = description }
) {
val containerModifier = if (content.showCaption) {
Modifier
- .padding(top = 6.dp)
- .clip(RoundedCornerShape(6.dp))
+ .padding(top = 6.dp)
+ .clip(RoundedCornerShape(6.dp))
} else {
Modifier
}
@@ -93,13 +95,13 @@ fun TimelineItemVideoView(
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
- .fillMaxWidth()
- .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
+ .fillMaxWidth()
+ .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData(
source = content.thumbnailSource,
- kind = MediaRequestData.Kind.File(
- fileName = content.filename,
- mimeType = content.mimeType
+ kind = MediaRequestData.Kind.Thumbnail(
+ width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH,
+ height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT,
)
),
contentScale = ContentScale.Fit,
@@ -137,6 +139,7 @@ fun TimelineItemVideoView(
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText(
modifier = Modifier
+ .padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption,
style = ElementRichTextEditorStyle.textStyle(),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index 3eb0c66594..001d40b254 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -93,6 +93,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
blurhash = messageType.info?.blurhash,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
+ thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
+ thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
@@ -146,6 +148,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
+ thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
+ thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
duration = messageType.info?.duration ?: Duration.ZERO,
blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
index 990c9e34e5..10e4898725 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
@@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat
import java.util.Date
+import java.util.TimeZone
open class AggregatedReactionProvider : PreviewParameterProvider {
override val values: Sequence
@@ -29,7 +30,9 @@ fun anAggregatedReaction(
count: Int = 1,
isHighlighted: Boolean = false,
): AggregatedReaction {
- val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, java.util.Locale.US)
+ val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, java.util.Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
val date = Date(1_689_061_264L)
val senders = buildList {
repeat(count) { index ->
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt
index efc2d4a100..e6e4bffb9b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt
@@ -7,9 +7,12 @@
package io.element.android.features.messages.impl.timeline.model.event
-import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
+import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
+import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
+import io.element.android.libraries.matrix.ui.media.MediaRequestData
data class TimelineItemImageContent(
override val filename: String,
@@ -23,15 +26,31 @@ data class TimelineItemImageContent(
val blurhash: String?,
val width: Int?,
val height: Int?,
+ val thumbnailWidth: Int?,
+ val thumbnailHeight: Int?,
val aspectRatio: Float?
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemImageContent"
val showCaption = caption != null
- val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
- mediaSource
- } else {
- thumbnailSource ?: mediaSource
+ val thumbnailMediaRequestData: MediaRequestData by lazy {
+ if (mimeType.isMimeTypeAnimatedImage()) {
+ MediaRequestData(
+ source = mediaSource,
+ kind = MediaRequestData.Kind.File(
+ fileName = filename,
+ mimeType = mimeType
+ )
+ )
+ } else {
+ MediaRequestData(
+ source = thumbnailSource ?: mediaSource,
+ kind = MediaRequestData.Kind.Thumbnail(
+ width = thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH,
+ height = thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT
+ ),
+ )
+ }
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt
index 8c645ab901..60edb0e6d7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt
@@ -37,6 +37,8 @@ fun aTimelineItemImageContent(
blurhash = blurhash,
width = null,
height = 300,
+ thumbnailWidth = null,
+ thumbnailHeight = 150,
aspectRatio = aspectRatio,
formattedFileSize = "4MB",
fileExtension = "jpg"
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt
index 3b2e6c1f21..5c0e601708 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt
@@ -22,6 +22,8 @@ data class TimelineItemVideoContent(
val blurHash: String?,
val height: Int?,
val width: Int?,
+ val thumbnailWidth: Int?,
+ val thumbnailHeight: Int?,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt
index 39d104b2af..b9390b4e52 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt
@@ -35,8 +35,10 @@ fun aTimelineItemVideoContent(
aspectRatio = aspectRatio,
duration = 100.milliseconds,
videoSource = MediaSource(""),
- height = 300,
width = 150,
+ height = 300,
+ thumbnailWidth = 150,
+ thumbnailHeight = 300,
mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB",
fileExtension = "mp4"
diff --git a/features/messages/impl/src/main/res/values-nl/translations.xml b/features/messages/impl/src/main/res/values-nl/translations.xml
index e7464c63bd..3cfd37be19 100644
--- a/features/messages/impl/src/main/res/values-nl/translations.xml
+++ b/features/messages/impl/src/main/res/values-nl/translations.xml
@@ -34,7 +34,7 @@
"Toon minder"
"Bericht gekopieerd"
"Je hebt geen toestemming om berichten in deze kamer te plaatsen"
- "Minder tonen"
+ "Toon minder"
"Meer tonen"
"Nieuw"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 7664379207..9ca5c0b98e 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -37,6 +37,7 @@ import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvi
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
+import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -69,9 +70,9 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
-import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
+import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.WarmUpRule
@@ -139,27 +140,6 @@ class MessagesPresenterTest {
}
}
- @Test
- fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
- val room = FakeMatrixRoom(
- canUserJoinCallResult = { Result.success(false) },
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- typingNoticeResult = { Result.success(Unit) },
- canUserPinUnpinResult = { Result.success(true) },
- ).apply {
- givenRoomInfo(aRoomInfo(hasRoomCall = true))
- }
- val presenter = createMessagesPresenter(matrixRoom = room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = consumeItemsUntilTimeout().last()
- assertThat(initialState.callState).isEqualTo(RoomCallState.DISABLED)
- }
- }
-
@Test
fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
@@ -344,6 +324,8 @@ class MessagesPresenterTest {
blurhash = null,
width = 20,
height = 20,
+ thumbnailWidth = null,
+ thumbnailHeight = null,
aspectRatio = 1.0f,
fileExtension = "jpg",
formattedFileSize = "4MB"
@@ -384,6 +366,8 @@ class MessagesPresenterTest {
blurHash = null,
width = 20,
height = 20,
+ thumbnailWidth = 20,
+ thumbnailHeight = 20,
aspectRatio = 1.0f,
fileExtension = "mp4",
formattedFileSize = "50MB"
@@ -1005,7 +989,7 @@ class MessagesPresenterTest {
messageComposerPresenter: Presenter = Presenter {
aMessageComposerState(
// Use TextEditorState.Markdown, so that we can request focus manually.
- textEditorState = TextEditorState.Markdown(MarkdownTextEditorState(initialText = "", initialFocus = false))
+ textEditorState = aTextEditorStateMarkdown(initialText = "", initialFocus = false)
)
},
actionListEventSink: (ActionListEvents) -> Unit = {},
@@ -1030,6 +1014,7 @@ class MessagesPresenterTest {
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
identityChangeStatePresenter = { anIdentityChangeState() },
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
+ roomCallStatePresenter = { aStandByCallState() },
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
index 5d59ce5464..ac753f6cdb 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
@@ -16,11 +16,19 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
+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.fixtures.aMediaAttachment
+import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.matrix.api.core.ProgressCallback
+import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.VideoInfo
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
@@ -29,23 +37,30 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
+import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.io.File
+@RunWith(RobolectricTestRunner::class)
class AttachmentsPreviewPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
- private val mediaPreProcessor = FakeMediaPreProcessor()
private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test
fun `present - send media success scenario`() = runTest {
- val sendMediaResult = lambdaRecorder> {
+ val sendFileResult = lambdaRecorder> { _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -54,9 +69,13 @@ class AttachmentsPreviewPresenterTest {
Pair(5, 10),
Pair(10, 10)
),
- sendMediaResult = sendMediaResult,
+ sendFileResult = sendFileResult,
+ )
+ val onDoneListener = lambdaRecorder { }
+ val presenter = createAttachmentsPreviewPresenter(
+ room = room,
+ onDoneListener = { onDoneListener() },
)
- val presenter = createAttachmentsPreviewPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -67,20 +86,117 @@ class AttachmentsPreviewPresenterTest {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
- val successState = awaitItem()
- assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
- sendMediaResult.assertions().isCalledOnce()
+ advanceUntilIdle()
+ sendFileResult.assertions().isCalledOnce()
+ onDoneListener.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - cancel scenario`() = runTest {
+ val onDoneListener = lambdaRecorder { }
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createAttachmentsPreviewPresenter(
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ onDoneListener = { onDoneListener() },
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ initialState.eventSink(AttachmentsPreviewEvents.Cancel)
+ deleteCallback.assertions().isCalledOnce()
+ onDoneListener.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - send image with caption success scenario`() = runTest {
+ val sendImageResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
+ Result.success(FakeMediaUploadHandler())
+ }
+ val mediaPreProcessor = FakeMediaPreProcessor().apply {
+ givenImageResult()
+ }
+ val room = FakeMatrixRoom(
+ sendImageResult = sendImageResult,
+ )
+ val onDoneListener = lambdaRecorder { }
+ val presenter = createAttachmentsPreviewPresenter(
+ room = room,
+ mediaPreProcessor = mediaPreProcessor,
+ onDoneListener = { onDoneListener() },
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ initialState.textEditorState.setMarkdown(A_CAPTION)
+ initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ advanceUntilIdle()
+ sendImageResult.assertions().isCalledOnce().with(
+ any(),
+ any(),
+ any(),
+ value(A_CAPTION),
+ any(),
+ any(),
+ )
+ onDoneListener.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - send video with caption success scenario`() = runTest {
+ val sendVideoResult =
+ lambdaRecorder> { _, _, _, _, _, _ ->
+ Result.success(FakeMediaUploadHandler())
+ }
+ val mediaPreProcessor = FakeMediaPreProcessor().apply {
+ givenVideoResult()
+ }
+ val room = FakeMatrixRoom(
+ sendVideoResult = sendVideoResult,
+ )
+ val onDoneListener = lambdaRecorder { }
+ val presenter = createAttachmentsPreviewPresenter(
+ room = room,
+ mediaPreProcessor = mediaPreProcessor,
+ onDoneListener = { onDoneListener() },
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ initialState.textEditorState.setMarkdown(A_CAPTION)
+ initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ advanceUntilIdle()
+ sendVideoResult.assertions().isCalledOnce().with(
+ any(),
+ any(),
+ any(),
+ value(A_CAPTION),
+ any(),
+ any(),
+ )
+ onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - send media failure scenario`() = runTest {
val failure = MediaPreProcessor.Failure(null)
- val sendMediaResult = lambdaRecorder> {
+ val sendFileResult = lambdaRecorder> { _, _, _ ->
Result.failure(failure)
}
val room = FakeMatrixRoom(
- sendMediaResult = sendMediaResult,
+ sendFileResult = sendFileResult,
)
val presenter = createAttachmentsPreviewPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -93,7 +209,7 @@ class AttachmentsPreviewPresenterTest {
assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing)
val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure))
- sendMediaResult.assertions().isCalledOnce()
+ sendFileResult.assertions().isCalledOnce()
failureState.eventSink(AttachmentsPreviewEvents.ClearSendState)
val clearedState = awaitItem()
assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Idle)
@@ -119,11 +235,18 @@ class AttachmentsPreviewPresenterTest {
localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl,
),
- room: MatrixRoom = FakeMatrixRoom()
+ room: MatrixRoom = FakeMatrixRoom(),
+ permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
+ mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
+ temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
+ onDoneListener: OnDoneListener = OnDoneListener {},
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
- mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore())
+ onDoneListener = onDoneListener,
+ mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
+ permalinkBuilder = permalinkBuilder,
+ temporaryUriDeleter = temporaryUriDeleter,
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt
index 92774f4feb..f87c727379 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt
@@ -9,9 +9,6 @@ package io.element.android.features.messages.impl.crypto.identity
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
-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.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -68,43 +65,6 @@ class IdentityChangeStatePresenterTest {
}
}
- @Test
- fun `present - when the room emits identity change, but the feature is disabled, the presenter does not emit new state`() = runTest {
- val room = FakeMatrixRoom(
- isEncrypted = true,
- )
- val featureFlagService = FakeFeatureFlagService(
- initialState = mapOf(
- FeatureFlags.IdentityPinningViolationNotifications.key to false,
- )
- )
- val presenter = createIdentityChangeStatePresenter(
- room = room,
- featureFlagService = featureFlagService,
- )
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.roomMemberIdentityStateChanges).isEmpty()
- room.emitIdentityStateChanges(
- listOf(
- IdentityStateChange(
- userId = A_USER_ID_2,
- identityState = IdentityState.PinViolation,
- ),
- )
- )
- // No item emitted.
- expectNoEvents()
- // Enable the feature
- featureFlagService.setFeatureEnabled(FeatureFlags.IdentityPinningViolationNotifications, true)
- val finalItem = awaitItem()
- assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1)
- val value = finalItem.roomMemberIdentityStateChanges.first()
- assertThat(value.identityRoomMember.userId).isEqualTo(A_USER_ID_2)
- assertThat(value.identityState).isEqualTo(IdentityState.PinViolation)
- }
- }
-
@Test
fun `present - when the clear room emits identity change, the presenter does not emit new state`() = runTest {
val room = FakeMatrixRoom(isEncrypted = false)
@@ -188,16 +148,10 @@ class IdentityChangeStatePresenterTest {
private fun createIdentityChangeStatePresenter(
room: MatrixRoom = FakeMatrixRoom(),
encryptionService: EncryptionService = FakeEncryptionService(),
- featureFlagService: FeatureFlagService = FakeFeatureFlagService(
- initialState = mapOf(
- FeatureFlags.IdentityPinningViolationNotifications.key to true,
- )
- ),
): IdentityChangeStatePresenter {
return IdentityChangeStatePresenter(
room = room,
encryptionService = encryptionService,
- featureFlagService = featureFlagService,
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
index b029cd724f..2e68c9b199 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
@@ -32,6 +32,7 @@ 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.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@@ -684,7 +685,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - Pick file from storage`() = runTest {
- val sendMediaResult = lambdaRecorder { _: ProgressCallback? ->
+ val sendFileResult = lambdaRecorder> { _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -693,7 +694,7 @@ class MessageComposerPresenterTest {
Pair(5, 10),
Pair(10, 10)
),
- sendMediaResult = sendMediaResult,
+ sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(this, room = room)
@@ -710,7 +711,7 @@ class MessageComposerPresenterTest {
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f))
val sentState = awaitItem()
assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
- sendMediaResult.assertions().isCalledOnce()
+ sendFileResult.assertions().isCalledOnce()
}
}
@@ -852,8 +853,11 @@ class MessageComposerPresenterTest {
@Test
fun `present - Uploading media failure can be recovered from`() = runTest {
+ val sendFileResult = lambdaRecorder> { _, _, _ ->
+ Result.failure(Exception())
+ }
val room = FakeMatrixRoom(
- sendMediaResult = { Result.failure(Exception()) },
+ sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(this, room = room)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
index 74df8ee46c..d153dd5743 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
@@ -27,6 +27,7 @@ import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
+import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@@ -685,5 +686,6 @@ internal fun TestScope.createTimelinePresenter(
timelineController = TimelineController(room),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() },
+ roomCallStatePresenter = { aStandByCallState() },
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
index 337ca9ab7e..e9343bf21d 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
@@ -246,6 +246,8 @@ class TimelineItemContentMessageFactoryTest {
width = null,
mimeType = MimeTypes.OctetStream,
formattedFileSize = "0 Bytes",
+ thumbnailWidth = null,
+ thumbnailHeight = null,
fileExtension = "",
)
assertThat(result).isEqualTo(expected)
@@ -294,6 +296,8 @@ class TimelineItemContentMessageFactoryTest {
width = 300,
mimeType = MimeTypes.Mp4,
formattedFileSize = "555 Bytes",
+ thumbnailWidth = 5,
+ thumbnailHeight = 10,
fileExtension = "mp4",
)
assertThat(result).isEqualTo(expected)
@@ -458,6 +462,8 @@ class TimelineItemContentMessageFactoryTest {
blurhash = null,
width = null,
height = null,
+ thumbnailWidth = null,
+ thumbnailHeight = null,
aspectRatio = null
)
assertThat(result).isEqualTo(expected)
@@ -531,6 +537,8 @@ class TimelineItemContentMessageFactoryTest {
blurhash = A_BLUR_HASH,
width = 5,
height = 10,
+ thumbnailWidth = 5,
+ thumbnailHeight = 10,
aspectRatio = 0.5f,
)
assertThat(result).isEqualTo(expected)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
index 0e0009e139..a7cbaaffd2 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.ProgressCallback
+import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
@@ -46,6 +47,7 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
+import java.io.File
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -57,9 +59,12 @@ class VoiceMessageComposerPresenterTest {
recordingDuration = RECORDING_DURATION
)
private val analyticsService = FakeAnalyticsService()
- private val sendMediaResult = lambdaRecorder> { Result.success(FakeMediaUploadHandler()) }
+ private val sendVoiceMessageResult =
+ lambdaRecorder, ProgressCallback?, Result> { _, _, _, _ ->
+ Result.success(FakeMediaUploadHandler())
+ }
private val matrixRoom = FakeMatrixRoom(
- sendMediaResult = sendMediaResult
+ sendVoiceMessageResult = sendVoiceMessageResult
)
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom, InMemorySessionPreferencesStore())
@@ -292,7 +297,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- sendMediaResult.assertions().isCalledOnce()
+ sendVoiceMessageResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState)
@@ -343,7 +348,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- sendMediaResult.assertions().isCalledOnce()
+ sendVoiceMessageResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState)
@@ -366,7 +371,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- sendMediaResult.assertions().isCalledOnce()
+ sendVoiceMessageResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState)
@@ -390,7 +395,7 @@ class VoiceMessageComposerPresenterTest {
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true))
- sendMediaResult.assertions().isNeverCalled()
+ sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).hasSize(0)
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@@ -415,13 +420,13 @@ class VoiceMessageComposerPresenterTest {
ensureAllEventsConsumed()
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
- sendMediaResult.assertions().isNeverCalled()
+ sendVoiceMessageResult.assertions().isNeverCalled()
mediaPreProcessor.givenAudioResult()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- sendMediaResult.assertions().isCalledOnce()
+ sendVoiceMessageResult.assertions().isCalledOnce()
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
testPauseAndDestroy(finalState)
@@ -458,7 +463,7 @@ class VoiceMessageComposerPresenterTest {
assertThat(showSendFailureDialog).isFalse()
}
- sendMediaResult.assertions().isNeverCalled()
+ sendVoiceMessageResult.assertions().isNeverCalled()
testPauseAndDestroy(finalState)
}
}
@@ -474,7 +479,7 @@ class VoiceMessageComposerPresenterTest {
initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
- sendMediaResult.assertions().isNeverCalled()
+ sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).hasSize(1)
voiceRecorder.assertCalls(started = 0)
@@ -493,7 +498,7 @@ class VoiceMessageComposerPresenterTest {
val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
- sendMediaResult.assertions().isNeverCalled()
+ sendVoiceMessageResult.assertions().isNeverCalled()
assertThat(analyticsService.trackedErrors).containsExactly(
VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
index ee54325ce1..296beaaa7a 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
@@ -37,7 +37,7 @@ class AdvancedSettingsPresenter @Inject constructor(
.collectAsState(initial = true)
val doesCompressMedia by sessionPreferencesStore
.doesCompressMedia()
- .collectAsState(initial = false)
+ .collectAsState(initial = true)
val theme by remember {
appPreferencesStore.getThemeFlow().mapToTheme()
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
index 10e59bf334..0c758852db 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
@@ -138,8 +138,8 @@ private fun ColumnScope.ManageAppSection(
}
if (state.showSecureBackup) {
ListItem(
- headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.KeySolid())),
+ headlineContent = { Text(stringResource(id = CommonStrings.common_encryption)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())),
trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
onClick = onSecureBackupClick,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
index 14d0bd7dcd..0e5a05be56 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
@@ -22,6 +22,7 @@ import androidx.core.net.toUri
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
+import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -43,6 +44,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
+ private val temporaryUriDeleter: TemporaryUriDeleter,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@@ -59,10 +61,20 @@ class EditUserProfilePresenter @AssistedInject constructor(
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
- onResult = { uri -> if (uri != null) userAvatarUri = uri }
+ onResult = { uri ->
+ if (uri != null) {
+ temporaryUriDeleter.delete(userAvatarUri)
+ userAvatarUri = uri
+ }
+ }
)
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
- onResult = { uri -> if (uri != null) userAvatarUri = uri }
+ onResult = { uri ->
+ if (uri != null) {
+ temporaryUriDeleter.delete(userAvatarUri)
+ userAvatarUri = uri
+ }
+ }
)
val avatarActions by remember(userAvatarUri) {
@@ -96,7 +108,10 @@ class EditUserProfilePresenter @AssistedInject constructor(
pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
- AvatarAction.Remove -> userAvatarUri = null
+ AvatarAction.Remove -> {
+ temporaryUriDeleter.delete(userAvatarUri)
+ userAvatarUri = null
+ }
}
}
@@ -155,7 +170,12 @@ class EditUserProfilePresenter @AssistedInject constructor(
private suspend fun updateAvatar(avatarUri: Uri?): Result {
return runCatching {
if (avatarUri != null) {
- val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
+ val preprocessed = mediaPreProcessor.process(
+ uri = avatarUri,
+ mimeType = MimeTypes.Jpeg,
+ deleteOriginal = false,
+ compressIfPossible = false,
+ ).getOrThrow()
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
} else {
matrixClient.removeAvatar().getOrThrow()
diff --git a/features/preferences/impl/src/main/res/values-cs/translations.xml b/features/preferences/impl/src/main/res/values-cs/translations.xml
index b49f9cfc95..bc0e36ed9e 100644
--- a/features/preferences/impl/src/main/res/values-cs/translations.xml
+++ b/features/preferences/impl/src/main/res/values-cs/translations.xml
@@ -8,6 +8,8 @@
"Vlastní URL pro Element Call"
"Nastavte vlastní URL pro Element Call."
"Neplatné URL, ujistěte se, že jste uvedli protokol (http/https) a správnou adresu."
+ "Optimalizovat pro nahrávání"
+ "Média"
"Poskytovatel push oznámení"
"Vypněte editor formátovaného textu pro ruční zadání Markdown."
"Potvrzení o přečtení"
diff --git a/features/preferences/impl/src/main/res/values-et/translations.xml b/features/preferences/impl/src/main/res/values-et/translations.xml
index f301fa46d5..bf7c028dd3 100644
--- a/features/preferences/impl/src/main/res/values-et/translations.xml
+++ b/features/preferences/impl/src/main/res/values-et/translations.xml
@@ -8,8 +8,8 @@
"Element Calli kohandatud teenuseaadress"
"Seadista kohandatud teenuseaadress Element Calli jaoks."
"Vigane url. Palun vaata, et url algaks protokolliga (http/https) ning aadress ise oleks ka õige."
- "Optimeeri üleslaadimiseks"
- "Meedia"
+ "Sellega laadid fotosid ja videoid kiiremini üles ning vähendad andmemahtu"
+ "Optimeeri meedia kvaliteeti"
"Tõuketeavituste pakkuja"
"Kui soovid Markdown-vormingut käsitsi lisada, siis lülita vormindatud teksti toimeti välja."
"Lugemisteatised"
diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml
index 4fa09c3b6f..82d28bd954 100644
--- a/features/preferences/impl/src/main/res/values-fr/translations.xml
+++ b/features/preferences/impl/src/main/res/values-fr/translations.xml
@@ -8,6 +8,8 @@
"URL de base pour Element Call personnalisée"
"Configurer une URL de base pour Element Call."
"URL invalide, assurez-vous d’inclure le protocol (http/https) et l’adresse correcte."
+ "Optimisé pour le téléchargement"
+ "Media"
"Fournisseur de Push"
"Désactivez l’éditeur de texte enrichi pour saisir manuellement du Markdown."
"Accusés de lecture"
diff --git a/features/preferences/impl/src/main/res/values-hu/translations.xml b/features/preferences/impl/src/main/res/values-hu/translations.xml
index 92c1df9871..cb01dd66f3 100644
--- a/features/preferences/impl/src/main/res/values-hu/translations.xml
+++ b/features/preferences/impl/src/main/res/values-hu/translations.xml
@@ -1,14 +1,16 @@
- "Annak érdekében, hogy soha ne maradjon le egyetlen fontos hívásról sem, módosítsa a beállításokat, hogy engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."
- "Növelje a hívásélményét"
+ "Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."
+ "Fokozza a hívásélményét"
"Válassza ki az értesítések fogadási módját"
"Fejlesztői mód"
"Engedélyezze, hogy elérje a fejlesztőknek szánt funkciókat."
"Egyéni Element Call alapwebcím"
"Egyéni alapwebcím beállítása az Element Callhoz."
"Érvénytelen webcím, győződjön meg arról, hogy szerepel-e benne a protokoll (http/https), és hogy helyes-e a cím."
- "Leküldéses értesítési szolgáltató"
+ "Töltse fel gyorsabban a fényképeket és videókat, valamint csökkentse az adatforgalmat"
+ "Média minőségének optimalizálása"
+ "Leküldéses értesítések szolgáltatója"
"A formázott szöveges szerkesztő letiltása, hogy kézzel írhasson Markdownt."
"Olvasási visszaigazolások"
"Ha ki van kapcsolva, az olvasási visszaigazolások nem lesznek elküldve senkinek. A többi felhasználó olvasási visszaigazolását továbbra is meg fogja kapni."
diff --git a/features/preferences/impl/src/main/res/values-in/translations.xml b/features/preferences/impl/src/main/res/values-in/translations.xml
index f6bb5b1968..7b9bc51a82 100644
--- a/features/preferences/impl/src/main/res/values-in/translations.xml
+++ b/features/preferences/impl/src/main/res/values-in/translations.xml
@@ -8,6 +8,8 @@
"URL dasar Element Call khusus"
"Tetapkan URL dasar khusus untuk Element Call."
"URL tidak valid, pastikan Anda menyertakan protokol (http/https) dan alamat yang benar."
+ "Unggah foto dan video lebih cepat dan kurangi penggunaan data"
+ "Optimalkan kualitas media"
"Penyedia notifikasi dorongan"
"Nonaktifkan penyunting teks kaya untuk mengetik Markdown secara manual."
"Laporan dibaca"
diff --git a/features/preferences/impl/src/main/res/values-pl/translations.xml b/features/preferences/impl/src/main/res/values-pl/translations.xml
index 6bc66cd63c..1416a9bd45 100644
--- a/features/preferences/impl/src/main/res/values-pl/translations.xml
+++ b/features/preferences/impl/src/main/res/values-pl/translations.xml
@@ -54,5 +54,5 @@ Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz."
"Powiadomienia systemowe wyłączone"
"Powiadomienia"
"Rozwiązywanie problemów"
- "Powiadomienia rozwiązywania problemów"
+ "Rozwiązywanie problemów powiadomień"
diff --git a/features/preferences/impl/src/main/res/values-pt/translations.xml b/features/preferences/impl/src/main/res/values-pt/translations.xml
index 5502334026..766e27f469 100644
--- a/features/preferences/impl/src/main/res/values-pt/translations.xml
+++ b/features/preferences/impl/src/main/res/values-pt/translations.xml
@@ -8,6 +8,8 @@
"URL base para Element Call personalizado"
"Define um URL base para a Element Call."
"URL inválido, certifica-te de que incluis o protocolo (http/https) e o endereço correto."
+ "Carrega fotos e vídeos mais rapidamente e reduz a utilização de dados"
+ "Otimiza a qualidade da mídia"
"Fornecedor de envio"
"Desativa o editor de texto rico para poderes escrever Markdown manualmente."
"Recibos de leitura"
diff --git a/features/preferences/impl/src/main/res/values-ru/translations.xml b/features/preferences/impl/src/main/res/values-ru/translations.xml
index bbe04958c5..c015011c89 100644
--- a/features/preferences/impl/src/main/res/values-ru/translations.xml
+++ b/features/preferences/impl/src/main/res/values-ru/translations.xml
@@ -8,8 +8,8 @@
"Базовый URL сервера звонков Element"
"Задайте свой сервер Element Call."
"Адрес указан неверно, удостоверьтесь, что вы указали протокол (http/https) и правильный адрес."
- "Оптимизировать для загрузки"
- "Медиа"
+ "Загружайте фотографии и видео быстрее и сокращайте потребление трафика"
+ "Оптимизировать качество мультимедиа"
"Поставщик push-уведомлений"
"Отключить редактор форматированного текста и включить Markdown."
"Уведомления о прочтении"
diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml
index d7b0f6dda2..d2a03f922f 100644
--- a/features/preferences/impl/src/main/res/values/localazy.xml
+++ b/features/preferences/impl/src/main/res/values/localazy.xml
@@ -8,8 +8,8 @@
"Custom Element Call base URL"
"Set a custom base URL for Element Call."
"Invalid URL, please make sure you include the protocol (http/https) and the correct address."
- "Optimize for upload"
- "Media"
+ "Upload photos and videos faster and reduce data usage"
+ "Optimise media quality"
"Push notification provider"
"Disable the rich text editor to type Markdown manually."
"Read receipts"
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt
index 1634918296..843df71afb 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt
@@ -34,7 +34,7 @@ class AdvancedSettingsPresenterTest {
assertThat(initialState.isDeveloperModeEnabled).isFalse()
assertThat(initialState.showChangeThemeDialog).isFalse()
assertThat(initialState.isSharePresenceEnabled).isTrue()
- assertThat(initialState.doesCompressMedia).isFalse()
+ assertThat(initialState.doesCompressMedia).isTrue()
assertThat(initialState.theme).isEqualTo(Theme.System)
}
}
@@ -76,11 +76,11 @@ class AdvancedSettingsPresenterTest {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem()
- assertThat(initialState.doesCompressMedia).isFalse()
- initialState.eventSink.invoke(AdvancedSettingsEvents.SetCompressMedia(true))
- assertThat(awaitItem().doesCompressMedia).isTrue()
+ assertThat(initialState.doesCompressMedia).isTrue()
initialState.eventSink.invoke(AdvancedSettingsEvents.SetCompressMedia(false))
assertThat(awaitItem().doesCompressMedia).isFalse()
+ initialState.eventSink.invoke(AdvancedSettingsEvents.SetCompressMedia(true))
+ assertThat(awaitItem().doesCompressMedia).isTrue()
}
}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
index 5fae451e0f..0f39ab491c 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
@@ -12,6 +12,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -29,6 +30,9 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
+import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -73,12 +77,14 @@ class EditUserProfilePresenterTest {
matrixClient: MatrixClient = FakeMatrixClient(),
matrixUser: MatrixUser = aMatrixUser(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
+ temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
): EditUserProfilePresenter {
return EditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = matrixUser,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
+ temporaryUriDeleter = temporaryUriDeleter,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
)
}
@@ -107,7 +113,12 @@ class EditUserProfilePresenterTest {
@Test
fun `present - updates state in response to changes`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
- val presenter = createEditUserProfilePresenter(matrixUser = user)
+ val presenter = createEditUserProfilePresenter(
+ matrixUser = user,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(
+ deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -136,7 +147,12 @@ class EditUserProfilePresenterTest {
fun `present - obtains avatar uris from gallery`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
- val presenter = createEditUserProfilePresenter(matrixUser = user)
+ val presenter = createEditUserProfilePresenter(
+ matrixUser = user,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(
+ deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -154,9 +170,13 @@ class EditUserProfilePresenterTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter()
+ val deleteCallback = lambdaRecorder {}
val presenter = createEditUserProfilePresenter(
matrixUser = user,
permissionsPresenter = fakePermissionsPresenter,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(
+ deleteLambda = deleteCallback,
+ ),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -177,6 +197,10 @@ class EditUserProfilePresenterTest {
stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithNewAvatar2 = awaitItem()
assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(userAvatarUri)
+ deleteCallback.assertions().isCalledExactly(2).withSequence(
+ listOf(value(userAvatarUri)),
+ listOf(value(anotherAvatarUri)),
+ )
}
}
@@ -184,7 +208,13 @@ class EditUserProfilePresenterTest {
fun `present - updates save button state`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(userAvatarUri)
- val presenter = createEditUserProfilePresenter(matrixUser = user)
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createEditUserProfilePresenter(
+ matrixUser = user,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(
+ deleteLambda = deleteCallback
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -210,6 +240,10 @@ class EditUserProfilePresenterTest {
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
+ deleteCallback.assertions().isCalledExactly(2).withSequence(
+ listOf(value(userAvatarUri)),
+ listOf(value(null)),
+ )
}
}
@@ -217,7 +251,13 @@ class EditUserProfilePresenterTest {
fun `present - updates save button state when initial values are null`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null)
fakePickerProvider.givenResult(userAvatarUri)
- val presenter = createEditUserProfilePresenter(matrixUser = user)
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createEditUserProfilePresenter(
+ matrixUser = user,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(
+ deleteLambda = deleteCallback
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -243,6 +283,10 @@ class EditUserProfilePresenterTest {
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
+ deleteCallback.assertions().isCalledExactly(2).withSequence(
+ listOf(value(null)),
+ listOf(value(userAvatarUri)),
+ )
}
}
@@ -252,7 +296,10 @@ class EditUserProfilePresenterTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
- matrixUser = user
+ matrixUser = user,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(
+ deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
+ ),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -318,7 +365,10 @@ class EditUserProfilePresenterTest {
givenPickerReturnsFile()
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
- matrixUser = user
+ matrixUser = user,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(
+ deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
+ ),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -337,7 +387,10 @@ class EditUserProfilePresenterTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
- matrixUser = user
+ matrixUser = user,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(
+ deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
+ ),
)
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
@@ -403,7 +456,13 @@ class EditUserProfilePresenterTest {
}
private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) {
- val presenter = createEditUserProfilePresenter(matrixUser = matrixUser, matrixClient = matrixClient)
+ val presenter = createEditUserProfilePresenter(
+ matrixUser = matrixUser,
+ matrixClient = matrixClient,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(
+ deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
+ ),
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
index 806837de7a..b7c6a07f89 100644
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt
@@ -17,7 +17,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.rageshake.api.crash.CrashDataStore
-import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
@@ -31,7 +30,6 @@ class BugReportPresenter @Inject constructor(
private val bugReporter: BugReporter,
private val crashDataStore: CrashDataStore,
private val screenshotHolder: ScreenshotHolder,
- private val logFilesRemover: LogFilesRemover,
private val appCoroutineScope: CoroutineScope,
) : Presenter {
private class BugReporterUploadListener(
@@ -143,6 +141,5 @@ class BugReportPresenter @Inject constructor(
private fun CoroutineScope.resetAll() = launch {
screenshotHolder.reset()
crashDataStore.reset()
- logFilesRemover.perform()
}
}
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
index 283d6296d2..15512b21e4 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
@@ -12,18 +12,15 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.crash.CrashDataStore
-import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
-import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.tests.testutils.WarmUpRule
-import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -111,11 +108,9 @@ class BugReportPresenterTest {
@Test
fun `present - reset all`() = runTest {
- val logFilesRemover = FakeLogFilesRemover()
val presenter = createPresenter(
crashDataStore = FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
screenshotHolder = FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
- logFilesRemover = logFilesRemover,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -127,7 +122,6 @@ class BugReportPresenterTest {
initialState.eventSink.invoke(BugReportEvents.ResetAll)
val resetState = awaitItem()
assertThat(resetState.hasCrashLogs).isFalse()
- logFilesRemover.performLambda.assertions().isCalledOnce()
// TODO Make it live assertThat(resetState.screenshotUri).isNull()
}
}
@@ -236,12 +230,10 @@ class BugReportPresenterTest {
bugReporter: BugReporter = FakeBugReporter(),
crashDataStore: CrashDataStore = FakeCrashDataStore(),
screenshotHolder: ScreenshotHolder = FakeScreenshotHolder(),
- logFilesRemover: LogFilesRemover = FakeLogFilesRemover(LambdaOneParamRecorder(ensureNeverCalled = true) { }),
) = BugReportPresenter(
bugReporter = bugReporter,
crashDataStore = crashDataStore,
screenshotHolder = screenshotHolder,
- logFilesRemover = logFilesRemover,
appCoroutineScope = this,
)
}
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt
index abb6682a66..ffe98680dd 100644
--- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlin.jvm.optionals.getOrElse
class RoomAliasResolverPresenter @AssistedInject constructor(
@Assisted private val roomAlias: RoomAlias,
@@ -57,7 +58,9 @@ class RoomAliasResolverPresenter @AssistedInject constructor(
private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch {
suspend {
- matrixClient.resolveRoomAlias(roomAlias).getOrThrow()
+ matrixClient.resolveRoomAlias(roomAlias)
+ .getOrThrow()
+ .getOrElse { error("Failed to resolve room alias $roomAlias") }
}.runCatchingUpdatingState(resolveState)
}
}
diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt
index e651cf41e8..9894c2b342 100644
--- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt
+++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt
@@ -24,6 +24,7 @@ import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
+import java.util.Optional
class RoomAliasResolverPresenterTest {
@get:Rule
@@ -42,7 +43,7 @@ class RoomAliasResolverPresenterTest {
@Test
fun `present - resolve alias to roomId`() = runTest {
- val result = aResolvedRoomAlias()
+ val result = Optional.of(aResolvedRoomAlias())
val client = FakeMatrixClient(
resolveRoomAliasResult = { Result.success(result) }
)
@@ -54,7 +55,7 @@ class RoomAliasResolverPresenterTest {
assertThat(awaitItem().resolveState.isLoading()).isTrue()
val resultState = awaitItem()
assertThat(resultState.roomAlias).isEqualTo(A_ROOM_ALIAS)
- assertThat(resultState.resolveState.dataOrNull()).isEqualTo(result)
+ assertThat(resultState.resolveState.dataOrNull()).isEqualTo(result.get())
}
}
diff --git a/features/roomcall/api/build.gradle.kts b/features/roomcall/api/build.gradle.kts
new file mode 100644
index 0000000000..12a2117b16
--- /dev/null
+++ b/features/roomcall/api/build.gradle.kts
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.roomcall.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+}
diff --git a/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt
new file mode 100644
index 0000000000..e47a623914
--- /dev/null
+++ b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.roomcall.api
+
+import androidx.compose.runtime.Immutable
+import io.element.android.features.roomcall.api.RoomCallState.OnGoing
+import io.element.android.features.roomcall.api.RoomCallState.StandBy
+
+@Immutable
+sealed interface RoomCallState {
+ data class StandBy(
+ val canStartCall: Boolean,
+ ) : RoomCallState
+
+ data class OnGoing(
+ val canJoinCall: Boolean,
+ val isUserInTheCall: Boolean,
+ val isUserLocallyInTheCall: Boolean,
+ ) : RoomCallState
+}
+
+fun RoomCallState.hasPermissionToJoin() = when (this) {
+ is StandBy -> canStartCall
+ is OnGoing -> canJoinCall
+}
diff --git a/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt
new file mode 100644
index 0000000000..6351c25479
--- /dev/null
+++ b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.roomcall.api
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+open class RoomCallStateProvider : PreviewParameterProvider {
+ override val values: Sequence = sequenceOf(
+ aStandByCallState(),
+ aStandByCallState(canStartCall = false),
+ anOngoingCallState(),
+ anOngoingCallState(canJoinCall = false),
+ anOngoingCallState(canJoinCall = true, isUserInTheCall = true),
+ )
+}
+
+fun anOngoingCallState(
+ canJoinCall: Boolean = true,
+ isUserInTheCall: Boolean = false,
+ isUserLocallyInTheCall: Boolean = isUserInTheCall,
+) = RoomCallState.OnGoing(
+ canJoinCall = canJoinCall,
+ isUserInTheCall = isUserInTheCall,
+ isUserLocallyInTheCall = isUserLocallyInTheCall,
+)
+
+fun aStandByCallState(
+ canStartCall: Boolean = true,
+) = RoomCallState.StandBy(
+ canStartCall = canStartCall,
+)
diff --git a/features/roomcall/impl/build.gradle.kts b/features/roomcall/impl/build.gradle.kts
new file mode 100644
index 0000000000..6ac4ff934e
--- /dev/null
+++ b/features/roomcall/impl/build.gradle.kts
@@ -0,0 +1,38 @@
+import extension.setupAnvil
+
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+}
+
+android {
+ namespace = "io.element.android.features.roomcall.impl"
+}
+
+setupAnvil()
+
+dependencies {
+ api(projects.features.roomcall.api)
+ implementation(libs.kotlinx.collections.immutable)
+ implementation(projects.features.call.api)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.features.call.test)
+ testImplementation(projects.tests.testutils)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
+}
diff --git a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt
new file mode 100644
index 0000000000..57ac545c19
--- /dev/null
+++ b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.roomcall.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import io.element.android.features.call.api.CurrentCall
+import io.element.android.features.call.api.CurrentCallService
+import io.element.android.features.roomcall.api.RoomCallState
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.ui.room.canCall
+import javax.inject.Inject
+
+class RoomCallStatePresenter @Inject constructor(
+ private val room: MatrixRoom,
+ private val currentCallService: CurrentCallService,
+) : Presenter {
+ @Composable
+ override fun present(): RoomCallState {
+ val roomInfo by room.roomInfoFlow.collectAsState(null)
+ val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
+ val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
+ val isUserInTheCall by remember {
+ derivedStateOf {
+ room.sessionId in roomInfo?.activeRoomCallParticipants.orEmpty()
+ }
+ }
+ val currentCall by currentCallService.currentCall.collectAsState()
+ val isUserLocallyInTheCall by remember {
+ derivedStateOf {
+ (currentCall as? CurrentCall.RoomCall)?.roomId == room.roomId
+ }
+ }
+ val callState = when {
+ roomInfo?.hasRoomCall == true -> RoomCallState.OnGoing(
+ canJoinCall = canJoinCall,
+ isUserInTheCall = isUserInTheCall,
+ isUserLocallyInTheCall = isUserLocallyInTheCall,
+ )
+ else -> RoomCallState.StandBy(canStartCall = canJoinCall)
+ }
+ return callState
+ }
+}
diff --git a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/di/RoomCallModule.kt b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/di/RoomCallModule.kt
new file mode 100644
index 0000000000..34c8d2448f
--- /dev/null
+++ b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/di/RoomCallModule.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.roomcall.impl.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Binds
+import dagger.Module
+import io.element.android.features.roomcall.api.RoomCallState
+import io.element.android.features.roomcall.impl.RoomCallStatePresenter
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.di.RoomScope
+
+@ContributesTo(RoomScope::class)
+@Module
+interface RoomCallModule {
+ @Binds
+ fun bindRoomCallStatePresenter(presenter: RoomCallStatePresenter): Presenter
+}
diff --git a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt
new file mode 100644
index 0000000000..d29bff08ff
--- /dev/null
+++ b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.roomcall.impl
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.call.api.CurrentCall
+import io.element.android.features.call.api.CurrentCallService
+import io.element.android.features.call.test.FakeCurrentCallService
+import io.element.android.features.roomcall.api.RoomCallState
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class RoomCallStatePresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val room = FakeMatrixRoom(
+ canUserJoinCallResult = { Result.success(false) },
+ )
+ val presenter = createRoomCallStatePresenter(matrixRoom = room)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState).isEqualTo(
+ RoomCallState.StandBy(
+ canStartCall = false,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - initial state - user can join call`() = runTest {
+ val room = FakeMatrixRoom(
+ canUserJoinCallResult = { Result.success(true) },
+ )
+ val presenter = createRoomCallStatePresenter(matrixRoom = room)
+ presenter.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState).isEqualTo(
+ RoomCallState.StandBy(
+ canStartCall = true,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
+ val room = FakeMatrixRoom(
+ canUserJoinCallResult = { Result.success(false) },
+ ).apply {
+ givenRoomInfo(aRoomInfo(hasRoomCall = true))
+ }
+ val presenter = createRoomCallStatePresenter(matrixRoom = room)
+ presenter.test {
+ skipItems(1)
+ assertThat(awaitItem()).isEqualTo(
+ RoomCallState.OnGoing(
+ canJoinCall = false,
+ isUserInTheCall = false,
+ isUserLocallyInTheCall = false,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - user has joined the call on another session`() = runTest {
+ val room = FakeMatrixRoom(
+ canUserJoinCallResult = { Result.success(true) },
+ ).apply {
+ givenRoomInfo(
+ aRoomInfo(
+ hasRoomCall = true,
+ activeRoomCallParticipants = listOf(sessionId),
+ )
+ )
+ }
+ val presenter = createRoomCallStatePresenter(matrixRoom = room)
+ presenter.test {
+ skipItems(1)
+ assertThat(awaitItem()).isEqualTo(
+ RoomCallState.OnGoing(
+ canJoinCall = true,
+ isUserInTheCall = true,
+ isUserLocallyInTheCall = false,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - user has joined the call locally`() = runTest {
+ val room = FakeMatrixRoom(
+ canUserJoinCallResult = { Result.success(true) },
+ ).apply {
+ givenRoomInfo(
+ aRoomInfo(
+ hasRoomCall = true,
+ activeRoomCallParticipants = listOf(sessionId),
+ )
+ )
+ }
+ val presenter = createRoomCallStatePresenter(
+ matrixRoom = room,
+ currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))),
+ )
+ presenter.test {
+ skipItems(1)
+ assertThat(awaitItem()).isEqualTo(
+ RoomCallState.OnGoing(
+ canJoinCall = true,
+ isUserInTheCall = true,
+ isUserLocallyInTheCall = true,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - user leaves the call`() = runTest {
+ val room = FakeMatrixRoom(
+ canUserJoinCallResult = { Result.success(true) },
+ ).apply {
+ givenRoomInfo(
+ aRoomInfo(
+ hasRoomCall = true,
+ activeRoomCallParticipants = listOf(sessionId),
+ )
+ )
+ }
+ val currentCall = MutableStateFlow(CurrentCall.RoomCall(room.roomId))
+ val currentCallService = FakeCurrentCallService(currentCall = currentCall)
+ val presenter = createRoomCallStatePresenter(
+ matrixRoom = room,
+ currentCallService = currentCallService
+ )
+ presenter.test {
+ skipItems(1)
+ assertThat(awaitItem()).isEqualTo(
+ RoomCallState.OnGoing(
+ canJoinCall = true,
+ isUserInTheCall = true,
+ isUserLocallyInTheCall = true,
+ )
+ )
+ currentCall.value = CurrentCall.None
+ assertThat(awaitItem()).isEqualTo(
+ RoomCallState.OnGoing(
+ canJoinCall = true,
+ isUserInTheCall = true,
+ isUserLocallyInTheCall = false,
+ )
+ )
+ room.givenRoomInfo(
+ aRoomInfo(
+ hasRoomCall = true,
+ activeRoomCallParticipants = emptyList(),
+ )
+ )
+ assertThat(awaitItem()).isEqualTo(
+ RoomCallState.OnGoing(
+ canJoinCall = true,
+ isUserInTheCall = false,
+ isUserLocallyInTheCall = false,
+ )
+ )
+ room.givenRoomInfo(
+ aRoomInfo(
+ hasRoomCall = false,
+ activeRoomCallParticipants = emptyList(),
+ )
+ )
+ assertThat(awaitItem()).isEqualTo(
+ RoomCallState.StandBy(
+ canStartCall = true,
+ )
+ )
+ }
+ }
+
+ private fun createRoomCallStatePresenter(
+ matrixRoom: MatrixRoom,
+ currentCallService: CurrentCallService = FakeCurrentCallService(),
+ ): RoomCallStatePresenter {
+ return RoomCallStatePresenter(
+ room = matrixRoom,
+ currentCallService = currentCallService,
+ )
+ }
+}
diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts
index 42f27963f3..231161e583 100644
--- a/features/roomdetails/impl/build.gradle.kts
+++ b/features/roomdetails/impl/build.gradle.kts
@@ -49,6 +49,7 @@ dependencies {
implementation(projects.services.analytics.compose)
implementation(projects.features.poll.api)
implementation(projects.features.messages.api)
+ implementation(projects.features.roomcall.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index eccc8dd9d2..586790b618 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -21,6 +21,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
+import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
@@ -37,7 +38,6 @@ import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
-import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
@@ -57,6 +57,7 @@ class RoomDetailsPresenter @Inject constructor(
private val notificationSettingsService: NotificationSettingsService,
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
private val leaveRoomPresenter: Presenter,
+ private val roomCallStatePresenter: Presenter,
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
@@ -87,18 +88,16 @@ class RoomDetailsPresenter @Inject constructor(
}
}
- val syncUpdateTimestamp by room.syncUpdateFlow.collectAsState()
-
val membersState by room.membersStateFlow.collectAsState()
val canInvite by getCanInvite(membersState)
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
- val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
val dmMember by room.getDirectRoomMember(membersState)
val currentMember by room.getCurrentRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember, currentMember)
+ val roomCallState = roomCallStatePresenter.present()
val topicState = remember(canEditTopic, roomTopic, roomType) {
val topic = roomTopic
@@ -143,7 +142,7 @@ class RoomDetailsPresenter @Inject constructor(
canInvite = canInvite,
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
canShowNotificationSettings = canShowNotificationSettings.value,
- canCall = canJoinCall,
+ roomCallState = roomCallState,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
index 2208748563..d43b0a813a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
@@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState
+import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
@@ -31,7 +32,7 @@ data class RoomDetailsState(
val canEdit: Boolean,
val canInvite: Boolean,
val canShowNotificationSettings: Boolean,
- val canCall: Boolean,
+ val roomCallState: RoomCallState,
val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?,
val isFavorite: Boolean,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
index 7e71d2b39f..49b9f73cb5 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
@@ -10,6 +10,8 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
+import io.element.android.features.roomcall.api.RoomCallState
+import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.aUserProfileState
@@ -42,7 +44,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
),
- aRoomDetailsState(canCall = false, canInvite = false),
+ aRoomDetailsState(roomCallState = aStandByCallState(false), canInvite = false),
aRoomDetailsState(isPublic = false),
aRoomDetailsState(heroes = aMatrixUserList()),
aRoomDetailsState(pinnedMessagesCount = 3),
@@ -89,7 +91,7 @@ fun aRoomDetailsState(
canInvite: Boolean = false,
canEdit: Boolean = false,
canShowNotificationSettings: Boolean = true,
- canCall: Boolean = true,
+ roomCallState: RoomCallState = aStandByCallState(),
roomType: RoomDetailsType = RoomDetailsType.Room,
roomMemberDetailsState: UserProfileState? = null,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
@@ -112,7 +114,7 @@ fun aRoomDetailsState(
canInvite = canInvite,
canEdit = canEdit,
canShowNotificationSettings = canShowNotificationSettings,
- canCall = canCall,
+ roomCallState = roomCallState,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
index 163a2c5fd5..7e89c3ef07 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
@@ -42,6 +42,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView
+import io.element.android.features.roomcall.api.hasPermissionToJoin
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
@@ -299,7 +300,8 @@ private fun MainActionsSection(
)
}
}
- if (state.canCall) {
+ if (state.roomCallState.hasPermissionToJoin()) {
+ // TODO Improve the view depending on all the cases here?
MainActionButton(
title = stringResource(CommonStrings.action_call),
imageVector = CompoundIcons.VideoCall(),
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt
index 8ad7eed8f4..df220f0d50 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt
@@ -20,6 +20,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
+import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -45,6 +46,7 @@ class RoomDetailsEditPresenter @Inject constructor(
private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
+ private val temporaryUriDeleter: TemporaryUriDeleter,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@@ -59,6 +61,7 @@ class RoomDetailsEditPresenter @Inject constructor(
var roomAvatarUriEdited by rememberSaveable { mutableStateOf(null) }
LaunchedEffect(roomAvatarUri) {
// Every time the roomAvatar change (from sync), we can set the new avatar.
+ temporaryUriDeleter.delete(roomAvatarUriEdited)
roomAvatarUriEdited = roomAvatarUri
}
@@ -98,10 +101,20 @@ class RoomDetailsEditPresenter @Inject constructor(
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
- onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
+ onResult = { uri ->
+ if (uri != null) {
+ temporaryUriDeleter.delete(roomAvatarUriEdited)
+ roomAvatarUriEdited = uri
+ }
+ }
)
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
- onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
+ onResult = { uri ->
+ if (uri != null) {
+ temporaryUriDeleter.delete(roomAvatarUriEdited)
+ roomAvatarUriEdited = uri
+ }
+ }
)
LaunchedEffect(cameraPermissionState.permissionGranted) {
@@ -143,7 +156,10 @@ class RoomDetailsEditPresenter @Inject constructor(
pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
- AvatarAction.Remove -> roomAvatarUriEdited = null
+ AvatarAction.Remove -> {
+ temporaryUriDeleter.delete(roomAvatarUriEdited)
+ roomAvatarUriEdited = null
+ }
}
}
@@ -202,7 +218,12 @@ class RoomDetailsEditPresenter @Inject constructor(
private suspend fun updateAvatar(avatarUri: Uri?): Result {
return runCatching {
if (avatarUri != null) {
- val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
+ val preprocessed = mediaPreProcessor.process(
+ uri = avatarUri,
+ mimeType = MimeTypes.Jpeg,
+ deleteOriginal = false,
+ compressIfPossible = false,
+ ).getOrThrow()
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
} else {
room.removeAvatar().getOrThrow()
diff --git a/features/roomdetails/impl/src/main/res/values-hu/translations.xml b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
index bacbf66536..8f992b1c7d 100644
--- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
@@ -35,8 +35,8 @@
"Téma hozzáadása"
"Már tag"
"Már meghívták"
- "Titkosítva"
- "Nincs titkosítva"
+ "Titkosított"
+ "Nem titkosított"
"Nyilvános szoba"
"Szoba szerkesztése"
"Ismeretlen hiba történt, és az információkat nem lehetett megváltoztatni."
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
index b1352cd7b6..b1edabbbff 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt
@@ -8,15 +8,14 @@
package io.element.android.features.roomdetails.edit
import android.net.Uri
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter
+import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@@ -30,9 +29,11 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -45,6 +46,7 @@ import org.junit.Rule
import org.junit.Test
import java.io.File
+@Suppress("LargeClass")
@ExperimentalCoroutinesApi
class RoomDetailsEditPresenterTest {
@get:Rule
@@ -76,12 +78,14 @@ class RoomDetailsEditPresenterTest {
private fun createRoomDetailsEditPresenter(
room: MatrixRoom,
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
+ temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
): RoomDetailsEditPresenter {
return RoomDetailsEditPresenter(
room = room,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
+ temporaryUriDeleter = temporaryUriDeleter,
)
}
@@ -94,10 +98,12 @@ class RoomDetailsEditPresenterTest {
emitRoomInfo = true,
canSendStateResult = { _, _ -> Result.success(true) }
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.roomId).isEqualTo(room.roomId)
assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME)
@@ -126,10 +132,12 @@ class RoomDetailsEditPresenterTest {
}
},
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
// Initially false
val initialState = awaitItem()
assertThat(initialState.canChangeName).isFalse()
@@ -140,6 +148,7 @@ class RoomDetailsEditPresenterTest {
assertThat(settledState.canChangeName).isTrue()
assertThat(settledState.canChangeAvatar).isFalse()
assertThat(settledState.canChangeTopic).isFalse()
+ deleteCallback.assertions().isCalledOnce().with(value(null))
}
}
@@ -156,10 +165,12 @@ class RoomDetailsEditPresenterTest {
}
}
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
// Initially false
val initialState = awaitItem()
assertThat(initialState.canChangeName).isFalse()
@@ -186,10 +197,12 @@ class RoomDetailsEditPresenterTest {
}
}
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
// Initially false
val initialState = awaitItem()
assertThat(initialState.canChangeName).isFalse()
@@ -212,10 +225,12 @@ class RoomDetailsEditPresenterTest {
emitRoomInfo = true,
canSendStateResult = { _, _ -> Result.success(true) }
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.roomTopic).isEqualTo("My topic")
assertThat(initialState.roomRawName).isEqualTo("Name")
@@ -257,10 +272,12 @@ class RoomDetailsEditPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(anotherAvatarUri)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
@@ -281,13 +298,13 @@ class RoomDetailsEditPresenterTest {
)
fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter()
+ val deleteCallback = lambdaRecorder {}
val presenter = createRoomDetailsEditPresenter(
room = room,
permissionsPresenter = fakePermissionsPresenter,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
@@ -304,6 +321,12 @@ class RoomDetailsEditPresenterTest {
stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithNewAvatar2 = awaitItem()
assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(roomAvatarUri)
+ deleteCallback.assertions().isCalledExactly(4).withSequence(
+ listOf(value(null)),
+ listOf(value(null)),
+ listOf(value(roomAvatarUri)),
+ listOf(value(anotherAvatarUri)),
+ )
}
}
@@ -317,10 +340,12 @@ class RoomDetailsEditPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(roomAvatarUri)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
@@ -366,10 +391,12 @@ class RoomDetailsEditPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(roomAvatarUri)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
@@ -420,10 +447,12 @@ class RoomDetailsEditPresenterTest {
removeAvatarResult = removeAvatarResult,
canSendStateResult = { _, _ -> Result.success(true) }
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name"))
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
@@ -444,10 +473,12 @@ class RoomDetailsEditPresenterTest {
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
@@ -464,14 +495,17 @@ class RoomDetailsEditPresenterTest {
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
initialState.eventSink(RoomDetailsEditEvents.Save)
cancelAndIgnoreRemainingEvents()
+ deleteCallback.assertions().isCalledOnce().with(value(null))
}
}
@@ -483,14 +517,17 @@ class RoomDetailsEditPresenterTest {
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
initialState.eventSink(RoomDetailsEditEvents.Save)
cancelAndIgnoreRemainingEvents()
+ deleteCallback.assertions().isCalledOnce().with(value(null))
}
}
@@ -505,15 +542,21 @@ class RoomDetailsEditPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) }
)
givenPickerReturnsFile()
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(4)
- updateAvatarResult.assertions().isCalledOnce().with(value("image/jpeg"), value(fakeFileContents))
+ updateAvatarResult.assertions().isCalledOnce().with(value(MimeTypes.Jpeg), value(fakeFileContents))
+ deleteCallback.assertions().isCalledExactly(2).withSequence(
+ listOf(value(null)),
+ listOf(value(null)),
+ )
}
}
@@ -527,10 +570,12 @@ class RoomDetailsEditPresenterTest {
)
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save)
@@ -575,7 +620,7 @@ class RoomDetailsEditPresenterTest {
removeAvatarResult = { Result.failure(Throwable("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
)
- saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
+ saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 3)
}
@Test
@@ -589,7 +634,7 @@ class RoomDetailsEditPresenterTest {
updateAvatarResult = { _, _ -> Result.failure(Throwable("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
)
- saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 3)
}
@Test
@@ -602,10 +647,12 @@ class RoomDetailsEditPresenterTest {
setTopicResult = { Result.failure(Throwable("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
)
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
initialState.eventSink(RoomDetailsEditEvents.Save)
@@ -616,17 +663,24 @@ class RoomDetailsEditPresenterTest {
}
}
- private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) {
- val presenter = createRoomDetailsEditPresenter(room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ private suspend fun saveAndAssertFailure(
+ room: MatrixRoom,
+ event: RoomDetailsEditEvents,
+ deleteCallbackNumberOfInvocation: Int = 2,
+ ) {
+ val deleteCallback = lambdaRecorder {}
+ val presenter = createRoomDetailsEditPresenter(
+ room = room,
+ temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
+ )
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(event)
initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(1)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
+ deleteCallback.assertions().isCalledExactly(deleteCallbackNumberOfInvocation)
}
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
index ea83f89181..b41090b661 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
@@ -17,6 +17,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
+import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
@@ -98,6 +99,7 @@ class RoomDetailsPresenterTest {
notificationSettingsService = matrixClient.notificationSettingsService(),
roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory,
leaveRoomPresenter = { leaveRoomState },
+ roomCallStatePresenter = { aStandByCallState() },
dispatchers = dispatchers,
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
analyticsService = analyticsService,
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
index 2f3b0cff75..a5b62ff1e3 100644
--- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
@@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
+private const val SEARCH_BATCH_SIZE = 20
+
class RoomDirectoryPresenter @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val roomDirectoryService: RoomDirectoryService,
@@ -51,7 +53,7 @@ class RoomDirectoryPresenter @Inject constructor(
loadingMore = false
// debounce search query
delay(300)
- roomDirectoryList.filter(searchQuery, 20)
+ roomDirectoryList.filter(filter = searchQuery, batchSize = SEARCH_BATCH_SIZE, viaServerName = null)
}
LaunchedEffect(loadingMore) {
if (loadingMore) {
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
index 10dda624bd..20a709bce7 100644
--- a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
@@ -81,7 +81,7 @@ import org.junit.Test
@Test
fun `present - emit search event`() = runTest {
- val filterLambda = lambdaRecorder { _: String?, _: Int ->
+ val filterLambda = lambdaRecorder { _: String?, _: Int, _: String? ->
Result.success(Unit)
}
val roomDirectoryList = FakeRoomDirectoryList(filterLambda = filterLambda)
@@ -99,7 +99,7 @@ import org.junit.Test
}
assert(filterLambda)
.isCalledOnce()
- .with(value("test"), any())
+ .with(value("test"), any(), value(null))
}
@Test
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/SetUpRecoveryKeyBanner.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/SetUpRecoveryKeyBanner.kt
index 75fd6bda9b..971edaf540 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/SetUpRecoveryKeyBanner.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/SetUpRecoveryKeyBanner.kt
@@ -25,6 +25,7 @@ internal fun SetUpRecoveryKeyBanner(
modifier = modifier,
title = stringResource(R.string.banner_set_up_recovery_title),
content = stringResource(R.string.banner_set_up_recovery_content),
+ actionText = stringResource(R.string.banner_set_up_recovery_submit),
onSubmitClick = onContinueClick,
onDismissClick = onDismissClick,
)
diff --git a/features/roomlist/impl/src/main/res/values-be/translations.xml b/features/roomlist/impl/src/main/res/values-be/translations.xml
index b5abcfc70e..a6201922ff 100644
--- a/features/roomlist/impl/src/main/res/values-be/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-be/translations.xml
@@ -5,6 +5,7 @@
"Ваш хатні сервер больш не падтрымлівае стары пратакол. Калі ласка, выйдзіце і ўвайдзіце зноў, каб працягнуць выкарыстанне праграмы."
"Даступна абнаўленне"
"Стварыце новы ключ аднаўлення, які можна выкарыстоўваць для аднаўлення зашыфраванай гісторыі паведамленняў у выпадку страты доступу да вашых прылад."
+ "Наладзьце аднаўленне"
"Наладзіць аднаўленне"
"Ваша рэзервовая копія чата зараз не сінхранізавана. Вам трэба пацвердзіць ключ аднаўлення, каб захаваць доступ да рэзервовай копіі чата."
"Увядзіце ключ аднаўлення"
diff --git a/features/roomlist/impl/src/main/res/values-cs/translations.xml b/features/roomlist/impl/src/main/res/values-cs/translations.xml
index 18fe226b8f..c26aeabc68 100644
--- a/features/roomlist/impl/src/main/res/values-cs/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-cs/translations.xml
@@ -5,6 +5,7 @@
"Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste."
"Upgrade k dispozici"
"Vygenerujte nový klíč pro obnovení, který lze použít k obnovení historie šifrovaných zpráv v případě, že ztratíte přístup ke svým zařízením."
+ "Nastavení obnovy"
"Nastavení obnovy"
"Vaše záloha chatu není aktuálně synchronizována. Abyste si zachovali přístup k záloze chatu, musíte potvrdit klíč pro obnovení."
"Potvrďte klíč pro obnovení"
diff --git a/features/roomlist/impl/src/main/res/values-de/translations.xml b/features/roomlist/impl/src/main/res/values-de/translations.xml
index ccb20fc3e8..8671c381b8 100644
--- a/features/roomlist/impl/src/main/res/values-de/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-de/translations.xml
@@ -5,6 +5,7 @@
"Dein Homeserver unterstützt das alte Protokoll nicht mehr. Bitte logge dich aus und melde dich wieder an, um die App weiter zu nutzen."
"Aktualisierung verfügbar"
"Erstelle einen neuen Wiederherstellungsschlüssel, mit dem du deinen verschlüsselten Nachrichtenverlauf wiederherstellen kannst, wenn du dich an einem neuen Gerät anmeldest."
+ "Wiederherstellung einrichten"
"Wiederherstellung einrichten"
"Dein Chat-Backup ist derzeit nicht synchronisiert. Du musst deinen Wiederherstellungsschlüssel bestätigen, um Zugriff auf dein Chat-Backup zu erhalten."
"Wiederherstellungsschlüssel bestätigen."
diff --git a/features/roomlist/impl/src/main/res/values-el/translations.xml b/features/roomlist/impl/src/main/res/values-el/translations.xml
index 9723550412..151d995f4f 100644
--- a/features/roomlist/impl/src/main/res/values-el/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-el/translations.xml
@@ -5,6 +5,7 @@
"Ο οικιακός διακομιστής σου δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για να συνεχίσεις να χρησιμοποιείς την εφαρμογή."
"Διαθέσιμη αναβάθμιση"
"Δημιούργησε ένα νέο κλειδί ανάκτησης που μπορεί να χρησιμοποιηθεί για την επαναφορά του ιστορικού των κρυπτογραφημένων μηνυμάτων σου σε περίπτωση που χάσεις την πρόσβαση στις συσκευές σου."
+ "Ρύθμιση ανάκτησης"
"Ρύθμιση ανάκτησης"
"Το αντίγραφο ασφαλείας της συνομιλίας σου δεν είναι συγχρονισμένο αυτήν τη στιγμή. Πρέπει να εισαγάγεις το κλειδί ανάκτησης για να διατηρήσεις την πρόσβαση στο αντίγραφο ασφαλείας της συνομιλίας σου."
"Εισήγαγε το κλειδί ανάκτησης"
diff --git a/features/roomlist/impl/src/main/res/values-es/translations.xml b/features/roomlist/impl/src/main/res/values-es/translations.xml
index 0c3e47822d..25f8e0ba21 100644
--- a/features/roomlist/impl/src/main/res/values-es/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-es/translations.xml
@@ -1,5 +1,6 @@
+ "Configurar la recuperación"
"La copia de seguridad del chat no está sincronizada en este momento. Debes confirmar tu clave de recuperación para mantener el acceso a la copia de seguridad del chat."
"Confirma tu clave de recuperación"
"¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?"
diff --git a/features/roomlist/impl/src/main/res/values-et/translations.xml b/features/roomlist/impl/src/main/res/values-et/translations.xml
index 214c7f2493..2eb1f63832 100644
--- a/features/roomlist/impl/src/main/res/values-et/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-et/translations.xml
@@ -5,9 +5,12 @@
"Sinu koduserver enam ei toeta vana protokolli. Jätkamaks rakenduse kasutamist palun logi välja ning seejärel tagasi."
"Saadaval on uuendus"
"Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele."
+ "Seadista andmete taastamine"
"Seadista taastamine"
- "Sinu vestluste varukoopia pole hetkel sünkroonis. Säilitamaks ligipääsu vestluse varukoopiale palun sisesta oma taastevõti."
- "Sisesta oma taastevõti"
+ "Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti."
+ "Sisesta oma taastevõti"
+ "Kas unustasid oma taastevõtme?"
+ "Sinu võtmehoidla pole sünkroonis"
"Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused."
"Sinu tõhusad telefonikõned"
"Kas sa oled kindel, et soovid keelduda liitumiskutsest: %1$s?"
diff --git a/features/roomlist/impl/src/main/res/values-fa/translations.xml b/features/roomlist/impl/src/main/res/values-fa/translations.xml
index be6024f012..f99eb289a6 100644
--- a/features/roomlist/impl/src/main/res/values-fa/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-fa/translations.xml
@@ -2,6 +2,7 @@
"خروج و ارتقا"
"ارتقا موجود است"
+ "برپایی بازیابی"
"برپایی بازیابی"
"ورود کلید بازیابیتان"
"بهبود تجریهٔ تماستان"
diff --git a/features/roomlist/impl/src/main/res/values-fr/translations.xml b/features/roomlist/impl/src/main/res/values-fr/translations.xml
index 1fe6c26004..91b0d9621d 100644
--- a/features/roomlist/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-fr/translations.xml
@@ -5,6 +5,7 @@
"Votre serveur d’accueil ne prend plus en charge l’ancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser l’application."
"Mise à niveau disponible"
"Générez une nouvelle clé de récupération qui peut être utilisée pour restaurer l’historique de vos messages chiffrés au cas où vous perdriez l’accès à vos appareils."
+ "Configurer la sauvegarde"
"Configurer la récupération"
"La sauvegarde des conversations est désynchronisée. Vous devez confirmer la clé de récupération pour accéder à votre historique."
"Confirmer votre clé de récupération"
diff --git a/features/roomlist/impl/src/main/res/values-hu/translations.xml b/features/roomlist/impl/src/main/res/values-hu/translations.xml
index a5aded8b5b..1505f13367 100644
--- a/features/roomlist/impl/src/main/res/values-hu/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-hu/translations.xml
@@ -5,11 +5,14 @@
"A Matrix-kiszolgáló már nem támogatja a régi protokollt. Az alkalmazás további használatához jelentkezzen ki és be."
"Frissítés érhető el"
"Hozzon létre egy új helyreállítási kulcsot, amellyel visszaállíthatja a titkosított üzenetek előzményeit, ha elveszíti az eszközökhöz való hozzáférést."
+ "Helyreállítás beállítása"
"Helyreállítás beállítása"
- "A csevegés biztonsági mentése nincs szinkronban. Meg kell erősítenie a helyreállítási kulcsát, hogy továbbra is hozzáférjen a csevegés biztonsági mentéséhez."
- "Helyreállítási kulcs megerősítése"
- "Annak érdekében, hogy soha ne maradjon le egyetlen fontos hívásról sem, módosítsa a beállításokat, hogy engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."
- "Növelje a hívásélményét"
+ "Erősítse meg a helyreállítási kulcsát, hogy továbbra is hozzáférjen a kulcstárolójához és az üzenetelőzményekhez."
+ "Adja meg a helyreállítási kulcsot"
+ "Elfelejtette a helyreállítási kulcsot?"
+ "A kulcstároló nincs szinkronizálva"
+ "Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."
+ "Fokozza a hívásélményét"
"Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"
"Meghívás elutasítása"
"Biztos, hogy elutasítja ezt a privát csevegést vele: %1$s?"
diff --git a/features/roomlist/impl/src/main/res/values-in/translations.xml b/features/roomlist/impl/src/main/res/values-in/translations.xml
index d284e83091..e3c3bb4800 100644
--- a/features/roomlist/impl/src/main/res/values-in/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-in/translations.xml
@@ -5,9 +5,12 @@
"Homeserver Anda tidak lagi mendukung protokol lama. Silakan keluar dan masuk kembali untuk terus menggunakan aplikasi."
"Peningkatan tersedia"
"Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda."
+ "Siapkan pemulihan"
"Siapkan pemulihan"
- "Cadangan percakapan Anda saat ini tidak tersinkron. Anda perlu mengonfirmasi kunci pemulihan Anda untuk tetap memiliki akses ke cadangan percakapan Anda."
- "Konfirmasi kunci pemulihan Anda"
+ "Konfirmasikan kunci pemulihan Anda untuk mempertahankan akses ke penyimpanan kunci dan riwayat pesan Anda."
+ "Masukkan kunci pemulihan Anda"
+ "Lupa kunci pemulihan Anda?"
+ "Penyimpanan kunci Anda tidak sinkron"
"Untuk memastikan Anda tidak melewatkan panggilan penting, silakan ubah pengaturan Anda untuk memperbolehkan notifikasi layar penuh ketika ponsel Anda terkunci."
"Tingkatkan pengalaman panggilan Anda"
"Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?"
@@ -16,6 +19,7 @@
"Tolak obrolan"
"Tidak ada undangan"
"%1$s (%2$s) mengundang Anda"
+ "Permintaan untuk bergabung dikirim"
"Ini adalah proses satu kali, terima kasih telah menunggu."
"Menyiapkan akun Anda."
"Buat percakapan atau ruangan baru"
diff --git a/features/roomlist/impl/src/main/res/values-it/translations.xml b/features/roomlist/impl/src/main/res/values-it/translations.xml
index 16fffb77c4..af305d4b7d 100644
--- a/features/roomlist/impl/src/main/res/values-it/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-it/translations.xml
@@ -5,6 +5,7 @@
"Il tuo homeserver non supporta più il vecchio protocollo. Esci e rientra per continuare a usare l\'app."
"Aggiornamento disponibile"
"Genera una nuova chiave di recupero che può essere usata per ripristinare la cronologia dei messaggi crittografati nel caso in cui tu perda l\'accesso ai tuoi dispositivi."
+ "Configura il recupero"
"Configura il ripristino"
"Il backup della chat non è attualmente sincronizzato. Devi confermare la chiave di recupero per mantenere l\'accesso al backup della chat."
"Inserisci la chiave di recupero"
diff --git a/features/roomlist/impl/src/main/res/values-ka/translations.xml b/features/roomlist/impl/src/main/res/values-ka/translations.xml
index dac714cd0a..0086aeb6e4 100644
--- a/features/roomlist/impl/src/main/res/values-ka/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-ka/translations.xml
@@ -1,5 +1,6 @@
+ "აღდგენის დაყენება"
"თქვენი ჩეთების სარეზერვო ასლი ამჟამად არ არის სინქრონიზებული. თქვენ უნდა შეიყვანოთ თქვენი აღდგენის გასაღები, რათა შეინარჩუნოთ წვდომა ჩეთების სარეზერვო ასლზე."
"შეიყვანეთ აღდგენის გასაღები"
"დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?"
diff --git a/features/roomlist/impl/src/main/res/values-nl/translations.xml b/features/roomlist/impl/src/main/res/values-nl/translations.xml
index 129c2579ee..8b3c48f88b 100644
--- a/features/roomlist/impl/src/main/res/values-nl/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-nl/translations.xml
@@ -1,5 +1,10 @@
+ "Uitloggen & Upgraden"
+ "Je server ondersteunt nu een nieuw, sneller protocol. Log uit en log opnieuw in om nu te upgraden. Als je dit nu doet, voorkom je dat je geforceerd uitlogt wordt wanneer het oude protocol later wordt verwijderd."
+ "Je homeserver ondersteunt het oude protocol niet meer. Log uit en log opnieuw in om de app te blijven gebruiken."
+ "Upgrade beschikbaar"
+ "Herstelmogelijkheid instellen"
"Je chatback-up is momenteel niet gesynchroniseerd. Je moet je herstelsleutel invoeren om toegang te behouden tot je chatback-up."
"Voer je herstelsleutel in"
"Verbeter je gesprekservaring"
@@ -9,6 +14,7 @@
"Chat weigeren"
"Geen uitnodigingen"
"%1$s (%2$s) heeft je uitgenodigd"
+ "Verzoek om toe te treden verzonden"
"Dit is een eenmalig proces, bedankt voor het wachten."
"Je account instellen."
"Begin een nieuw gesprek of maak een nieuwe kamer"
diff --git a/features/roomlist/impl/src/main/res/values-pl/translations.xml b/features/roomlist/impl/src/main/res/values-pl/translations.xml
index d3c87eb81f..9323ddb96f 100644
--- a/features/roomlist/impl/src/main/res/values-pl/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-pl/translations.xml
@@ -5,6 +5,7 @@
"Twój serwer domowy już nie wspiera starego protokołu. Zaloguj się ponownie, aby kontynuować korzystanie z aplikacji."
"Dostępna aktualizacja"
"Wygeneruj nowy klucz przywracania, którego można użyć do przywrócenia historii wiadomości szyfrowanych w przypadku utraty dostępu do swoich urządzeń."
+ "Skonfiguruj przywracanie"
"Skonfiguruj przywracanie"
"Twoja kopia zapasowa czatu jest obecnie niezsynchronizowana. Aby zachować dostęp do kopii zapasowej czatu, musisz potwierdzić klucz odzyskiwania."
"Wprowadź swój klucz przywracania"
@@ -16,6 +17,7 @@
"Odrzuć czat"
"Brak zaproszeń"
"%1$s (%2$s) zaprosił Cię"
+ "Wysłano prośbę o dołączenie"
"Jest to jednorazowy proces, dziękujemy za czekanie."
"Konfigurowanie Twojego konta."
"Utwórz nową rozmowę lub pokój"
diff --git a/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml
index 5f896a6427..6b1efae98c 100644
--- a/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml
@@ -1,5 +1,6 @@
+ "Configurar a recuperação"
"Insira sua chave de recuperação"
"Tem certeza de que deseja recusar o convite para ingressar em %1$s?"
"Recusar convite"
diff --git a/features/roomlist/impl/src/main/res/values-pt/translations.xml b/features/roomlist/impl/src/main/res/values-pt/translations.xml
index d38b92f076..e0ddffa056 100644
--- a/features/roomlist/impl/src/main/res/values-pt/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-pt/translations.xml
@@ -5,9 +5,12 @@
"Seu homeserver não suporta mais o protocolo antigo. Termine sessão e volte a iniciar sessão para continuar a utilizar a aplicação."
"Atualização disponível"
"Gere uma nova chave de recuperação que pode ser usada para restaurar seu histórico de mensagens criptografadas caso você perca o acesso aos seus dispositivos."
+ "Configurar recuperação"
"Configurar a recuperação"
- "A tua cópia de segurança das conversas está atualmente dessincronizada. Tens de inserir a tua chave de recuperação para manteres o acesso à cópia."
- "Insere a tua chave de recuperação"
+ "Confirma a tua chave de recuperação para manteres o acesso ao teu armazenamento de chaves e ao histórico de mensagens."
+ "Introduz a tua chave de recuperação"
+ "Esqueceste-te da tua chave de recuperação?"
+ "O teu armazenamento de chaves não está sincronizado"
"Para garantir que nunca perdes uma chamada importante, altera as configurações para permitir notificações em ecrã inteiro quando o telemóvel está bloqueado."
"Melhora a tua experiência de chamada"
"Tens a certeza que queres rejeitar o convite para entra em %1$s?"
diff --git a/features/roomlist/impl/src/main/res/values-ro/translations.xml b/features/roomlist/impl/src/main/res/values-ro/translations.xml
index 3e219883ad..93ff52b7e9 100644
--- a/features/roomlist/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-ro/translations.xml
@@ -1,5 +1,6 @@
+ "Configurați recuperarea"
"Backup-ul pentru chat nu este sincronizat în prezent. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."
"Confirmați cheia de recuperare"
"Pentru a vă asigura că nu pierdeți niciodată un apel important, vă rugăm să modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat."
diff --git a/features/roomlist/impl/src/main/res/values-ru/translations.xml b/features/roomlist/impl/src/main/res/values-ru/translations.xml
index 2b0bd5ea40..9c2e902afc 100644
--- a/features/roomlist/impl/src/main/res/values-ru/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-ru/translations.xml
@@ -5,9 +5,12 @@
"Ваш домашний сервер больше не поддерживает старый протокол. Пожалуйста, выйдите и войдите в свою учётную запись снова, чтобы продолжить использование приложения."
"Доступно обновление"
"Создайте новый ключ восстановления, который можно использовать для восстановления зашифрованной истории сообщений в случае потери доступа к своим устройствам."
- "Настроить восстановление"
- "В настоящее время резервная копия ваших чатов не синхронизирована. Вам потребуется ввести свой ключ восстановления, чтобы сохранить доступ к резервной копии чатов."
- "Введите ключ восстановления"
+ "Настроить восстановление"
+ "Для защиты вашего аккаунта рекомендуется настроить восстановление"
+ "Подтвердите ключ восстановления, чтобы сохранить доступ к хранилищу ключей и истории сообщений."
+ "Введите ключ восстановления"
+ "Забыли ключ восстановления?"
+ "Хранилище ключей не синхронизировано"
"Чтобы больше не пропускать важные звонки, разрешите приложению показывать полноэкранные уведомления на заблокированном экране телефона."
"Улучшите качество звонков"
"Вы уверены, что хотите отклонить приглашение в %1$s?"
diff --git a/features/roomlist/impl/src/main/res/values-sk/translations.xml b/features/roomlist/impl/src/main/res/values-sk/translations.xml
index 4646ff3896..4c5079a51b 100644
--- a/features/roomlist/impl/src/main/res/values-sk/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-sk/translations.xml
@@ -5,6 +5,7 @@
"Váš domovský server už nepodporuje starý protokol. Ak chcete pokračovať v používaní aplikácie, odhláste sa a znova sa prihláste."
"Aktualizácia je k dispozícii"
"Vytvorte nový kľúč na obnovenie, ktorý môžete použiť na obnovenie vašej histórie šifrovaných správ v prípade straty prístupu k vašim zariadeniam."
+ "Nastaviť obnovenie"
"Nastaviť obnovenie"
"Vaša záloha konverzácie nie je momentálne synchronizovaná. Na zachovanie prístupu k zálohe konverzácie musíte potvrdiť svoj kľúč na obnovu."
"Potvrďte svoj kľúč na obnovenie"
diff --git a/features/roomlist/impl/src/main/res/values-sv/translations.xml b/features/roomlist/impl/src/main/res/values-sv/translations.xml
index 2471c26a91..e614c42764 100644
--- a/features/roomlist/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-sv/translations.xml
@@ -3,6 +3,7 @@
"Din server stöder nu ett nytt, snabbare protokoll. Logga ut och logga in igen för att uppgradera nu. Om du gör detta nu hjälper du dig att undvika en tvingad utloggning när det gamla protokollet tas bort senare."
"Uppgradering tillgänglig"
"Skapa en ny återställningsnyckel som kan användas för att återställa din krypterade meddelandehistorik om du förlorar åtkomst till dina enheter."
+ "Ställ in återställning"
"Ställ in återställning"
"Din chattsäkerhetskopia är för närvarande inte synkroniserad. Du måste ange din återställningsnyckel för att behålla åtkomsten till din chattsäkerhetskopia."
"Ange din återställningsnyckel"
diff --git a/features/roomlist/impl/src/main/res/values-uk/translations.xml b/features/roomlist/impl/src/main/res/values-uk/translations.xml
index 8b33aee7e4..91564da550 100644
--- a/features/roomlist/impl/src/main/res/values-uk/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-uk/translations.xml
@@ -1,5 +1,6 @@
+ "Налаштувати відновлення"
"Ваша резервна копія чату наразі не синхронізована. Вам потрібно підтвердити ключ відновлення, щоб зберегти доступ до резервної копії чату."
"Підтвердіть ключ відновлення"
"Щоб ніколи не пропустити важливий дзвінок, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."
diff --git a/features/roomlist/impl/src/main/res/values-uz/translations.xml b/features/roomlist/impl/src/main/res/values-uz/translations.xml
index 7ca2ae798a..0fb8858304 100644
--- a/features/roomlist/impl/src/main/res/values-uz/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-uz/translations.xml
@@ -1,5 +1,6 @@
+ "Qayta tiklashni sozlang"
"Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?"
"Taklifni rad etish"
"Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?"
diff --git a/features/roomlist/impl/src/main/res/values-zh/translations.xml b/features/roomlist/impl/src/main/res/values-zh/translations.xml
index 3e49dc899a..f578323790 100644
--- a/features/roomlist/impl/src/main/res/values-zh/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-zh/translations.xml
@@ -5,6 +5,7 @@
"您的服务器不再支持旧协议。请登出并重新登录以继续使用此应用。"
"有可用升级"
"生成新的恢复密钥,该密钥可用于在您无法访问设备时恢复加密的消息历史记录。"
+ "设置恢复"
"设置恢复"
"聊天备份目前不同步,需要输入恢复密钥才能访问聊天备份。"
"输入恢复密钥"
diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml
index 79f9b493e6..e10d8da908 100644
--- a/features/roomlist/impl/src/main/res/values/localazy.xml
+++ b/features/roomlist/impl/src/main/res/values/localazy.xml
@@ -4,10 +4,13 @@
"Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."
"Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app."
"Upgrade available"
- "Generate a new recovery key that can be used to restore your encrypted message history in case you lose access to your devices."
- "Set up recovery"
- "Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."
- "Enter your recovery key"
+ "Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices."
+ "Set up recovery"
+ "Set up recovery to protect your account"
+ "Confirm your recovery key to maintain access to your key storage and message history."
+ "Enter your recovery key"
+ "Forgot your recovery key?"
+ "Your key storage is out of sync"
"To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked."
"Enhance your call experience"
"Are you sure you want to decline the invitation to join %1$s?"
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
index cb7ef2852a..69e9a7d401 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt
@@ -136,7 +136,7 @@ class RoomListPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue()
- sessionVerificationService.givenNeedsSessionVerification(false)
+ sessionVerificationService.emitNeedsSessionVerification(false)
encryptionService.emitBackupState(BackupState.ENABLED)
val finalState = awaitItem()
assertThat(finalState.showAvatarIndicator).isFalse()
@@ -231,7 +231,7 @@ class RoomListPresenterTest {
roomListService = roomListService,
encryptionService = encryptionService,
sessionVerificationService = FakeSessionVerificationService().apply {
- givenNeedsSessionVerification(false)
+ emitNeedsSessionVerification(false)
},
syncService = FakeSyncService(MutableStateFlow(SyncState.Running)),
)
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
index 63327d0d49..66821ada35 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
@@ -121,12 +121,9 @@ class RoomListViewTest {
),
onSetUpRecoveryClick = callback,
)
-
// Remove automatic initial events
eventsRecorder.clear()
-
- rule.clickOn(CommonStrings.action_continue)
-
+ rule.clickOn(R.string.banner_set_up_recovery_submit)
eventsRecorder.assertEmpty()
}
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
index d3388cb37e..5d9aeec21e 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
@@ -22,7 +22,6 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
-import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
import io.element.android.features.securebackup.impl.reset.ResetIdentityFlowNode
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
@@ -63,9 +62,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Parcelize
data object Disable : NavTarget
- @Parcelize
- data object Enable : NavTarget
-
@Parcelize
data object EnterRecoveryKey : NavTarget
@@ -91,10 +87,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
backstack.push(NavTarget.Disable)
}
- override fun onEnableClick() {
- backstack.push(NavTarget.Enable)
- }
-
override fun onConfirmRecoveryKeyClick() {
backstack.push(NavTarget.EnterRecoveryKey)
}
@@ -116,9 +108,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
NavTarget.Disable -> {
createNode(buildContext)
}
- NavTarget.Enable -> {
- createNode(buildContext)
- }
NavTarget.EnterRecoveryKey -> {
val callback = object : SecureBackupEnterRecoveryKeyNode.Callback {
override fun onEnterRecoveryKeySuccess() {
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt
index 4e661e0a13..fc721d23ca 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt
@@ -37,11 +37,7 @@ class SecureBackupDisablePresenter @Inject constructor(
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: SecureBackupDisableEvents) {
when (event) {
- is SecureBackupDisableEvents.DisableBackup -> if (disableAction.value.isConfirming()) {
- coroutineScope.disableBackup(disableAction)
- } else {
- disableAction.value = AsyncAction.ConfirmingNoParams
- }
+ is SecureBackupDisableEvents.DisableBackup -> coroutineScope.disableBackup(disableAction)
SecureBackupDisableEvents.DismissDialogs -> {
disableAction.value = AsyncAction.Uninitialized
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt
index 1e603ae20d..8d32dba4c2 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt
@@ -7,6 +7,7 @@
package io.element.android.features.securebackup.impl.disable
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -25,7 +26,6 @@ import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
-import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -44,7 +44,7 @@ fun SecureBackupDisableView(
onBackClick = onBackClick,
title = stringResource(id = R.string.screen_key_backup_disable_title),
subTitle = stringResource(id = R.string.screen_key_backup_disable_description),
- iconStyle = BigIcon.Style.Default(CompoundIcons.KeyOffSolid()),
+ iconStyle = BigIcon.Style.AlertSolid,
buttons = { Buttons(state = state) },
) {
Content(state = state)
@@ -52,12 +52,6 @@ fun SecureBackupDisableView(
AsyncActionView(
async = state.disableAction,
- confirmationDialog = {
- SecureBackupDisableConfirmationDialog(
- onConfirm = { state.eventSink.invoke(SecureBackupDisableEvents.DisableBackup) },
- onDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
- )
- },
progressDialog = {},
errorMessage = { it.message ?: it.toString() },
onErrorDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
@@ -65,18 +59,6 @@ fun SecureBackupDisableView(
)
}
-@Composable
-private fun SecureBackupDisableConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
- ConfirmationDialog(
- title = stringResource(id = R.string.screen_key_backup_disable_confirmation_title),
- content = stringResource(id = R.string.screen_key_backup_disable_confirmation_description),
- submitText = stringResource(id = R.string.screen_key_backup_disable_confirmation_action_turn_off),
- destructiveSubmit = true,
- onSubmitClick = onConfirm,
- onDismiss = onDismiss,
- )
-}
-
@Composable
private fun ColumnScope.Buttons(
state: SecureBackupDisableState,
@@ -105,15 +87,20 @@ private fun Content(state: SecureBackupDisableState) {
@Composable
private fun SecureBackupDisableItem(text: String) {
- Row(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(color = ElementTheme.colors.bgActionSecondaryHovered)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary,
- modifier = Modifier.size(20.dp)
+ modifier = Modifier.size(24.dp)
)
Text(
- modifier = Modifier.padding(start = 8.dp, end = 4.dp),
text = text,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableEvents.kt
deleted file mode 100644
index 57e43268d1..0000000000
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableEvents.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.securebackup.impl.enable
-
-sealed interface SecureBackupEnableEvents {
- data object EnableBackup : SecureBackupEnableEvents
- data object DismissDialog : SecureBackupEnableEvents
-}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt
deleted file mode 100644
index 1ae7044edc..0000000000
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.securebackup.impl.enable
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import com.bumble.appyx.core.modality.BuildContext
-import com.bumble.appyx.core.node.Node
-import com.bumble.appyx.core.plugin.Plugin
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedInject
-import io.element.android.anvilannotations.ContributesNode
-import io.element.android.libraries.di.SessionScope
-
-@ContributesNode(SessionScope::class)
-class SecureBackupEnableNode @AssistedInject constructor(
- @Assisted buildContext: BuildContext,
- @Assisted plugins: List,
- private val presenter: SecureBackupEnablePresenter,
-) : Node(buildContext, plugins = plugins) {
- @Composable
- override fun View(modifier: Modifier) {
- val state = presenter.present()
- SecureBackupEnableView(
- state = state,
- modifier = modifier,
- onSuccess = ::navigateUp,
- onBackClick = ::navigateUp,
- )
- }
-}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenter.kt
deleted file mode 100644
index ae2ee57c9c..0000000000
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenter.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.securebackup.impl.enable
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import io.element.android.features.securebackup.impl.loggerTagDisable
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.architecture.runCatchingUpdatingState
-import io.element.android.libraries.matrix.api.encryption.EncryptionService
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import timber.log.Timber
-import javax.inject.Inject
-
-class SecureBackupEnablePresenter @Inject constructor(
- private val encryptionService: EncryptionService,
-) : Presenter {
- @Composable
- override fun present(): SecureBackupEnableState {
- val enableAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
- val coroutineScope = rememberCoroutineScope()
- fun handleEvents(event: SecureBackupEnableEvents) {
- when (event) {
- is SecureBackupEnableEvents.EnableBackup ->
- coroutineScope.enableBackup(enableAction)
- SecureBackupEnableEvents.DismissDialog -> {
- enableAction.value = AsyncAction.Uninitialized
- }
- }
- }
-
- return SecureBackupEnableState(
- enableAction = enableAction.value,
- eventSink = ::handleEvents
- )
- }
-
- private fun CoroutineScope.enableBackup(action: MutableState>) = launch {
- suspend {
- Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()")
- encryptionService.enableBackups().getOrThrow()
- }.runCatchingUpdatingState(action)
- }
-}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableState.kt
deleted file mode 100644
index 058ba49cb6..0000000000
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableState.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.securebackup.impl.enable
-
-import io.element.android.libraries.architecture.AsyncAction
-
-data class SecureBackupEnableState(
- val enableAction: AsyncAction,
- val eventSink: (SecureBackupEnableEvents) -> Unit
-)
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableStateProvider.kt
deleted file mode 100644
index 482f11f1fd..0000000000
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableStateProvider.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.securebackup.impl.enable
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.architecture.AsyncAction
-
-open class SecureBackupEnableStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aSecureBackupEnableState(),
- aSecureBackupEnableState(enableAction = AsyncAction.Loading),
- aSecureBackupEnableState(enableAction = AsyncAction.Failure(Exception("Failed to enable"))),
- // Add other states here
- )
-}
-
-fun aSecureBackupEnableState(
- enableAction: AsyncAction = AsyncAction.Uninitialized,
-) = SecureBackupEnableState(
- enableAction = enableAction,
- eventSink = {}
-)
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt
deleted file mode 100644
index f14e361c46..0000000000
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.securebackup.impl.enable
-
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.securebackup.impl.R
-import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
-import io.element.android.libraries.designsystem.components.BigIcon
-import io.element.android.libraries.designsystem.components.async.AsyncActionView
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.Button
-
-@Composable
-fun SecureBackupEnableView(
- state: SecureBackupEnableState,
- onSuccess: () -> Unit,
- onBackClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- FlowStepPage(
- modifier = modifier,
- onBackClick = onBackClick,
- title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
- iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
- buttons = { Buttons(state = state) }
- )
- AsyncActionView(
- async = state.enableAction,
- progressDialog = { },
- onSuccess = { onSuccess() },
- onErrorDismiss = { state.eventSink.invoke(SecureBackupEnableEvents.DismissDialog) }
- )
-}
-
-@Composable
-private fun ColumnScope.Buttons(
- state: SecureBackupEnableState,
-) {
- Button(
- text = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
- showProgress = state.enableAction.isLoading(),
- modifier = Modifier.fillMaxWidth(),
- onClick = { state.eventSink.invoke(SecureBackupEnableEvents.EnableBackup) }
- )
-}
-
-@PreviewsDayNight
-@Composable
-internal fun SecureBackupEnableViewPreview(
- @PreviewParameter(SecureBackupEnableStateProvider::class) state: SecureBackupEnableState
-) = ElementPreview {
- SecureBackupEnableView(
- state = state,
- onSuccess = {},
- onBackClick = {},
- )
-}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootEvents.kt
index 89de592805..f80e3c638b 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootEvents.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootEvents.kt
@@ -9,4 +9,7 @@ package io.element.android.features.securebackup.impl.root
sealed interface SecureBackupRootEvents {
data object RetryKeyBackupState : SecureBackupRootEvents
+ data object EnableKeyStorage : SecureBackupRootEvents
+ data object DisplayKeyStorageDisabledError : SecureBackupRootEvents
+ data object DismissDialog : SecureBackupRootEvents
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt
index 113c569d39..a0eace77e6 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt
@@ -34,7 +34,6 @@ class SecureBackupRootNode @AssistedInject constructor(
fun onSetupClick()
fun onChangeClick()
fun onDisableClick()
- fun onEnableClick()
fun onConfirmRecoveryKeyClick()
}
@@ -50,10 +49,6 @@ class SecureBackupRootNode @AssistedInject constructor(
plugins().forEach { it.onDisableClick() }
}
- private fun onEnableClick() {
- plugins().forEach { it.onEnableClick() }
- }
-
private fun onConfirmRecoveryKeyClick() {
plugins().forEach { it.onConfirmRecoveryKeyClick() }
}
@@ -71,7 +66,6 @@ class SecureBackupRootNode @AssistedInject constructor(
onBackClick = ::navigateUp,
onSetupClick = ::onSetupClick,
onChangeClick = ::onChangeClick,
- onEnableClick = ::onEnableClick,
onDisableClick = ::onDisableClick,
onConfirmRecoveryKeyClick = ::onConfirmRecoveryKeyClick,
onLearnMoreClick = { onLearnMoreClick(uriHandler) },
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt
index 075c9980a1..655be4f7a2 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt
@@ -15,7 +15,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import io.element.android.features.securebackup.impl.loggerTagDisable
import io.element.android.features.securebackup.impl.loggerTagRoot
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -41,7 +44,8 @@ class SecureBackupRootPresenter @Inject constructor(
val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
-
+ val enableAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) }
+ var displayKeyStorageDisabledError by remember { mutableStateOf(false) }
Timber.tag(loggerTagRoot.value).d("backupState: $backupState")
Timber.tag(loggerTagRoot.value).d("recoveryState: $recoveryState")
@@ -56,14 +60,22 @@ class SecureBackupRootPresenter @Inject constructor(
fun handleEvents(event: SecureBackupRootEvents) {
when (event) {
SecureBackupRootEvents.RetryKeyBackupState -> localCoroutineScope.getKeyBackupStatus(doesBackupExistOnServerAction)
+ SecureBackupRootEvents.EnableKeyStorage -> localCoroutineScope.enableBackup(enableAction)
+ SecureBackupRootEvents.DismissDialog -> {
+ enableAction.value = AsyncAction.Uninitialized
+ displayKeyStorageDisabledError = false
+ }
+ SecureBackupRootEvents.DisplayKeyStorageDisabledError -> displayKeyStorageDisabledError = true
}
}
return SecureBackupRootState(
+ enableAction = enableAction.value,
backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServerAction.value,
recoveryState = recoveryState,
appName = buildMeta.applicationName,
+ displayKeyStorageDisabledError = displayKeyStorageDisabledError,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents,
)
@@ -74,4 +86,11 @@ class SecureBackupRootPresenter @Inject constructor(
encryptionService.doesBackupExistOnServer().getOrThrow()
}.runCatchingUpdatingState(action)
}
+
+ private fun CoroutineScope.enableBackup(action: MutableState>) = launch {
+ suspend {
+ Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()")
+ encryptionService.enableBackups().getOrThrow()
+ }.runCatchingUpdatingState(action)
+ }
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt
index 944706e6cc..5da4f0c5f3 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt
@@ -7,16 +7,31 @@
package io.element.android.features.securebackup.impl.root
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
data class SecureBackupRootState(
+ val enableAction: AsyncAction,
val backupState: BackupState,
val doesBackupExistOnServer: AsyncData,
val recoveryState: RecoveryState,
val appName: String,
+ val displayKeyStorageDisabledError: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (SecureBackupRootEvents) -> Unit,
-)
+) {
+ val isKeyStorageEnabled: Boolean
+ get() = when (backupState) {
+ BackupState.UNKNOWN -> doesBackupExistOnServer.dataOrNull() == true
+ BackupState.CREATING,
+ BackupState.ENABLING,
+ BackupState.RESUMING,
+ BackupState.DOWNLOADING,
+ BackupState.ENABLED -> true
+ BackupState.WAITING_FOR_SYNC,
+ BackupState.DISABLING -> false
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt
index c07c166975..003fab5f3f 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt
@@ -8,6 +8,7 @@
package io.element.android.features.securebackup.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.encryption.BackupState
@@ -22,28 +23,47 @@ open class SecureBackupRootStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized,
backupState: BackupState = BackupState.UNKNOWN,
doesBackupExistOnServer: AsyncData = AsyncData.Uninitialized,
recoveryState: RecoveryState = RecoveryState.UNKNOWN,
+ displayKeyStorageDisabledError: Boolean = false,
snackbarMessage: SnackbarMessage? = null,
) = SecureBackupRootState(
+ enableAction = enableAction,
backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState,
appName = "Element",
+ displayKeyStorageDisabledError = displayKeyStorageDisabledError,
snackbarMessage = snackbarMessage,
eventSink = {},
)
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt
index 47e3068869..2a4cfbbe6e 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt
@@ -7,28 +7,27 @@
package io.element.android.features.securebackup.impl.root
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.progressSemantics
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.designsystem.components.async.AsyncLoading
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
-import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
-import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.encryption.BackupState
@@ -41,7 +40,6 @@ fun SecureBackupRootView(
onBackClick: () -> Unit,
onSetupClick: () -> Unit,
onChangeClick: () -> Unit,
- onEnableClick: () -> Unit,
onDisableClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onLearnMoreClick: () -> Unit,
@@ -52,122 +50,186 @@ fun SecureBackupRootView(
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
- title = stringResource(id = CommonStrings.common_chat_backup),
+ title = stringResource(id = CommonStrings.common_encryption),
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
- val text = buildAnnotatedStringWithStyledPart(
- fullTextRes = R.string.screen_chat_backup_key_backup_description,
- coloredTextRes = CommonStrings.action_learn_more,
- color = ElementTheme.colors.textPrimary,
- underline = false,
- bold = true,
- )
- PreferenceText(
- title = stringResource(id = R.string.screen_chat_backup_key_backup_title),
- subtitleAnnotated = text,
+ ListItem(
+ headlineContent = {
+ Text(
+ text = stringResource(id = R.string.screen_chat_backup_key_backup_title),
+ )
+ },
+ supportingContent = {
+ Text(
+ text = buildAnnotatedStringWithStyledPart(
+ fullTextRes = R.string.screen_chat_backup_key_backup_description,
+ coloredTextRes = CommonStrings.action_learn_more,
+ color = ElementTheme.colors.textPrimary,
+ underline = false,
+ bold = true,
+ ),
+ )
+ },
onClick = onLearnMoreClick,
)
- // Disable / Enable backup
- when (state.backupState) {
- BackupState.WAITING_FOR_SYNC -> Unit
- BackupState.UNKNOWN -> {
- when (state.doesBackupExistOnServer) {
- is AsyncData.Success -> when (state.doesBackupExistOnServer.data) {
- true -> {
- PreferenceText(
- title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable),
- tintColor = ElementTheme.colors.textCriticalPrimary,
- onClick = onDisableClick,
- )
+ // Disable / Enable key storage
+ ListItem(
+ headlineContent = {
+ Text(
+ text = stringResource(id = R.string.screen_chat_backup_key_storage_toggle_title),
+ )
+ },
+ trailingContent = when (state.backupState) {
+ BackupState.WAITING_FOR_SYNC,
+ BackupState.DISABLING -> ListItemContent.Custom { LoadingView() }
+ BackupState.UNKNOWN -> {
+ when (state.doesBackupExistOnServer) {
+ is AsyncData.Success -> {
+ ListItemContent.Switch(checked = state.doesBackupExistOnServer.data)
}
- false -> {
- PreferenceText(
- title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
- onClick = onEnableClick,
+ is AsyncData.Loading,
+ AsyncData.Uninitialized -> ListItemContent.Custom { LoadingView() }
+ is AsyncData.Failure -> ListItemContent.Custom {
+ Text(
+ text = stringResource(id = CommonStrings.action_retry)
)
}
}
- is AsyncData.Loading,
- AsyncData.Uninitialized -> {
- ListItem(headlineContent = {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.Center,
- ) {
- CircularProgressIndicator()
- }
- })
- }
- is AsyncData.Failure -> {
- ListItem(
- headlineContent = {
- Text(
- text = stringResource(id = CommonStrings.error_unknown),
- )
- },
- trailingContent = ListItemContent.Custom {
- TextButton(
- text = stringResource(
- id = CommonStrings.action_retry
- ),
- onClick = { state.eventSink.invoke(SecureBackupRootEvents.RetryKeyBackupState) }
- )
+ }
+ BackupState.CREATING,
+ BackupState.ENABLING,
+ BackupState.RESUMING,
+ BackupState.ENABLED,
+ BackupState.DOWNLOADING -> ListItemContent.Switch(checked = true)
+ },
+ onClick = {
+ when (state.backupState) {
+ BackupState.WAITING_FOR_SYNC,
+ BackupState.DISABLING -> Unit
+ BackupState.UNKNOWN -> {
+ when (state.doesBackupExistOnServer) {
+ is AsyncData.Success -> {
+ if (state.doesBackupExistOnServer.data) {
+ onDisableClick()
+ } else {
+ state.eventSink.invoke(SecureBackupRootEvents.EnableKeyStorage)
+ }
}
- )
-
- PreferenceText(
- title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
- onClick = onEnableClick,
- )
+ is AsyncData.Loading,
+ AsyncData.Uninitialized -> Unit
+ is AsyncData.Failure -> state.eventSink.invoke(SecureBackupRootEvents.RetryKeyBackupState)
+ }
}
+ BackupState.CREATING,
+ BackupState.ENABLING,
+ BackupState.RESUMING,
+ BackupState.ENABLED,
+ BackupState.DOWNLOADING -> onDisableClick()
}
- }
- BackupState.CREATING,
- BackupState.ENABLING,
- BackupState.RESUMING,
- BackupState.ENABLED,
- BackupState.DOWNLOADING -> {
- PreferenceText(
- title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable),
- tintColor = ElementTheme.colors.textCriticalPrimary,
- onClick = onDisableClick,
- )
- }
- BackupState.DISABLING -> {
- AsyncLoading()
- }
- }
-
- PreferenceDivider()
-
+ },
+ )
+ HorizontalDivider()
// Setup recovery
when (state.recoveryState) {
RecoveryState.UNKNOWN,
RecoveryState.WAITING_FOR_SYNC -> Unit
RecoveryState.DISABLED -> {
- PreferenceText(
- title = stringResource(id = R.string.screen_chat_backup_recovery_action_setup),
- subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName),
- onClick = onSetupClick,
- showEndBadge = true,
+ ListItem(
+ headlineContent = {
+ Text(
+ text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup),
+ )
+ },
+ supportingContent = {
+ Text(
+ text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName),
+ )
+ },
+ trailingContent = ListItemContent.Badge,
+ enabled = state.isKeyStorageEnabled,
+ alwaysClickable = true,
+ onClick = {
+ if (state.isKeyStorageEnabled) {
+ onSetupClick()
+ } else {
+ state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
+ }
+ },
)
}
RecoveryState.ENABLED -> {
- PreferenceText(
- title = stringResource(id = R.string.screen_chat_backup_recovery_action_change),
- onClick = onChangeClick,
+ ListItem(
+ headlineContent = {
+ Text(
+ text = stringResource(id = R.string.screen_chat_backup_recovery_action_change),
+ )
+ },
+ supportingContent = {
+ Text(
+ text = stringResource(id = R.string.screen_chat_backup_recovery_action_change_description),
+ )
+ },
+ enabled = state.isKeyStorageEnabled,
+ alwaysClickable = true,
+ onClick = {
+ if (state.isKeyStorageEnabled) {
+ onChangeClick()
+ } else {
+ state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
+ }
+ },
)
}
RecoveryState.INCOMPLETE ->
- PreferenceText(
- title = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm),
- subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description),
- showEndBadge = true,
- onClick = onConfirmRecoveryKeyClick,
+ ListItem(
+ headlineContent = {
+ Text(
+ text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm),
+ )
+ },
+ supportingContent = {
+ Text(
+ text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description),
+ )
+ },
+ trailingContent = ListItemContent.Badge,
+ enabled = state.isKeyStorageEnabled,
+ alwaysClickable = true,
+ onClick = {
+ if (state.isKeyStorageEnabled) {
+ onConfirmRecoveryKeyClick()
+ } else {
+ state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
+ }
+ },
)
}
}
+
+ AsyncActionView(
+ async = state.enableAction,
+ progressDialog = { },
+ onSuccess = { },
+ onErrorDismiss = { state.eventSink.invoke(SecureBackupRootEvents.DismissDialog) }
+ )
+ if (state.displayKeyStorageDisabledError) {
+ ErrorDialog(
+ title = null,
+ content = stringResource(id = R.string.screen_chat_backup_key_storage_disabled_error),
+ onSubmit = { state.eventSink.invoke(SecureBackupRootEvents.DismissDialog) },
+ )
+ }
+}
+
+@Composable
+private fun LoadingView() {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .progressSemantics()
+ .size(24.dp),
+ strokeWidth = 2.dp
+ )
}
@PreviewsDayNight
@@ -180,7 +242,6 @@ internal fun SecureBackupRootViewPreview(
onBackClick = {},
onSetupClick = {},
onChangeClick = {},
- onEnableClick = {},
onDisableClick = {},
onConfirmRecoveryKeyClick = {},
onLearnMoreClick = {},
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt
index 06fa7b2c46..7ebf2d0219 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt
@@ -91,14 +91,14 @@ private fun RecoveryKeyStaticContent(
) {
Row(
modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(14.dp))
- .background(
- color = ElementTheme.colors.bgSubtleSecondary,
- shape = RoundedCornerShape(14.dp)
- )
- .clickableIfNotNull(onClick)
- .padding(horizontal = 16.dp, vertical = 16.dp),
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .background(
+ color = ElementTheme.colors.bgSubtleSecondary,
+ shape = RoundedCornerShape(14.dp)
+ )
+ .clickableIfNotNull(onClick)
+ .padding(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (state.formattedRecoveryKey != null) {
@@ -116,15 +116,15 @@ private fun RecoveryKeyStaticContent(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 11.dp)
+ .fillMaxWidth()
+ .padding(vertical = 11.dp)
) {
if (state.inProgress) {
CircularProgressIndicator(
modifier = Modifier
- .progressSemantics()
- .padding(end = 8.dp)
- .size(16.dp),
+ .progressSemantics()
+ .padding(end = 8.dp)
+ .size(16.dp),
color = ElementTheme.colors.textPrimary,
strokeWidth = 1.5.dp,
)
@@ -161,12 +161,12 @@ private fun RecoveryKeyFormContent(
}
OutlinedTextField(
modifier = Modifier
- .fillMaxWidth()
- .testTag(TestTags.recoveryKey)
- .autofill(
- autofillTypes = listOf(AutofillType.Password),
- onFill = { onChange(it) },
- ),
+ .fillMaxWidth()
+ .testTag(TestTags.recoveryKey)
+ .autofill(
+ autofillTypes = listOf(AutofillType.Password),
+ onFill = { onChange(it) },
+ ),
minLines = 2,
value = state.formattedRecoveryKey.orEmpty(),
onValueChange = onChange,
@@ -189,30 +189,18 @@ private fun RecoveryKeyFooter(state: RecoveryKeyViewState) {
RecoveryKeyUserStory.Setup,
RecoveryKeyUserStory.Change -> {
if (state.formattedRecoveryKey == null) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = CompoundIcons.InfoSolid(),
- contentDescription = null,
- tint = ElementTheme.colors.iconSecondary,
- modifier = Modifier
- .padding(start = 16.dp)
- .size(20.dp),
- )
- Text(
- text = stringResource(
- id = if (state.recoveryKeyUserStory == RecoveryKeyUserStory.Change) {
- R.string.screen_recovery_key_change_generate_key_description
- } else {
- R.string.screen_recovery_key_setup_generate_key_description
- }
- ),
- color = ElementTheme.colors.textSecondary,
- modifier = Modifier.padding(start = 8.dp),
- style = ElementTheme.typography.fontBodySmRegular,
- )
- }
+ Text(
+ text = stringResource(
+ id = if (state.recoveryKeyUserStory == RecoveryKeyUserStory.Change) {
+ R.string.screen_recovery_key_change_generate_key_description
+ } else {
+ R.string.screen_recovery_key_setup_generate_key_description
+ }
+ ),
+ color = ElementTheme.colors.textSecondary,
+ modifier = Modifier.padding(start = 16.dp),
+ style = ElementTheme.typography.fontBodySmRegular,
+ )
} else {
Text(
text = stringResource(id = R.string.screen_recovery_key_save_key_description),
diff --git a/features/securebackup/impl/src/main/res/values-be/translations.xml b/features/securebackup/impl/src/main/res/values-be/translations.xml
index 8af42b8da5..3a57f36ded 100644
--- a/features/securebackup/impl/src/main/res/values-be/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-be/translations.xml
@@ -42,7 +42,6 @@
"Увесці…"
"Страцілі ключ аднаўлення?"
"Ключ аднаўлення пацверджаны"
- "Увядзіце ключ аднаўлення"
"Ключ аднаўлення скапіраваны"
"Стварэнне…"
"Захаваць ключ аднаўлення"
diff --git a/features/securebackup/impl/src/main/res/values-bg/translations.xml b/features/securebackup/impl/src/main/res/values-bg/translations.xml
index 37973150c5..7dfb012c34 100644
--- a/features/securebackup/impl/src/main/res/values-bg/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-bg/translations.xml
@@ -15,7 +15,6 @@
"Въведете 48-символния код."
"Въведете…"
"Ключът за възстановяване е потвърден"
- "Потвърдете ключа си за възстановяване"
"Копиран ключ за възстановяване"
"Запазване на ключа за възстановяване"
"Въведете…"
diff --git a/features/securebackup/impl/src/main/res/values-cs/translations.xml b/features/securebackup/impl/src/main/res/values-cs/translations.xml
index 0ab538d4d0..30e9f5128d 100644
--- a/features/securebackup/impl/src/main/res/values-cs/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-cs/translations.xml
@@ -4,6 +4,7 @@
"Zapnout zálohování"
"Bezpečně uložte svou kryptografickou identitu a klíče zpráv na serveru. To vám umožní zobrazit historii zpráv na všech nových zařízeních. %1$s."
"Úložiště klíčů"
+ "Pro nastavení obnovení musí být zapnuto úložiště klíčů."
"Nahrát klíče z tohoto zařízení"
"Povolit ukládání klíčů"
"Změnit klíč pro obnovení"
@@ -45,7 +46,6 @@
"Zadejte…"
"Ztratili jste klíč pro obnovení?"
"Klíč pro obnovení potvrzen"
- "Potvrďte klíč pro obnovení"
"Klíč pro obnovení zkopírován"
"Generování…"
"Uložit klíč pro obnovení"
diff --git a/features/securebackup/impl/src/main/res/values-de/translations.xml b/features/securebackup/impl/src/main/res/values-de/translations.xml
index 5a184d4d14..7201aaba3a 100644
--- a/features/securebackup/impl/src/main/res/values-de/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-de/translations.xml
@@ -57,7 +57,6 @@
"Eingeben…"
"Hast du deinen Wiederherstellungschlüssel vergessen?"
"Wiederherstellungsschlüssel bestätigt"
- "Wiederherstellungsschlüssel bestätigen."
"Wiederherstellungsschlüssel kopiert"
"Generieren…"
"Wiederherstellungsschlüssel speichern"
diff --git a/features/securebackup/impl/src/main/res/values-el/translations.xml b/features/securebackup/impl/src/main/res/values-el/translations.xml
index 21c9009ebe..add44a56f7 100644
--- a/features/securebackup/impl/src/main/res/values-el/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-el/translations.xml
@@ -42,7 +42,6 @@
"Εισαγωγή…"
"Έχασες το κλειδί ανάκτησης;"
"Επιβεβαιώθηκε το κλειδί ανάκτησης"
- "Εισήγαγε το κλειδί ανάκτησης"
"Αντιγράφηκε το κλειδί ανάκτησης"
"Δημιουργία…"
"Αποθήκευση κλειδιού ανάκτησης"
diff --git a/features/securebackup/impl/src/main/res/values-es/translations.xml b/features/securebackup/impl/src/main/res/values-es/translations.xml
index 4c37eba8fb..64a98d6f94 100644
--- a/features/securebackup/impl/src/main/res/values-es/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-es/translations.xml
@@ -7,7 +7,7 @@
"Cambiar la clave de recuperación"
"Introduzca la clave de recuperación"
"La copia de seguridad de tus chats no está sincronizada ahora mismo."
- "Configurar la clave de recuperación"
+ "Configurar la recuperación"
"Accede a tus mensajes cifrados si pierdes todos tus dispositivos o cierras sesión de %1$s en cualquier lugar."
"Desactivar"
"Perderás tus mensajes cifrados si cierras sesión en todos los dispositivos."
@@ -27,7 +27,6 @@
"Introduce el código de 48 caracteres."
"Ingresar…"
"Clave de recuperación confirmada"
- "Confirma tu clave de recuperación"
"Clave de recuperación copiada"
"Generando…"
"Guardar clave de recuperación"
diff --git a/features/securebackup/impl/src/main/res/values-et/translations.xml b/features/securebackup/impl/src/main/res/values-et/translations.xml
index f67d204a54..9a7b09259b 100644
--- a/features/securebackup/impl/src/main/res/values-et/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-et/translations.xml
@@ -4,13 +4,14 @@
"Lülita võtmete varundamine sisse"
"Salvesta oma krüptoidentiteet ja sõnumite krüptovõtmed turvaliselt serveris. See tagab, et sinu sõnumite ajalugu on alati loetav, ka kõikides uutes seadmetes. %1$s."
"Krüptovõtmete varundus"
+ "Taastamise seadistamiseks peab võtmehoidla olema sisselülitatud."
"Laadi siin seadmes leiduvad võtmed üles"
"Luba krüptovõtmete salvestamine"
"Muuda taastevõtit"
"Kui sa oled kaotanud ligipääsu kõikidele oma olemasolevatele seadmetele, siis sa saad taastevõtme abil taastada ligipääsu oma krüptoidentiteedile ja sõnumite ajaloole."
"Sisesta taastevõti"
- "Sinu vestluste krüptograafia varukoopia pole hetkel enam sünkroonis."
- "Seadista krüptovõtmete varundus"
+ "Sinu krüptovõtmete varundus pole hetkel enam sünkroonis."
+ "Seadista andmete taastamine"
"Säilita ligipääs oma krüptitud sõnumitele ka siis, kui sa kaotad kõik oma seadmed ja/või logid kõikjal välja rakendusest %1$s."
"Ava %1$s töölauaga seadmes"
"Logi uuesti sisse oma kasutajakontole"
@@ -39,7 +40,7 @@
"Kas muudame taastevõtme?"
"Loo uus taastevõti"
"Palun vaata, et keegi teine ei näeks seda ekraanivaadet!"
- "Kinnitamaks ligipääsu sinu vestluse varukoopiale, palun proovi uuesti"
+ "Kinnitamaks ligipääsu sinu krüptovõtmete varundusele, palun proovi uuesti"
"Vigane taastevõti"
"Kui sul on turvavõti või turvafraas, siis need toimivad ka."
"Sisesta…"
diff --git a/features/securebackup/impl/src/main/res/values-fa/translations.xml b/features/securebackup/impl/src/main/res/values-fa/translations.xml
index b891d5e510..a0d71400c8 100644
--- a/features/securebackup/impl/src/main/res/values-fa/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-fa/translations.xml
@@ -35,7 +35,6 @@
"ورود…"
"گم کردن کلید بازیابیتان؟"
"کلید بازیابی تأیید شد"
- "ورود کلید بازیابیتان"
"کلید بازیابی رونوشت شد"
"تولید کردن…"
"ذخیرهٔ کلید بازیابی"
diff --git a/features/securebackup/impl/src/main/res/values-fr/translations.xml b/features/securebackup/impl/src/main/res/values-fr/translations.xml
index 7f39b91093..43c3fdbc28 100644
--- a/features/securebackup/impl/src/main/res/values-fr/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-fr/translations.xml
@@ -2,12 +2,15 @@
"Désactiver la sauvegarde"
"Activer la sauvegarde"
- "La sauvegarde assure que vous ne perdiez pas l’historique des discussions. %1$s."
- "Sauvegarde"
+ "Stockez votre identité cryptographique et vos clés de message en toute sécurité sur le serveur. Cela vous permettra de consulter l’historique de vos messages sur tous les nouveaux appareils. %1$s."
+ "Stockage des clés"
+ "Télécharger les clés depuis cet appareil"
+ "Autoriser le stockage des clés"
"Changer la clé de récupération"
+ "Récupérez votre identité cryptographique et l’historique de vos messages à l’aide d’une clé de récupération si vous avez perdu tous vos appareils existants."
"Utiliser la clé de récupération"
"La sauvegarde des discussions est désynchronisée."
- "Configurer la récupération"
+ "Configurer la sauvegarde"
"Accédez à vos messages chiffrés si vous perdez tous vos appareils ou que vous êtes déconnectés de %1$s partout."
"Ouvrez %1$s sur un ordinateur"
"Connectez-vous à nouveau à votre compte"
@@ -31,7 +34,7 @@
"Êtes-vous certain de vouloir désactiver la sauvegarde?"
"Obtenez une nouvelle clé de récupération dans le cas où vous avez oublié l’ancienne. Après le changement, l’ancienne clé ne sera plus utilisable."
"Générer une nouvelle clé"
- "Assurez-vous de conserver la clé dans un endroit sûr"
+ "Ne partagez cela avec personne !"
"Clé de récupération modifée"
"Changer la clé de récupération?"
"Créer une nouvelle clé de récupération"
@@ -42,18 +45,17 @@
"Saisissez la clé ici…"
"Clé de récupération perdue?"
"Clé de récupération confirmée"
- "Confirmer votre clé de récupération"
"Clé de récupération copiée"
"Génération…"
"Enregistrer la clé"
- "Recopier votre clé de récupération dans un endroit sécurisé ou enregistrer la dans un manager de mot de passe."
+ "Recopier cette clé de récupération dans un endroit sûr, comme un gestionnaire de mots de passe, une note chiffrée ou un coffre-fort physique."
"Taper pour copier la clé"
"Sauvegarder la clé"
"La clé ne pourra plus être affichée après cette étape."
"Avez-vous sauvegardé votre clé de récupération?"
"Votre sauvegarde est protégée par votre clé de récupération. Si vous avez besoin d’une nouvelle clé après la configuration, vous pourrez en créer une nouvelle en cliquant sur \"Changer la clé de récupération\"."
"Générer la clé de récupération"
- "Assurez-vous de conserver la clé dans un endroit sûr"
+ "Ne partagez cela avec personne !"
"Sauvegarde mise en place avec succès"
"Configurer la sauvegarde"
"Oui, réinitialisez maintenant"
diff --git a/features/securebackup/impl/src/main/res/values-hu/translations.xml b/features/securebackup/impl/src/main/res/values-hu/translations.xml
index eb0cc7fd27..f655234864 100644
--- a/features/securebackup/impl/src/main/res/values-hu/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-hu/translations.xml
@@ -2,11 +2,15 @@
"Biztonsági mentés kikapcsolása"
"Biztonsági mentés bekapcsolása"
- "A biztonsági mentés biztosítja, hogy ne veszítse el az üzenetelőzményeit. %1$s."
- "Biztonsági mentés"
+ "Tárolja kriptográfiai személyazonosságát és üzenetkulcsait biztonságosan a kiszolgálón. Ez lehetővé teszi, hogy bármilyen új eszközön megtekinthesse üzenetelőzményeit. %1$s."
+ "Kulcstároló"
+ "A helyreállítás beállításához be kell kapcsolni a kulcstárolást."
+ "Kulcsok feltöltése erről az eszközről"
+ "Kulcstárolás engedélyezése"
"Helyreállítási kulcs módosítása"
+ "Ha az összes meglévő eszközét elvesztette, akkor egy helyreállítási kulccsal visszaszerezheti a kriptográfiai személyazonosságát és az üzenetelőzményeit."
"Adja meg a helyreállítási kulcsot"
- "A csevegéselőzményei nincsenek szinkronban."
+ "A kulcstároló jelenleg nincs szinkronizálva."
"Helyreállítás beállítása"
"Szerezzen hozzáférést a titkosított üzeneteihez, ha elvesztette az összes eszközét, vagy ha mindenütt kijelentkezett az %1$sből."
"Nyissa meg az %1$set egy asztali eszközön"
@@ -31,29 +35,29 @@
"Biztos, hogy kikapcsolja a biztonsági mentéseket?"
"Szerezzen új helyreállítási kulcsot, ha elvesztette a meglévőt. A helyreállítása kulcsa módosítása után a régi már nem fog működni."
"Új helyreállítási kulcs előállítása"
- "Gondoskodjon arról, hogy biztonságos helyen tárolja a helyreállítási kulcsát"
+ "Ezt ne ossza meg senkivel!"
"Helyreállítási kulcs lecserélve"
"Módosítja a helyreállítási kulcsot?"
"Új helyreállítási kulcs létrehozása"
"Győződjön meg arról, hogy senki sem látja ezt a képernyőt!"
- "Próbálja meg újra megerősíteni a csevegés biztonsági mentéséhez való hozzáférését."
+ "Próbálja újra megerősíteni a kulcstárolóhoz való hozzáférést."
"Helytelen helyreállítási kulcs"
"Ha van biztonsági kulcsa vagy biztonsági jelmondata, akkor ez is fog működni."
"Megadás…"
"Elvesztette a helyreállítási kulcsát?"
"Helyreállítási kulcs megerősítve"
- "Helyreállítási kulcs megerősítése"
+ "Adja meg a helyreállítási kulcsot"
"Helyreállítási kulcs másolva"
"Előállítás…"
"Helyreállítási kulcs mentése"
- "Írja le a helyreállítási kulcsát valami biztonságos helyre, vagy mentse egy jelszókezelőbe."
+ "Írja le a helyreállítási kulcsát valami biztonságos helyre, például mentse egy jelszókezelőbe, egy titkosított jegyzetbe vagy egy fizikai széfbe."
"Koppintson a helyreállítási kulcs másolásához"
"Mentse el a helyreállítási kulcsát"
"Ezután a lépés után nem fog tudni hozzáférni az új helyreállítási kulcsához."
"Mentette a helyreállítási kulcsát?"
"A csevegései biztonsági mentését a helyreállítási kulcsa védi. Ha új helyreállítási kulcsra van szüksége a beállítás után, akkor a „Helyreállítási kulcs módosítása” választásával újból létrehozhat egyet."
"Helyreállítási kulcs előállítása"
- "Gondoskodjon arról, hogy biztonságos helyen tárolja a helyreállítási kulcsát"
+ "Ezt ne ossza meg senkivel!"
"A helyreállítás beállítása sikeres"
"Helyreállítás beállítása"
"Igen, visszaállítás most"
diff --git a/features/securebackup/impl/src/main/res/values-in/translations.xml b/features/securebackup/impl/src/main/res/values-in/translations.xml
index 9c1e59800e..bf88293c2b 100644
--- a/features/securebackup/impl/src/main/res/values-in/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-in/translations.xml
@@ -4,9 +4,13 @@
"Nyalakan pencadangan"
"Simpan identitas kriptografi Anda dan kunci-kunci pesan secara aman di server. Ini akan memungkinkan Anda untuk melihat riwayat pesan Anda di perangkat yang baru. %1$s."
"Penyimpanan kunci"
+ "Penyimpanan kunci harus diaktifkan untuk menyiapkan pemulihan."
+ "Unggah kunci dari perangkat ini"
+ "Izinkan penyimpanan kunci"
"Ubah kunci pemulihan"
+ "Pulihkan identitas kriptografi dan riwayat pesan Anda dengan kunci pemulihan jika Anda kehilangan semua perangkat yang ada."
"Masukkan kunci pemulihan"
- "Pencadangan percakapan Anda saat ini tidak tersinkron."
+ "Penyimpanan kunci Anda saat ini tidak sinkron."
"Siapkan pemulihan"
"Dapatkan akses ke pesan terenkripsi Anda jika Anda kehilangan semua perangkat Anda atau keluar dari %1$s di mana pun."
"Buka %1$s di perangkat desktop"
@@ -36,13 +40,13 @@
"Ubah kunci pemulihan?"
"Buat kunci pemulihan baru"
"Pastikan tidak ada yang bisa melihat layar ini!"
- "Silakan coba lagi untuk mengonfirmasi akses ke cadangan percakapan Anda."
+ "Silakan coba lagi untuk mengonfirmasi akses ke penyimpanan kunci Anda."
"Kunci pemulihan salah"
"Jika Anda memiliki kunci keamanan atau frasa keamanan, ini juga bisa digunakan."
"Masukkan…"
"Kehilangan kunci pemulihan Anda?"
"Kunci pemulihan dikonfirmasi"
- "Konfirmasi kunci pemulihan Anda"
+ "Masukkan kunci pemulihan Anda"
"Kunci pemulihan disalin"
"Membuat…"
"Simpan kunci pemulihan"
diff --git a/features/securebackup/impl/src/main/res/values-it/translations.xml b/features/securebackup/impl/src/main/res/values-it/translations.xml
index f8d55ad3ea..d6b629ea93 100644
--- a/features/securebackup/impl/src/main/res/values-it/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-it/translations.xml
@@ -42,7 +42,6 @@
"Inserisci…"
"Hai perso la chiave di recupero?"
"Chiave di recupero confermata"
- "Inserisci la chiave di recupero"
"Chiave di recupero copiata"
"Generazione…"
"Salva la chiave di recupero"
diff --git a/features/securebackup/impl/src/main/res/values-ka/translations.xml b/features/securebackup/impl/src/main/res/values-ka/translations.xml
index 160d1b06e0..61e4c51c1e 100644
--- a/features/securebackup/impl/src/main/res/values-ka/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-ka/translations.xml
@@ -3,7 +3,7 @@
"სარეზერვო ასლის გამორთვა"
"სარეზერვო ასლის ჩართვა"
"სარეზერვო ასლი უზრუნველყოფს იმას, რომ თქვენ შეტყობინებების ისტორიას არ დაკარგავთ. %1$s"
- "სარეზერვო ასლი"
+ "გასაღების საცავი"
"აღდგენის გასაღების შეცვლა"
"თქვენი ჩატის სარეზერვო ასლი ამჟამად არ არის სინქრონიზებული."
"აღდგენის დაყენება"
@@ -26,7 +26,6 @@
"თუ თქვენ გაქვთ უსაფრთხოების გასაღები ან უსაფრთხოების ფრაზა, ეს ასევე იმუშავებს."
"შეყვანა"
"აღდგენის გასაღები დადასტურებულია"
- "შეიყვანეთ აღდგენის გასაღები"
"დაკოპირებულია აღდგენის გასაღები"
"გენერირება…"
"აღდგენის გასაღების შენახვა"
diff --git a/features/securebackup/impl/src/main/res/values-nl/translations.xml b/features/securebackup/impl/src/main/res/values-nl/translations.xml
index a12c0492af..30f8362ed6 100644
--- a/features/securebackup/impl/src/main/res/values-nl/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-nl/translations.xml
@@ -2,7 +2,7 @@
"Back-up uitschakelen"
"Back-up inschakelen"
- "Een back-up maken zorgt ervoor dat je je berichtgeschiedenis niet verliest. %1$s."
+ "Sla je cryptografische identiteit en berichtsleutels veilig op de server op. Zo kun je je berichtgeschiedenis bekijken op nieuwe apparaten. %1$s."
"Back-up"
"Herstelsleutel wijzigen"
"Voer herstelsleutel in"
@@ -16,6 +16,12 @@
"Volg de instructies om een nieuwe herstelsleutel te maken"
"Sla je nieuwe herstelsleutel op in een wachtwoordmanager of versleutelde notitie"
"Stel de versleuteling voor je account opnieuw in met een ander apparaat"
+ "Doorgaan met opnieuw instellen"
+ "Je accountgegevens, contacten, voorkeuren en chatlijst worden bewaard"
+ "Je verliest alle berichtgeschiedenis die alleen op de server is opgeslagen"
+ "Je moet al je bestaande apparaten en contacten opnieuw verifiëren"
+ "Stel je identiteit alleen opnieuw in als je geen toegang hebt tot een ander aangemeld apparaat en je je herstelsleutel kwijt bent."
+ "Kun je dit niet bevestigen? Je zult je identiteit opnieuw moeten instellen."
"Uitschakelen"
"Je verliest je versleutelde berichten als je bent uitgelogd op alle apparaten."
"Weet je zeker dat je de back-up wilt uitschakelen?"
@@ -25,7 +31,7 @@
"Weet je zeker dat je de back-up wilt uitschakelen?"
"Maak een nieuwe herstelsleutel aan als je je bestaande kwijt bent. Nadat je je herstelsleutel hebt gewijzigd, werkt je oude herstelsleutel niet meer."
"Genereer een nieuwe herstelsleutel"
- "Zorg ervoor dat je je herstelsleutel op een veilige plek kunt bewaren"
+ "Deel dit met niemand!"
"Herstelsleutel gewijzigd"
"Herstelsleutel wijzigen?"
"Maak een nieuwe herstelsleutel"
@@ -36,19 +42,24 @@
"Voer in…"
"Herstelsleutel kwijt?"
"Herstelsleutel bevestigd"
- "Voer je herstelsleutel in"
"Herstelsleutel gekopieerd"
"Genereren…"
"Herstelsleutel opslaan"
- "Noteer je herstelsleutel op een veilige plek of bewaar deze in een wachtwoordmanager."
+ "Bewaar je herstelsleutel op een veilige plek, zoals in een wachtwoordbeheerder, een versleutelde notitie of in een fysieke kluis."
"Tik om de herstelsleutel te kopiëren"
"Sla je herstelsleutel op"
"Na deze stap kun je je nieuwe herstelsleutel niet meer inzien."
"Heb je je herstelsleutel opgeslagen?"
"Je chatback-up wordt beschermd door een herstelsleutel. Als je na de installatie een nieuwe herstelsleutel nodig hebt, kun je deze opnieuw aanmaken door \'Herstelsleutel wijzigen\' te selecteren."
"Genereer je herstelsleutel"
- "Zorg ervoor dat je je herstelsleutel op een veilige plek kunt bewaren"
+ "Deel dit met niemand!"
"Herstelmogelijkheid succesvol ingesteld"
"Herstelmogelijkheid instellen"
+ "Ja, nu opnieuw instellen"
+ "Dit proces is onomkeerbaar."
+ "Weet je zeker dat je je identiteit opnieuw wilt instellen?"
+ "Er is een onbekende fout opgetreden. Controleer of het wachtwoord van je account juist is en probeer het opnieuw."
"Voer in…"
+ "Bevestig dat je je identiteit opnieuw wilt instellen."
+ "Voer het wachtwoord van je account in om verder te gaan"
diff --git a/features/securebackup/impl/src/main/res/values-pl/translations.xml b/features/securebackup/impl/src/main/res/values-pl/translations.xml
index 9ca1e9a44a..82ee0c6790 100644
--- a/features/securebackup/impl/src/main/res/values-pl/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-pl/translations.xml
@@ -2,9 +2,12 @@
"Wyłącz backup"
"Włącz backup"
- "Backup zapewnia, że nie stracisz swojej historii wiadomości. %1$s"
- "Backup"
+ "Bezpiecznie przechowuj swoją tożsamość kryptograficzną i klucze wiadomości na serwerze. Umożliwi to przeglądanie historii wiadomości na każdym nowym urządzeniu. %1$s"
+ "Magazyn kluczy"
+ "Prześlij klucze z tego urządzenia"
+ "Zezwól na magazynowanie kluczy"
"Zmień klucz przywracania"
+ "Odzyskaj swoją tożsamość kryptograficzną i historię wiadomości za pomocą klucza przywracania, jeśli utraciłeś dostęp do wszystkich swoich urządzeń."
"Wprowadź klucz przywracania"
"Backup czatu jest niezsynchronizowany."
"Skonfiguruj przywracanie"
@@ -31,7 +34,7 @@
"Czy na pewno chcesz wyłączyć backup?"
"Uzyskaj nowy klucz przywracania, jeśli straciłeś dostęp do obecnego. Po zmianie klucza przywracania stary nie będzie już działał."
"Generuj nowy klucz przywracania"
- "Upewnij się, że klucz przywracania możesz przechowywać w bezpiecznym miejscu"
+ "Nie udostępniaj tego nikomu!"
"Zmieniono klucz przywracania"
"Zmienić klucz przywracania?"
"Utwórz nowy klucz przywracania"
@@ -42,18 +45,17 @@
"Wprowadź…"
"Zgubiłeś swój kod przywracania?"
"Potwierdzono klucz przywracania"
- "Wprowadź swój klucz przywracania"
"Skopiowano klucz przywracania"
"Generuję…"
"Zapisz klucz przywracania"
- "Zapisz klucz przywracania w bezpiecznym miejscu lub zapisz go w menedżerze haseł."
+ "Zapisz klucz przywracania w bezpiecznym miejscu, np. w menedżerze haseł, notatce szyfrowanej lub sejfie."
"Stuknij, by skopiować klucz przywracania"
"Zapisz klucz przywracania"
"Po tym kroku nie będziesz mieć dostępu do nowego klucza przywracania."
"Czy zapisałeś swój klucz przywracania?"
"Backup czatu jest chroniony przez klucz przywracania. Jeśli potrzebujesz utworzyć nowy klucz, możesz to zrobić wybierając `Zmień klucz przywracania`."
"Wygeneruj klucz przywracania"
- "Upewnij się, że klucz przywracania możesz przechowywać w bezpiecznym miejscu"
+ "Nie udostępniaj tego nikomu!"
"Skonfigurowano przywracanie pomyślnie"
"Skonfiguruj przywracanie"
"Tak, zresetuj teraz"
diff --git a/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml
index e22133b881..f353a80d7c 100644
--- a/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml
@@ -26,7 +26,6 @@
"Se você tiver uma chave de segurança ou frase de segurança, isso também funcionará."
"Inserir…"
"Chave de recuperação confirmada"
- "Insira sua chave de recuperação"
"Chave de recuperação copiada"
"Gerando…"
"Salvar chave de recuperação"
diff --git a/features/securebackup/impl/src/main/res/values-pt/translations.xml b/features/securebackup/impl/src/main/res/values-pt/translations.xml
index b0709c64a2..a6a58acda0 100644
--- a/features/securebackup/impl/src/main/res/values-pt/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-pt/translations.xml
@@ -2,11 +2,15 @@
"Desativar a cópia de segurança"
"Ativar a cópia de segurança"
- "A cópia de segurança garante que não perdes o teu histórico de mensagens. %1$s."
- "Cópia de segurança"
+ "Guarda a tua identidade criptográfica e as chaves de mensagens de forma segura no servidor. Isto permitir-te-á ver o teu histórico de mensagens em qualquer dispositivo novo. %1$s."
+ "Armazenamento de chaves"
+ "O armazenamento de chaves deve ser ativado para configurar a recuperação."
+ "Carrega chaves a partir deste dispositivo"
+ "Permite o armazenamento de chaves"
"Alterar chave de recuperação"
+ "Recupera a tua identidade criptográfica e o histórico de mensagens com uma chave de recuperação, caso tenhas perdido todos os teus dispositivos existentes."
"Insere a chave de recuperação"
- "A tua cópia de segurança das conversas está atualmente dessincronizada."
+ "O teu armazenamento de chaves está atualmente dessincronizado."
"Configurar recuperação"
"Obtém acesso às tuas mensagens cifradas mesmo se perderes todos os teus dispositivos ou se terminares todas as tuas sessões %1$s."
"Abre a %1$s num computador"
@@ -31,29 +35,29 @@
"Tens a certeza que queres desativar a cópia de segurança?"
"Obtém uma nova chave de recuperação se tiveres perdido a atual. Depois de a alterares, a antiga deixará de funcionar."
"Gerar uma nova chave de recuperação"
- "Certifica-te de que podes guardar a tua chave de recuperação num local seguro"
+ "Não partilhes isto com ninguém!"
"Chave de recuperação alterada"
"Alterar a chave de recuperação?"
"Criar nova chave de recuperação"
"Certifica-te de que ninguém consegue ver esta página!"
- "Por favor, tenta novamente para confirmar o acesso à tua cópia de segurança das conversas."
+ "Tenta novamente para confirmar o acesso ao teu armazenamento de chaves."
"Chave de recuperação incorreta"
"Também funciona se tiveres uma chave ou frase de segurança."
"Inserir…"
"Perdeste a tua chave?"
"Chave de recuperação confirmada"
- "Insere a tua chave de recuperação"
+ "Introduz a tua chave de recuperação"
"Chave de recuperação copiada"
"A gerar…"
"Guardar chave"
- "Anota a tua chave de recuperação num local seguro ou guarda-a num gestor de senhas."
+ "Anota esta chave de recuperação num local seguro, como um gestor de palavras-passe, uma nota encriptada ou um cofre físico."
"Toca para copiar a chave de recuperação"
"Guarda a tua chave de recuperação"
"Não poderás aceder à tua nova chave de recuperação após este passo."
"Guardaste a tua chave de recuperação?"
"A tua cópia de segurança das conversas está protegida por uma chave de recuperação. Se precisares de uma nova chave após a configuração, podes recriá-la selecionando \"Alterar chave de recuperação\"."
"Gerar a tua chave de recuperação"
- "Certifica-te de que podes guardar a tua chave de recuperação num local seguro"
+ "Não partilhes isto com ninguém!"
"Recuperação configurada com sucesso"
"Configurar recuperação"
"Sim, repor agora"
diff --git a/features/securebackup/impl/src/main/res/values-ro/translations.xml b/features/securebackup/impl/src/main/res/values-ro/translations.xml
index 5e62e1e722..9045e713fd 100644
--- a/features/securebackup/impl/src/main/res/values-ro/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-ro/translations.xml
@@ -36,7 +36,6 @@
"Introduceți…"
"Ați pierdut cheia de recuperare?"
"Cheia de recuperare confirmată"
- "Confirmați cheia de recuperare"
"Cheia de recuperare copiată"
"Se generează…"
"Salvați cheia de recuperare"
diff --git a/features/securebackup/impl/src/main/res/values-ru/translations.xml b/features/securebackup/impl/src/main/res/values-ru/translations.xml
index f69fa99951..9b209c5ec6 100644
--- a/features/securebackup/impl/src/main/res/values-ru/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-ru/translations.xml
@@ -1,9 +1,10 @@
- "Отключить резервное копирование"
+ "Удалить хранилище ключей"
"Включить резервное копирование"
"Сохраните вашу криптографическую идентификацию и ключи сообщений в безопасности на сервере. Это позволит вам просматривать историю сообщений на любых новых устройствах.%1$s ."
"Хранилище ключей"
+ "Для настройки восстановления необходимо включить хранилище ключей."
"Загрузить ключи с этого устройства"
"Разрешить хранение ключей"
"Изменить ключ восстановления"
@@ -28,10 +29,10 @@
"Выключить"
"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."
"Вы действительно хотите отключить резервное копирование?"
- "Отключение резервного копирования удалит текущую резервную копию ключа шифрования и отключит другие функции безопасности. В этом случае вы выполните следующие действия:"
+ "Удаление хранилища ключей приведёт к удалению вашей криптографической идентификации и ключей сообщений с сервера, а также отключению следующих функций безопасности:"
"Нет зашифрованной истории сообщений на новых устройствах"
"Вы потеряете доступ к зашифрованным сообщениям, если выйдете из %1$s везде"
- "Вы действительно хотите отключить резервное копирование?"
+ "Вы уверены, что хотите отключить хранение ключей и удалить их?"
"Получите новый ключ восстановления, если вы потеряли существующий. После смены ключа восстановления старый ключ больше не будет работать."
"Создать новый ключ восстановления"
"Не сообщайте эту информацию никому!"
@@ -54,7 +55,7 @@
"Сохраните ключ восстановления"
"После этого шага вы не сможете получить доступ к новому ключу восстановления."
"Вы сохранили ключ восстановления?"
- "Резервная копия чата защищена ключом восстановления. Если после настройки вам понадобится новый ключ восстановления, вы можете создать его заново, выбрав «Изменить ключ восстановления»."
+ "Ваше хранилище ключей защищено ключом восстановления. Если после настройки вам понадобится новый ключ восстановления, вы можете его пересоздать, выбрав «Изменить ключ восстановления»."
"Создайте ключ восстановления"
"Не сообщайте эту информацию никому!"
"Настройка восстановления выполнена успешно"
diff --git a/features/securebackup/impl/src/main/res/values-sk/translations.xml b/features/securebackup/impl/src/main/res/values-sk/translations.xml
index 1d04001c76..0d75ce1bf6 100644
--- a/features/securebackup/impl/src/main/res/values-sk/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-sk/translations.xml
@@ -10,7 +10,7 @@
"Obnovte svoju kryptografickú totožnosť a históriu správ pomocou kľúča na obnovenie, ak ste stratili všetky svoje existujúce zariadenia."
"Zadajte kľúč na obnovenie"
"Vaša záloha konverzácie nie je momentálne synchronizovaná."
- "Nastaviť obnovovanie"
+ "Nastaviť obnovenie"
"Získajte prístup k vašim šifrovaným správam aj keď stratíte všetky svoje zariadenia alebo sa odhlásite zo všetkých %1$s zariadení."
"Otvoriť %1$s v stolnom počítači"
"Znova sa prihláste do svojho účtu"
@@ -45,7 +45,6 @@
"Zadať…"
"Stratili ste kľúč na obnovenie?"
"Kľúč na obnovu potvrdený"
- "Potvrďte svoj kľúč na obnovenie"
"Skopírovaný kľúč na obnovenie"
"Generovanie…"
"Uložiť kľúč na obnovenie"
diff --git a/features/securebackup/impl/src/main/res/values-sv/translations.xml b/features/securebackup/impl/src/main/res/values-sv/translations.xml
index 5d1bbeea79..4bd702a339 100644
--- a/features/securebackup/impl/src/main/res/values-sv/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-sv/translations.xml
@@ -42,7 +42,6 @@
"Ange …"
"Blivit av med din återställningsnyckel?"
"Återställningsnyckel bekräftad"
- "Ange din återställningsnyckel"
"Kopierade återställningsnyckel"
"Genererar …"
"Spara återställningsnyckeln"
diff --git a/features/securebackup/impl/src/main/res/values-uk/translations.xml b/features/securebackup/impl/src/main/res/values-uk/translations.xml
index 8d06098c6f..a1ce5e529f 100644
--- a/features/securebackup/impl/src/main/res/values-uk/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-uk/translations.xml
@@ -41,7 +41,6 @@
"Ввести…"
"Загубили ключ відновлення?"
"Ключ відновлення підтверджено"
- "Підтвердіть ключ відновлення"
"Скопійовано ключ відновлення"
"Створення…"
"Зберегти ключ відновлення"
diff --git a/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml b/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml
index 8dcc1bbcc9..188e3b22bc 100644
--- a/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml
@@ -6,6 +6,5 @@
"備份"
"變更復原金鑰"
"關閉"
- "輸入您的復原金鑰"
"點擊以複製復原金鑰"
diff --git a/features/securebackup/impl/src/main/res/values-zh/translations.xml b/features/securebackup/impl/src/main/res/values-zh/translations.xml
index 8f6ef15eb0..74ff0f4414 100644
--- a/features/securebackup/impl/src/main/res/values-zh/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-zh/translations.xml
@@ -7,7 +7,7 @@
"更改恢复密钥"
"输入恢复密钥"
"您的聊天备份当前不同步。"
- "设置恢复密钥"
+ "设置恢复"
"在丢失或从 %1$s 登出所有设备的情况下访问加密消息。"
"在桌面设备中打开 %1$s"
"再次登录您的账户"
@@ -42,7 +42,6 @@
"输入……"
"丢失了恢复密钥?"
"恢复密钥已确认"
- "输入恢复密钥"
"恢复密钥已复制"
"正在生成……"
"保存恢复密钥"
diff --git a/features/securebackup/impl/src/main/res/values/localazy.xml b/features/securebackup/impl/src/main/res/values/localazy.xml
index 3fb2c3b027..0113c90596 100644
--- a/features/securebackup/impl/src/main/res/values/localazy.xml
+++ b/features/securebackup/impl/src/main/res/values/localazy.xml
@@ -1,15 +1,16 @@
- "Turn off backup"
+ "Delete key storage"
"Turn on backup"
"Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices. %1$s."
"Key storage"
+ "Key storage must be turned on to set up recovery."
"Upload keys from this device"
"Allow key storage"
"Change recovery key"
"Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices."
"Enter recovery key"
- "Your chat backup is currently out of sync."
+ "Your key storage is currently out of sync."
"Set up recovery"
"Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere."
"Open %1$s in a desktop device"
@@ -28,10 +29,10 @@
"Turn off"
"You will lose your encrypted messages if you are signed out of all devices."
"Are you sure you want to turn off backup?"
- "Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:"
- "Not have encrypted message history on new devices"
- "Lose access to your encrypted messages if you are signed out of %1$s everywhere"
- "Are you sure you want to turn off backup?"
+ "Deleting key storage will remove your cryptographic identity and message keys from the server and turn off the following security features:"
+ "You will not have encrypted message history on new devices"
+ "You will lose access to your encrypted messages if you are signed out of %1$s everywhere"
+ "Are you sure you want to turn off key storage and delete it?"
"Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work."
"Generate a new recovery key"
"Do not share this with anyone!"
@@ -39,7 +40,7 @@
"Change recovery key?"
"Create new recovery key"
"Make sure nobody can see this screen!"
- "Please try again to confirm access to your chat backup."
+ "Please try again to confirm access to your key storage."
"Incorrect recovery key"
"If you have a security key or security phrase, this will work too."
"Enter…"
@@ -54,7 +55,7 @@
"Save your recovery key somewhere safe"
"You will not be able to access your new recovery key after this step."
"Have you saved your recovery key?"
- "Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting ‘Change recovery key’."
+ "Your key storage is protected by a recovery key. If you need a new recovery key after setup, you can recreate it by selecting ‘Change recovery key’."
"Generate your recovery key"
"Do not share this with anyone!"
"Recovery setup successful"
diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt
index b0d6399c04..073a2de11c 100644
--- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt
+++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt
@@ -38,22 +38,6 @@ class SecureBackupDisablePresenterTest {
}
}
- @Test
- fun `present - user delete backup and cancel`() = runTest {
- val presenter = createSecureBackupDisablePresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
- val state = awaitItem()
- assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
- initialState.eventSink(SecureBackupDisableEvents.DismissDialogs)
- val finalState = awaitItem()
- assertThat(finalState.disableAction).isEqualTo(AsyncAction.Uninitialized)
- }
- }
-
@Test
fun `present - user delete backup success`() = runTest {
val presenter = createSecureBackupDisablePresenter()
@@ -63,9 +47,6 @@ class SecureBackupDisablePresenterTest {
val initialState = awaitItem()
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
- val state = awaitItem()
- assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
- initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val loadingState = awaitItem()
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
val finalState = awaitItem()
@@ -87,9 +68,6 @@ class SecureBackupDisablePresenterTest {
val initialState = awaitItem()
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
- val state = awaitItem()
- assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
- initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val loadingState = awaitItem()
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem()
diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenterTest.kt
deleted file mode 100644
index 46d210ad93..0000000000
--- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenterTest.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.securebackup.impl.enable
-
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
-import com.google.common.truth.Truth.assertThat
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.matrix.api.encryption.EncryptionService
-import io.element.android.libraries.matrix.test.AN_EXCEPTION
-import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
-import io.element.android.tests.testutils.WarmUpRule
-import kotlinx.coroutines.test.runTest
-import org.junit.Rule
-import org.junit.Test
-
-class SecureBackupEnablePresenterTest {
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Test
- fun `present - initial state`() = runTest {
- val presenter = createPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.enableAction).isEqualTo(AsyncAction.Uninitialized)
- }
- }
-
- @Test
- fun `present - user enable backup`() = runTest {
- val presenter = createPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink(SecureBackupEnableEvents.EnableBackup)
- val loadingState = awaitItem()
- assertThat(loadingState.enableAction).isInstanceOf(AsyncAction.Loading::class.java)
- val finalState = awaitItem()
- assertThat(finalState.enableAction).isEqualTo(AsyncAction.Success(Unit))
- }
- }
-
- @Test
- fun `present - user enable backup with error`() = runTest {
- val encryptionService = FakeEncryptionService()
- encryptionService.givenEnableBackupsFailure(AN_EXCEPTION)
- val presenter = createPresenter(encryptionService = encryptionService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink(SecureBackupEnableEvents.EnableBackup)
- val loadingState = awaitItem()
- assertThat(loadingState.enableAction).isInstanceOf(AsyncAction.Loading::class.java)
- val errorState = awaitItem()
- assertThat(errorState.enableAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
- errorState.eventSink(SecureBackupEnableEvents.DismissDialog)
- val finalState = awaitItem()
- assertThat(finalState.enableAction).isEqualTo(AsyncAction.Uninitialized)
- }
- }
-
- private fun createPresenter(
- encryptionService: EncryptionService = FakeEncryptionService(),
- ) = SecureBackupEnablePresenter(
- encryptionService = encryptionService,
- )
-}
diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt
index 6d276ded53..5dc715547b 100644
--- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt
+++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt
@@ -11,6 +11,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.encryption.BackupState
@@ -38,6 +39,8 @@ class SecureBackupRootPresenterTest {
val initialState = awaitItem()
assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN)
assertThat(initialState.doesBackupExistOnServer.dataOrNull()).isTrue()
+ assertThat(initialState.enableAction).isEqualTo(AsyncAction.Uninitialized)
+ assertThat(initialState.displayKeyStorageDisabledError).isFalse()
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
assertThat(initialState.appName).isEqualTo("Element")
assertThat(initialState.snackbarMessage).isNull()
@@ -70,6 +73,35 @@ class SecureBackupRootPresenterTest {
}
}
+ @Test
+ fun `present - setting up encryption when key storage is disabled should emit a state to render a dialog`() = runTest {
+ val presenter = createSecureBackupRootPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(2)
+ val initialState = awaitItem()
+ initialState.eventSink(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
+ assertThat(awaitItem().displayKeyStorageDisabledError).isTrue()
+ initialState.eventSink(SecureBackupRootEvents.DismissDialog)
+ assertThat(awaitItem().displayKeyStorageDisabledError).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - enable key storage invoke the expected API`() = runTest {
+ val presenter = createSecureBackupRootPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(2)
+ val initialState = awaitItem()
+ initialState.eventSink(SecureBackupRootEvents.EnableKeyStorage)
+ assertThat(awaitItem().enableAction.isLoading()).isTrue()
+ assertThat(awaitItem().enableAction.isSuccess()).isTrue()
+ }
+ }
+
private fun createSecureBackupRootPresenter(
encryptionService: EncryptionService = FakeEncryptionService(),
appName: String = "Element",
diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt
index de53096076..f4ed61dba8 100644
--- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt
+++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt
@@ -46,7 +46,7 @@ class DefaultShareService @Inject constructor(
PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS
)
.activities
- .firstOrNull { it.name.endsWith(".ShareActivity") }
+ ?.firstOrNull { it.name.endsWith(".ShareActivity") }
?.let { shareActivityInfo ->
ComponentName(
shareActivityInfo.packageName,
diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
index a834baf2bc..10d42716a9 100644
--- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
+++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt
@@ -16,6 +16,8 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.ProgressCallback
+import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -25,12 +27,14 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
+import java.io.File
@RunWith(RobolectricTestRunner::class)
class SharePresenterTest {
@@ -112,8 +116,11 @@ class SharePresenterTest {
@Test
fun `present - send media ok`() = runTest {
+ val sendFileResult = lambdaRecorder> { _, _, _ ->
+ Result.success(FakeMediaUploadHandler())
+ }
val matrixRoom = FakeMatrixRoom(
- sendMediaResult = { Result.success(FakeMediaUploadHandler()) },
+ sendFileResult = sendFileResult,
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, matrixRoom)
@@ -141,6 +148,7 @@ class SharePresenterTest {
val success = awaitItem()
assertThat(success.shareAction.isSuccess()).isTrue()
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
+ sendFileResult.assertions().isCalledOnce()
}
}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
index c87b443d4a..2dac6b7d98 100644
--- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
+++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt
@@ -106,7 +106,7 @@ fun UserProfileView(
private fun VerifyUserSection(state: UserProfileState) {
if (state.isVerified.dataOrNull() == false) {
ListItem(
- headlineContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_title, state.userName ?: state.userId)) },
+ headlineContent = { Text(stringResource(CommonStrings.common_verify_identity)) },
supportingContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_subtitle)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
enabled = false,
diff --git a/features/userprofile/shared/src/main/res/values-pl/translations.xml b/features/userprofile/shared/src/main/res/values-pl/translations.xml
index ebac7599dc..58a067e2e4 100644
--- a/features/userprofile/shared/src/main/res/values-pl/translations.xml
+++ b/features/userprofile/shared/src/main/res/values-pl/translations.xml
@@ -13,5 +13,7 @@
"Odblokuj"
"Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika."
"Odblokuj użytkownika"
+ "Użyj aplikacji internetowej, aby zweryfikować tego użytkownika."
+ "Zweryfikuj %1$s"
"Wystąpił błąd podczas próby rozpoczęcia czatu"
diff --git a/features/verifysession/api/build.gradle.kts b/features/verifysession/api/build.gradle.kts
index 37914dc0ba..748051b623 100644
--- a/features/verifysession/api/build.gradle.kts
+++ b/features/verifysession/api/build.gradle.kts
@@ -15,4 +15,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
}
diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt
new file mode 100644
index 0000000000..908816ac00
--- /dev/null
+++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
+
+interface IncomingVerificationEntryPoint : FeatureEntryPoint {
+ data class Params(
+ val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
+ ) : NodeInputs
+
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun params(params: Params): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback : Plugin {
+ fun onDone()
+ }
+}
diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts
index 8f5f6cae24..85d3b463ce 100644
--- a/features/verifysession/impl/build.gradle.kts
+++ b/features/verifysession/impl/build.gradle.kts
@@ -27,6 +27,7 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
+ implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
@@ -43,6 +44,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.logout.test)
+ testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.tests.testutils)
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
deleted file mode 100644
index b5762b6d5f..0000000000
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-/*
- * Copyright 2023, 2024 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- * Please see LICENSE in the repository root for full details.
- */
-
-package io.element.android.features.verifysession.impl
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
-import io.element.android.libraries.architecture.AsyncAction
-import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.matrix.api.verification.SessionVerificationData
-import io.element.android.libraries.matrix.api.verification.VerificationEmoji
-
-open class VerifySelfSessionStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aVerifySelfSessionState(displaySkipButton = true),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.AwaitingOtherDeviceResponse
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Canceled
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Ready
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true)
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true)
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Completed,
- displaySkipButton = true,
- ),
- aVerifySelfSessionState(
- signOutAction = AsyncAction.Loading,
- displaySkipButton = true,
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Loading
- ),
- aVerifySelfSessionState(
- verificationFlowStep = VerificationStep.Skipped
- ),
- // Add other state here
- )
-}
-
-internal fun aEmojisSessionVerificationData(
- emojiList: List = aVerificationEmojiList(),
-): SessionVerificationData {
- return SessionVerificationData.Emojis(emojiList)
-}
-
-private fun aDecimalsSessionVerificationData(
- decimals: List = listOf(123, 456, 789),
-): SessionVerificationData {
- return SessionVerificationData.Decimals(decimals)
-}
-
-internal fun aVerifySelfSessionState(
- verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false),
- signOutAction: AsyncAction = AsyncAction.Uninitialized,
- displaySkipButton: Boolean = false,
- eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
-) = VerifySelfSessionState(
- verificationFlowStep = verificationFlowStep,
- displaySkipButton = displaySkipButton,
- eventSink = eventSink,
- signOutAction = signOutAction,
-)
-
-private fun aVerificationEmojiList() = listOf(
- VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"),
- VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
- VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
- VerificationEmoji(number = 42, emoji = "📕", description = "Book"),
- VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
- VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
- VerificationEmoji(number = 63, emoji = "📌", description = "Pin"),
-)
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt
new file mode 100644
index 0000000000..24ea086720
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultIncomingVerificationEntryPoint @Inject constructor() : IncomingVerificationEntryPoint {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): IncomingVerificationEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : IncomingVerificationEntryPoint.NodeBuilder {
+ override fun callback(callback: IncomingVerificationEntryPoint.Callback): IncomingVerificationEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun params(params: IncomingVerificationEntryPoint.Params): IncomingVerificationEntryPoint.NodeBuilder {
+ plugins += params
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt
new file mode 100644
index 0000000000..9ae5a09dd8
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming
+
+fun interface IncomingVerificationNavigator {
+ fun onFinish()
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt
new file mode 100644
index 0000000000..14df3ef8d9
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class IncomingVerificationNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: IncomingVerificationPresenter.Factory,
+) : Node(buildContext, plugins = plugins),
+ IncomingVerificationNavigator {
+ private val presenter = presenterFactory.create(
+ sessionVerificationRequestDetails = inputs().sessionVerificationRequestDetails,
+ navigator = this,
+ )
+
+ override fun onFinish() {
+ plugins().forEach { it.onDone() }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ IncomingVerificationView(
+ state = state,
+ modifier = modifier,
+ )
+ }
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt
new file mode 100644
index 0000000000..ebd897d84c
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.verifysession.impl.incoming
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import com.freeletics.flowredux.compose.rememberStateAndDispatch
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
+import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
+import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import io.element.android.libraries.matrix.api.verification.VerificationFlowState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import timber.log.Timber
+import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.Event as StateMachineEvent
+import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState
+
+class IncomingVerificationPresenter @AssistedInject constructor(
+ @Assisted private val sessionVerificationRequestDetails: SessionVerificationRequestDetails,
+ @Assisted private val navigator: IncomingVerificationNavigator,
+ private val sessionVerificationService: SessionVerificationService,
+ private val stateMachine: IncomingVerificationStateMachine,
+ private val dateFormatter: LastMessageTimestampFormatter,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(
+ sessionVerificationRequestDetails: SessionVerificationRequestDetails,
+ navigator: IncomingVerificationNavigator,
+ ): IncomingVerificationPresenter
+ }
+
+ @Composable
+ override fun present(): IncomingVerificationState {
+ LaunchedEffect(Unit) {
+ // Force reset, just in case the service was left in a broken state
+ sessionVerificationService.reset(
+ cancelAnyPendingVerificationAttempt = false
+ )
+ // Acknowledge the request right now
+ sessionVerificationService.acknowledgeVerificationRequest(sessionVerificationRequestDetails)
+ }
+ val stateAndDispatch = stateMachine.rememberStateAndDispatch()
+ val formattedSignInTime = remember {
+ dateFormatter.format(sessionVerificationRequestDetails.firstSeenTimestamp)
+ }
+ val step by remember {
+ derivedStateOf {
+ stateAndDispatch.state.value.toVerificationStep(
+ sessionVerificationRequestDetails = sessionVerificationRequestDetails,
+ formattedSignInTime = formattedSignInTime,
+ )
+ }
+ }
+
+ LaunchedEffect(stateAndDispatch.state.value) {
+ if ((stateAndDispatch.state.value as? IncomingVerificationStateMachine.State.Initial)?.isCancelled == true) {
+ // The verification was canceled before it was started, maybe because another session accepted it
+ navigator.onFinish()
+ }
+ }
+
+ // Start this after observing state machine
+ LaunchedEffect(Unit) {
+ observeVerificationService()
+ }
+
+ fun handleEvents(event: IncomingVerificationViewEvents) {
+ Timber.d("Verification user action: ${event::class.simpleName}")
+ when (event) {
+ IncomingVerificationViewEvents.StartVerification ->
+ stateAndDispatch.dispatchAction(StateMachineEvent.AcceptIncomingRequest)
+ IncomingVerificationViewEvents.IgnoreVerification ->
+ navigator.onFinish()
+ IncomingVerificationViewEvents.ConfirmVerification ->
+ stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
+ IncomingVerificationViewEvents.DeclineVerification ->
+ stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
+ IncomingVerificationViewEvents.GoBack -> {
+ when (val verificationStep = step) {
+ is Step.Initial -> if (verificationStep.isWaiting) {
+ stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
+ } else {
+ navigator.onFinish()
+ }
+ is Step.Verifying -> if (verificationStep.isWaiting) {
+ // What do we do in this case?
+ } else {
+ stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
+ }
+ Step.Canceled,
+ Step.Completed,
+ Step.Failure -> navigator.onFinish()
+ }
+ }
+ }
+ }
+
+ return IncomingVerificationState(
+ step = step,
+ eventSink = ::handleEvents,
+ )
+ }
+
+ private fun StateMachineState?.toVerificationStep(
+ sessionVerificationRequestDetails: SessionVerificationRequestDetails,
+ formattedSignInTime: String,
+ ): Step =
+ when (val machineState = this) {
+ is StateMachineState.Initial,
+ IncomingVerificationStateMachine.State.AcceptingIncomingVerification,
+ IncomingVerificationStateMachine.State.RejectingIncomingVerification,
+ null -> {
+ Step.Initial(
+ deviceDisplayName = sessionVerificationRequestDetails.displayName ?: sessionVerificationRequestDetails.deviceId.value,
+ deviceId = sessionVerificationRequestDetails.deviceId,
+ formattedSignInTime = formattedSignInTime,
+ isWaiting = machineState == IncomingVerificationStateMachine.State.AcceptingIncomingVerification ||
+ machineState == IncomingVerificationStateMachine.State.RejectingIncomingVerification,
+ )
+ }
+ is IncomingVerificationStateMachine.State.ChallengeReceived ->
+ Step.Verifying(
+ data = machineState.data,
+ isWaiting = false,
+ )
+ IncomingVerificationStateMachine.State.Completed -> Step.Completed
+ IncomingVerificationStateMachine.State.Canceling,
+ IncomingVerificationStateMachine.State.Failure -> Step.Failure
+ is IncomingVerificationStateMachine.State.AcceptingChallenge ->
+ Step.Verifying(
+ data = machineState.data,
+ isWaiting = true,
+ )
+ is IncomingVerificationStateMachine.State.RejectingChallenge ->
+ Step.Verifying(
+ data = machineState.data,
+ isWaiting = true,
+ )
+ IncomingVerificationStateMachine.State.Canceled -> Step.Canceled
+ }
+
+ private fun CoroutineScope.observeVerificationService() {
+ sessionVerificationService.verificationFlowState
+ .onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
+ .onEach { verificationAttemptState ->
+ when (verificationAttemptState) {
+ VerificationFlowState.Initial,
+ VerificationFlowState.DidAcceptVerificationRequest,
+ VerificationFlowState.DidStartSasVerification -> Unit
+ is VerificationFlowState.DidReceiveVerificationData -> {
+ stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
+ }
+ VerificationFlowState.DidFinish -> {
+ stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidAcceptChallenge)
+ }
+ VerificationFlowState.DidCancel -> {
+ // Can happen when:
+ // - the remote party cancel the verification (before it is started)
+ // - another session has accepted the incoming verification request
+ // - the user reject the challenge from this application (I think this is an error). In this case, the state
+ // machine will ignore this event and change state to Failure.
+ stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidCancel)
+ }
+ VerificationFlowState.DidFail -> {
+ stateMachine.dispatch(IncomingVerificationStateMachine.Event.DidFail)
+ }
+ }
+ }
+ .launchIn(this)
+ }
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt
new file mode 100644
index 0000000000..4fcb86dfb8
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import io.element.android.libraries.matrix.api.core.DeviceId
+import io.element.android.libraries.matrix.api.verification.SessionVerificationData
+
+@Immutable
+data class IncomingVerificationState(
+ val step: Step,
+ val eventSink: (IncomingVerificationViewEvents) -> Unit,
+) {
+ @Stable
+ sealed interface Step {
+ data class Initial(
+ val deviceDisplayName: String,
+ val deviceId: DeviceId,
+ val formattedSignInTime: String,
+ val isWaiting: Boolean,
+ ) : Step
+
+ data class Verifying(
+ val data: SessionVerificationData,
+ val isWaiting: Boolean,
+ ) : Step
+
+ data object Canceled : Step
+ data object Completed : Step
+ data object Failure : Step
+ }
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt
new file mode 100644
index 0000000000..b8c3276af4
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.verifysession.impl.incoming
+
+import com.freeletics.flowredux.dsl.FlowReduxStateMachine
+import io.element.android.features.verifysession.impl.util.andLogStateChange
+import io.element.android.features.verifysession.impl.util.logReceivedEvents
+import io.element.android.libraries.matrix.api.verification.SessionVerificationData
+import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import javax.inject.Inject
+import com.freeletics.flowredux.dsl.State as MachineState
+
+class IncomingVerificationStateMachine @Inject constructor(
+ private val sessionVerificationService: SessionVerificationService,
+) : FlowReduxStateMachine(
+ initialState = State.Initial(isCancelled = false)
+) {
+ init {
+ spec {
+ inState {
+ on { _: Event.AcceptIncomingRequest, state ->
+ state.override { State.AcceptingIncomingVerification.andLogStateChange() }
+ }
+ }
+ inState {
+ onEnterEffect {
+ sessionVerificationService.acceptVerificationRequest()
+ }
+ on { event: Event.DidReceiveChallenge, state ->
+ state.override { State.ChallengeReceived(event.data).andLogStateChange() }
+ }
+ }
+ inState {
+ on { _: Event.AcceptChallenge, state ->
+ state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() }
+ }
+ on { _: Event.DeclineChallenge, state ->
+ state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() }
+ }
+ }
+ inState {
+ onEnterEffect { _ ->
+ sessionVerificationService.approveVerification()
+ }
+ on { _: Event.DidAcceptChallenge, state ->
+ state.override { State.Completed.andLogStateChange() }
+ }
+ }
+ inState {
+ onEnterEffect { _ ->
+ sessionVerificationService.declineVerification()
+ }
+ }
+ inState {
+ onEnterEffect {
+ sessionVerificationService.cancelVerification()
+ }
+ }
+ inState {
+ logReceivedEvents()
+ on { _: Event.Cancel, state: MachineState ->
+ when (state.snapshot) {
+ State.Completed, State.Canceled -> state.noChange()
+ else -> {
+ sessionVerificationService.cancelVerification()
+ state.override { State.Canceled.andLogStateChange() }
+ }
+ }
+ }
+ on { _: Event.DidCancel, state: MachineState ->
+ when (state.snapshot) {
+ is State.RejectingChallenge -> {
+ state.override { State.Failure.andLogStateChange() }
+ }
+ is State.Initial -> state.mutate { State.Initial(isCancelled = true).andLogStateChange() }
+ State.AcceptingIncomingVerification,
+ State.RejectingIncomingVerification,
+ is State.ChallengeReceived,
+ is State.AcceptingChallenge,
+ State.Canceling -> state.override { State.Canceled.andLogStateChange() }
+ State.Canceled,
+ State.Completed,
+ State.Failure -> state.noChange()
+ }
+ }
+ on { _: Event.DidFail, state: MachineState ->
+ state.override { State.Failure.andLogStateChange() }
+ }
+ }
+ }
+ }
+
+ sealed interface State {
+ /** The initial state, before verification started. */
+ data class Initial(val isCancelled: Boolean) : State
+
+ /** User is accepting the incoming verification. */
+ data object AcceptingIncomingVerification : State
+
+ /** User is rejecting the incoming verification. */
+ data object RejectingIncomingVerification : State
+
+ /** Verification accepted and emojis received. */
+ data class ChallengeReceived(val data: SessionVerificationData) : State
+
+ /** Accepting the verification challenge. */
+ data class AcceptingChallenge(val data: SessionVerificationData) : State
+
+ /** Rejecting the verification challenge. */
+ data class RejectingChallenge(val data: SessionVerificationData) : State
+
+ /** The verification is being canceled. */
+ data object Canceling : State
+
+ /** The verification has been canceled, remotely or locally. */
+ data object Canceled : State
+
+ /** Verification successful. */
+ data object Completed : State
+
+ /** Verification failure. */
+ data object Failure : State
+ }
+
+ sealed interface Event {
+ /** User accepts the incoming request. */
+ data object AcceptIncomingRequest : Event
+
+ /** Has received data. */
+ data class DidReceiveChallenge(val data: SessionVerificationData) : Event
+
+ /** Emojis match. */
+ data object AcceptChallenge : Event
+
+ /** Emojis do not match. */
+ data object DeclineChallenge : Event
+
+ /** Remote accepted challenge. */
+ data object DidAcceptChallenge : Event
+
+ /** Request cancellation. */
+ data object Cancel : Event
+
+ /** Verification cancelled. */
+ data object DidCancel : Event
+
+ /** Request failed. */
+ data object DidFail : Event
+ }
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt
new file mode 100644
index 0000000000..0b43dcdd3c
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
+import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
+import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
+import io.element.android.libraries.matrix.api.core.DeviceId
+
+open class IncomingVerificationStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ anIncomingVerificationState(),
+ anIncomingVerificationState(step = aStepInitial(isWaiting = true)),
+ anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)),
+ anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)),
+ anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)),
+ anIncomingVerificationState(step = Step.Completed),
+ anIncomingVerificationState(step = Step.Failure),
+ anIncomingVerificationState(step = Step.Canceled),
+ // Add other state here
+ )
+}
+
+internal fun aStepInitial(
+ isWaiting: Boolean = false,
+) = Step.Initial(
+ deviceDisplayName = "Element X Android",
+ deviceId = DeviceId("ILAKNDNASDLK"),
+ formattedSignInTime = "12:34",
+ isWaiting = isWaiting,
+)
+
+internal fun anIncomingVerificationState(
+ step: Step = aStepInitial(),
+ eventSink: (IncomingVerificationViewEvents) -> Unit = {},
+) = IncomingVerificationState(
+ step = step,
+ eventSink = eventSink,
+)
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt
new file mode 100644
index 0000000000..3d6adf28cd
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.verifysession.impl.R
+import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
+import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView
+import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
+import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
+import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.PageTitle
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.InvisibleButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.verification.SessionVerificationData
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * [Figma](https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=819-7324).
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun IncomingVerificationView(
+ state: IncomingVerificationState,
+ modifier: Modifier = Modifier,
+) {
+ val step = state.step
+
+ BackHandler {
+ state.eventSink(IncomingVerificationViewEvents.GoBack)
+ }
+ HeaderFooterPage(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {},
+ )
+ },
+ header = {
+ IncomingVerificationHeader(step = step)
+ },
+ footer = {
+ IncomingVerificationBottomMenu(
+ state = state,
+ )
+ }
+ ) {
+ IncomingVerificationContent(
+ step = step,
+ )
+ }
+}
+
+@Composable
+private fun IncomingVerificationHeader(step: Step) {
+ val iconStyle = when (step) {
+ Step.Canceled -> BigIcon.Style.AlertSolid
+ is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
+ is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
+ Step.Completed -> BigIcon.Style.SuccessSolid
+ Step.Failure -> BigIcon.Style.AlertSolid
+ }
+ val titleTextId = when (step) {
+ Step.Canceled -> R.string.screen_session_verification_request_failure_title
+ is Step.Initial -> R.string.screen_session_verification_request_title
+ is Step.Verifying -> when (step.data) {
+ is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
+ is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
+ }
+ Step.Completed -> R.string.screen_session_verification_request_success_title
+ Step.Failure -> R.string.screen_session_verification_request_failure_title
+ }
+ val subtitleTextId = when (step) {
+ Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle
+ is Step.Initial -> R.string.screen_session_verification_request_subtitle
+ is Step.Verifying -> when (step.data) {
+ is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
+ is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
+ }
+ Step.Completed -> R.string.screen_session_verification_request_success_subtitle
+ Step.Failure -> R.string.screen_session_verification_request_failure_subtitle
+ }
+ PageTitle(
+ iconStyle = iconStyle,
+ title = stringResource(id = titleTextId),
+ subtitle = stringResource(id = subtitleTextId)
+ )
+}
+
+@Composable
+private fun IncomingVerificationContent(
+ step: Step,
+) {
+ when (step) {
+ is Step.Initial -> ContentInitial(step)
+ is Step.Verifying -> VerificationContentVerifying(step.data)
+ else -> Unit
+ }
+}
+
+@Composable
+private fun ContentInitial(
+ initialIncoming: Step.Initial,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ SessionDetailsView(
+ deviceName = initialIncoming.deviceDisplayName,
+ deviceId = initialIncoming.deviceId,
+ signInFormattedTimestamp = initialIncoming.formattedSignInTime,
+ )
+ Text(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(bottom = 16.dp),
+ text = stringResource(R.string.screen_session_verification_request_footer),
+ style = ElementTheme.typography.fontBodyMdMedium,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+private fun IncomingVerificationBottomMenu(
+ state: IncomingVerificationState,
+) {
+ val step = state.step
+ val eventSink = state.eventSink
+
+ when (step) {
+ is Step.Initial -> {
+ if (step.isWaiting) {
+ VerificationBottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_identity_waiting_on_other_device),
+ onClick = {},
+ enabled = false,
+ showProgress = true,
+ )
+ InvisibleButton()
+ }
+ } else {
+ VerificationBottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_start),
+ onClick = { eventSink(IncomingVerificationViewEvents.StartVerification) },
+ )
+ TextButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_ignore),
+ onClick = { eventSink(IncomingVerificationViewEvents.IgnoreVerification) },
+ )
+ }
+ }
+ }
+ is Step.Verifying -> {
+ if (step.isWaiting) {
+ VerificationBottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing),
+ onClick = {},
+ enabled = false,
+ showProgress = true,
+ )
+ InvisibleButton()
+ }
+ } else {
+ VerificationBottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_they_match),
+ onClick = { eventSink(IncomingVerificationViewEvents.ConfirmVerification) },
+ )
+ TextButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_they_dont_match),
+ onClick = { eventSink(IncomingVerificationViewEvents.DeclineVerification) },
+ )
+ }
+ }
+ }
+ Step.Canceled,
+ is Step.Completed,
+ is Step.Failure -> {
+ VerificationBottomMenu {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_done),
+ onClick = { eventSink(IncomingVerificationViewEvents.GoBack) },
+ )
+ }
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun IncomingVerificationViewPreview(@PreviewParameter(IncomingVerificationStateProvider::class) state: IncomingVerificationState) = ElementPreview {
+ IncomingVerificationView(
+ state = state,
+ )
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt
new file mode 100644
index 0000000000..c1fef2ff88
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming
+
+sealed interface IncomingVerificationViewEvents {
+ data object GoBack : IncomingVerificationViewEvents
+ data object StartVerification : IncomingVerificationViewEvents
+ data object IgnoreVerification : IncomingVerificationViewEvents
+ data object ConfirmVerification : IncomingVerificationViewEvents
+ data object DeclineVerification : IncomingVerificationViewEvents
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt
new file mode 100644
index 0000000000..2a17502176
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming.ui
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.verifysession.impl.R
+import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
+import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule
+import io.element.android.libraries.designsystem.icons.CompoundDrawables
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.core.DeviceId
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun SessionDetailsView(
+ deviceName: String,
+ deviceId: DeviceId,
+ signInFormattedTimestamp: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .border(
+ width = 1.dp,
+ color = ElementTheme.colors.borderDisabled,
+ shape = RoundedCornerShape(8.dp)
+ )
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RoundedIconAtom(
+ modifier = Modifier,
+ size = RoundedIconAtomSize.Big,
+ resourceId = CompoundDrawables.ic_compound_devices
+ )
+ Text(
+ text = deviceName,
+ style = ElementTheme.typography.fontBodyMdMedium,
+ color = ElementTheme.colors.textPrimary,
+ )
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TextWithLabelMolecule(
+ label = stringResource(R.string.screen_session_verification_request_details_timestamp),
+ text = signInFormattedTimestamp,
+ modifier = Modifier.weight(2f),
+ )
+ TextWithLabelMolecule(
+ label = stringResource(CommonStrings.common_device_id),
+ text = deviceId.value,
+ modifier = Modifier.weight(5f),
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun SessionDetailsViewPreview() = ElementPreview {
+ SessionDetailsView(
+ deviceName = "Element X Android",
+ deviceId = DeviceId("ILAKNDNASDLK"),
+ signInFormattedTimestamp = "12:34",
+ )
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultVerifySessionEntryPoint.kt
similarity index 95%
rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt
rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultVerifySessionEntryPoint.kt
index def5c4c84c..4563c8db56 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultVerifySessionEntryPoint.kt
@@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
-package io.element.android.features.verifysession.impl
+package io.element.android.features.verifysession.impl.outgoing
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionNode.kt
similarity index 97%
rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionNode.kt
index 3eb33b0c8d..cf4c7ae084 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionNode.kt
@@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
-package io.element.android.features.verifysession.impl
+package io.element.android.features.verifysession.impl.outgoing
import android.app.Activity
import androidx.compose.runtime.Composable
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt
similarity index 70%
rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt
rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt
index a8667217fe..97778b322b 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenter.kt
@@ -7,7 +7,7 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
-package io.element.android.features.verifysession.impl
+package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -39,8 +39,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
-import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
-import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState
+import timber.log.Timber
+import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
+import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
class VerifySelfSessionPresenter @AssistedInject constructor(
@Assisted private val showDeviceVerifiedScreen: Boolean,
@@ -61,7 +62,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state
- sessionVerificationService.reset()
+ sessionVerificationService.reset(true)
}
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
@@ -70,13 +71,13 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
val signOutAction = remember {
mutableStateOf>(AsyncAction.Uninitialized)
}
- val verificationFlowStep by remember {
+ val step by remember {
derivedStateOf {
if (skipVerification) {
- VerifySelfSessionState.VerificationStep.Skipped
+ VerifySelfSessionState.Step.Skipped
} else {
when (sessionVerifiedStatus) {
- SessionVerifiedStatus.Unknown -> VerifySelfSessionState.VerificationStep.Loading
+ SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
SessionVerifiedStatus.NotVerified -> {
stateAndDispatch.state.value.toVerificationStep(
canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
@@ -85,10 +86,10 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
SessionVerifiedStatus.Verified -> {
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
// The user has verified the session, we need to show the success screen
- VerifySelfSessionState.VerificationStep.Completed
+ VerifySelfSessionState.Step.Completed
} else {
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
- VerifySelfSessionState.VerificationStep.Skipped
+ VerifySelfSessionState.Step.Skipped
}
}
}
@@ -101,7 +102,9 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
}
fun handleEvents(event: VerifySelfSessionViewEvents) {
+ Timber.d("Verification user action: ${event::class.simpleName}")
when (event) {
+ VerifySelfSessionViewEvents.UseAnotherDevice -> stateAndDispatch.dispatchAction(StateMachineEvent.UseAnotherDevice)
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
@@ -115,7 +118,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
}
}
return VerifySelfSessionState(
- verificationFlowStep = verificationFlowStep,
+ step = step,
signOutAction = signOutAction.value,
displaySkipButton = buildMeta.isDebuggable,
eventSink = ::handleEvents,
@@ -124,27 +127,30 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
private fun StateMachineState?.toVerificationStep(
canEnterRecoveryKey: Boolean
- ): VerifySelfSessionState.VerificationStep =
+ ): VerifySelfSessionState.Step =
when (val machineState = this) {
StateMachineState.Initial, null -> {
- VerifySelfSessionState.VerificationStep.Initial(
+ VerifySelfSessionState.Step.Initial(
canEnterRecoveryKey = canEnterRecoveryKey,
isLastDevice = encryptionService.isLastDevice.value
)
}
+ VerifySelfSessionStateMachine.State.UseAnotherDevice -> {
+ VerifySelfSessionState.Step.UseAnotherDevice
+ }
StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted,
StateMachineState.Canceling -> {
- VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
+ VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
}
StateMachineState.VerificationRequestAccepted -> {
- VerifySelfSessionState.VerificationStep.Ready
+ VerifySelfSessionState.Step.Ready
}
StateMachineState.Canceled -> {
- VerifySelfSessionState.VerificationStep.Canceled
+ VerifySelfSessionState.Step.Canceled
}
is StateMachineState.Verifying -> {
@@ -152,38 +158,41 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
is StateMachineState.Verifying.Replying -> AsyncData.Loading()
else -> AsyncData.Uninitialized
}
- VerifySelfSessionState.VerificationStep.Verifying(machineState.data, async)
+ VerifySelfSessionState.Step.Verifying(machineState.data, async)
}
StateMachineState.Completed -> {
- VerifySelfSessionState.VerificationStep.Completed
+ VerifySelfSessionState.Step.Completed
}
}
private fun CoroutineScope.observeVerificationService() {
- sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
- when (verificationAttemptState) {
- VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
- VerificationFlowState.AcceptedVerificationRequest -> {
- stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
- }
- VerificationFlowState.StartedSasVerification -> {
- stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
- }
- is VerificationFlowState.ReceivedVerificationData -> {
- stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
- }
- VerificationFlowState.Finished -> {
- stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
- }
- VerificationFlowState.Canceled -> {
- stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
- }
- VerificationFlowState.Failed -> {
- stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
+ sessionVerificationService.verificationFlowState
+ .onEach { Timber.d("Verification flow state: ${it::class.simpleName}") }
+ .onEach { verificationAttemptState ->
+ when (verificationAttemptState) {
+ VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
+ VerificationFlowState.DidAcceptVerificationRequest -> {
+ stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
+ }
+ VerificationFlowState.DidStartSasVerification -> {
+ stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification)
+ }
+ is VerificationFlowState.DidReceiveVerificationData -> {
+ stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.data))
+ }
+ VerificationFlowState.DidFinish -> {
+ stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge)
+ }
+ VerificationFlowState.DidCancel -> {
+ stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel)
+ }
+ VerificationFlowState.DidFail -> {
+ stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail)
+ }
}
}
- }.launchIn(this)
+ .launchIn(this)
}
private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch {
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt
similarity index 60%
rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt
rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt
index 81062d57c7..32664b347f 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionState.kt
@@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
-package io.element.android.features.verifysession.impl
+package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
@@ -15,22 +15,23 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
@Immutable
data class VerifySelfSessionState(
- val verificationFlowStep: VerificationStep,
+ val step: Step,
val signOutAction: AsyncAction,
val displaySkipButton: Boolean,
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
) {
@Stable
- sealed interface VerificationStep {
- data object Loading : VerificationStep
+ sealed interface Step {
+ data object Loading : Step
// FIXME canEnterRecoveryKey value is never read.
- data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : VerificationStep
- data object Canceled : VerificationStep
- data object AwaitingOtherDeviceResponse : VerificationStep
- data object Ready : VerificationStep
- data class Verifying(val data: SessionVerificationData, val state: AsyncData) : VerificationStep
- data object Completed : VerificationStep
- data object Skipped : VerificationStep
+ data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
+ data object UseAnotherDevice : Step
+ data object Canceled : Step
+ data object AwaitingOtherDeviceResponse : Step
+ data object Ready : Step
+ data class Verifying(val data: SessionVerificationData, val state: AsyncData) : Step
+ data object Completed : Step
+ data object Skipped : Step
}
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt
similarity index 84%
rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt
rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt
index 55c3d7e94f..a67cc4d331 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateMachine.kt
@@ -8,9 +8,11 @@
@file:Suppress("WildcardImport")
@file:OptIn(ExperimentalCoroutinesApi::class)
-package io.element.android.features.verifysession.impl
+package io.element.android.features.verifysession.impl.outgoing
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
+import io.element.android.features.verifysession.impl.util.andLogStateChange
+import io.element.android.features.verifysession.impl.util.logReceivedEvents
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -36,11 +38,13 @@ class VerifySelfSessionStateMachine @Inject constructor(
init {
spec {
inState {
- on { _: Event.RequestVerification, state ->
- state.override { State.RequestingVerification }
+ on { _: Event.UseAnotherDevice, state ->
+ state.override { State.UseAnotherDevice.andLogStateChange() }
}
- on { _: Event.StartSasVerification, state ->
- state.override { State.StartingSasVerification }
+ }
+ inState {
+ on { _: Event.RequestVerification, state ->
+ state.override { State.RequestingVerification.andLogStateChange() }
}
}
inState {
@@ -48,7 +52,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
sessionVerificationService.requestVerification()
}
on { _: Event.DidAcceptVerificationRequest, state ->
- state.override { State.VerificationRequestAccepted }
+ state.override { State.VerificationRequestAccepted.andLogStateChange() }
}
}
inState {
@@ -58,28 +62,25 @@ class VerifySelfSessionStateMachine @Inject constructor(
}
inState {
on { _: Event.StartSasVerification, state ->
- state.override { State.StartingSasVerification }
+ state.override { State.StartingSasVerification.andLogStateChange() }
}
}
inState {
- on { _: Event.RequestVerification, state ->
- state.override { State.RequestingVerification }
- }
on { _: Event.Reset, state ->
- state.override { State.Initial }
+ state.override { State.Initial.andLogStateChange() }
}
}
inState {
on { event: Event.DidReceiveChallenge, state ->
- state.override { State.Verifying.ChallengeReceived(event.data) }
+ state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() }
}
}
inState {
on { _: Event.AcceptChallenge, state ->
- state.override { State.Verifying.Replying(state.snapshot.data, accept = true) }
+ state.override { State.Verifying.Replying(state.snapshot.data, accept = true).andLogStateChange() }
}
on { _: Event.DeclineChallenge, state ->
- state.override { State.Verifying.Replying(state.snapshot.data, accept = false) }
+ state.override { State.Verifying.Replying(state.snapshot.data, accept = false).andLogStateChange() }
}
}
inState {
@@ -100,7 +101,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
.first()
}
}
- state.override { State.Completed }
+ state.override { State.Completed.andLogStateChange() }
}
}
inState {
@@ -110,27 +111,29 @@ class VerifySelfSessionStateMachine @Inject constructor(
}
}
inState {
+ logReceivedEvents()
on { _: Event.DidStartSasVerification, state: MachineState ->
- state.override { State.SasVerificationStarted }
+ state.override { State.SasVerificationStarted.andLogStateChange() }
}
on { _: Event.Cancel, state: MachineState ->
when (state.snapshot) {
State.Initial, State.Completed, State.Canceled -> state.noChange()
+ State.UseAnotherDevice -> state.override { State.Initial.andLogStateChange() }
// For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
// `Canceling` state to `Canceled` automatically anymore
else -> {
sessionVerificationService.cancelVerification()
- state.override { State.Canceled }
+ state.override { State.Canceled.andLogStateChange() }
}
}
}
on { _: Event.DidCancel, state: MachineState ->
- state.override { State.Canceled }
+ state.override { State.Canceled.andLogStateChange() }
}
on { _: Event.DidFail, state: MachineState ->
when (state.snapshot) {
- is State.RequestingVerification -> state.override { State.Initial }
- else -> state.override { State.Canceled }
+ is State.RequestingVerification -> state.override { State.Initial.andLogStateChange() }
+ else -> state.override { State.Canceled.andLogStateChange() }
}
}
}
@@ -141,6 +144,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
/** The initial state, before verification started. */
data object Initial : State
+ /** Let the user know that they need to get ready on their other session. */
+ data object UseAnotherDevice : State
+
/** Waiting for verification acceptance. */
data object RequestingVerification : State
@@ -172,6 +178,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
}
sealed interface Event {
+ /** User wants to use another session. */
+ data object UseAnotherDevice : Event
+
/** Request verification. */
data object RequestVerification : Event
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt
new file mode 100644
index 0000000000..b9de96aae4
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionStateProvider.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2023, 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.outgoing
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
+import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
+import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.AsyncData
+
+open class VerifySelfSessionStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aVerifySelfSessionState(displaySkipButton = true),
+ aVerifySelfSessionState(
+ step = Step.AwaitingOtherDeviceResponse
+ ),
+ aVerifySelfSessionState(
+ step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
+ ),
+ aVerifySelfSessionState(
+ step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
+ ),
+ aVerifySelfSessionState(
+ step = Step.Canceled
+ ),
+ aVerifySelfSessionState(
+ step = Step.Ready
+ ),
+ aVerifySelfSessionState(
+ step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
+ ),
+ aVerifySelfSessionState(
+ step = Step.Initial(canEnterRecoveryKey = true)
+ ),
+ aVerifySelfSessionState(
+ step = Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)
+ ),
+ aVerifySelfSessionState(
+ step = Step.Completed,
+ displaySkipButton = true,
+ ),
+ aVerifySelfSessionState(
+ signOutAction = AsyncAction.Loading,
+ displaySkipButton = true,
+ ),
+ aVerifySelfSessionState(
+ step = Step.Loading
+ ),
+ aVerifySelfSessionState(
+ step = Step.Skipped
+ ),
+ aVerifySelfSessionState(
+ step = Step.UseAnotherDevice
+ ),
+ // Add other state here
+ )
+}
+
+internal fun aVerifySelfSessionState(
+ step: Step = Step.Initial(canEnterRecoveryKey = false),
+ signOutAction: AsyncAction = AsyncAction.Uninitialized,
+ displaySkipButton: Boolean = false,
+ eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
+) = VerifySelfSessionState(
+ step = step,
+ displaySkipButton = displaySkipButton,
+ eventSink = eventSink,
+ signOutAction = signOutAction,
+)
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt
similarity index 55%
rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt
index 5b0c9105ad..e1e22eda33 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionView.kt
@@ -5,25 +5,17 @@
* Please see LICENSE in the repository root for full details.
*/
-package io.element.android.features.verifysession.impl
+package io.element.android.features.verifysession.impl.outgoing
import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -31,18 +23,17 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
-import io.element.android.features.verifysession.impl.emoji.toEmojiResource
+import io.element.android.features.verifysession.impl.R
+import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
+import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
+import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
@@ -51,14 +42,13 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.InvisibleButton
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
-import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.ui.strings.CommonStrings
-import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -71,12 +61,15 @@ fun VerifySelfSessionView(
onSuccessLogout: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
+ val step = state.step
fun cancelOrResetFlow() {
- when (state.verificationFlowStep) {
- is FlowStep.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
- is FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
- is FlowStep.Verifying -> {
- if (!state.verificationFlowStep.state.isLoading()) {
+ when (step) {
+ is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
+ is Step.AwaitingOtherDeviceResponse,
+ Step.UseAnotherDevice,
+ Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
+ is Step.Verifying -> {
+ if (!step.state.isLoading()) {
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
}
}
@@ -85,18 +78,17 @@ fun VerifySelfSessionView(
}
val latestOnFinish by rememberUpdatedState(newValue = onFinish)
- LaunchedEffect(state.verificationFlowStep, latestOnFinish) {
- if (state.verificationFlowStep is FlowStep.Skipped) {
+ LaunchedEffect(step, latestOnFinish) {
+ if (step is Step.Skipped) {
latestOnFinish()
}
}
BackHandler {
cancelOrResetFlow()
}
- val verificationFlowStep = state.verificationFlowStep
- if (state.verificationFlowStep is FlowStep.Loading ||
- state.verificationFlowStep is FlowStep.Skipped) {
+ if (step is Step.Loading ||
+ step is Step.Skipped) {
// Just display a loader in this case, to avoid UI glitch.
Box(
modifier = Modifier.fillMaxSize(),
@@ -111,7 +103,7 @@ fun VerifySelfSessionView(
TopAppBar(
title = {},
actions = {
- if (state.verificationFlowStep !is FlowStep.Completed &&
+ if (step !is Step.Completed &&
state.displaySkipButton &&
LocalInspectionMode.current.not()) {
TextButton(
@@ -119,7 +111,7 @@ fun VerifySelfSessionView(
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
)
}
- if (state.verificationFlowStep is FlowStep.Initial) {
+ if (step is Step.Initial) {
TextButton(
text = stringResource(CommonStrings.action_signout),
onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) }
@@ -129,10 +121,10 @@ fun VerifySelfSessionView(
)
},
header = {
- HeaderContent(verificationFlowStep = verificationFlowStep)
+ VerifySelfSessionHeader(step = step)
},
footer = {
- BottomMenu(
+ VerifySelfSessionBottomMenu(
screenState = state,
onCancelClick = ::cancelOrResetFlow,
onEnterRecoveryKey = onEnterRecoveryKey,
@@ -141,8 +133,8 @@ fun VerifySelfSessionView(
)
}
) {
- Content(
- flowState = verificationFlowStep,
+ VerifySelfSessionContent(
+ flowState = step,
onLearnMoreClick = onLearnMoreClick,
)
}
@@ -165,38 +157,44 @@ fun VerifySelfSessionView(
}
@Composable
-private fun HeaderContent(verificationFlowStep: FlowStep) {
- val iconStyle = when (verificationFlowStep) {
- VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
- is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
- FlowStep.Canceled -> BigIcon.Style.AlertSolid
- FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
- FlowStep.Completed -> BigIcon.Style.SuccessSolid
- is FlowStep.Skipped -> return
+private fun VerifySelfSessionHeader(step: Step) {
+ val iconStyle = when (step) {
+ Step.Loading -> error("Should not happen")
+ is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
+ Step.UseAnotherDevice -> BigIcon.Style.Default(CompoundIcons.Devices())
+ Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.Devices())
+ Step.Canceled -> BigIcon.Style.AlertSolid
+ Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
+ Step.Completed -> BigIcon.Style.SuccessSolid
+ is Step.Skipped -> return
}
- val titleTextId = when (verificationFlowStep) {
- VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
- is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
- FlowStep.Canceled -> CommonStrings.common_verification_cancelled
- FlowStep.Ready -> R.string.screen_session_verification_compare_emojis_title
- FlowStep.Completed -> R.string.screen_identity_confirmed_title
- is FlowStep.Verifying -> when (verificationFlowStep.data) {
+ val titleTextId = when (step) {
+ Step.Loading -> error("Should not happen")
+ is Step.Initial -> R.string.screen_identity_confirmation_title
+ Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_title
+ Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_title
+ Step.Canceled -> CommonStrings.common_verification_failed
+ Step.Ready -> R.string.screen_session_verification_compare_emojis_title
+ Step.Completed -> R.string.screen_identity_confirmed_title
+ is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
}
- is FlowStep.Skipped -> return
+ is Step.Skipped -> return
}
- val subtitleTextId = when (verificationFlowStep) {
- VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
- is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
- FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle
- FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
- FlowStep.Completed -> R.string.screen_identity_confirmed_subtitle
- is FlowStep.Verifying -> when (verificationFlowStep.data) {
+ val subtitleTextId = when (step) {
+ Step.Loading -> error("Should not happen")
+ is Step.Initial -> R.string.screen_identity_confirmation_subtitle
+ Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_subtitle
+ Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_subtitle
+ Step.Canceled -> R.string.screen_session_verification_failed_subtitle
+ Step.Ready -> R.string.screen_session_verification_ready_subtitle
+ Step.Completed -> R.string.screen_identity_confirmed_subtitle
+ is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
}
- is FlowStep.Skipped -> return
+ is Step.Skipped -> return
}
PageTitle(
@@ -207,16 +205,16 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
}
@Composable
-private fun Content(
- flowState: FlowStep,
+private fun VerifySelfSessionContent(
+ flowState: Step,
onLearnMoreClick: () -> Unit,
) {
when (flowState) {
- is VerifySelfSessionState.VerificationStep.Initial -> {
+ is Step.Initial -> {
ContentInitial(onLearnMoreClick)
}
- is FlowStep.Verifying -> {
- ContentVerifying(flowState)
+ is Step.Verifying -> {
+ VerificationContentVerifying(flowState.data)
}
else -> Unit
}
@@ -241,98 +239,34 @@ private fun ContentInitial(
}
@Composable
-private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- when (verificationFlowStep.data) {
- is SessionVerificationData.Decimals -> {
- val text = verificationFlowStep.data.decimals.joinToString(separator = " - ") { it.toString() }
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = text,
- style = ElementTheme.typography.fontHeadingLgBold,
- color = MaterialTheme.colorScheme.primary,
- textAlign = TextAlign.Center,
- )
- }
- is SessionVerificationData.Emojis -> {
- // We want each row to have up to 4 emojis
- val rows = verificationFlowStep.data.emojis.chunked(4)
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(40.dp),
- ) {
- rows.forEach { emojis ->
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
- for (emoji in emojis) {
- EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp))
- }
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) {
- val emojiResource = emoji.number.toEmojiResource()
- Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
- Image(
- modifier = Modifier.size(48.dp),
- painter = painterResource(id = emojiResource.drawableRes),
- contentDescription = null,
- )
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- text = stringResource(id = emojiResource.nameRes),
- style = ElementTheme.typography.fontBodyMdRegular,
- color = MaterialTheme.colorScheme.secondary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
-}
-
-@Composable
-private fun BottomMenu(
+private fun VerifySelfSessionBottomMenu(
screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
onCancelClick: () -> Unit,
onContinueClick: () -> Unit,
) {
- val verificationViewState = screenState.verificationFlowStep
+ val verificationViewState = screenState.step
val eventSink = screenState.eventSink
- val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading
+ val isVerifying = (verificationViewState as? Step.Verifying)?.state is AsyncData.Loading
when (verificationViewState) {
- VerifySelfSessionState.VerificationStep.Loading -> error("Should not happen")
- is FlowStep.Initial -> {
- BottomMenu {
- if (verificationViewState.isLastDevice) {
- Button(
- modifier = Modifier.fillMaxWidth(),
- text = stringResource(R.string.screen_session_verification_enter_recovery_key),
- onClick = onEnterRecoveryKey,
- )
- } else {
+ Step.Loading -> error("Should not happen")
+ is Step.Initial -> {
+ VerificationBottomMenu {
+ if (verificationViewState.isLastDevice.not()) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_use_another_device),
- onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
- )
- Button(
- modifier = Modifier.fillMaxWidth(),
- text = stringResource(R.string.screen_session_verification_enter_recovery_key),
- onClick = onEnterRecoveryKey,
+ onClick = { eventSink(VerifySelfSessionViewEvents.UseAnotherDevice) },
)
}
- // This option should always be displayed
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_enter_recovery_key),
+ onClick = onEnterRecoveryKey,
+ )
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
@@ -340,22 +274,28 @@ private fun BottomMenu(
)
}
}
- is FlowStep.Canceled -> {
- BottomMenu {
+ is Step.UseAnotherDevice -> {
+ VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
- text = stringResource(R.string.screen_session_verification_positive_button_canceled),
+ text = stringResource(CommonStrings.action_start_verification),
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
)
- TextButton(
+ InvisibleButton()
+ }
+ }
+ is Step.Canceled -> {
+ VerificationBottomMenu {
+ Button(
modifier = Modifier.fillMaxWidth(),
- text = stringResource(CommonStrings.action_cancel),
+ text = stringResource(CommonStrings.action_done),
onClick = onCancelClick,
)
+ InvisibleButton()
}
}
- is FlowStep.Ready -> {
- BottomMenu {
+ is Step.Ready -> {
+ VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start),
@@ -368,66 +308,58 @@ private fun BottomMenu(
)
}
}
- is FlowStep.AwaitingOtherDeviceResponse -> {
- BottomMenu {
+ is Step.AwaitingOtherDeviceResponse -> {
+ VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_waiting_on_other_device),
onClick = {},
showProgress = true,
+ enabled = false,
)
- // Placeholder so the 1st button keeps its vertical position
- Spacer(modifier = Modifier.height(40.dp))
+ InvisibleButton()
}
}
- is FlowStep.Verifying -> {
+ is Step.Verifying -> {
val positiveButtonTitle = if (isVerifying) {
stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
} else {
stringResource(R.string.screen_session_verification_they_match)
}
- BottomMenu {
+ VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = positiveButtonTitle,
showProgress = isVerifying,
+ enabled = !isVerifying,
onClick = {
if (!isVerifying) {
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
}
},
)
- TextButton(
- modifier = Modifier.fillMaxWidth(),
- text = stringResource(R.string.screen_session_verification_they_dont_match),
- onClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
- )
+ if (isVerifying) {
+ InvisibleButton()
+ } else {
+ TextButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.screen_session_verification_they_dont_match),
+ onClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
+ )
+ }
}
}
- is FlowStep.Completed -> {
- BottomMenu {
+ is Step.Completed -> {
+ VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_continue),
onClick = onContinueClick,
)
- // Placeholder so the 1st button keeps its vertical position
- Spacer(modifier = Modifier.height(48.dp))
+ InvisibleButton()
}
}
- is FlowStep.Skipped -> return
- }
-}
-
-@Composable
-private fun BottomMenu(
- modifier: Modifier = Modifier,
- buttons: @Composable ColumnScope.() -> Unit,
-) {
- ButtonColumnMolecule(
- modifier = modifier.padding(bottom = 16.dp)
- ) {
- buttons()
+ is Step.Skipped -> return
}
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt
similarity index 84%
rename from features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt
rename to features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt
index 1f0c235842..b4af38f780 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewEvents.kt
@@ -5,9 +5,10 @@
* Please see LICENSE in the repository root for full details.
*/
-package io.element.android.features.verifysession.impl
+package io.element.android.features.verifysession.impl.outgoing
sealed interface VerifySelfSessionViewEvents {
+ data object UseAnotherDevice : VerifySelfSessionViewEvents
data object RequestVerification : VerifySelfSessionViewEvents
data object StartSasVerification : VerifySelfSessionViewEvents
data object ConfirmVerification : VerifySelfSessionViewEvents
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt
new file mode 100644
index 0000000000..345663fa11
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.ui
+
+import io.element.android.libraries.matrix.api.verification.SessionVerificationData
+import io.element.android.libraries.matrix.api.verification.VerificationEmoji
+
+internal fun aEmojisSessionVerificationData(
+ emojiList: List = aVerificationEmojiList(),
+): SessionVerificationData {
+ return SessionVerificationData.Emojis(emojiList)
+}
+
+internal fun aDecimalsSessionVerificationData(
+ decimals: List = listOf(123, 456, 789),
+): SessionVerificationData {
+ return SessionVerificationData.Decimals(decimals)
+}
+
+private fun aVerificationEmojiList() = listOf(
+ VerificationEmoji(number = 27, emoji = "🍕", description = "Pizza"),
+ VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
+ VerificationEmoji(number = 54, emoji = "🚀", description = "Rocket"),
+ VerificationEmoji(number = 42, emoji = "📕", description = "Book"),
+ VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
+ VerificationEmoji(number = 48, emoji = "🔨", description = "Hammer"),
+ VerificationEmoji(number = 63, emoji = "📌", description = "Pin"),
+)
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt
new file mode 100644
index 0000000000..33ab0fa378
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.ui
+
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
+
+@Composable
+internal fun VerificationBottomMenu(
+ modifier: Modifier = Modifier,
+ buttons: @Composable ColumnScope.() -> Unit,
+) {
+ ButtonColumnMolecule(
+ modifier = modifier.padding(bottom = 16.dp)
+ ) {
+ buttons()
+ }
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt
new file mode 100644
index 0000000000..7f988a0d3d
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.verifysession.impl.emoji.toEmojiResource
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.verification.SessionVerificationData
+import io.element.android.libraries.matrix.api.verification.VerificationEmoji
+
+@Composable
+internal fun VerificationContentVerifying(
+ data: SessionVerificationData,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ when (data) {
+ is SessionVerificationData.Decimals -> {
+ val text = data.decimals.joinToString(separator = " - ") { it.toString() }
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = text,
+ style = ElementTheme.typography.fontHeadingLgBold,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Center,
+ )
+ }
+ is SessionVerificationData.Emojis -> {
+ // We want each row to have up to 4 emojis
+ val rows = data.emojis.chunked(4)
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(40.dp),
+ ) {
+ rows.forEach { emojis ->
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
+ for (emoji in emojis) {
+ EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) {
+ val emojiResource = emoji.number.toEmojiResource()
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
+ Image(
+ modifier = Modifier.size(48.dp),
+ painter = painterResource(id = emojiResource.drawableRes),
+ contentDescription = null,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(id = emojiResource.nameRes),
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = MaterialTheme.colorScheme.secondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt
new file mode 100644
index 0000000000..096a0be9ea
--- /dev/null
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.util
+
+import com.freeletics.flowredux.dsl.InStateBuilderBlock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import timber.log.Timber
+import com.freeletics.flowredux.dsl.State as MachineState
+
+internal fun T.andLogStateChange() = also {
+ Timber.w("Verification: state machine state moved to [${this::class.simpleName}]")
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+inline fun InStateBuilderBlock.logReceivedEvents() {
+ on { event: Event, state: MachineState ->
+ Timber.w("Verification in state [${state.snapshot::class.simpleName}] receiving event [${event::class.simpleName}]")
+ state.noChange()
+ }
+}
diff --git a/features/verifysession/impl/src/main/res/values-fr/translations.xml b/features/verifysession/impl/src/main/res/values-fr/translations.xml
index 4632373a92..9fbfbb7f76 100644
--- a/features/verifysession/impl/src/main/res/values-fr/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-fr/translations.xml
@@ -17,6 +17,7 @@
"Comparez les nombres"
"Votre nouvelle session est désormais vérifiée. Elle a accès à vos messages chiffrés et les autres utilisateurs la verront identifiée comme fiable."
"Utiliser la clé de récupération"
+ "Soit la demande a expiré, soit elle a été refusée, soit il y a eu une non-concordance de vérification."
"Prouvez qu’il s’agit bien de vous pour accéder à l’historique de vos messages chiffrés."
"Ouvrir une session existante"
"Réessayer la vérification"
@@ -24,8 +25,13 @@
"En attente de correspondance"
"Comparer un groupe unique d’Emojis."
"Comparez les emoji uniques en veillant à ce qu’ils apparaissent dans le même ordre."
+ "Connecté"
+ "Soit la demande a expiré, soit elle a été refusée, soit il y a eu une non-concordance de vérification."
+ "Échec de la vérification"
"Continuez uniquement si c’est vous qui avez commencé cette vérification."
"Vérifiez l’autre appareil pour sécuriser l’historique de vos messages."
+ "Vous pouvez désormais lire ou envoyer des messages en toute sécurité sur votre autre appareil."
+ "Appareil vérifié"
"Vérification demandée"
"Ils ne correspondent pas"
"Ils correspondent"
diff --git a/features/verifysession/impl/src/main/res/values-hu/translations.xml b/features/verifysession/impl/src/main/res/values-hu/translations.xml
index 10e0559bca..2f25c0006e 100644
--- a/features/verifysession/impl/src/main/res/values-hu/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-hu/translations.xml
@@ -26,8 +26,12 @@
"Egyedi emodzsik összehasonlítása."
"Hasonlítsa össze az egyedi emodzsikat, meggyőződve arról, hogy azonos a sorrendjük."
"Bejelentkezve"
+ "A kérés túllépte az időkorlátot, el lett utasítva, vagy ellenőrzési eltérés történt."
+ "Az ellenőrzés sikertelen"
"Csak akkor folytassa, ha Ön kezdeményezte ezt az ellenőrzést."
"Az üzenetelőzmények biztonságának megőrzése érdekében ellenőrizze a másik eszközt."
+ "Mostantól biztonságosan olvashat vagy küldhet üzeneteket a másik eszközén."
+ "Eszköz ellenőrizve"
"Ellenőrzés kérve"
"Nem egyeznek"
"Megegyeznek"
diff --git a/features/verifysession/impl/src/main/res/values-in/translations.xml b/features/verifysession/impl/src/main/res/values-in/translations.xml
index 961cc20cb3..fb4df0e8a3 100644
--- a/features/verifysession/impl/src/main/res/values-in/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-in/translations.xml
@@ -17,6 +17,7 @@
"Bandingkan angka"
"Sesi baru Anda sekarang diverifikasi. Ini memiliki akses ke pesan terenkripsi Anda, dan pengguna lain akan melihatnya sebagai tepercaya."
"Masukkan kunci pemulihan"
+ "Entah permintaan habis waktu, permintaan ditolak, atau ada ketidakcocokan verifikasi."
"Buktikan bahwa ini memang Anda untuk mengakses riwayat pesan terenkripsi Anda."
"Buka sesi yang sudah ada"
"Verifikasi ulang"
@@ -24,6 +25,14 @@
"Menunggu untuk mencocokkan"
"Bandingkan satu set emoji yang unik."
"Bandingkan emoji unik, dan pastikan emoji tersebut muncul dalam urutan yang sama."
+ "Sudah masuk"
+ "Entah permintaan habis waktu, permintaan ditolak, atau ada ketidakcocokan verifikasi."
+ "Verifikasi gagal"
+ "Lanjutkan hanya jika Anda memulai verifikasi ini."
+ "Verifikasi perangkat lain untuk menjaga riwayat pesan Anda tetap aman."
+ "Sekarang Anda dapat membaca atau mengirim pesan dengan aman di perangkat Anda yang lain."
+ "Perangkat diverifikasi"
+ "Verifikasi diminta"
"Mereka tidak cocok"
"Mereka cocok"
"Terima permintaan untuk memulai proses verifikasi di sesi Anda yang lain untuk melanjutkan."
diff --git a/features/verifysession/impl/src/main/res/values-nl/translations.xml b/features/verifysession/impl/src/main/res/values-nl/translations.xml
index bebac551e2..20e588e2db 100644
--- a/features/verifysession/impl/src/main/res/values-nl/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-nl/translations.xml
@@ -1,10 +1,14 @@
+ "Kan ik dit niet bevestigen?"
"Maak een nieuwe herstelsleutel"
"Verifieer dit apparaat om beveiligde berichten in te stellen."
"Bevestig dat jij het bent"
+ "Gebruik een ander apparaat"
+ "Gebruik de herstelsleutel"
"Nu kun je veilig berichten lezen of verzenden, en iedereen met wie je chat kan dit apparaat ook vertrouwen."
"Apparaat geverifieerd"
+ "Gebruik een ander apparaat"
"Wachten op ander apparaat…"
"Er lijkt iets niet goed te gaan. Of er is een time-out opgetreden of het verzoek is geweigerd."
"Bevestig dat de emoji\'s hieronder overeenkomen met de emoji\'s in je andere sessie."
diff --git a/features/verifysession/impl/src/main/res/values-pl/translations.xml b/features/verifysession/impl/src/main/res/values-pl/translations.xml
index 41d950f2f1..2ee77b1f7d 100644
--- a/features/verifysession/impl/src/main/res/values-pl/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-pl/translations.xml
@@ -24,6 +24,12 @@
"Oczekiwanie na dopasowanie"
"Porównaj unikalny zestaw emoji."
"Porównaj unikalne emoji, upewniając się, że pojawiły się w tej samej kolejności."
+ "Zalogowano"
+ "Weryfikacja nie powiodła się"
+ "Kontynuuj tylko, jeśli to Ty zainicjowałeś tę weryfikację."
+ "Zweryfikuj drugie urządzenie, aby zabezpieczyć historię wiadomości."
+ "Urządzenie zweryfikowane"
+ "Zażądano weryfikacji"
"Nie pasują do siebie"
"Pasują do siebie"
"Zaakceptuj prośbę o rozpoczęcie procesu weryfikacji w innej sesji, aby kontynuować."
diff --git a/features/verifysession/impl/src/main/res/values-pt/translations.xml b/features/verifysession/impl/src/main/res/values-pt/translations.xml
index d5bdaaf719..66d02f41e2 100644
--- a/features/verifysession/impl/src/main/res/values-pt/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-pt/translations.xml
@@ -17,6 +17,7 @@
"Comparar números"
"A tua nova sessão está agora verificada, pelo que tem acesso às tuas mensagens cifradas e os outros utilizadores vão vê-la como de confiança."
"Insere a chave de recuperação"
+ "O pedido expirou, o pedido foi recusado ou houve um erro de verificação."
"Prova que és tu para acederes ao teu histórico de mensagens cifradas."
"Abrir sessão existente"
"Repetir verificação"
@@ -25,8 +26,12 @@
"Compara um conjunto único de emojis."
"Compara os emojis únicos, certificando-te de que aparecem pela mesma ordem."
"Sessão iniciada"
+ "O pedido expirou, o pedido foi recusado ou houve um erro de verificação."
+ "A verificação falhou"
"Continue apenas se tiver iniciado esta verificação."
"Verifique o outro dispositivo para manter o histórico de mensagens seguro."
+ "Agora podes ler ou enviar mensagens de forma segura no teu outro dispositivo."
+ "Dispositivo verificado"
"Verificação solicitada"
"Não correspondem"
"Correspondem"
diff --git a/features/verifysession/impl/src/main/res/values-ru/translations.xml b/features/verifysession/impl/src/main/res/values-ru/translations.xml
index cc08320dfe..3a6e1a976e 100644
--- a/features/verifysession/impl/src/main/res/values-ru/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-ru/translations.xml
@@ -17,7 +17,7 @@
"Сравните числа"
"Ваш новый сеанс подтвержден. У него есть доступ к вашим зашифрованным сообщениям, и другие пользователи увидят его как доверенное."
"Введите ключ восстановления"
- "Запрос был отклонен, так как время ожидания запроса истекло, либо произошла ошибка при проверке."
+ "Время ожидания подтверждения истекло, запрос был отклонён, или при подтверждении произошло несоответствие."
"Чтобы получить доступ к зашифрованной истории сообщений, докажите, что это вы."
"Открыть существующий сеанс"
"Повторить подтверждение"
@@ -35,7 +35,7 @@
"Запрошено подтверждение"
"Они не совпадают"
"Они совпадают"
- "Для продолжения работы примите запрос на запуск процесса проверки в другом сеансе."
+ "Чтобы продолжить, примите запрос на запуск процесса подтверждения в другом сеансе."
"Ожидание принятия запроса"
"Выполняется выход…"
diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml
index f67a2024b9..db18590cbe 100644
--- a/features/verifysession/impl/src/main/res/values/localazy.xml
+++ b/features/verifysession/impl/src/main/res/values/localazy.xml
@@ -35,6 +35,10 @@
"Verification requested"
"They don’t match"
"They match"
+ "Make sure you have the app open in the other device before starting verification from here."
+ "Open the app on another verified device"
+ "You should see a popup on the other device. Start the verification from there now."
+ "Start verification on the other device"
"Accept the request to start the verification process in your other session to continue."
"Waiting to accept request"
"Signing out…"
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt
new file mode 100644
index 0000000000..773b7b390b
--- /dev/null
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt
@@ -0,0 +1,292 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
+import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
+import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
+import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
+import io.element.android.libraries.matrix.api.core.FlowId
+import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
+import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import io.element.android.libraries.matrix.api.verification.VerificationFlowState
+import io.element.android.libraries.matrix.test.A_DEVICE_ID
+import io.element.android.libraries.matrix.test.A_TIMESTAMP
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+@ExperimentalCoroutinesApi
+class IncomingVerificationPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - nominal case - incoming verification successful`() = runTest {
+ val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> }
+ val acceptVerificationRequestLambda = lambdaRecorder { }
+ val approveVerificationLambda = lambdaRecorder { }
+ val resetLambda = lambdaRecorder { }
+ val fakeSessionVerificationService = FakeSessionVerificationService(
+ acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
+ acceptVerificationRequestLambda = acceptVerificationRequestLambda,
+ approveVerificationLambda = approveVerificationLambda,
+ resetLambda = resetLambda,
+ )
+ createPresenter(
+ service = fakeSessionVerificationService,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.step).isEqualTo(
+ IncomingVerificationState.Step.Initial(
+ deviceDisplayName = "a device name",
+ deviceId = A_DEVICE_ID,
+ formattedSignInTime = A_FORMATTED_DATE,
+ isWaiting = false,
+ )
+ )
+ resetLambda.assertions().isCalledOnce().with(value(false))
+ acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
+ acceptVerificationRequestLambda.assertions().isNeverCalled()
+ // User accept the incoming verification
+ initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
+ skipItems(1)
+ val initialWaitingState = awaitItem()
+ assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
+ advanceUntilIdle()
+ acceptVerificationRequestLambda.assertions().isCalledOnce()
+ // Remote sent the data
+ fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
+ fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
+ fakeSessionVerificationService.emitVerificationFlowState(
+ VerificationFlowState.DidReceiveVerificationData(
+ data = aEmojisSessionVerificationData()
+ )
+ )
+ val emojiState = awaitItem()
+ assertThat(emojiState.step).isEqualTo(
+ IncomingVerificationState.Step.Verifying(
+ data = aEmojisSessionVerificationData(),
+ isWaiting = false
+ )
+ )
+ // User claims that the emoji matches
+ emojiState.eventSink(IncomingVerificationViewEvents.ConfirmVerification)
+ val emojiWaitingItem = awaitItem()
+ assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
+ approveVerificationLambda.assertions().isCalledOnce()
+ // Remote confirm that the emojis match
+ fakeSessionVerificationService.emitVerificationFlowState(
+ VerificationFlowState.DidFinish
+ )
+ val finalItem = awaitItem()
+ assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Completed)
+ }
+ }
+
+ @Test
+ fun `present - emoji not matching case - incoming verification failure`() = runTest {
+ val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> }
+ val acceptVerificationRequestLambda = lambdaRecorder { }
+ val declineVerificationLambda = lambdaRecorder { }
+ val resetLambda = lambdaRecorder { }
+ val fakeSessionVerificationService = FakeSessionVerificationService(
+ acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
+ acceptVerificationRequestLambda = acceptVerificationRequestLambda,
+ declineVerificationLambda = declineVerificationLambda,
+ resetLambda = resetLambda,
+ )
+ createPresenter(
+ service = fakeSessionVerificationService,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.step).isEqualTo(
+ IncomingVerificationState.Step.Initial(
+ deviceDisplayName = "a device name",
+ deviceId = A_DEVICE_ID,
+ formattedSignInTime = A_FORMATTED_DATE,
+ isWaiting = false,
+ )
+ )
+ resetLambda.assertions().isCalledOnce().with(value(false))
+ acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
+ acceptVerificationRequestLambda.assertions().isNeverCalled()
+ // User accept the incoming verification
+ initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
+ skipItems(1)
+ val initialWaitingState = awaitItem()
+ assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
+ advanceUntilIdle()
+ acceptVerificationRequestLambda.assertions().isCalledOnce()
+ // Remote sent the data
+ fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
+ fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
+ fakeSessionVerificationService.emitVerificationFlowState(
+ VerificationFlowState.DidReceiveVerificationData(
+ data = aEmojisSessionVerificationData()
+ )
+ )
+ val emojiState = awaitItem()
+ // User claims that the emojis do not match
+ emojiState.eventSink(IncomingVerificationViewEvents.DeclineVerification)
+ val emojiWaitingItem = awaitItem()
+ assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
+ declineVerificationLambda.assertions().isCalledOnce()
+ // Remote confirm that there is a failure
+ fakeSessionVerificationService.emitVerificationFlowState(
+ VerificationFlowState.DidFail
+ )
+ val finalItem = awaitItem()
+ assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure)
+ }
+ }
+
+ @Test
+ fun `present - incoming verification is remotely canceled`() = runTest {
+ val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> }
+ val acceptVerificationRequestLambda = lambdaRecorder { }
+ val declineVerificationLambda = lambdaRecorder { }
+ val resetLambda = lambdaRecorder { }
+ val onFinishLambda = lambdaRecorder { }
+ val fakeSessionVerificationService = FakeSessionVerificationService(
+ acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
+ acceptVerificationRequestLambda = acceptVerificationRequestLambda,
+ declineVerificationLambda = declineVerificationLambda,
+ resetLambda = resetLambda,
+ )
+ createPresenter(
+ service = fakeSessionVerificationService,
+ navigator = IncomingVerificationNavigator(onFinishLambda),
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.step).isEqualTo(
+ IncomingVerificationState.Step.Initial(
+ deviceDisplayName = "a device name",
+ deviceId = A_DEVICE_ID,
+ formattedSignInTime = A_FORMATTED_DATE,
+ isWaiting = false,
+ )
+ )
+ // Remote cancel the verification request
+ fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidCancel)
+ // The screen is dismissed
+ skipItems(2)
+ onFinishLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest {
+ val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> }
+ val acceptVerificationRequestLambda = lambdaRecorder { }
+ val declineVerificationLambda = lambdaRecorder { }
+ val resetLambda = lambdaRecorder { }
+ val fakeSessionVerificationService = FakeSessionVerificationService(
+ acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
+ acceptVerificationRequestLambda = acceptVerificationRequestLambda,
+ declineVerificationLambda = declineVerificationLambda,
+ resetLambda = resetLambda,
+ )
+ createPresenter(
+ service = fakeSessionVerificationService,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.step).isEqualTo(
+ IncomingVerificationState.Step.Initial(
+ deviceDisplayName = "a device name",
+ deviceId = A_DEVICE_ID,
+ formattedSignInTime = A_FORMATTED_DATE,
+ isWaiting = false,
+ )
+ )
+ resetLambda.assertions().isCalledOnce().with(value(false))
+ acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(aSessionVerificationRequestDetails))
+ acceptVerificationRequestLambda.assertions().isNeverCalled()
+ // User accept the incoming verification
+ initialState.eventSink(IncomingVerificationViewEvents.StartVerification)
+ skipItems(1)
+ val initialWaitingState = awaitItem()
+ assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue()
+ advanceUntilIdle()
+ acceptVerificationRequestLambda.assertions().isCalledOnce()
+ // Remote sent the data
+ fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
+ fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
+ fakeSessionVerificationService.emitVerificationFlowState(
+ VerificationFlowState.DidReceiveVerificationData(
+ data = aEmojisSessionVerificationData()
+ )
+ )
+ val emojiState = awaitItem()
+ // User goes back
+ emojiState.eventSink(IncomingVerificationViewEvents.GoBack)
+ val emojiWaitingItem = awaitItem()
+ assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue()
+ declineVerificationLambda.assertions().isCalledOnce()
+ // Remote confirm that there is a failure
+ fakeSessionVerificationService.emitVerificationFlowState(
+ VerificationFlowState.DidFail
+ )
+ val finalItem = awaitItem()
+ assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure)
+ }
+ }
+
+ @Test
+ fun `present - user ignores incoming request`() = runTest {
+ val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> }
+ val acceptVerificationRequestLambda = lambdaRecorder { }
+ val resetLambda = lambdaRecorder { }
+ val fakeSessionVerificationService = FakeSessionVerificationService(
+ acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
+ acceptVerificationRequestLambda = acceptVerificationRequestLambda,
+ resetLambda = resetLambda,
+ )
+ val navigatorLambda = lambdaRecorder { }
+ createPresenter(
+ service = fakeSessionVerificationService,
+ navigator = IncomingVerificationNavigator(navigatorLambda),
+ ).test {
+ val initialState = awaitItem()
+ initialState.eventSink(IncomingVerificationViewEvents.IgnoreVerification)
+ skipItems(1)
+ navigatorLambda.assertions().isCalledOnce()
+ }
+ }
+
+ private val aSessionVerificationRequestDetails = SessionVerificationRequestDetails(
+ senderId = A_USER_ID,
+ flowId = FlowId("flowId"),
+ deviceId = A_DEVICE_ID,
+ displayName = "a device name",
+ firstSeenTimestamp = A_TIMESTAMP,
+ )
+
+ private fun createPresenter(
+ sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails,
+ navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
+ service: SessionVerificationService = FakeSessionVerificationService(),
+ dateFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE),
+ ) = IncomingVerificationPresenter(
+ sessionVerificationRequestDetails = sessionVerificationRequestDetails,
+ navigator = navigator,
+ sessionVerificationService = service,
+ stateMachine = IncomingVerificationStateMachine(service),
+ dateFormatter = dateFormatter,
+ )
+}
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt
new file mode 100644
index 0000000000..7517486c00
--- /dev/null
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.features.verifysession.impl.incoming
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.verifysession.impl.R
+import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class IncomingVerificationViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ // region step Initial
+ @Test
+ fun `back key pressed - ignore the verification`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = aStepInitial(),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
+ }
+
+ @Test
+ fun `ignore incoming verification emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = aStepInitial(),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(CommonStrings.action_ignore)
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.IgnoreVerification)
+ }
+
+ @Test
+ fun `start incoming verification emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = aStepInitial(),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(CommonStrings.action_start)
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.StartVerification)
+ }
+
+ @Test
+ fun `back key pressed - when awaiting response cancels the verification`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = aStepInitial(
+ isWaiting = true,
+ ),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
+ }
+ // endregion step Initial
+
+ // region step Verifying
+ @Test
+ fun `back key pressed - when ready to verify cancels the verification`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = IncomingVerificationState.Step.Verifying(
+ data = aEmojisSessionVerificationData(),
+ isWaiting = false,
+ ),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
+ }
+
+ @Test
+ fun `back key pressed - when verifying and loading emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = IncomingVerificationState.Step.Verifying(
+ data = aEmojisSessionVerificationData(),
+ isWaiting = true,
+ ),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
+ }
+
+ @Test
+ fun `clicking on they do not match emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = IncomingVerificationState.Step.Verifying(
+ data = aEmojisSessionVerificationData(),
+ isWaiting = false,
+ ),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(R.string.screen_session_verification_they_dont_match)
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.DeclineVerification)
+ }
+
+ @Test
+ fun `clicking on they match emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = IncomingVerificationState.Step.Verifying(
+ data = aEmojisSessionVerificationData(),
+ isWaiting = false,
+ ),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(R.string.screen_session_verification_they_match)
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.ConfirmVerification)
+ }
+ // endregion
+
+ // region step Failure
+ @Test
+ fun `back key pressed - when failure resets the flow`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = IncomingVerificationState.Step.Failure,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
+ }
+
+ @Test
+ fun `click on done - when failure resets the flow`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = IncomingVerificationState.Step.Failure,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(CommonStrings.action_done)
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
+ }
+
+ // endregion
+
+ // region step Completed
+ @Test
+ fun `back key pressed - on Completed step emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = IncomingVerificationState.Step.Completed,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
+ }
+
+ @Test
+ fun `when flow is completed and the user clicks on the done button, the expected event is emitted`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setIncomingVerificationView(
+ anIncomingVerificationState(
+ step = IncomingVerificationState.Step.Completed,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(CommonStrings.action_done)
+ eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack)
+ }
+ // endregion
+
+ private fun AndroidComposeTestRule.setIncomingVerificationView(
+ state: IncomingVerificationState,
+ ) {
+ setContent {
+ IncomingVerificationView(
+ state = state,
+ )
+ }
+ }
+}
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt
similarity index 52%
rename from features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt
rename to features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt
index 188c895f5c..d8fe2f671c 100644
--- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionPresenterTest.kt
@@ -5,21 +5,19 @@
* Please see LICENSE in the repository root for full details.
*/
-package io.element.android.features.verifysession.impl
+package io.element.android.features.verifysession.impl.outgoing
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.features.logout.test.FakeLogoutUseCase
-import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
+import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
+import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
@@ -29,8 +27,10 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -43,12 +43,12 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - Initial state is received`() = runTest {
- val presenter = createVerifySelfSessionPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val presenter = createVerifySelfSessionPresenter(
+ service = unverifiedSessionService(),
+ )
+ presenter.test {
awaitItem().run {
- assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
+ assertThat(step).isEqualTo(Step.Initial(false))
assertThat(displaySkipButton).isTrue()
}
}
@@ -57,82 +57,66 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - hides skip verification button on non-debuggable builds`() = runTest {
val buildMeta = aBuildMeta(isDebuggable = false)
- val presenter = createVerifySelfSessionPresenter(buildMeta = buildMeta)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ val presenter = createVerifySelfSessionPresenter(
+ service = unverifiedSessionService(),
+ buildMeta = buildMeta,
+ )
+ presenter.test {
assertThat(awaitItem().displaySkipButton).isFalse()
}
}
@Test
fun `present - Initial state is received, can use recovery key`() = runTest {
+ val resetLambda = lambdaRecorder { }
val presenter = createVerifySelfSessionPresenter(
+ service = unverifiedSessionService(
+ resetLambda = resetLambda
+ ),
encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE)
}
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true))
+ presenter.test {
+ assertThat(awaitItem().step).isEqualTo(Step.Initial(true))
+ resetLambda.assertions().isCalledOnce().with(value(true))
}
}
@Test
fun `present - Initial state is received, can use recovery key and is last device`() = runTest {
val presenter = createVerifySelfSessionPresenter(
+ service = unverifiedSessionService(),
encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true)
emitRecoveryState(RecoveryState.INCOMPLETE)
}
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true))
+ presenter.test {
+ assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
}
}
@Test
fun `present - Handles requestVerification`() = runTest {
- val service = unverifiedSessionService()
+ val service = unverifiedSessionService(
+ requestVerificationLambda = { },
+ startVerificationLambda = { },
+ )
val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
requestVerificationAndAwaitVerifyingState(service)
}
}
@Test
- fun `present - Handles startSasVerification`() = runTest {
- val service = unverifiedSessionService()
- val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
- val eventSink = initialState.eventSink
- eventSink(VerifySelfSessionViewEvents.StartSasVerification)
- // Await for other device response:
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
- // ChallengeReceived:
- service.triggerReceiveVerificationData(SessionVerificationData.Emojis(emptyList()))
- val verifyingState = awaitItem()
- assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
- }
- }
-
- @Test
- fun `present - Cancelation on initial state does nothing`() = runTest {
- val presenter = createVerifySelfSessionPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ fun `present - Cancellation on initial state does nothing`() = runTest {
+ val presenter = createVerifySelfSessionPresenter(
+ service = unverifiedSessionService(),
+ )
+ presenter.test {
val initialState = awaitItem()
- assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
+ assertThat(initialState.step).isEqualTo(Step.Initial(false))
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents()
@@ -141,92 +125,81 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - A failure when verifying cancels it`() = runTest {
- val service = unverifiedSessionService()
+ val service = unverifiedSessionService(
+ requestVerificationLambda = { },
+ startVerificationLambda = { },
+ approveVerificationLambda = { },
+ )
val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
- service.shouldFail = true
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
// Cancelling
- assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
+ assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
+ service.emitVerificationFlowState(VerificationFlowState.DidFail)
// Cancelled
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
+ assertThat(awaitItem().step).isEqualTo(Step.Canceled)
}
}
@Test
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
- val service = unverifiedSessionService()
+ val service = unverifiedSessionService(
+ requestVerificationLambda = { },
+ )
val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- service.shouldFail = true
+ presenter.test {
+ awaitItem().eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
- service.shouldFail = false
- assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
+ service.emitVerificationFlowState(VerificationFlowState.DidFail)
+ assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
+ assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
}
}
@Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
- val service = unverifiedSessionService()
+ val service = unverifiedSessionService(
+ requestVerificationLambda = { },
+ startVerificationLambda = { },
+ cancelVerificationLambda = { },
+ )
val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.Cancel)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
+ assertThat(awaitItem().step).isEqualTo(Step.Canceled)
}
}
@Test
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
- val service = unverifiedSessionService()
+ val service = unverifiedSessionService(
+ requestVerificationLambda = { },
+ startVerificationLambda = { },
+ )
val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
requestVerificationAndAwaitVerifyingState(service)
- service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(SessionVerificationData.Emojis(emptyList())))
+ service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
ensureAllEventsConsumed()
}
}
@Test
- fun `present - Restart after cancelation returns to requesting verification`() = runTest {
- val service = unverifiedSessionService()
- val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val state = requestVerificationAndAwaitVerifyingState(service)
- service.givenVerificationFlowState(VerificationFlowState.Canceled)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
- state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
- // Went back to requesting verification
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `present - Go back after cancelation returns to initial state`() = runTest {
- val service = unverifiedSessionService()
+ fun `present - Go back after cancellation returns to initial state`() = runTest {
+ val service = unverifiedSessionService(
+ requestVerificationLambda = { },
+ startVerificationLambda = { },
+ )
val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
- service.givenVerificationFlowState(VerificationFlowState.Canceled)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
+ service.emitVerificationFlowState(VerificationFlowState.DidCancel)
+ assertThat(awaitItem().step).isEqualTo(Step.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
+ assertThat(awaitItem().step).isEqualTo(Step.Initial(false))
cancelAndIgnoreRemainingEvents()
}
}
@@ -236,110 +209,117 @@ class VerifySelfSessionPresenterTest {
val emojis = listOf(
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
)
- val service = unverifiedSessionService()
+ val service = unverifiedSessionService(
+ requestVerificationLambda = { },
+ startVerificationLambda = { },
+ approveVerificationLambda = { },
+ )
val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val state = requestVerificationAndAwaitVerifyingState(
service,
SessionVerificationData.Emojis(emojis)
)
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(
- VerificationStep.Verifying(
+ assertThat(awaitItem().step).isEqualTo(
+ Step.Verifying(
SessionVerificationData.Emojis(emojis),
AsyncData.Loading(),
)
)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
+ service.emitVerificationFlowState(VerificationFlowState.DidFinish)
+ assertThat(awaitItem().step).isEqualTo(Step.Completed)
}
}
@Test
fun `present - When verification is declined, the flow is canceled`() = runTest {
- val service = unverifiedSessionService()
+ val service = unverifiedSessionService(
+ requestVerificationLambda = { },
+ startVerificationLambda = { },
+ declineVerificationLambda = { },
+ )
val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(
- VerificationStep.Verifying(
+ assertThat(awaitItem().step).isEqualTo(
+ Step.Verifying(
SessionVerificationData.Emojis(emptyList()),
AsyncData.Loading(),
)
)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
+ service.emitVerificationFlowState(VerificationFlowState.DidCancel)
+ assertThat(awaitItem().step).isEqualTo(Step.Canceled)
}
}
@Test
fun `present - Skip event skips the flow`() = runTest {
- val service = unverifiedSessionService()
+ val service = unverifiedSessionService(
+ requestVerificationLambda = { },
+ startVerificationLambda = { },
+ )
val presenter = createVerifySelfSessionPresenter(service)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
+ assertThat(awaitItem().step).isEqualTo(Step.Skipped)
}
}
@Test
fun `present - When verification is done using recovery key, the flow is completed`() = runTest {
- val service = FakeSessionVerificationService().apply {
- givenNeedsSessionVerification(false)
- givenVerifiedStatus(SessionVerifiedStatus.Verified)
- givenVerificationFlowState(VerificationFlowState.Finished)
+ val service = FakeSessionVerificationService(
+ resetLambda = { },
+ ).apply {
+ emitNeedsSessionVerification(false)
+ emitVerifiedStatus(SessionVerifiedStatus.Verified)
+ emitVerificationFlowState(VerificationFlowState.DidFinish)
}
val presenter = createVerifySelfSessionPresenter(
service = service,
showDeviceVerifiedScreen = true,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
+ presenter.test {
+ assertThat(awaitItem().step).isEqualTo(Step.Completed)
}
}
@Test
fun `present - When verification is not needed, the flow is skipped`() = runTest {
- val service = FakeSessionVerificationService().apply {
- givenNeedsSessionVerification(false)
- givenVerifiedStatus(SessionVerifiedStatus.Verified)
- givenVerificationFlowState(VerificationFlowState.Finished)
+ val service = FakeSessionVerificationService(
+ resetLambda = { },
+ ).apply {
+ emitNeedsSessionVerification(false)
+ emitVerifiedStatus(SessionVerifiedStatus.Verified)
+ emitVerificationFlowState(VerificationFlowState.DidFinish)
}
val presenter = createVerifySelfSessionPresenter(
service = service,
showDeviceVerifiedScreen = false,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
+ assertThat(awaitItem().step).isEqualTo(Step.Skipped)
}
}
@Test
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
- val service = FakeSessionVerificationService().apply {
- givenNeedsSessionVerification(false)
- givenVerifiedStatus(SessionVerifiedStatus.Verified)
- givenVerificationFlowState(VerificationFlowState.Finished)
+ val service = FakeSessionVerificationService(
+ resetLambda = { },
+ ).apply {
+ emitNeedsSessionVerification(false)
+ emitVerifiedStatus(SessionVerifiedStatus.Verified)
+ emitVerificationFlowState(VerificationFlowState.DidFinish)
}
val signOutLambda = lambdaRecorder { "aUrl" }
val presenter = createVerifySelfSessionPresenter(
service,
logoutUseCase = FakeLogoutUseCase(signOutLambda)
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
val initialItem = awaitItem()
initialItem.eventSink(VerifySelfSessionViewEvents.SignOut)
@@ -356,33 +336,56 @@ class VerifySelfSessionPresenterTest {
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState {
var state = awaitItem()
- assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
+ assertThat(state.step).isEqualTo(Step.Initial(false))
+ state.eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
+ state = awaitItem()
+ assertThat(state.step).isEqualTo(Step.UseAnotherDevice)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response:
+ fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
state = awaitItem()
- assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
+ assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
// Await for the state to be Ready
state = awaitItem()
- assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Ready)
+ assertThat(state.step).isEqualTo(Step.Ready)
state.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response (again):
+ fakeService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
state = awaitItem()
- assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
- fakeService.triggerReceiveVerificationData(sessionVerificationData)
+ assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse)
// Finally, ChallengeReceived:
+ fakeService.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(sessionVerificationData))
state = awaitItem()
- assertThat(state.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
+ assertThat(state.step).isInstanceOf(Step.Verifying::class.java)
return state
}
- private fun unverifiedSessionService(): FakeSessionVerificationService {
- return FakeSessionVerificationService().apply {
- givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
+ private suspend fun unverifiedSessionService(
+ requestVerificationLambda: () -> Unit = { lambdaError() },
+ cancelVerificationLambda: () -> Unit = { lambdaError() },
+ approveVerificationLambda: () -> Unit = { lambdaError() },
+ declineVerificationLambda: () -> Unit = { lambdaError() },
+ startVerificationLambda: () -> Unit = { lambdaError() },
+ resetLambda: (Boolean) -> Unit = { },
+ acknowledgeVerificationRequestLambda: (SessionVerificationRequestDetails) -> Unit = { lambdaError() },
+ acceptVerificationRequestLambda: () -> Unit = { lambdaError() },
+ ): FakeSessionVerificationService {
+ return FakeSessionVerificationService(
+ requestVerificationLambda = requestVerificationLambda,
+ cancelVerificationLambda = cancelVerificationLambda,
+ approveVerificationLambda = approveVerificationLambda,
+ declineVerificationLambda = declineVerificationLambda,
+ startVerificationLambda = startVerificationLambda,
+ resetLambda = resetLambda,
+ acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda,
+ acceptVerificationRequestLambda = acceptVerificationRequestLambda,
+ ).apply {
+ emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
}
}
private fun createVerifySelfSessionPresenter(
- service: SessionVerificationService = unverifiedSessionService(),
+ service: SessionVerificationService,
encryptionService: EncryptionService = FakeEncryptionService(),
buildMeta: BuildMeta = aBuildMeta(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewTest.kt
similarity index 87%
rename from features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
rename to features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewTest.kt
index dfe8aaf85d..5429ac3637 100644
--- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/VerifySelfSessionViewTest.kt
@@ -5,12 +5,14 @@
* Please see LICENSE in the repository root for full details.
*/
-package io.element.android.features.verifysession.impl
+package io.element.android.features.verifysession.impl.outgoing
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.verifysession.impl.R
+import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
@@ -36,7 +38,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
+ step = VerifySelfSessionState.Step.Canceled,
eventSink = eventsRecorder
),
)
@@ -49,7 +51,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
+ step = VerifySelfSessionState.Step.AwaitingOtherDeviceResponse,
eventSink = eventsRecorder
),
)
@@ -62,7 +64,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
+ step = VerifySelfSessionState.Step.Ready,
eventSink = eventsRecorder
),
)
@@ -75,7 +77,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
+ step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
@@ -91,7 +93,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
+ step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Loading(),
),
@@ -107,7 +109,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
+ step = VerifySelfSessionState.Step.Completed,
eventSink = eventsRecorder
),
)
@@ -121,7 +123,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
+ step = VerifySelfSessionState.Step.Completed,
eventSink = eventsRecorder
),
onFinished = callback,
@@ -137,7 +139,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
+ step = VerifySelfSessionState.Step.Initial(true),
eventSink = eventsRecorder
),
onEnterRecoveryKey = callback,
@@ -153,7 +155,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
+ step = VerifySelfSessionState.Step.Initial(true),
eventSink = eventsRecorder
),
onLearnMoreClick = callback,
@@ -167,7 +169,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
+ step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
@@ -183,7 +185,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
+ step = VerifySelfSessionState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
@@ -199,7 +201,7 @@ class VerifySelfSessionViewTest {
val eventsRecorder = EventsRecorder()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true),
+ step = VerifySelfSessionState.Step.Initial(canEnterRecoveryKey = true),
displaySkipButton = true,
eventSink = eventsRecorder
),
@@ -213,7 +215,7 @@ class VerifySelfSessionViewTest {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Skipped,
+ step = VerifySelfSessionState.Step.Skipped,
displaySkipButton = true,
eventSink = EnsureNeverCalledWithParam(),
),
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index fe043863a3..1177419d2e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,9 +4,9 @@
[versions]
# Project
android_gradle_plugin = "8.7.1"
-kotlin = "2.0.20"
+kotlin = "2.0.21"
kotlinpoet = "2.0.0"
-ksp = "2.0.20-1.0.25"
+ksp = "2.0.21-1.0.26"
firebaseAppDistribution = "5.0.0"
# AndroidX
@@ -17,19 +17,19 @@ core = "1.13.1"
# due to the DefaultMigrationStore not behaving as expected.
# Stick to 1.0.0 for now, and ensure that this scenario cannot be reproduced when upgrading the version.
datastore = "1.0.0"
-constraintlayout = "2.1.4"
-constraintlayout_compose = "1.0.1"
+constraintlayout = "2.2.0"
+constraintlayout_compose = "1.1.0"
lifecycle = "2.8.6"
activity = "1.9.3"
media3 = "1.4.1"
-camera = "1.3.4"
+camera = "1.4.0"
# Compose
-compose_bom = "2024.10.00"
+compose_bom = "2024.10.01"
composecompiler = "1.5.15"
# Coroutines
-coroutines = "1.8.1"
+coroutines = "1.9.0"
# Accompanist
accompanist = "0.36.0"
@@ -37,17 +37,21 @@ accompanist = "0.36.0"
# Test
test_core = "1.6.1"
+# Jetbrain
+datetime = "0.6.1"
+serialization_json = "1.7.3"
+
#other
coil = "2.7.0"
-datetime = "0.6.0"
-dependencyAnalysis = "2.3.0"
-serialization_json = "1.6.3"
showkase = "1.0.3"
appyx = "1.4.0"
sqldelight = "2.0.2"
wysiwyg = "2.37.13"
telephoto = "0.13.0"
+# Dependency analysis
+dependencyAnalysis = "2.4.2"
+
# DI
dagger = "2.52"
anvil = "0.3.3"
@@ -81,7 +85,7 @@ ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version
# AndroidX
androidx_core = { module = "androidx.core:core", version.ref = "core" }
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
-androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.9.0"
+androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.9.1"
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.7"
@@ -162,14 +166,14 @@ coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" }
compound = { module = "io.element.android:compound-android", version = "0.1.1" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
-kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7"
+kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8"
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
jsoup = "org.jsoup:jsoup:1.18.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.58"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.60"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@@ -178,19 +182,19 @@ sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions",
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite-ktx:2.4.0"
unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0"
-otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.1"
+otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.5.2"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
-maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.1"
+maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.1.0"
zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
# Analytics
-posthog = "com.posthog:posthog-android:3.8.2"
+posthog = "com.posthog:posthog-android:3.9.0"
sentry = "io.sentry:sentry-android:7.16.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
@@ -233,7 +237,7 @@ ktlint = "org.jlleitschuh.gradle.ktlint:12.1.1"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:10.0.4"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
-paparazzi = "app.cash.paparazzi:1.3.4"
+paparazzi = "app.cash.paparazzi:1.3.5"
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" }
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/TemporaryUriDeleter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/TemporaryUriDeleter.kt
new file mode 100644
index 0000000000..4eabda8972
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/TemporaryUriDeleter.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.androidutils.file
+
+import android.content.Context
+import android.net.Uri
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import timber.log.Timber
+import javax.inject.Inject
+
+interface TemporaryUriDeleter {
+ /**
+ * Delete the Uri only if it is a temporary one.
+ */
+ fun delete(uri: Uri?)
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultTemporaryUriDeleter @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : TemporaryUriDeleter {
+ private val baseCacheUri = "content://${context.packageName}.fileprovider/cache"
+
+ override fun delete(uri: Uri?) {
+ uri ?: return
+ if (uri.toString().startsWith(baseCacheUri)) {
+ context.contentResolver.delete(uri, null, null)
+ } else {
+ Timber.d("Do not delete the uri")
+ }
+ }
+}
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt
index 68748ecdb5..a54d0eb9d8 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.core.coroutine
+import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
@@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.stateIn
* A [StateFlow] that derives its value from a [Flow].
* Useful when you want to apply transformations to a [Flow] and expose it as a [StateFlow].
*/
+@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
class DerivedStateFlow(
private val getValue: () -> T,
private val flow: Flow
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
index 8dc47d06d8..cacdfddc00 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
@@ -38,6 +38,7 @@ object MimeTypes {
fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this
fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse()
+ fun String?.isMimeTypeAnimatedImage() = this == Gif || this == WebP
fun String?.isMimeTypeVideo() = this?.startsWith("video/").orFalse()
fun String?.isMimeTypeAudio() = this?.startsWith("audio/").orFalse()
fun String?.isMimeTypeApplication() = this?.startsWith("application/").orFalse()
diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt
index db68141a8e..7edcf321cb 100644
--- a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt
+++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt
@@ -11,8 +11,9 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
const val A_FORMATTED_DATE = "formatted_date"
-class FakeLastMessageTimestampFormatter : LastMessageTimestampFormatter {
- private var format = ""
+class FakeLastMessageTimestampFormatter(
+ var format: String = "",
+) : LastMessageTimestampFormatter {
fun givenFormat(format: String) {
this.format = format
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/TextWithLabelMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/TextWithLabelMolecule.kt
new file mode 100644
index 0000000000..4562332060
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/TextWithLabelMolecule.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.designsystem.atomic.molecules
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.theme.components.Text
+
+@Composable
+fun TextWithLabelMolecule(
+ label: String,
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ Text(
+ text = label,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ Text(
+ text = text,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textPrimary,
+ )
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt
index 01182343f8..9f49bca0bb 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -118,6 +119,14 @@ fun TextButton(
leadingIcon = leadingIcon
)
+@Composable
+fun InvisibleButton(
+ modifier: Modifier = Modifier,
+ size: ButtonSize = ButtonSize.Large,
+) {
+ Spacer(modifier = modifier.height(size.toMinHeight()))
+}
+
@Composable
private fun ButtonInternal(
text: String,
@@ -131,14 +140,7 @@ private fun ButtonInternal(
showProgress: Boolean = false,
leadingIcon: IconSource? = null,
) {
- val minHeight = when (size) {
- ButtonSize.Small -> 32.dp
- ButtonSize.Medium,
- ButtonSize.MediumLowPadding -> 40.dp
- ButtonSize.Large,
- ButtonSize.LargeLowPadding -> 48.dp
- }
-
+ val minHeight = size.toMinHeight()
val hasStartDrawable = showProgress || leadingIcon != null
val contentPadding = when (size) {
@@ -253,6 +255,14 @@ private fun ButtonInternal(
}
}
+private fun ButtonSize.toMinHeight() = when (this) {
+ ButtonSize.Small -> 32.dp
+ ButtonSize.Medium,
+ ButtonSize.MediumLowPadding -> 40.dp
+ ButtonSize.Large,
+ ButtonSize.LargeLowPadding -> 48.dp
+}
+
@Immutable
sealed interface IconSource {
val contentDescription: String?
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt
index 0dce260f06..44d658c5ec 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt
@@ -42,6 +42,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
* @param trailingContent The content to be displayed after the headline content.
* @param style The style to use for the list item. This may change the color and text styles of the contents. [ListItemStyle.Default] is used by default.
* @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens.
+ * @param alwaysClickable Whether the list item should always be clickable, even when disabled.
* @param onClick The callback to be called when the list item is clicked.
*/
@Suppress("LongParameterList")
@@ -54,6 +55,7 @@ fun ListItem(
trailingContent: ListItemContent? = null,
style: ListItemStyle = ListItemStyle.Default,
enabled: Boolean = true,
+ alwaysClickable: Boolean = false,
onClick: (() -> Unit)? = null,
) {
val colors = ListItemDefaults.colors(
@@ -74,6 +76,7 @@ fun ListItem(
trailingContent = trailingContent,
colors = colors,
enabled = enabled,
+ alwaysClickable = alwaysClickable,
onClick = onClick,
)
}
@@ -87,6 +90,7 @@ fun ListItem(
* @param leadingContent The content to be displayed before the headline content.
* @param trailingContent The content to be displayed after the headline content.
* @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens.
+ * @param alwaysClickable Whether the list item should always be clickable, even when disabled.
* @param onClick The callback to be called when the list item is clicked.
*/
@Suppress("LongParameterList")
@@ -99,6 +103,7 @@ fun ListItem(
leadingContent: ListItemContent? = null,
trailingContent: ListItemContent? = null,
enabled: Boolean = true,
+ alwaysClickable: Boolean = false,
onClick: (() -> Unit)? = null,
) {
// We cannot just pass the disabled colors, they must be set manually: https://issuetracker.google.com/issues/280480132
@@ -149,7 +154,7 @@ fun ListItem(
headlineContent = decoratedHeadlineContent,
modifier = if (onClick != null) {
Modifier
- .clickable(enabled = enabled, onClick = onClick)
+ .clickable(enabled = enabled || alwaysClickable, onClick = onClick)
.then(modifier)
} else {
modifier
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
index ab7a19a9c7..11873ee7c1 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
@@ -95,7 +95,9 @@ class DefaultPinnedMessagesBannerFormatter @Inject constructor(
messageType.bestDescription.prefixWith(CommonStrings.common_audio)
}
is VoiceMessageType -> {
- messageType.bestDescription.prefixWith(CommonStrings.common_voice_message)
+ // In this case, do not use bestDescription, because the filename is useless, only use the caption if available.
+ messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message))
+ ?: sp.getString(CommonStrings.common_voice_message)
}
is OtherMessageType -> {
messageType.body
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
index 6b43fc3607..4031915445 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
@@ -110,25 +110,27 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
messageType.toPlainText(permalinkParser)
}
is VideoMessageType -> {
- sp.getString(CommonStrings.common_video)
+ messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_video))
}
is ImageMessageType -> {
- sp.getString(CommonStrings.common_image)
+ messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_image))
}
is StickerMessageType -> {
- sp.getString(CommonStrings.common_sticker)
+ messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_sticker))
}
is LocationMessageType -> {
sp.getString(CommonStrings.common_shared_location)
}
is FileMessageType -> {
- sp.getString(CommonStrings.common_file)
+ messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_file))
}
is AudioMessageType -> {
- sp.getString(CommonStrings.common_audio)
+ messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_audio))
}
is VoiceMessageType -> {
- sp.getString(CommonStrings.common_voice_message)
+ // In this case, do not use bestDescription, because the filename is useless, only use the caption if available.
+ messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message))
+ ?: sp.getString(CommonStrings.common_voice_message)
}
is OtherMessageType -> {
messageType.body
@@ -140,7 +142,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
return message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
- private fun String.prefixIfNeeded(
+ private fun CharSequence.prefixIfNeeded(
senderDisambiguatedDisplayName: String,
isDmRoom: Boolean,
isOutgoing: Boolean,
diff --git a/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml b/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml
index dba242a458..1462ac4a24 100644
--- a/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml
@@ -45,6 +45,12 @@
"Je hebt de kamernaam verwijderd"
"%1$s heeft geen wijzigingen aangebracht"
"Je hebt geen wijzigingen aangebracht"
+ "%1$s heeft de vastgezette berichten gewijzigd"
+ "Je hebt de vastgezette berichten gewijzigd"
+ "%1$s heeft een bericht vastgezet"
+ "Je hebt een bericht vastgezet"
+ "%1$s heeft een bericht losgemaakt"
+ "Je hebt een bericht losgemaakt"
"%1$s heeft de uitnodiging afgewezen"
"Je hebt de uitnodiging afgewezen"
"%1$s heeft %2$s verwijderd"
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
index af347135fb..80a7691a1d 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
@@ -159,11 +159,11 @@ class DefaultPinnedMessagesBannerFormatterTest {
val expectedResult = when (type) {
is VideoMessageType,
is AudioMessageType,
- is VoiceMessageType,
is ImageMessageType,
is StickerMessageType,
is FileMessageType,
is LocationMessageType -> AnnotatedString::class.java
+ is VoiceMessageType,
is EmoteMessageType,
is TextMessageType,
is NoticeMessageType,
@@ -176,7 +176,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
val expectedResult = when (type) {
is VideoMessageType -> "Video: Shared body"
is AudioMessageType -> "Audio: Shared body"
- is VoiceMessageType -> "Voice message: Shared body"
+ is VoiceMessageType -> "Voice message"
is ImageMessageType -> "Image: Shared body"
is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File: Shared body"
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
index efc748d10f..3c5038069a 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
@@ -208,32 +208,51 @@ class DefaultRoomLastMessageFormatterTest {
// Verify results of DM mode
for ((type, result) in resultsInDm) {
+ val string = result.toString()
val expectedResult = when (type) {
- is VideoMessageType -> "Video"
- is AudioMessageType -> "Audio"
+ is VideoMessageType -> "Video: Shared body"
+ is AudioMessageType -> "Audio: Shared body"
is VoiceMessageType -> "Voice message"
- is ImageMessageType -> "Image"
- is StickerMessageType -> "Sticker"
- is FileMessageType -> "File"
+ is ImageMessageType -> "Image: Shared body"
+ is StickerMessageType -> "Sticker: Shared body"
+ is FileMessageType -> "File: Shared body"
is LocationMessageType -> "Shared location"
is EmoteMessageType -> "* $senderName ${type.body}"
is TextMessageType,
is NoticeMessageType,
is OtherMessageType -> body
}
- assertWithMessage("$type was not properly handled for DM").that(result).isEqualTo(expectedResult)
+ val shouldCreateAnnotatedString = when (type) {
+ is VideoMessageType -> true
+ is AudioMessageType -> true
+ is VoiceMessageType -> false
+ is ImageMessageType -> true
+ is StickerMessageType -> true
+ is FileMessageType -> true
+ is LocationMessageType -> false
+ is EmoteMessageType -> false
+ is TextMessageType -> false
+ is NoticeMessageType -> false
+ is OtherMessageType -> false
+ }
+ if (shouldCreateAnnotatedString) {
+ assertWithMessage("$type doesn't produce an AnnotatedString")
+ .that(result)
+ .isInstanceOf(AnnotatedString::class.java)
+ }
+ assertWithMessage("$type was not properly handled for DM").that(string).isEqualTo(expectedResult)
}
// Verify results of Room mode
for ((type, result) in resultsInRoom) {
val string = result.toString()
val expectedResult = when (type) {
- is VideoMessageType -> "$expectedPrefix: Video"
- is AudioMessageType -> "$expectedPrefix: Audio"
+ is VideoMessageType -> "$expectedPrefix: Video: Shared body"
+ is AudioMessageType -> "$expectedPrefix: Audio: Shared body"
is VoiceMessageType -> "$expectedPrefix: Voice message"
- is ImageMessageType -> "$expectedPrefix: Image"
- is StickerMessageType -> "$expectedPrefix: Sticker"
- is FileMessageType -> "$expectedPrefix: File"
+ is ImageMessageType -> "$expectedPrefix: Image: Shared body"
+ is StickerMessageType -> "$expectedPrefix: Sticker: Shared body"
+ is FileMessageType -> "$expectedPrefix: File: Shared body"
is LocationMessageType -> "$expectedPrefix: Shared location"
is TextMessageType,
is NoticeMessageType,
@@ -249,7 +268,8 @@ class DefaultRoomLastMessageFormatterTest {
is FileMessageType -> true
is LocationMessageType -> false
is EmoteMessageType -> false
- is TextMessageType, is NoticeMessageType -> true
+ is TextMessageType -> true
+ is NoticeMessageType -> true
is OtherMessageType -> true
}
if (shouldCreateAnnotatedString) {
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 3a6bd16f77..490c12ebe0 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -126,17 +126,18 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
- IdentityPinningViolationNotifications(
- key = "feature.identityPinningViolationNotifications",
- title = "Identity pinning violation notifications",
- description = null,
- defaultValue = { buildMeta ->
- when (buildMeta.buildType) {
- // Do not enable this feature in release builds
- BuildType.RELEASE -> false
- else -> true
- }
- },
+ Knock(
+ key = "feature.knock",
+ title = "Ask to join",
+ description = "Allow creating rooms which users can request access to.",
+ defaultValue = { false },
+ isFinished = false,
+ ),
+ MediaUploadOnSendQueue(
+ key = "feature.media_upload_through_send_queue",
+ title = "Media upload through send queue",
+ description = "Experimental support for treating media uploads as regular events, with an improved retry and cancellation implementation.",
+ defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
isFinished = false,
),
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index 6af1763e83..504db1c6d9 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -108,7 +108,14 @@ interface MatrixClient : Closeable {
suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result
suspend fun getRecentlyVisitedRooms(): Result>
- suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result
+
+ /**
+ * Resolves the given room alias to a roomID (and a list of servers), if possible.
+ * @param roomAlias the room alias to resolve
+ * @return the resolved room alias if any, an empty result if not found,or an error if the resolution failed.
+ *
+ */
+ suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result>
/**
* Enables or disables the sending queue, according to the given parameter.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt
index b7b44ba45e..019bd0d8f0 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt
@@ -56,4 +56,7 @@ interface MatrixAuthenticationService {
suspend fun loginWithOidc(callbackUrl: String): Result
suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result
+
+ /** Listen to new Matrix clients being created on authentication. */
+ fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit)
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt
new file mode 100644
index 0000000000..c1b298d62a
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.core
+
+import java.io.Serializable
+
+@JvmInline
+value class FlowId(val value: String) : Serializable {
+ override fun toString(): String = value
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt
index 940fef7326..2e13623c96 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt
@@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.createroom
import io.element.android.libraries.matrix.api.core.UserId
+import java.util.Optional
data class CreateRoomParameters(
val name: String?,
@@ -18,4 +19,6 @@ data class CreateRoomParameters(
val preset: RoomPreset,
val invite: List? = null,
val avatar: String? = null,
+ val joinRuleOverride: JoinRuleOverride = JoinRuleOverride.None,
+ val canonicalAlias: Optional = Optional.empty(),
)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/JoinRuleOverride.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/JoinRuleOverride.kt
new file mode 100644
index 0000000000..f59f393c3e
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/JoinRuleOverride.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.createroom
+
+/**
+ * Rules to override the default room join rules.
+ */
+sealed interface JoinRuleOverride {
+ data object Knock : JoinRuleOverride
+ data object None : JoinRuleOverride
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index fcc1fd1812..3bbbf6fdd4 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -132,8 +132,8 @@ interface MatrixRoom : Closeable {
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
- body: String?,
- formattedBody: String?,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?
): Result
@@ -141,8 +141,8 @@ interface MatrixRoom : Closeable {
file: File,
thumbnailFile: File?,
videoInfo: VideoInfo,
- body: String?,
- formattedBody: String?,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?
): Result
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt
index 5f362dfda5..88cabf265b 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt
@@ -10,8 +10,22 @@ package io.element.android.libraries.matrix.api.roomdirectory
import kotlinx.coroutines.flow.Flow
interface RoomDirectoryList {
- suspend fun filter(filter: String?, batchSize: Int): Result
+ /**
+ * Starts a filtered search for the server.
+ * If the filter is not provided it will search for all the rooms. You can specify a batch_size to control the number of rooms to fetch per request.
+ * If the via_server is not provided it will search in the current homeserver by default.
+ * This method will clear the current search results and start a new one
+ */
+ suspend fun filter(filter: String?, batchSize: Int, viaServerName: String?): Result
+
+ /**
+ * Load more rooms from the current search results.
+ */
suspend fun loadMore(): Result
+
+ /**
+ * The current search results as a state flow.
+ */
val state: Flow
data class State(
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
index 085c4d49ea..695fe906c5 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
@@ -75,8 +75,8 @@ interface Timeline : AutoCloseable {
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
- body: String?,
- formattedBody: String?,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?
): Result
@@ -84,8 +84,8 @@ interface Timeline : AutoCloseable {
file: File,
thumbnailFile: File?,
videoInfo: VideoInfo,
- body: String?,
- formattedBody: String?,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?
): Result
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt
index e6a0eae7ed..c95fe46741 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt
@@ -34,6 +34,10 @@ sealed interface LocalEventSendState {
*/
val users: List
) : VerifiedUser
+
+ data class InvalidMimeType(val mimeType: String) : Failed
+
+ data object MissingMediaContent : Failed
}
data class Sent(
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt
new file mode 100644
index 0000000000..93d791a21c
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Please see LICENSE in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.verification
+
+import android.os.Parcelable
+import io.element.android.libraries.matrix.api.core.DeviceId
+import io.element.android.libraries.matrix.api.core.FlowId
+import io.element.android.libraries.matrix.api.core.UserId
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class SessionVerificationRequestDetails(
+ val senderId: UserId,
+ val flowId: FlowId,
+ val deviceId: DeviceId,
+ val displayName: String?,
+ val firstSeenTimestamp: Long,
+) : Parcelable
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt
index 193d5eb48e..4bd30a21fc 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt
@@ -56,7 +56,27 @@ interface SessionVerificationService {
/**
* Returns the verification service state to the initial step.
*/
- suspend fun reset()
+ suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean)
+
+ /**
+ * Register a listener to be notified of incoming session verification requests.
+ */
+ fun setListener(listener: SessionVerificationServiceListener?)
+
+ /**
+ * Set this particular request as the currently active one and register for
+ * events pertaining it.
+ */
+ suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails)
+
+ /**
+ * Accept the previously acknowledged verification request.
+ */
+ suspend fun acceptVerificationRequest()
+}
+
+interface SessionVerificationServiceListener {
+ fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails)
}
/** Verification status of the current session. */
@@ -82,20 +102,20 @@ sealed interface VerificationFlowState {
data object Initial : VerificationFlowState
/** Session verification request was accepted by another device. */
- data object AcceptedVerificationRequest : VerificationFlowState
+ data object DidAcceptVerificationRequest : VerificationFlowState
/** Short Authentication String (SAS) verification started between the 2 devices. */
- data object StartedSasVerification : VerificationFlowState
+ data object DidStartSasVerification : VerificationFlowState
/** Verification data for the SAS verification received. */
- data class ReceivedVerificationData(val data: SessionVerificationData) : VerificationFlowState
+ data class DidReceiveVerificationData(val data: SessionVerificationData) : VerificationFlowState
/** Verification completed successfully. */
- data object Finished : VerificationFlowState
+ data object DidFinish : VerificationFlowState
/** Verification was cancelled by either device. */
- data object Canceled : VerificationFlowState
+ data object DidCancel : VerificationFlowState
/** Verification failed with an error. */
- data object Failed : VerificationFlowState
+ data object DidFail : VerificationFlowState
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index 101d049659..3165cef1bf 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -12,6 +12,7 @@ import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
+import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.ProgressCallback
@@ -21,6 +22,7 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
+import io.element.android.libraries.matrix.api.createroom.JoinRuleOverride
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -32,6 +34,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.PendingRoom
+import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
@@ -71,7 +74,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
@@ -108,11 +110,11 @@ import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters
+import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
-@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixClient(
private val client: Client,
private val baseDirectory: File,
@@ -124,6 +126,7 @@ class RustMatrixClient(
baseCacheDirectory: File,
clock: SystemClock,
timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
+ featureFlagService: FeatureFlagService,
) : MatrixClient {
override val sessionId: UserId = UserId(client.userId())
override val deviceId: DeviceId = DeviceId(client.deviceId())
@@ -187,6 +190,7 @@ class RustMatrixClient(
roomContentForwarder = RoomContentForwarder(innerRoomListService),
roomSyncSubscriber = roomSyncSubscriber,
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
+ featureFlagService = featureFlagService,
)
override val mediaLoader: MatrixMediaLoader = RustMediaLoader(
@@ -304,14 +308,33 @@ class RustMatrixClient(
RoomVisibility.PUBLIC -> RustRoomVisibility.PUBLIC
RoomVisibility.PRIVATE -> RustRoomVisibility.PRIVATE
},
- preset = when (createRoomParams.preset) {
- RoomPreset.PRIVATE_CHAT -> RustRoomPreset.PRIVATE_CHAT
- RoomPreset.PUBLIC_CHAT -> RustRoomPreset.PUBLIC_CHAT
- RoomPreset.TRUSTED_PRIVATE_CHAT -> RustRoomPreset.TRUSTED_PRIVATE_CHAT
+ preset = when (createRoomParams.visibility) {
+ RoomVisibility.PRIVATE -> {
+ if (createRoomParams.isDirect) {
+ RustRoomPreset.TRUSTED_PRIVATE_CHAT
+ } else {
+ RustRoomPreset.PRIVATE_CHAT
+ }
+ }
+ RoomVisibility.PUBLIC -> {
+ RustRoomPreset.PUBLIC_CHAT
+ }
},
invite = createRoomParams.invite?.map { it.value },
avatar = createRoomParams.avatar,
- powerLevelContentOverride = defaultRoomCreationPowerLevels,
+ powerLevelContentOverride = defaultRoomCreationPowerLevels.copy(
+ invite = if (createRoomParams.joinRuleOverride == JoinRuleOverride.Knock) {
+ // override the invite power level so it's the same as kick.
+ RoomMember.Role.MODERATOR.powerLevel.toInt()
+ } else {
+ null
+ }
+ ),
+ joinRuleOverride = when (createRoomParams.joinRuleOverride) {
+ JoinRuleOverride.Knock -> RustJoinRule.Knock
+ JoinRuleOverride.None -> null
+ },
+ canonicalAlias = createRoomParams.canonicalAlias.getOrNull(),
)
val roomId = RoomId(client.createRoom(rustParams))
// Wait to receive the room back from the sync but do not returns failure if it fails.
@@ -420,13 +443,15 @@ class RustMatrixClient(
}
}
- override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result = withContext(sessionDispatcher) {
+ override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result> = withContext(sessionDispatcher) {
runCatching {
- val result = client.resolveRoomAlias(roomAlias.value)
- ResolvedRoomAlias(
- roomId = RoomId(result.roomId),
- servers = result.servers,
- )
+ val result = client.resolveRoomAlias(roomAlias.value)?.let {
+ ResolvedRoomAlias(
+ roomId = RoomId(it.roomId),
+ servers = it.servers,
+ )
+ }
+ Optional.ofNullable(result)
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
index c33ccdb543..e22eda03f2 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
@@ -25,6 +25,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.SlidingSyncVersion
@@ -51,8 +52,9 @@ class RustMatrixClientFactory @Inject constructor(
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val clientBuilderProvider: ClientBuilderProvider,
) {
+ private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
+
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
- val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
val client = getBaseClientBuilder(
sessionPaths = sessionData.getSessionPaths(),
passphrase = sessionData.passphrase,
@@ -60,18 +62,21 @@ class RustMatrixClientFactory @Inject constructor(
)
.homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId)
- .setSessionDelegate(sessionDelegate)
.use { it.build() }
client.restoreSession(sessionData.toSession())
+ create(client)
+ }
+
+ suspend fun create(client: Client): RustMatrixClient {
+ val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
+
val syncService = client.syncService()
.withUtdHook(UtdTracker(analyticsService))
.finish()
- val (anonymizedAccessToken, anonymizedRefreshToken) = sessionData.anonymizedTokens()
-
- RustMatrixClient(
+ return RustMatrixClient(
client = client,
baseDirectory = baseDirectory,
sessionStore = sessionStore,
@@ -82,6 +87,7 @@ class RustMatrixClientFactory @Inject constructor(
baseCacheDirectory = cacheDirectory,
clock = clock,
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
+ featureFlagService = featureFlagService,
).also {
Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
}
@@ -97,6 +103,7 @@ class RustMatrixClientFactory @Inject constructor(
dataPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
)
+ .setSessionDelegate(sessionDelegate)
.passphrase(passphrase)
.userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides())
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
index 434b0b2aef..5ea9ce8550 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
@@ -51,7 +51,6 @@ import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
-import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk.OidcAuthorizationData
import javax.inject.Inject
@@ -77,6 +76,11 @@ class RustMatrixAuthenticationService @Inject constructor(
private var currentClient: Client? = null
private var currentHomeserver = MutableStateFlow(null)
+ private var newMatrixClientObserver: ((MatrixClient) -> Unit)? = null
+ override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) {
+ newMatrixClientObserver = lambda
+ }
+
private fun rotateSessionPath(): SessionPaths {
sessionPaths?.deleteRecursively()
return sessionPathsFactory.create()
@@ -155,7 +159,7 @@ class RustMatrixAuthenticationService @Inject constructor(
passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths,
)
- clear()
+ newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
sessionStore.storeData(sessionData)
SessionId(sessionData.userId)
}.mapFailure { failure ->
@@ -226,9 +230,9 @@ class RustMatrixAuthenticationService @Inject constructor(
passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths,
)
- clear()
pendingOidcAuthorizationData?.close()
pendingOidcAuthorizationData = null
+ newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
sessionStore.storeData(sessionData)
SessionId(sessionData.userId)
}.mapFailure { failure ->
@@ -256,15 +260,14 @@ class RustMatrixAuthenticationService @Inject constructor(
oidcConfiguration = oidcConfiguration,
progressListener = progressListener,
)
- val sessionData = client.use { rustClient ->
- rustClient.session()
- .toSessionData(
- isTokenValid = true,
- loginType = LoginType.QR,
- passphrase = pendingPassphrase,
- sessionPaths = emptySessionPaths,
- )
- }
+ val sessionData = client.session()
+ .toSessionData(
+ isTokenValid = true,
+ loginType = LoginType.QR,
+ passphrase = pendingPassphrase,
+ sessionPaths = emptySessionPaths,
+ )
+ newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
sessionStore.storeData(sessionData)
SessionId(sessionData.userId)
}.mapFailure {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
index 9604d6af6d..17ba1d2c4d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
@@ -37,7 +37,7 @@ class RustMediaLoader(
withContext(mediaDispatcher) {
runCatching {
source.toRustMediaSource().use { source ->
- innerClient.getMediaContent(source).toUByteArray().toByteArray()
+ innerClient.getMediaContent(source)
}
}
}
@@ -55,7 +55,7 @@ class RustMediaLoader(
mediaSource = mediaSource,
width = width.toULong(),
height = height.toULong()
- ).toUByteArray().toByteArray()
+ )
}
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index 31d8ae1f43..8b72282cd5 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.extensions.mapFailure
+import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
@@ -103,6 +104,7 @@ class RustMatrixRoom(
private val roomContentForwarder: RoomContentForwarder,
private val roomSyncSubscriber: RoomSyncSubscriber,
private val matrixRoomInfoMapper: MatrixRoomInfoMapper,
+ private val featureFlagService: FeatureFlagService,
) : MatrixRoom {
override val roomId = RoomId(innerRoom.id())
@@ -445,22 +447,22 @@ class RustMatrixRoom(
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
- body: String?,
- formattedBody: String?,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result {
- return liveTimeline.sendImage(file, thumbnailFile, imageInfo, body, formattedBody, progressCallback)
+ return liveTimeline.sendImage(file, thumbnailFile, imageInfo, caption, formattedCaption, progressCallback)
}
override suspend fun sendVideo(
file: File,
thumbnailFile: File?,
videoInfo: VideoInfo,
- body: String?,
- formattedBody: String?,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result {
- return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, body, formattedBody, progressCallback)
+ return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback)
}
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result {
@@ -700,6 +702,7 @@ class RustMatrixRoom(
dispatcher = roomDispatcher,
roomContentForwarder = roomContentForwarder,
onNewSyncedEvent = onNewSyncedEvent,
+ featureFlagsService = featureFlagService,
)
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
index e06424e723..1b9fe4115e 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.room
import androidx.collection.lruCache
import io.element.android.appconfig.TimelineConfig
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@@ -49,6 +50,7 @@ class RustRoomFactory(
private val innerRoomListService: InnerRoomListService,
private val roomSyncSubscriber: RoomSyncSubscriber,
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
+ private val featureFlagService: FeatureFlagService,
) {
@OptIn(ExperimentalCoroutinesApi::class)
private val dispatcher = dispatchers.io.limitedParallelism(1)
@@ -117,6 +119,7 @@ class RustRoomFactory(
roomContentForwarder = roomContentForwarder,
roomSyncSubscriber = roomSyncSubscriber,
matrixRoomInfoMapper = matrixRoomInfoMapper,
+ featureFlagService = featureFlagService,
)
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt
index 7219e9c3ed..576b93d26d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt
@@ -41,9 +41,9 @@ class RustRoomDirectoryList(
.launchIn(coroutineScope)
}
- override suspend fun filter(filter: String?, batchSize: Int): Result {
+ override suspend fun filter(filter: String?, batchSize: Int, viaServerName: String?): Result {
return execute {
- inner.search(filter = filter, batchSize = batchSize.toUInt())
+ inner.search(filter = filter, batchSize = batchSize.toUInt(), viaServerName = viaServerName)
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
index af597a88ab..98f7ccfba1 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
@@ -7,6 +7,8 @@
package io.element.android.libraries.matrix.impl.timeline
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
@@ -85,6 +87,7 @@ class RustTimeline(
private val coroutineScope: CoroutineScope,
private val dispatcher: CoroutineDispatcher,
private val roomContentForwarder: RoomContentForwarder,
+ private val featureFlagsService: FeatureFlagService,
onNewSyncedEvent: () -> Unit,
) : Timeline {
private val initLatch = CompletableDeferred()
@@ -326,20 +329,21 @@ class RustTimeline(
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
- body: String?,
- formattedBody: String?,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result {
+ val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
inner.sendImage(
url = file.path,
thumbnailUrl = thumbnailFile?.path,
imageInfo = imageInfo.map(),
- caption = body,
- formattedCaption = formattedBody?.let {
+ caption = caption,
+ formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
- storeInCache = true,
+ useSendQueue = useSendQueue,
progressWatcher = progressCallback?.toProgressWatcher()
)
}
@@ -349,26 +353,28 @@ class RustTimeline(
file: File,
thumbnailFile: File?,
videoInfo: VideoInfo,
- body: String?,
- formattedBody: String?,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result