diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml
index 136dfb1f20..74696c9a22 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.6
+ - uses: mobile-dev-inc/action-maestro-cloud@v1.9.7
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/CHANGES.md b/CHANGES.md
index 777cd401c9..3dc07f4c66 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,53 @@
+Changes in Element X v0.7.4 (2024-11-20)
+========================================
+
+## What's Changed
+### 🙌 Improvements
+* Update the strings for unsupported calls by @bmarty in https://github.com/element-hq/element-x-android/pull/3857
+### 🐛 Bugfixes
+* Stop incoming call ringing if answered on another device. by @bmarty in https://github.com/element-hq/element-x-android/pull/3842
+* Use formatted captions for images and video by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3864
+* Fix unified push unregister by @bmarty in https://github.com/element-hq/element-x-android/pull/3877
+* Hide the keyboard when navigating from the chat room screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3878
+* Fix long click not working for media timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3879
+* Instantiate the verification controller ASAP by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3893
+* fix : display security banner for room list empty state by @ganfra in https://github.com/element-hq/element-x-android/pull/3892
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3852
+* Sync Strings - add translations to Finnish by @ElementBot in https://github.com/element-hq/element-x-android/pull/3883
+### 🚧 In development 🚧
+* Create room : improve handling of room address by @ganfra in https://github.com/element-hq/element-x-android/pull/3868
+### Dependency upgrades
+* Update anvil to v0.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3792
+* Update kotlin to v2.0.21-1.0.27 by @renovate in https://github.com/element-hq/element-x-android/pull/3836
+* Update dependency org.maplibre.gl:android-sdk to v11.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3793
+* Update android.gradle.plugin to v8.7.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3785
+* Update lifecycle to v2.8.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3763
+* Update plugin dependencycheck to v11 by @renovate in https://github.com/element-hq/element-x-android/pull/3723
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.61 by @renovate in https://github.com/element-hq/element-x-android/pull/3841
+* Update mobile-dev-inc/action-maestro-cloud action to v1.9.6 by @renovate in https://github.com/element-hq/element-x-android/pull/3846
+* Update dependency com.posthog:posthog-android to v3.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3856
+* Update core to v1.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3766
+* Update dependency com.android.tools:desugar_jdk_libs to v2.1.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3825
+* Update dependency io.nlopez.compose.rules:detekt to v0.4.18 by @renovate in https://github.com/element-hq/element-x-android/pull/3860
+* Update dependency com.posthog:posthog-android to v3.9.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3861
+* Update dependency io.sentry:sentry-android to v7.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3862
+* Update dependency androidx.compose:compose-bom to v2024.11.00 by @renovate in https://github.com/element-hq/element-x-android/pull/3869
+* Update telephoto to v0.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3870
+* Update SDK bindings version to `0.2.62` and fix `SendHandle` usages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3876
+* Update codecov/codecov-action action to v5 by @renovate in https://github.com/element-hq/element-x-android/pull/3874
+* Update dependency com.google.firebase:firebase-bom to v33.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3880
+* Update kotlin to v2.0.21-1.0.28 by @renovate in https://github.com/element-hq/element-x-android/pull/3881
+* Update dependency org.robolectric:robolectric to v4.14 by @renovate in https://github.com/element-hq/element-x-android/pull/3882
+* Update appyx to v1.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3889
+* Update dependency io.nlopez.compose.rules:detekt to v0.4.19 by @renovate in https://github.com/element-hq/element-x-android/pull/3900
+* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.63 by @renovate in https://github.com/element-hq/element-x-android/pull/3898
+### Others
+* Design system : implement new TextField by @ganfra in https://github.com/element-hq/element-x-android/pull/3834
+* Remove :samples:minimal module by @bmarty in https://github.com/element-hq/element-x-android/pull/3871
+* Replace `textPlaceholder` color usages with `textSecondary` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3873
+* Room Preview API changes by @ganfra in https://github.com/element-hq/element-x-android/pull/3875
+
Changes in Element X v0.7.3 (2024-11-08)
========================================
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
index d7cad2f9e1..390ba5a532 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
@@ -16,5 +16,5 @@ object ElementCallConfig {
/**
* The default duration of a ringing call in seconds before it's automatically dismissed.
*/
- const val RINGING_CALL_DURATION_SECONDS = 15
+ const val RINGING_CALL_DURATION_SECONDS = 90
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt
index c5f605e5fe..83ea9e3b77 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt
@@ -11,14 +11,20 @@ import android.graphics.Color
import androidx.annotation.ColorInt
object NotificationConfig {
- // TODO EAx Implement and set to true at some point
- const val SUPPORT_MARK_AS_READ_ACTION = false
+ /**
+ * If set to true, the notification will have a "Mark as read" action.
+ */
+ const val SHOW_MARK_AS_READ_ACTION = true
- // TODO EAx Implement and set to true at some point
- const val SUPPORT_JOIN_DECLINE_INVITE = false
+ /**
+ * If set to true, the notification for invitation will have two actions to accept or decline the invite.
+ */
+ const val SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS = true
- // TODO EAx Implement and set to true at some point
- const val SUPPORT_QUICK_REPLY_ACTION = false
+ /**
+ * If set to true, the notification will have a "Quick reply" action, allow to compose and send a message to the room.
+ */
+ const val SHOW_QUICK_REPLY_ACTION = true
@ColorInt
val NOTIFICATION_ACCENT_COLOR: Int = Color.parseColor("#FF0DBD8B")
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index 5677d3834d..940e576a30 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -20,11 +20,20 @@ import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.navigation.NavElements
+import com.bumble.appyx.core.navigation.NavKey
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.BackStack.State.ACTIVE
+import com.bumble.appyx.navmodel.backstack.BackStack.State.CREATED
+import com.bumble.appyx.navmodel.backstack.BackStack.State.STASHED
+import com.bumble.appyx.navmodel.backstack.BackStackElement
+import com.bumble.appyx.navmodel.backstack.BackStackElements
+import com.bumble.appyx.navmodel.backstack.operation.BackStackOperation
+import com.bumble.appyx.navmodel.backstack.operation.Push
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
@@ -312,7 +321,7 @@ class LoggedInFlowNode @AssistedInject constructor(
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
- coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias()) }
+ coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) }
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
@@ -400,6 +409,11 @@ class LoggedInFlowNode @AssistedInject constructor(
is NavTarget.SecureBackup -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
+ .callback(object : SecureBackupEntryPoint.Callback {
+ override fun onDone() {
+ backstack.pop()
+ }
+ })
.build()
}
NavTarget.Ftue -> {
@@ -467,21 +481,21 @@ class LoggedInFlowNode @AssistedInject constructor(
serverNames: List = emptyList(),
trigger: JoinedRoom.Trigger? = null,
eventId: EventId? = null,
+ clearBackstack: Boolean,
) {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.RoomList
}
attachChild {
- backstack.push(
- NavTarget.Room(
- roomIdOrAlias = roomIdOrAlias,
- serverNames = serverNames,
- trigger = trigger,
- initialElement = RoomNavigationTarget.Messages(
- focusedEventId = eventId
- )
+ val roomNavTarget = NavTarget.Room(
+ roomIdOrAlias = roomIdOrAlias,
+ serverNames = serverNames,
+ trigger = trigger,
+ initialElement = RoomNavigationTarget.Messages(
+ focusedEventId = eventId
)
)
+ backstack.accept(AttachRoomOperation(roomNavTarget, clearBackstack))
}
}
@@ -526,3 +540,31 @@ class LoggedInFlowNode @AssistedInject constructor(
@Assisted plugins: List,
) : Node(buildContext, plugins = plugins)
}
+
+@Parcelize
+private class AttachRoomOperation(
+ val roomTarget: LoggedInFlowNode.NavTarget.Room,
+ val clearBackstack: Boolean,
+) : BackStackOperation {
+ override fun isApplicable(elements: NavElements) = true
+
+ override fun invoke(elements: BackStackElements): BackStackElements {
+ return if (clearBackstack) {
+ // Makes sure the room list target is alone in the backstack and stashed
+ elements.mapNotNull { element ->
+ if (element.key.navTarget == LoggedInFlowNode.NavTarget.RoomList) {
+ element.transitionTo(STASHED, this)
+ } else {
+ null
+ }
+ } + BackStackElement(
+ key = NavKey(roomTarget),
+ fromState = CREATED,
+ targetState = ACTIVE,
+ operation = this
+ )
+ } else {
+ Push(roomTarget).invoke(elements)
+ }
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
index 53f6de6032..143595d934 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -303,6 +303,7 @@ class RootFlowNode @AssistedInject constructor(
trigger = JoinedRoom.Trigger.MobilePermalink,
serverNames = permalinkData.viaParameters,
eventId = permalinkData.eventId,
+ clearBackstack = true
)
}
is PermalinkData.UserLink -> {
@@ -318,7 +319,7 @@ class RootFlowNode @AssistedInject constructor(
.apply {
when (deeplinkData) {
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
- is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias())
+ is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true)
}
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
index 91f5d7da19..e96b603d21 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
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.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
@@ -102,10 +103,7 @@ class LoggedInPresenter @Inject constructor(
}
}
LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch {
- // Force the user to log out if they were using the proxy sliding sync and it's no longer available, but native sliding sync is.
- forceNativeSlidingSyncMigration = !matrixClient.isUsingNativeSlidingSync() &&
- matrixClient.isNativeSlidingSyncSupported() &&
- !matrixClient.isSlidingSyncProxySupported()
+ forceNativeSlidingSyncMigration = matrixClient.forceNativeSlidingSyncMigration().getOrDefault(false)
}
LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch {
// Enable native sliding sync if it wasn't already the case
@@ -125,6 +123,18 @@ class LoggedInPresenter @Inject constructor(
)
}
+ // Force the user to log out if they were using the proxy sliding sync and it's no longer available, but native sliding sync is.
+ private suspend fun MatrixClient.forceNativeSlidingSyncMigration(): Result = runCatching {
+ val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
+ if (currentSlidingSyncVersion == SlidingSyncVersion.Proxy) {
+ val availableSlidingSyncVersions = availableSlidingSyncVersions().getOrThrow()
+ availableSlidingSyncVersions.contains(SlidingSyncVersion.Native) &&
+ !availableSlidingSyncVersions.contains(SlidingSyncVersion.Proxy)
+ } else {
+ false
+ }
+ }
+
private suspend fun ensurePusherIsRegistered(pusherRegistrationState: MutableState>) {
Timber.tag(pusherTag.value).d("Ensure pusher is registered")
val currentPushProvider = pushService.getCurrentPushProvider()
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
index 0562c5e2d6..a70ea78a28 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
@@ -48,9 +48,11 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.getRoomInfoFlow
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
+import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@@ -67,6 +69,7 @@ class RoomFlowNode @AssistedInject constructor(
private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val networkMonitor: NetworkMonitor,
+ private val membershipObserver: RoomMembershipObserver,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Loading,
@@ -145,10 +148,6 @@ class RoomFlowNode @AssistedInject constructor(
backstack.newRoot(NavTarget.JoinedRoom(roomId))
}
}
- CurrentUserMembership.LEFT -> {
- // Left the room, navigate out of this flow
- navigateUp()
- }
else -> {
// Was invited or the room is not known, display the join room screen
backstack.newRoot(
@@ -161,6 +160,15 @@ class RoomFlowNode @AssistedInject constructor(
}
}
}.launchIn(lifecycleScope)
+
+ // If the user leaves the room from this client, close the room flow.
+ lifecycleScope.launch {
+ membershipObserver.updates
+ .first { it.roomId == roomId && !it.isUserInRoom }
+ .run {
+ navigateUp()
+ }
+ }
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
index ec44876634..0785ba73cd 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
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.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -501,9 +502,8 @@ class LoggedInPresenterTest {
// - The sliding sync proxy is no longer supported
// - The native sliding sync is supported
val matrixClient = FakeMatrixClient(
- isUsingNativeSlidingSyncLambda = { false },
- isSlidingSyncProxySupportedLambda = { false },
- isNativeSlidingSyncSupportedLambda = { true },
+ currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
+ availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) },
)
val presenter = createLoggedInPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
@@ -521,9 +521,8 @@ class LoggedInPresenterTest {
@Test
fun `present - CheckSlidingSyncProxyAvailability will not force the migration if native sliding sync is not supported too`() = runTest {
val matrixClient = FakeMatrixClient(
- isUsingNativeSlidingSyncLambda = { false },
- isSlidingSyncProxySupportedLambda = { false },
- isNativeSlidingSyncSupportedLambda = { false },
+ currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
+ availableSlidingSyncVersionsLambda = { Result.success(emptyList()) },
)
val presenter = createLoggedInPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
diff --git a/build.gradle.kts b/build.gradle.kts
index 187b09de54..31edb5b08c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -52,6 +52,10 @@ allprojects {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.19")
}
+ tasks.withType().configureEach {
+ exclude("io/element/android/tests/konsist/failures/**")
+ }
+
// KtLint
apply {
plugin("org.jlleitschuh.gradle.ktlint")
@@ -75,6 +79,7 @@ allprojects {
val generatedPath = "${layout.buildDirectory.asFile.get()}/generated/"
filter {
exclude { element -> element.file.path.contains(generatedPath) }
+ exclude("io/element/android/tests/konsist/failures/**")
}
}
// Dependency check
diff --git a/fastlane/metadata/android/en-US/changelogs/40007050.txt b/fastlane/metadata/android/en-US/changelogs/40007050.txt
new file mode 100644
index 0000000000..a4b397f1bb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40007050.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
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 0495f8ec7d..2622c48529 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
@@ -44,6 +44,7 @@ import io.element.android.features.call.impl.pip.PictureInPictureState
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.Presenter
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementThemeApp
@@ -62,7 +63,7 @@ class ElementCallActivity :
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
- private lateinit var presenter: CallScreenPresenter
+ private lateinit var presenter: Presenter
private lateinit var audioManager: AudioManager
@@ -92,6 +93,10 @@ class ElementCallActivity :
)
setCallType(intent)
+ // If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
+ if (!::presenter.isInitialized) {
+ return
+ }
if (savedInstanceState == null) {
updateUiMode(resources.configuration)
@@ -193,19 +198,26 @@ class ElementCallActivity :
?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
}
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()
+ if (currentCallType == null) {
+ if (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 {
+ Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
+ webViewTarget.value = callType
+ presenter = presenterFactory.create(callType, this)
+ }
} else {
- Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
+ if (callType == null) {
+ Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
+ } else if (callType != currentCallType) {
+ Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
+ setIntent(intent)
+ recreate()
+ } else {
+ // Starting the same call again, should not happen, the UI is preventing this. But maybe when using external links.
+ Timber.tag(loggerTag.value).d("Starting the same call again, do nothing")
+ }
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
index 609c04a0e6..588fd25ac0 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
@@ -66,19 +66,34 @@ class WebViewWidgetMessageInterceptor(
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
// No network for instance, transmit the error
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")
- onError(error?.description?.toString())
+
+ // Only propagate the error if it happens while loading the current page
+ if (view?.url == request?.url.toString()) {
+ onError(error?.description.toString())
+ }
+
super.onReceivedError(view, request, error)
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) {
Timber.e("onReceivedHttpError error: ${errorResponse?.statusCode} ${errorResponse?.reasonPhrase}")
- onError(errorResponse?.statusCode.toString())
+
+ // Only propagate the error if it happens while loading the current page
+ if (view?.url == request?.url.toString()) {
+ onError(errorResponse?.statusCode.toString())
+ }
+
super.onReceivedHttpError(view, request, errorResponse)
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
Timber.e("onReceivedSslError error: ${error?.primaryError}")
- onError(error?.primaryError?.toString())
+
+ // Only propagate the error if it happens while loading the current page
+ if (view?.url == error?.url.toString()) {
+ onError(error?.toString())
+ }
+
super.onReceivedSslError(view, handler, error)
}
}
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 67e697e8e4..164a82af62 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
@@ -102,7 +102,10 @@ class CreateRoomDataStore @Inject constructor(
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
- is RoomVisibilityState.Public -> config.roomVisibility.copy(roomAddress = RoomAddress.Edited(address))
+ is RoomVisibilityState.Public -> {
+ val sanitizedAddress = address.lowercase()
+ config.roomVisibility.copy(roomAddress = RoomAddress.Edited(sanitizedAddress))
+ }
else -> config.roomVisibility
}
)
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 e34bb27a8b..fabbe2aecc 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
@@ -240,9 +240,9 @@ private fun RoomTopic(
modifier = modifier,
label = stringResource(R.string.screen_create_room_topic_label),
value = topic,
- placeholder = stringResource(CommonStrings.common_topic_placeholder),
onValueChange = onTopicChange,
maxLines = 3,
+ supportingText = stringResource(CommonStrings.common_topic_placeholder),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
),
diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml
index 441f747e7e..09ad753211 100644
--- a/features/createroom/impl/src/main/res/values-de/translations.xml
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -3,11 +3,22 @@
"Neuer Raum"
"Personen einladen"
"Beim Erstellen des Chats ist ein Fehler aufgetreten"
- "Die Nachrichten in diesem Chat sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."
- "Privater Raum (nur auf Einladung)"
- "Die Nachrichten sind nicht verschlüsselt und können von jedem gelesen werden. Die Verschlüsselung kann zu einem späteren Zeitpunkt aktiviert werden."
- "Öffentlicher Raum (für alle)"
+ "Nur eingeladene Personen haben Zutritt zu diesem Chatroom. Alle Nachrichten sind durchgehend verschlüsselt."
+ "Privater Chatroom"
+ "Jeder kann diesen Chatroom finden.
+Sie können dies aber jederzeit in den Chatroomeinstellungen ändern."
+ "Öffentlicher Chatroom"
+ "Jeder kann diesem Chatroom beitreten"
+ "Jemand"
+ "Chatroom Zugang"
+ "Jeder kann darum bitten, dem Chatroom beizutreten, aber ein Administrator oder ein Moderator muss die Anfrage akzeptieren."
+ "Beitritt beantragen"
+ "Einige Zeichen sind nicht erlaubt. Es werden nur Buchstaben, Ziffern und die folgenden Symbole unterstützt: ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _"
+ "Diese Chatroomadresse existiert bereits. Bitte versuchen Sie, das Adressenfeld des Chatrooms zu bearbeiten oder den Namen des Chatrooms zu ändern"
+ "Damit dieser Chatroom im öffentlichen Chatroomverzeichnis sichtbar ist, benötigen Sie eine Chatroomadresse."
+ "Chatroom Adresse"
"Raumname"
+ " Sichtbarkeit des Chatrooms"
"Raum erstellen"
"Thema (optional)"
"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"
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 72e1d997db..16096c8a68 100644
--- a/features/createroom/impl/src/main/res/values-el/translations.xml
+++ b/features/createroom/impl/src/main/res/values-el/translations.xml
@@ -13,7 +13,12 @@
"Πρόσβαση Δωματίου"
"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στο δωμάτιο, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχθεί το αίτημα"
"Αίτημα συμμετοχής"
+ "Ορισμένοι χαρακτήρες δεν επιτρέπονται. Υποστηρίζονται μόνο γράμματα, ψηφία και τα ακόλουθα σύμβολα ! $ & \'() * +/; = ? @ [] - . _"
+ "Αυτή η διεύθυνση δωματίου υπάρχει ήδη, δοκίμασε να επεξεργαστείς το πεδίο διεύθυνσης δωματίου ή να αλλάξεις το όνομα δωματίου"
+ "Για να είναι ορατό αυτό το δωμάτιο στον κατάλογο των δημόσιων δωματίων, θα χρειαστείς μια διεύθυνση δωματίου."
+ "Διεύθυνση δωματίου"
"Όνομα δωματίου"
+ "Ορατότητα δωματίου"
"Δημιούργησε ένα δωμάτιο"
"Θέμα (προαιρετικό)"
"Παρουσιάστηκε σφάλμα κατά την προσπάθεια έναρξης μιας συνομιλίας"
diff --git a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
index bc61411edc..b10b86f5a0 100644
--- a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
@@ -3,9 +3,10 @@
"Nova sala"
"Convidar pessoas"
"Ocorreu um erro ao criar a sala"
- "As mensagens nesta sala serão criptografadas. A criptografia não pode ser desativada posteriormente."
+ "Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são criptografadas de ponta a ponta."
"Sala privativa (somente por convite)"
- "As mensagens não serão criptografadas e qualquer pessoa pode lê-las. Você pode ativar a criptografia posteriormente."
+ "Qualquer um pode encontrar esta sala.
+Você pode mudar isso a qualquer momento nas configurações da sala."
"Sala pública (qualquer pessoa)"
"Nome da sala"
"Criar uma sala"
diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml
index 7900aef801..7a08c7af87 100644
--- a/features/createroom/impl/src/main/res/values/localazy.xml
+++ b/features/createroom/impl/src/main/res/values/localazy.xml
@@ -14,7 +14,7 @@ You can change this anytime in room settings."
"Anyone can ask to join the room but an administrator or a moderator will have to accept the request"
"Ask to join"
"Some characters are not allowed. Only letters, digits and the following symbols are supported ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _"
- "This room address already exists, please try editing the room address field or change the room name"
+ "This room address already exists. Please try editing the room address field or change the room name"
"In order for this room to be visible in the public room directory, you will need a room address."
"Room address"
"Room name"
diff --git a/features/deactivation/impl/src/main/res/values-de/translations.xml b/features/deactivation/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..0830d6ba3f
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Bitte bestätigen Sie, dass Sie Ihr Benutzerkonto deaktivieren möchten. Diese Aktion kann nicht rückgängig gemacht werden."
+ "Lösche alle meine Nachrichten"
+ "Warnung: Benutzern werden möglicherweise unvollständige Konversationen angezeigt."
+ "Wenn Sie Ihr Konto deaktivieren%1$s, wird es:"
+ "irreversibel"
+ "%1$s Ihr Konto (Sie können sich nicht erneut anmelden und Ihre ID kann nicht wiederverwendet werden)."
+ "Dauerhaft deaktivieren"
+ "Sie werden aus allen Chatrooms entfernt."
+ "Löschen Sie Ihre Kontoinformationen von unserem Identitätsserver."
+ "Gelöschte Nachrichten werden für bereits registrierte Benutzer weiterhin sichtbar sein, wenn sie auch neuen oder nicht registrierten Benutzern nicht mehr zur Verfügung stehen."
+ "Benutzerkonto deaktivieren"
+
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/SharedPreferencesWelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/SharedPreferencesWelcomeScreenStore.kt
similarity index 94%
rename from features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/SharedPreferencesWelcomeScreenState.kt
rename to features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/SharedPreferencesWelcomeScreenStore.kt
index 551a00dd20..b03ccae482 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/SharedPreferencesWelcomeScreenState.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/SharedPreferencesWelcomeScreenStore.kt
@@ -16,7 +16,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
-class SharedPreferencesWelcomeScreenState @Inject constructor(
+class SharedPreferencesWelcomeScreenStore @Inject constructor(
private val sharedPreferences: SharedPreferences,
) : WelcomeScreenStore {
companion object {
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/InMemoryWelcomeScreenState.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/InMemoryWelcomeScreenStore.kt
similarity index 90%
rename from features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/InMemoryWelcomeScreenState.kt
rename to features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/InMemoryWelcomeScreenStore.kt
index a310a43a7f..cf3a73f490 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/InMemoryWelcomeScreenState.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/InMemoryWelcomeScreenStore.kt
@@ -7,7 +7,7 @@
package io.element.android.features.ftue.impl.welcome.state
-class InMemoryWelcomeScreenState : WelcomeScreenStore {
+class InMemoryWelcomeScreenStore : WelcomeScreenStore {
private var isWelcomeScreenNeeded = true
override fun isWelcomeScreenNeeded(): Boolean {
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt
index 44f4d8def0..bcd8190003 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt
@@ -43,14 +43,14 @@ class JoinRoomNode @AssistedInject constructor(
state = state,
onBackClick = ::navigateUp,
onJoinSuccess = ::navigateUp,
- onCancelKnockSuccess = ::navigateUp,
- onKnockSuccess = { },
+ onCancelKnockSuccess = {},
+ onKnockSuccess = {},
modifier = modifier
)
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInvite = {},
- onDeclineInvite = { navigateUp() },
+ onDeclineInvite = {},
modifier = Modifier
)
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
index ba909a147b..1984c0c4b3 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
@@ -48,8 +48,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Optional
-private const val MAX_KNOCK_MESSAGE_LENGTH = 500
-
class JoinRoomPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId,
@Assisted private val roomIdOrAlias: RoomIdOrAlias,
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
index 6049e6cd5a..b53c1e0f7d 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt
@@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.ui.model.InviteSender
+internal const val MAX_KNOCK_MESSAGE_LENGTH = 500
+
@Immutable
data class JoinRoomState(
val contentState: ContentState,
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
index 33dcf786e5..d3bbc59c0c 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
@@ -40,17 +40,6 @@ open class JoinRoomStateProvider : PreviewParameterProvider {
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin)
),
- aJoinRoomState(
- contentState = aLoadedContentState(
- joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock,
- topic = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt" +
- " ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco" +
- " laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in" +
- " voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" +
- " non proident sunt in culpa qui officia deserunt mollit anim id est laborum",
- numberOfMembers = 888,
- )
- ),
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null))
),
@@ -81,6 +70,29 @@ open class JoinRoomStateProvider : PreviewParameterProvider {
isDm = true,
)
),
+ aJoinRoomState(
+ contentState = aLoadedContentState(
+ joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock,
+ topic = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt" +
+ " ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco" +
+ " laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in" +
+ " voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" +
+ " non proident sunt in culpa qui officia deserunt mollit anim id est laborum",
+ numberOfMembers = 888,
+ )
+ ),
+ aJoinRoomState(
+ knockMessage = "Let me in please!",
+ contentState = aLoadedContentState(
+ joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock,
+ topic = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt" +
+ " ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco" +
+ " laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in" +
+ " voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" +
+ " non proident sunt in culpa qui officia deserunt mollit anim id est laborum",
+ numberOfMembers = 888,
+ )
+ ),
aJoinRoomState(
contentState = aLoadedContentState(
name = "A knocked Room",
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
index 3a1c7c8421..d06b5badf3 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
@@ -390,13 +390,18 @@ private fun DefaultLoadedContent(
)
} else if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanKnock) {
Spacer(modifier = Modifier.height(24.dp))
+ val supportingText = if (knockMessage.isNotEmpty()) {
+ "${knockMessage.length}/$MAX_KNOCK_MESSAGE_LENGTH"
+ } else {
+ stringResource(R.string.screen_join_room_knock_message_description)
+ }
TextField(
value = knockMessage,
onValueChange = onKnockMessageUpdate,
maxLines = 3,
minLines = 3,
modifier = Modifier.fillMaxWidth(),
- supportingText = stringResource(R.string.screen_join_room_knock_message_description)
+ supportingText = supportingText
)
}
}
diff --git a/features/joinroom/impl/src/main/res/values-de/translations.xml b/features/joinroom/impl/src/main/res/values-de/translations.xml
index 95718ce371..cbdf0c34a3 100644
--- a/features/joinroom/impl/src/main/res/values-de/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-de/translations.xml
@@ -1,7 +1,14 @@
+ "Anfrage abbrechen"
+ "Ja, abbrechen"
+ "Möchten Sie Ihre Beitrittsanfrage für diesen Chatroom wirklich stornieren?"
+ "Beitrittsanfrage stornieren"
"Raum beitreten"
"Anklopfen"
+ "Nachricht (optional)"
+ "Falls Ihre Anfrage, dem Raum beizutreten, akzeptiert wird, werden Sie eine Einladung erhalten."
+ "Beitrittsanfrage geschickt"
"%1$s unterstützt noch keine Spaces. Du kannst auf Spaces im Web zugreifen."
"Spaces werden noch nicht unterstützt"
"Klopfe an um einen Administrator zu benachrichtigen. Nach der Freigabe kannst du dich an der Unterhaltung beteiligen."
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
index 168a684922..e36639e079 100644
--- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
@@ -22,7 +22,6 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.isDm
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -30,7 +29,6 @@ import javax.inject.Inject
class LeaveRoomPresenter @Inject constructor(
private val client: MatrixClient,
- private val roomMembershipObserver: RoomMembershipObserver,
private val dispatchers: CoroutineDispatchers,
) : Presenter {
@Composable
@@ -58,7 +56,6 @@ class LeaveRoomPresenter @Inject constructor(
is LeaveRoomEvent.LeaveRoom -> scope.launch(dispatchers.io) {
client.leaveRoom(
roomId = event.roomId,
- roomMembershipObserver = roomMembershipObserver,
confirmation = confirmation,
progress = progress,
error = error,
@@ -88,7 +85,6 @@ private suspend fun showLeaveRoomAlert(
private suspend fun MatrixClient.leaveRoom(
roomId: RoomId,
- roomMembershipObserver: RoomMembershipObserver,
confirmation: MutableState,
progress: MutableState,
error: MutableState,
@@ -96,12 +92,11 @@ private suspend fun MatrixClient.leaveRoom(
confirmation.value = LeaveRoomState.Confirmation.Hidden
progress.value = LeaveRoomState.Progress.Shown
getRoom(roomId)?.use { room ->
- room.leave().onSuccess {
- roomMembershipObserver.notifyUserLeftRoom(room.roomId)
- }.onFailure {
- Timber.e(it, "Error while leaving room ${room.displayName} - ${room.roomId}")
- error.value = LeaveRoomState.Error.Shown
- }
+ room.leave()
+ .onFailure {
+ Timber.e(it, "Error while leaving room ${room.roomId}")
+ error.value = LeaveRoomState.Error.Shown
+ }
}
progress.value = LeaveRoomState.Progress.Hidden
}
diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterTest.kt
index 9689de4eb2..71dbdb62be 100644
--- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterTest.kt
+++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterTest.kt
@@ -14,19 +14,21 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.matrix.api.MatrixClient
-import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
-import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
-import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
+@OptIn(ExperimentalCoroutinesApi::class)
class LeaveRoomPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -126,26 +128,27 @@ class LeaveRoomPresenterTest {
@Test
fun `present - leaving a room leaves the room`() = runTest {
- val roomMembershipObserver = RoomMembershipObserver()
+ val leaveRoomLambda = lambdaRecorder> { Result.success(Unit) }
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom(
- leaveRoomLambda = { Result.success(Unit) }
+ leaveRoomLambda = leaveRoomLambda
),
)
},
- roomMembershipObserver = roomMembershipObserver
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
- // Membership observer should receive a 'left room' change
- assertThat(roomMembershipObserver.updates.first().change).isEqualTo(MembershipChange.LEFT)
+ advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
+ assert(leaveRoomLambda)
+ .isCalledOnce()
+ .withNoParameter()
}
}
@@ -227,9 +230,7 @@ class LeaveRoomPresenterTest {
private fun TestScope.createLeaveRoomPresenter(
client: MatrixClient = FakeMatrixClient(),
- roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
): LeaveRoomPresenter = LeaveRoomPresenter(
client = client,
- roomMembershipObserver = roomMembershipObserver,
dispatchers = testCoroutineDispatchers(false),
)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
index 577e233164..a7cd2af9a9 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
@@ -10,7 +10,7 @@ package io.element.android.features.lockscreen.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
-import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
@@ -45,7 +45,7 @@ class DefaultLockScreenService @Inject constructor(
private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService,
- biometricUnlockManager: BiometricUnlockManager,
+ biometricAuthenticatorManager: BiometricAuthenticatorManager,
) : LockScreenService {
private val _lockState = MutableStateFlow(LockScreenLockState.Unlocked)
override val lockState: StateFlow = _lockState
@@ -62,8 +62,8 @@ class DefaultLockScreenService @Inject constructor(
_lockState.value = LockScreenLockState.Unlocked
}
})
- biometricUnlockManager.addCallback(object : DefaultBiometricUnlockCallback() {
- override fun onBiometricUnlockSuccess() {
+ biometricAuthenticatorManager.addCallback(object : DefaultBiometricUnlockCallback() {
+ override fun onBiometricAuthenticationSuccess() {
_lockState.value = LockScreenLockState.Unlocked
coroutineScope.launch {
lockScreenStore.resetCounter()
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlock.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt
similarity index 70%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlock.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt
index a922ff0ded..2981761b43 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlock.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt
@@ -21,11 +21,11 @@ import timber.log.Timber
import java.security.InvalidKeyException
import javax.crypto.Cipher
-interface BiometricUnlock {
+interface BiometricAuthenticator {
interface Callback {
fun onBiometricSetupError()
- fun onBiometricUnlockSuccess()
- fun onBiometricUnlockFailed(error: Exception?)
+ fun onBiometricAuthenticationSuccess()
+ fun onBiometricAuthenticationFailed(error: Exception?)
}
sealed interface AuthenticationResult {
@@ -38,23 +38,23 @@ interface BiometricUnlock {
suspend fun authenticate(): AuthenticationResult
}
-class NoopBiometricUnlock : BiometricUnlock {
+class NoopBiometricAuthentication : BiometricAuthenticator {
override val isActive: Boolean = false
override fun setup() = Unit
- override suspend fun authenticate() = BiometricUnlock.AuthenticationResult.Failure()
+ override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure()
}
-class DefaultBiometricUnlock(
+class DefaultBiometricAuthentication(
private val activity: FragmentActivity,
private val promptInfo: PromptInfo,
private val secretKeyRepository: SecretKeyRepository,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val keyAlias: String,
- private val callbacks: List
-) : BiometricUnlock {
+ private val callbacks: List
+) : BiometricAuthenticator {
override val isActive: Boolean = true
- private lateinit var cryptoObject: CryptoObject
+ private var cryptoObject: CryptoObject? = null
override fun setup() {
try {
@@ -67,11 +67,10 @@ class DefaultBiometricUnlock(
}
}
- override suspend fun authenticate(): BiometricUnlock.AuthenticationResult {
- if (!this::cryptoObject.isInitialized) {
- return BiometricUnlock.AuthenticationResult.Failure()
- }
- val deferredAuthenticationResult = CompletableDeferred()
+ override suspend fun authenticate(): BiometricAuthenticator.AuthenticationResult {
+ val cryptoObject = cryptoObject ?: return BiometricAuthenticator.AuthenticationResult.Failure()
+
+ val deferredAuthenticationResult = CompletableDeferred()
val executor = ContextCompat.getMainExecutor(activity.baseContext)
val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
val prompt = BiometricPrompt(activity, executor, callback)
@@ -80,7 +79,7 @@ class DefaultBiometricUnlock(
deferredAuthenticationResult.await()
} catch (cancellation: CancellationException) {
prompt.cancelAuthentication()
- BiometricUnlock.AuthenticationResult.Failure(cancellation)
+ BiometricAuthenticator.AuthenticationResult.Failure(cancellation)
}
}
@@ -91,30 +90,30 @@ class DefaultBiometricUnlock(
}
private class AuthenticationCallback(
- private val callbacks: List,
- private val deferredAuthenticationResult: CompletableDeferred,
+ private val callbacks: List,
+ private val deferredAuthenticationResult: CompletableDeferred,
) : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
val biometricUnlockError = BiometricUnlockError(errorCode, errString.toString())
- callbacks.forEach { it.onBiometricUnlockFailed(biometricUnlockError) }
- deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(biometricUnlockError))
+ callbacks.forEach { it.onBiometricAuthenticationFailed(biometricUnlockError) }
+ deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Failure(biometricUnlockError))
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
- callbacks.forEach { it.onBiometricUnlockFailed(null) }
+ callbacks.forEach { it.onBiometricAuthenticationFailed(null) }
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
if (result.cryptoObject?.cipher.isValid()) {
- callbacks.forEach { it.onBiometricUnlockSuccess() }
- deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Success)
+ callbacks.forEach { it.onBiometricAuthenticationSuccess() }
+ deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success)
} else {
val error = IllegalStateException("Invalid cipher")
- callbacks.forEach { it.onBiometricUnlockFailed(error) }
- deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure())
+ callbacks.forEach { it.onBiometricAuthenticationFailed(error) }
+ deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Failure())
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt
similarity index 53%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockManager.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt
index 6e625c8ce9..ce2375a1a8 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt
@@ -9,7 +9,7 @@ package io.element.android.features.lockscreen.impl.biometric
import androidx.compose.runtime.Composable
-interface BiometricUnlockManager {
+interface BiometricAuthenticatorManager {
/**
* If the device is secured for example with a pin, pattern or password.
*/
@@ -20,9 +20,18 @@ interface BiometricUnlockManager {
*/
val hasAvailableAuthenticator: Boolean
- fun addCallback(callback: BiometricUnlock.Callback)
- fun removeCallback(callback: BiometricUnlock.Callback)
+ fun addCallback(callback: BiometricAuthenticator.Callback)
+ fun removeCallback(callback: BiometricAuthenticator.Callback)
+ /**
+ * Remember a biometric authenticator ready for unlocking the app.
+ */
@Composable
- fun rememberBiometricUnlock(): BiometricUnlock
+ fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator
+
+ /**
+ * Remember a biometric authenticator ready for confirmation.
+ */
+ @Composable
+ fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
similarity index 76%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
index 4d74574beb..af2c37958c 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
@@ -31,6 +31,7 @@ import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.concurrent.CopyOnWriteArrayList
@@ -40,15 +41,15 @@ private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_BIOMETRIC"
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
-class DefaultBiometricUnlockManager @Inject constructor(
+class DefaultBiometricAuthenticatorManager @Inject constructor(
@ApplicationContext private val context: Context,
private val lockScreenStore: LockScreenStore,
private val lockScreenConfig: LockScreenConfig,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val secretKeyRepository: SecretKeyRepository,
private val coroutineScope: CoroutineScope,
-) : BiometricUnlockManager {
- private val callbacks = CopyOnWriteArrayList()
+) : BiometricAuthenticatorManager {
+ private val callbacks = CopyOnWriteArrayList()
private val biometricManager = BiometricManager.from(context)
private val keyguardManager: KeyguardManager = context.getSystemService()!!
@@ -85,16 +86,42 @@ class DefaultBiometricUnlockManager @Inject constructor(
}
@Composable
- override fun rememberBiometricUnlock(): BiometricUnlock {
+ override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
val isBiometricAllowed by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
val isAvailable by remember(lifecycleState) {
- derivedStateOf {
- isBiometricAllowed && hasAvailableAuthenticator
- }
+ derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }
}
val promptTitle = stringResource(id = R.string.screen_app_lock_biometric_unlock_title_android)
val promptNegative = stringResource(id = R.string.screen_app_lock_use_pin_android)
+ return rememberBiometricAuthenticator(
+ isAvailable = isAvailable,
+ promptTitle = promptTitle,
+ promptNegative = promptNegative,
+ )
+ }
+
+ @Composable
+ override fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator {
+ val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
+ val isAvailable by remember(lifecycleState) {
+ derivedStateOf { hasAvailableAuthenticator }
+ }
+ val promptTitle = stringResource(id = R.string.screen_app_lock_confirm_biometric_authentication_android)
+ val promptNegative = stringResource(id = CommonStrings.action_cancel)
+ return rememberBiometricAuthenticator(
+ isAvailable = isAvailable,
+ promptTitle = promptTitle,
+ promptNegative = promptNegative,
+ )
+ }
+
+ @Composable
+ private fun rememberBiometricAuthenticator(
+ isAvailable: Boolean,
+ promptTitle: String,
+ promptNegative: String,
+ ): BiometricAuthenticator {
val activity = LocalContext.current.findFragmentActivity()
return remember(isAvailable) {
if (isAvailable && activity != null) {
@@ -108,7 +135,7 @@ class DefaultBiometricUnlockManager @Inject constructor(
setNegativeButtonText(promptNegative)
setAllowedAuthenticators(authenticators)
}.build()
- DefaultBiometricUnlock(
+ DefaultBiometricAuthentication(
activity = activity,
promptInfo = promptInfo,
secretKeyRepository = secretKeyRepository,
@@ -117,16 +144,16 @@ class DefaultBiometricUnlockManager @Inject constructor(
callbacks = callbacks + internalCallback
)
} else {
- NoopBiometricUnlock()
+ NoopBiometricAuthentication()
}
}
}
- override fun addCallback(callback: BiometricUnlock.Callback) {
+ override fun addCallback(callback: BiometricAuthenticator.Callback) {
callbacks.add(callback)
}
- override fun removeCallback(callback: BiometricUnlock.Callback) {
+ override fun removeCallback(callback: BiometricAuthenticator.Callback) {
callbacks.remove(callback)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt
index 6511ead1a7..ec49129528 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt
@@ -7,8 +7,8 @@
package io.element.android.features.lockscreen.impl.biometric
-open class DefaultBiometricUnlockCallback : BiometricUnlock.Callback {
+open class DefaultBiometricUnlockCallback : BiometricAuthenticator.Callback {
override fun onBiometricSetupError() = Unit
- override fun onBiometricUnlockSuccess() = Unit
- override fun onBiometricUnlockFailed(error: Exception?) = Unit
+ override fun onBiometricAuthenticationSuccess() = Unit
+ override fun onBiometricAuthenticationFailed(error: Exception?) = Unit
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
index 69b3e60dd6..a05ba1d567 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
@@ -15,7 +15,8 @@ import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.lockscreen.impl.LockScreenConfig
-import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.architecture.Presenter
@@ -27,7 +28,7 @@ class LockScreenSettingsPresenter @Inject constructor(
private val lockScreenConfig: LockScreenConfig,
private val pinCodeManager: PinCodeManager,
private val lockScreenStore: LockScreenStore,
- private val biometricUnlockManager: BiometricUnlockManager,
+ private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val coroutineScope: CoroutineScope,
) : Presenter {
@Composable
@@ -42,6 +43,8 @@ class LockScreenSettingsPresenter @Inject constructor(
mutableStateOf(false)
}
+ val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
+
fun handleEvents(event: LockScreenSettingsEvents) {
when (event) {
LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
@@ -56,7 +59,14 @@ class LockScreenSettingsPresenter @Inject constructor(
LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
LockScreenSettingsEvents.ToggleBiometricAllowed -> {
coroutineScope.launch {
- lockScreenStore.setIsBiometricUnlockAllowed(!isBiometricEnabled)
+ if (!isBiometricEnabled) {
+ biometricUnlock.setup()
+ if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
+ lockScreenStore.setIsBiometricUnlockAllowed(true)
+ }
+ } else {
+ lockScreenStore.setIsBiometricUnlockAllowed(false)
+ }
}
}
}
@@ -66,7 +76,7 @@ class LockScreenSettingsPresenter @Inject constructor(
showRemovePinOption = showRemovePinOption,
isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation,
- showToggleBiometric = biometricUnlockManager.isDeviceSecured,
+ showToggleBiometric = biometricAuthenticatorManager.isDeviceSecured,
eventSink = ::handleEvents
)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt
index 3ade948549..7a36d0705e 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt
@@ -20,6 +20,7 @@ import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.setup.biometric.SetupBiometricNode
@@ -35,6 +36,7 @@ class LockScreenSetupFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val pinCodeManager: PinCodeManager,
+ val biometricAuthenticatorManager: BiometricAuthenticatorManager,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Pin,
@@ -61,7 +63,11 @@ class LockScreenSetupFlowNode @AssistedInject constructor(
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
- backstack.newRoot(NavTarget.Biometric)
+ if (biometricAuthenticatorManager.hasAvailableAuthenticator) {
+ backstack.newRoot(NavTarget.Biometric)
+ } else {
+ onSetupDone()
+ }
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt
index da6a24337a..3689df78a7 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt
@@ -13,6 +13,8 @@ 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.lockscreen.impl.biometric.BiometricAuthenticator
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch
@@ -20,6 +22,7 @@ import javax.inject.Inject
class SetupBiometricPresenter @Inject constructor(
private val lockScreenStore: LockScreenStore,
+ private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
) : Presenter {
@Composable
override fun present(): SetupBiometricState {
@@ -28,12 +31,16 @@ class SetupBiometricPresenter @Inject constructor(
}
val coroutineScope = rememberCoroutineScope()
+ val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
fun handleEvents(event: SetupBiometricEvents) {
when (event) {
SetupBiometricEvents.AllowBiometric -> coroutineScope.launch {
- lockScreenStore.setIsBiometricUnlockAllowed(true)
- isBiometricSetupDone = true
+ biometricUnlock.setup()
+ if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
+ lockScreenStore.setIsBiometricUnlockAllowed(true)
+ isBiometricSetupDone = true
+ }
}
SetupBiometricEvents.UsePin -> coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(false)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt
index 62346229e2..646e744e88 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt
@@ -11,14 +11,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
-import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import javax.inject.Inject
class PinUnlockHelper @Inject constructor(
- private val biometricUnlockManager: BiometricUnlockManager,
+ private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val pinCodeManager: PinCodeManager
) {
@Composable
@@ -26,7 +26,7 @@ class PinUnlockHelper @Inject constructor(
val latestOnUnlock by rememberUpdatedState(onUnlock)
DisposableEffect(Unit) {
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
- override fun onBiometricUnlockSuccess() {
+ override fun onBiometricAuthenticationSuccess() {
latestOnUnlock()
}
}
@@ -35,10 +35,10 @@ class PinUnlockHelper @Inject constructor(
latestOnUnlock()
}
}
- biometricUnlockManager.addCallback(biometricUnlockCallback)
+ biometricAuthenticatorManager.addCallback(biometricUnlockCallback)
pinCodeManager.addCallback(pinCodeVerifiedCallback)
onDispose {
- biometricUnlockManager.removeCallback(biometricUnlockCallback)
+ biometricAuthenticatorManager.removeCallback(biometricUnlockCallback)
pinCodeManager.removeCallback(pinCodeVerifiedCallback)
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
index c561b36f0d..66a345f71f 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
@@ -15,8 +15,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
-import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
-import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
@@ -32,7 +32,7 @@ import javax.inject.Inject
class PinUnlockPresenter @Inject constructor(
private val pinCodeManager: PinCodeManager,
- private val biometricUnlockManager: BiometricUnlockManager,
+ private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val logoutUseCase: LogoutUseCase,
private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper,
@@ -56,12 +56,12 @@ class PinUnlockPresenter @Inject constructor(
mutableStateOf>(AsyncAction.Uninitialized)
}
var biometricUnlockResult by remember {
- mutableStateOf(null)
+ mutableStateOf(null)
}
val isUnlocked = remember {
mutableStateOf(false)
}
- val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
+ val biometricUnlock = biometricAuthenticatorManager.rememberUnlockBiometricAuthenticator()
LaunchedEffect(Unit) {
suspend {
val pinCodeSize = pinCodeManager.getPinCodeSize()
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt
index c0a28b9091..faed3ac394 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt
@@ -7,7 +7,7 @@
package io.element.android.features.lockscreen.impl.unlock
-import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.AsyncAction
@@ -21,7 +21,7 @@ data class PinUnlockState(
val signOutAction: AsyncAction,
val showBiometricUnlock: Boolean,
val isUnlocked: Boolean,
- val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,
+ val biometricUnlockResult: BiometricAuthenticator.AuthenticationResult?,
val eventSink: (PinUnlockEvents) -> Unit
) {
val isSignOutPromptCancellable = when (remainingAttempts) {
@@ -30,7 +30,7 @@ data class PinUnlockState(
}
val biometricUnlockErrorMessage = when {
- biometricUnlockResult is BiometricUnlock.AuthenticationResult.Failure &&
+ biometricUnlockResult is BiometricAuthenticator.AuthenticationResult.Failure &&
biometricUnlockResult.error is BiometricUnlockError &&
biometricUnlockResult.error.isAuthDisabledError -> {
biometricUnlockResult.error.message
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
index fa6bca0ea8..f54846194d 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
@@ -9,7 +9,7 @@ package io.element.android.features.lockscreen.impl.unlock
import androidx.biometric.BiometricPrompt
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.AsyncAction
@@ -25,7 +25,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider {
aPinUnlockState(showBiometricUnlock = false),
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
aPinUnlockState(signOutAction = AsyncAction.Loading),
- aPinUnlockState(biometricUnlockResult = BiometricUnlock.AuthenticationResult.Failure(
+ aPinUnlockState(biometricUnlockResult = BiometricAuthenticator.AuthenticationResult.Failure(
BiometricUnlockError(BiometricPrompt.ERROR_LOCKOUT, "Biometric auth disabled")
)),
)
@@ -37,7 +37,7 @@ fun aPinUnlockState(
showWrongPinTitle: Boolean = false,
showSignOutPrompt: Boolean = false,
showBiometricUnlock: Boolean = true,
- biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
+ biometricUnlockResult: BiometricAuthenticator.AuthenticationResult? = null,
isUnlocked: Boolean = false,
signOutAction: AsyncAction = AsyncAction.Uninitialized,
) = PinUnlockState(
diff --git a/features/lockscreen/impl/src/main/res/values-de/translations.xml b/features/lockscreen/impl/src/main/res/values-de/translations.xml
index 4042df3d1b..e56938285c 100644
--- a/features/lockscreen/impl/src/main/res/values-de/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-de/translations.xml
@@ -3,6 +3,7 @@
"biometrische Authentifizierung"
"biometrisches Entsperren"
"Mit Biometrie entsperren"
+ "Biometrische Daten bestätigen"
"PIN vergessen?"
"PIN-Code ändern"
"Biometrisches Entsperren zulassen"
diff --git a/features/lockscreen/impl/src/main/res/values-el/translations.xml b/features/lockscreen/impl/src/main/res/values-el/translations.xml
index ebc22971c2..e4e7d566b6 100644
--- a/features/lockscreen/impl/src/main/res/values-el/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-el/translations.xml
@@ -3,6 +3,7 @@
"βιομετρική ταυτοποίηση"
"βιομετρικό ξεκλείδωμα"
"Ξεκλείδωμα με βιομετρικά"
+ "Επιβεβαίωσε τον βιομετρικό έλεγχο ταυτότητας"
"Ξέχασες το PIN;"
"Αλλαγή κωδικού PIN"
"Να επιτρέπεται το βιομετρικό ξεκλείδωμα"
diff --git a/features/lockscreen/impl/src/main/res/values-et/translations.xml b/features/lockscreen/impl/src/main/res/values-et/translations.xml
index 3f2a8f4f55..4449479ba6 100644
--- a/features/lockscreen/impl/src/main/res/values-et/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-et/translations.xml
@@ -3,6 +3,7 @@
"biomeetrilist autentimist"
"biomeetrilist lukustuse eemaldamist"
"Eemalda lukustus biomeetrilise tuvastuse abil"
+ "Kinnita biomeetriline tuvastus"
"Kas unustasid PIN-koodi?"
"Muuda PIN-koodi"
"Kasuta lukustuse eemaldamiseks biomeetrilist tuvastust"
diff --git a/features/lockscreen/impl/src/main/res/values-fr/translations.xml b/features/lockscreen/impl/src/main/res/values-fr/translations.xml
index a8b3757022..b77df23254 100644
--- a/features/lockscreen/impl/src/main/res/values-fr/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-fr/translations.xml
@@ -3,6 +3,7 @@
"l’authentification biométrique"
"déverrouillage biométrique"
"Déverrouiller avec la biométrie"
+ "Confirmer la biométrie"
"Code PIN oublié?"
"Modifier le code PIN"
"Autoriser le déverrouillage biométrique"
diff --git a/features/lockscreen/impl/src/main/res/values-ru/translations.xml b/features/lockscreen/impl/src/main/res/values-ru/translations.xml
index a70ff5015c..eae4799f93 100644
--- a/features/lockscreen/impl/src/main/res/values-ru/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-ru/translations.xml
@@ -3,6 +3,7 @@
"биометрическая идентификация"
"биометрическая разблокировка"
"Разблокировать с помощью биометрии"
+ "Подтвердить биометрические данные"
"Забыли PIN-код?"
"Изменить PIN-код"
"Разрешить биометрическую разблокировку"
diff --git a/features/lockscreen/impl/src/main/res/values-sk/translations.xml b/features/lockscreen/impl/src/main/res/values-sk/translations.xml
index 7333d2a53f..0cfb2e88cd 100644
--- a/features/lockscreen/impl/src/main/res/values-sk/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-sk/translations.xml
@@ -3,6 +3,7 @@
"biometrické overenie"
"biometrické odomknutie"
"Odomknúť pomocou biometrie"
+ "Potvrdiť biometrické údaje"
"Zabudli ste PIN?"
"Zmeniť PIN kód"
"Povoliť biometrické odomknutie"
diff --git a/features/lockscreen/impl/src/main/res/values/localazy.xml b/features/lockscreen/impl/src/main/res/values/localazy.xml
index 0836efa99b..8d6d298b59 100644
--- a/features/lockscreen/impl/src/main/res/values/localazy.xml
+++ b/features/lockscreen/impl/src/main/res/values/localazy.xml
@@ -3,6 +3,7 @@
"biometric authentication"
"biometric unlock"
"Unlock with biometric"
+ "Confirm biometric"
"Forgot PIN?"
"Change PIN code"
"Allow biometric unlock"
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt
new file mode 100644
index 0000000000..e6bd169392
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.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.lockscreen.impl.biometric
+
+class FakeBiometricAuthenticator(
+ override val isActive: Boolean = false,
+ private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
+) : BiometricAuthenticator {
+ override fun setup() = Unit
+ override suspend fun authenticate() = authenticateLambda()
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt
new file mode 100644
index 0000000000..2907c98310
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.lockscreen.impl.biometric
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+class FakeBiometricAuthenticatorManager(
+ override var isDeviceSecured: Boolean = true,
+ override var hasAvailableAuthenticator: Boolean = false,
+ private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
+) : BiometricAuthenticatorManager {
+ override fun addCallback(callback: BiometricAuthenticator.Callback) {
+ // no-op
+ }
+
+ override fun removeCallback(callback: BiometricAuthenticator.Callback) {
+ // no-op
+ }
+
+ @Composable
+ override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
+ return remember {
+ createBiometricAuthenticator()
+ }
+ }
+
+ @Composable
+ override fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator {
+ return remember {
+ createBiometricAuthenticator()
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricUnlockManager.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricUnlockManager.kt
deleted file mode 100644
index 14cbef8a1d..0000000000
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricUnlockManager.kt
+++ /dev/null
@@ -1,31 +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.lockscreen.impl.biometric
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-
-class FakeBiometricUnlockManager : BiometricUnlockManager {
- override var isDeviceSecured: Boolean = true
- override var hasAvailableAuthenticator: Boolean = false
-
- override fun addCallback(callback: BiometricUnlock.Callback) {
- // no-op
- }
-
- override fun removeCallback(callback: BiometricUnlock.Callback) {
- // no-op
- }
-
- @Composable
- override fun rememberBiometricUnlock(): BiometricUnlock {
- return remember {
- NoopBiometricUnlock()
- }
- }
-}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
index fee45d0b0c..52fdf2e352 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
@@ -7,28 +7,38 @@
package io.element.android.features.lockscreen.impl.settings
-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.features.lockscreen.impl.LockScreenConfig
-import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
+import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticator
+import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
+import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.test
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LockScreenSettingsPresenterTest {
+ @Test
+ fun `present - remove pin option is hidden when mandatory`() = runTest {
+ val presenter = createLockScreenSettingsPresenter(this, lockScreenConfig = aLockScreenConfig(isPinMandatory = true))
+ presenter.test {
+ awaitItem().also { state ->
+ assertThat(state.showRemovePinOption).isFalse()
+ }
+ }
+ }
+
@Test
fun `present - remove pin flow`() = runTest {
val presenter = createLockScreenSettingsPresenter(this)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
consumeItemsUntilPredicate { state ->
state.showRemovePinOption
}.last().also { state ->
@@ -55,11 +65,95 @@ class LockScreenSettingsPresenterTest {
}
}
+ @Test
+ fun `present - show toggle biometric if device is secured`() = runTest {
+ val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
+ isDeviceSecured = true,
+ )
+ val presenter = createLockScreenSettingsPresenter(
+ coroutineScope = this,
+ biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
+ )
+ presenter.test {
+ skipItems(1)
+ assertThat(awaitItem().showToggleBiometric).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - enable biometric unlock success`() = runTest {
+ val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
+ createBiometricAuthenticator = {
+ FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success })
+ }
+ )
+ val presenter = createLockScreenSettingsPresenter(
+ coroutineScope = this,
+ biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
+ )
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
+ }
+ awaitItem().also { state ->
+ assertThat(state.isBiometricEnabled).isTrue()
+ }
+ }
+ }
+
+ @Test
+ fun `present - enable biometric unlock failure`() = runTest {
+ val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
+ createBiometricAuthenticator = {
+ FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
+ }
+ )
+ val presenter = createLockScreenSettingsPresenter(
+ coroutineScope = this,
+ biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
+ )
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
+ }
+ }
+ }
+
+ @Test
+ fun `present - disable biometric unlock`() = runTest {
+ val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
+ createBiometricAuthenticator = {
+ FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
+ }
+ )
+ val lockScreenStore = InMemoryLockScreenStore()
+ val presenter = createLockScreenSettingsPresenter(
+ coroutineScope = this,
+ lockScreenStore = lockScreenStore,
+ biometricAuthenticatorManager = fakeBiometricAuthenticatorManager
+ )
+ lockScreenStore.setIsBiometricUnlockAllowed(true)
+
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.isBiometricEnabled).isTrue()
+ state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
+ }
+ awaitItem().also { state ->
+ assertThat(state.isBiometricEnabled).isFalse()
+ }
+ }
+ }
+
private suspend fun createLockScreenSettingsPresenter(
coroutineScope: CoroutineScope,
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
+ biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
+ lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
): LockScreenSettingsPresenter {
- val lockScreenStore = InMemoryLockScreenStore()
val pinCodeManager = aPinCodeManager(lockScreenStore = lockScreenStore).apply {
createPinCode("1234")
}
@@ -68,7 +162,7 @@ class LockScreenSettingsPresenterTest {
pinCodeManager = pinCodeManager,
coroutineScope = coroutineScope,
lockScreenConfig = lockScreenConfig,
- biometricUnlockManager = FakeBiometricUnlockManager(),
+ biometricAuthenticatorManager = biometricAuthenticatorManager,
)
}
}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt
index e22bf0b683..83271d4131 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt
@@ -11,6 +11,10 @@ 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.features.lockscreen.impl.biometric.BiometricAuthenticator
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
+import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticator
+import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
import kotlinx.coroutines.flow.first
@@ -19,9 +23,12 @@ import org.junit.Test
class SetupBiometricPresenterTest {
@Test
- fun `present - allow flow`() = runTest {
+ fun `present - allow flow with biometric authentication success`() = runTest {
val lockScreenStore = InMemoryLockScreenStore()
- val presenter = createSetupBiometricPresenter(lockScreenStore)
+ val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(createBiometricAuthenticator = {
+ FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success })
+ })
+ val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -36,6 +43,24 @@ class SetupBiometricPresenterTest {
assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isTrue()
}
+ @Test
+ fun `present - allow flow with biometric authentication failure`() = runTest {
+ val lockScreenStore = InMemoryLockScreenStore()
+ val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(createBiometricAuthenticator = {
+ FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
+ })
+ val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().also { state ->
+ assertThat(state.isBiometricSetupDone).isFalse()
+ state.eventSink(SetupBiometricEvents.AllowBiometric)
+ }
+ }
+ assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse()
+ }
+
@Test
fun `present - skip flow`() = runTest {
val lockScreenStore = InMemoryLockScreenStore()
@@ -55,10 +80,12 @@ class SetupBiometricPresenterTest {
}
private fun createSetupBiometricPresenter(
- lockScreenStore: LockScreenStore = InMemoryLockScreenStore()
+ lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
+ biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
): SetupBiometricPresenter {
return SetupBiometricPresenter(
lockScreenStore = lockScreenStore,
+ biometricAuthenticatorManager = biometricAuthenticatorManager
)
}
}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
index 98533b9863..cdb0c8143e 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
@@ -11,8 +11,8 @@ 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.features.lockscreen.impl.biometric.BiometricUnlockManager
-import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
+import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
@@ -137,7 +137,7 @@ class PinUnlockPresenterTest {
private suspend fun createPinUnlockPresenter(
scope: CoroutineScope,
- biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(),
+ biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }),
): PinUnlockPresenter {
@@ -147,10 +147,10 @@ class PinUnlockPresenterTest {
}
return PinUnlockPresenter(
pinCodeManager = pinCodeManager,
- biometricUnlockManager = biometricUnlockManager,
+ biometricAuthenticatorManager = biometricAuthenticatorManager,
logoutUseCase = logoutUseCase,
coroutineScope = scope,
- pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager),
+ pinUnlockHelper = PinUnlockHelper(biometricAuthenticatorManager, pinCodeManager),
)
}
}
diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml
index 10ed45beee..468056f6b7 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -60,6 +60,7 @@ Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Ger
"Wähle %1$s"
"\"Neues Gerät verknüpfen\""
"Scanne den QR-Code mit diesem Gerät"
+ "Nur verfügbar für den Fall dass Ihr Kontoanbieter dies unterstützt."
"Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten"
"Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird."
"Erneut versuchen"
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index fc3fba9c59..8cbb7b6d74 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -40,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -51,6 +52,7 @@ import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.Overlay
+import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
@@ -66,8 +68,8 @@ import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
-import io.element.android.libraries.mediaviewer.api.local.MediaInfo
-import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.services.analytics.api.AnalyticsService
@@ -86,6 +88,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val showLocationEntryPoint: ShowLocationEntryPoint,
private val createPollEntryPoint: CreatePollEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint,
+ private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val analyticsService: AnalyticsService,
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
@@ -228,14 +231,22 @@ class MessagesFlowNode @AssistedInject constructor(
createNode(buildContext, listOf(callback, inputs))
}
is NavTarget.MediaViewer -> {
- val inputs = MediaViewerNode.Inputs(
+ val params = MediaViewerEntryPoint.Params(
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canDownload = true,
canShare = true,
)
- createNode(buildContext, listOf(inputs))
+ val callback = object : MediaViewerEntryPoint.Callback {
+ override fun onDone() {
+ overlay.hide()
+ }
+ }
+ mediaViewerEntryPoint.nodeBuilder(this, buildContext)
+ .params(params)
+ .callback(callback)
+ .build()
}
is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
@@ -319,100 +330,93 @@ class MessagesFlowNode @AssistedInject constructor(
}
private fun processEventClick(event: TimelineItem.Event): Boolean {
- return when (event.content) {
+ val navTarget = when (event.content) {
is TimelineItemImageContent -> {
- val navTarget = NavTarget.MediaViewer(
- mediaInfo = MediaInfo(
- filename = event.content.filename,
- caption = event.content.caption,
- mimeType = event.content.mimeType,
- formattedFileSize = event.content.formattedFileSize,
- fileExtension = event.content.fileExtension
- ),
+ buildMediaViewerNavTarget(
+ event = event,
+ content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
)
- overlay.show(navTarget)
- true
}
is TimelineItemStickerContent -> {
/* Sticker may have an empty url and no thumbnail
if encrypted on certain bridges */
- if (event.content.preferredMediaSource != null) {
- val navTarget = NavTarget.MediaViewer(
- mediaInfo = MediaInfo(
- filename = event.content.filename,
- caption = event.content.caption,
- mimeType = event.content.mimeType,
- formattedFileSize = event.content.formattedFileSize,
- fileExtension = event.content.fileExtension
- ),
- mediaSource = event.content.preferredMediaSource,
+ event.content.preferredMediaSource?.let { preferredMediaSource ->
+ buildMediaViewerNavTarget(
+ event = event,
+ content = event.content,
+ mediaSource = preferredMediaSource,
thumbnailSource = event.content.thumbnailSource,
)
- overlay.show(navTarget)
- true
- } else {
- false
}
}
is TimelineItemVideoContent -> {
- val navTarget = NavTarget.MediaViewer(
- mediaInfo = MediaInfo(
- filename = event.content.filename,
- caption = event.content.caption,
- mimeType = event.content.mimeType,
- formattedFileSize = event.content.formattedFileSize,
- fileExtension = event.content.fileExtension
- ),
- mediaSource = event.content.videoSource,
+ buildMediaViewerNavTarget(
+ event = event,
+ content = event.content,
+ mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
)
- overlay.show(navTarget)
- true
}
is TimelineItemFileContent -> {
- val navTarget = NavTarget.MediaViewer(
- mediaInfo = MediaInfo(
- filename = event.content.filename,
- caption = event.content.caption,
- mimeType = event.content.mimeType,
- formattedFileSize = event.content.formattedFileSize,
- fileExtension = event.content.fileExtension
- ),
- mediaSource = event.content.fileSource,
+ buildMediaViewerNavTarget(
+ event = event,
+ content = event.content,
+ mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
)
- overlay.show(navTarget)
- true
}
is TimelineItemAudioContent -> {
- val navTarget = NavTarget.MediaViewer(
- mediaInfo = MediaInfo(
- filename = event.content.filename,
- caption = event.content.caption,
- mimeType = event.content.mimeType,
- formattedFileSize = event.content.formattedFileSize,
- fileExtension = event.content.fileExtension
- ),
+ buildMediaViewerNavTarget(
+ event = event,
+ content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = null,
)
- overlay.show(navTarget)
- true
}
is TimelineItemLocationContent -> {
- val navTarget = NavTarget.LocationViewer(
+ NavTarget.LocationViewer(
location = event.content.location,
description = event.content.description,
)
+ }
+ else -> null
+ }
+ return when (navTarget) {
+ is NavTarget.MediaViewer -> {
overlay.show(navTarget)
true
}
+ is NavTarget.LocationViewer -> {
+ backstack.push(navTarget)
+ true
+ }
else -> false
}
}
+ private fun buildMediaViewerNavTarget(
+ event: TimelineItem.Event,
+ content: TimelineItemEventContentWithAttachment,
+ mediaSource: MediaSource,
+ thumbnailSource: MediaSource?,
+ ): NavTarget {
+ return NavTarget.MediaViewer(
+ mediaInfo = MediaInfo(
+ filename = content.filename,
+ caption = content.caption,
+ mimeType = content.mimeType,
+ formattedFileSize = content.formattedFileSize,
+ fileExtension = content.fileExtension,
+ senderName = event.safeSenderName,
+ dateSent = event.sentTime,
+ ),
+ mediaSource = mediaSource,
+ thumbnailSource = thumbnailSource,
+ )
+ }
+
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles(currentUserId = room.sessionId)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
index 3eba997eea..470d9da9c4 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
@@ -7,13 +7,16 @@
package io.element.android.features.messages.impl
+import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
+import kotlinx.collections.immutable.ImmutableList
interface MessagesNavigator {
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClick(eventId: EventId)
fun onReportContentClick(eventId: EventId, senderId: UserId)
fun onEditPollClick(eventId: EventId)
+ fun onPreviewAttachment(attachments: ImmutableList)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
index 9fd823445f..7d5bad4d63 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
@@ -28,9 +28,13 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.messages.impl.actionlist.ActionListPresenter
+import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
+import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelineEvents
+import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -60,12 +64,20 @@ class MessagesNode @AssistedInject constructor(
@Assisted plugins: List,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
+ messageComposerPresenterFactory: MessageComposerPresenter.Factory,
+ timelinePresenterFactory: TimelinePresenter.Factory,
presenterFactory: MessagesPresenter.Factory,
+ actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
- private val presenter = presenterFactory.create(this)
+ private val presenter = presenterFactory.create(
+ navigator = this,
+ composerPresenter = messageComposerPresenterFactory.create(this),
+ timelinePresenter = timelinePresenterFactory.create(this),
+ actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
+ )
private val callbacks = plugins()
data class Inputs(val focusedEventId: EventId?) : NodeInputs
@@ -114,10 +126,6 @@ class MessagesNode @AssistedInject constructor(
.orFalse()
}
- private fun onPreviewAttachments(attachments: ImmutableList) {
- callbacks.forEach { it.onPreviewAttachments(attachments) }
- }
-
private fun onUserDataClick(userId: UserId) {
callbacks.forEach { it.onUserDataClick(userId) }
}
@@ -178,6 +186,10 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onEditPollClick(eventId) }
}
+ override fun onPreviewAttachment(attachments: ImmutableList) {
+ callbacks.forEach { it.onPreviewAttachments(attachments) }
+ }
+
private fun onViewAllPinnedMessagesClick() {
callbacks.forEach { it.onViewAllPinnedEvents() }
}
@@ -213,7 +225,6 @@ class MessagesNode @AssistedInject constructor(
onBackClick = this::navigateUp,
onRoomDetailsClick = this::onRoomDetailsClick,
onEventContentClick = this::onEventClick,
- onPreviewAttachments = this::onPreviewAttachments,
onUserDataClick = this::onUserDataClick,
onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
onSendLocationClick = this::onSendLocationClick,
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 7840c307c7..4895390ab8 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
@@ -28,21 +28,20 @@ import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents
-import io.element.android.features.messages.impl.actionlist.ActionListPresenter
+import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
-import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
-import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@@ -88,12 +87,12 @@ import timber.log.Timber
class MessagesPresenter @AssistedInject constructor(
@Assisted private val navigator: MessagesNavigator,
private val room: MatrixRoom,
- private val composerPresenter: Presenter,
+ @Assisted private val composerPresenter: Presenter,
private val voiceMessageComposerPresenter: Presenter,
- timelinePresenterFactory: TimelinePresenter.Factory,
+ @Assisted private val timelinePresenter: Presenter,
private val timelineProtectionPresenter: Presenter,
private val identityChangeStatePresenter: Presenter,
- private val actionListPresenterFactory: ActionListPresenter.Factory,
+ @Assisted private val actionListPresenter: Presenter,
private val customReactionPresenter: Presenter,
private val reactionSummaryPresenter: Presenter,
private val readReceiptBottomSheetPresenter: Presenter,
@@ -110,12 +109,14 @@ class MessagesPresenter @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
private val analyticsService: AnalyticsService,
) : Presenter {
- private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
- private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
-
@AssistedFactory
interface Factory {
- fun create(navigator: MessagesNavigator): MessagesPresenter
+ fun create(
+ navigator: MessagesNavigator,
+ composerPresenter: Presenter,
+ timelinePresenter: Presenter,
+ actionListPresenter: Presenter,
+ ): MessagesPresenter
}
@Composable
@@ -269,10 +270,14 @@ class MessagesPresenter @AssistedInject constructor(
timelineState: TimelineState,
) = launch {
when (action) {
- TimelineItemAction.Copy -> handleCopyContents(targetEvent)
+ TimelineItemAction.CopyText -> handleCopyContents(targetEvent)
+ TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent)
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
+ TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
+ TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
+ TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
@@ -285,6 +290,16 @@ class MessagesPresenter @AssistedInject constructor(
}
}
+ private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) {
+ timelineController.invokeOnCurrentTimeline {
+ editCaption(
+ eventOrTransactionId = targetEvent.eventOrTransactionId,
+ caption = null,
+ formattedCaption = null,
+ )
+ }
+ }
+
private suspend fun handlePinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return
analyticsService.capture(
@@ -387,6 +402,34 @@ class MessagesPresenter @AssistedInject constructor(
}
}
+ private suspend fun handleActionAddCaption(
+ targetEvent: TimelineItem.Event,
+ composerState: MessageComposerState,
+ ) {
+ val composerMode = MessageComposerMode.EditCaption(
+ eventOrTransactionId = targetEvent.eventOrTransactionId,
+ content = "",
+ showCaptionCompatibilityWarning = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaCaptionWarning),
+ )
+ composerState.eventSink(
+ MessageComposerEvents.SetMode(composerMode)
+ )
+ }
+
+ private suspend fun handleActionEditCaption(
+ targetEvent: TimelineItem.Event,
+ composerState: MessageComposerState,
+ ) {
+ val composerMode = MessageComposerMode.EditCaption(
+ eventOrTransactionId = targetEvent.eventOrTransactionId,
+ content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
+ showCaptionCompatibilityWarning = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaCaptionWarning),
+ )
+ composerState.eventSink(
+ MessageComposerEvents.SetMode(composerMode)
+ )
+ }
+
private suspend fun handleActionReply(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
@@ -446,11 +489,17 @@ class MessagesPresenter @AssistedInject constructor(
is TimelineItemStateContent -> event.content.body
else -> return
}
-
clipboardHelper.copyPlainText(content)
-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied))
}
}
+
+ private suspend fun handleCopyCaption(event: TimelineItem.Event) {
+ val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return
+ clipboardHelper.copyPlainText(content)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
+ }
+ }
}
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 e8dc5329f4..c3daabf3ed 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
@@ -12,7 +12,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
-import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@@ -62,16 +61,6 @@ open class MessagesStateProvider : PreviewParameterProvider {
enableVoiceMessages = true,
voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true),
),
- aMessagesState(
- composerState = aMessageComposerState(
- attachmentsState = AttachmentsState.Sending.Processing(persistentListOf())
- ),
- ),
- aMessagesState(
- composerState = aMessageComposerState(
- attachmentsState = AttachmentsState.Sending.Uploading(0.33f)
- ),
- ),
aMessagesState(
roomCallState = anOngoingCallState(),
),
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 4351dbcae9..47e4721f7f 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
@@ -32,10 +32,8 @@ 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
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -55,10 +53,8 @@ import io.element.android.compound.theme.ElementTheme
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
-import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
-import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView
@@ -83,8 +79,6 @@ import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorVi
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
-import io.element.android.libraries.designsystem.components.ProgressDialogType
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
@@ -96,6 +90,7 @@ import io.element.android.libraries.designsystem.theme.components.BottomSheetDra
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
+import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
@@ -117,7 +112,6 @@ fun MessagesView(
onEventContentClick: (event: TimelineItem.Event) -> Boolean,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
- onPreviewAttachments: (ImmutableList) -> Unit,
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit,
@@ -131,11 +125,7 @@ fun MessagesView(
KeepScreenOn(state.voiceMessageComposerState.keepScreenOn)
- AttachmentStateView(
- state = state.composerState.attachmentsState,
- onPreviewAttachments = onPreviewAttachments,
- onCancel = { state.composerState.eventSink(MessageComposerEvents.CancelSendAttachment) },
- )
+ HideKeyboardWhenDisposed()
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@@ -276,34 +266,6 @@ private fun ReinviteDialog(state: MessagesState) {
}
}
-@Composable
-private fun AttachmentStateView(
- state: AttachmentsState,
- onPreviewAttachments: (ImmutableList) -> Unit,
- onCancel: () -> Unit,
-) {
- when (state) {
- AttachmentsState.None -> Unit
- is AttachmentsState.Previewing -> {
- val latestOnPreviewAttachments by rememberUpdatedState(onPreviewAttachments)
- LaunchedEffect(state) {
- latestOnPreviewAttachments(state.attachments)
- }
- }
- is AttachmentsState.Sending -> {
- ProgressDialog(
- type = when (state) {
- is AttachmentsState.Sending.Uploading -> ProgressDialogType.Determinate(state.progress)
- is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate
- },
- text = stringResource(id = CommonStrings.common_sending),
- showCancelButton = true,
- onDismissRequest = onCancel,
- )
- }
- }
-}
-
@Composable
private fun MessagesViewContent(
state: MessagesState,
@@ -572,7 +534,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onEventContentClick = { false },
onUserDataClick = {},
onLinkClick = {},
- onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index 39e6cd8836..411ff37c8f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
@@ -21,12 +21,14 @@ import dagger.assisted.AssistedInject
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
+import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionComparator
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
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.messages.impl.timeline.model.event.TimelineItemEventContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
@@ -36,6 +38,8 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeForwa
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
+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.room.MatrixRoom
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@@ -59,6 +63,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val room: MatrixRoom,
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
+ private val featureFlagService: FeatureFlagService,
) : ActionListPresenter {
@AssistedFactory
@ContributesBinding(RoomScope::class)
@@ -66,6 +71,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
override fun create(postProcessor: TimelineItemActionPostProcessor): DefaultActionListPresenter
}
+ private val comparator = TimelineItemActionComparator()
+
@Composable
override fun present(): ActionListState {
val localCoroutineScope = rememberCoroutineScope()
@@ -133,7 +140,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
}
}
- private fun buildActions(
+ private suspend fun buildActions(
timelineItem: TimelineItem.Event,
usersEventPermissions: UserEventPermissions,
isDeveloperModeEnabled: Boolean,
@@ -141,7 +148,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
isEventPinned: Boolean,
): List {
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
- return buildList {
+ return buildSet {
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
if (timelineItem.isThreaded) {
add(TimelineItemAction.ReplyInThread)
@@ -153,7 +160,19 @@ class DefaultActionListPresenter @AssistedInject constructor(
add(TimelineItemAction.Forward)
}
if (timelineItem.isEditable) {
- add(TimelineItemAction.Edit)
+ if (timelineItem.content is TimelineItemEventContentWithAttachment) {
+ // Caption
+ if (timelineItem.content.caption == null) {
+ if (featureFlagService.isFeatureEnabled(FeatureFlags.MediaCaptionCreation)) {
+ add(TimelineItemAction.AddCaption)
+ }
+ } else {
+ add(TimelineItemAction.EditCaption)
+ add(TimelineItemAction.RemoveCaption)
+ }
+ } else {
+ add(TimelineItemAction.Edit)
+ }
}
if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) {
add(TimelineItemAction.EndPoll)
@@ -167,7 +186,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
}
}
if (timelineItem.content.canBeCopied()) {
- add(TimelineItemAction.Copy)
+ add(TimelineItemAction.CopyText)
+ } else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) {
+ add(TimelineItemAction.CopyCaption)
}
if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
@@ -183,6 +204,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
}
}
.postFilter(timelineItem.content)
+ .sortedWith(comparator)
.let(postProcessor::process)
}
}
@@ -190,7 +212,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
/**
* Post filter the actions based on the content of the event.
*/
-private fun List.postFilter(content: TimelineItemEventContent): List {
+private fun Iterable.postFilter(content: TimelineItemEventContent): Iterable {
return filter { action ->
when (content) {
is TimelineItemCallNotifyContent,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
index 78e65c839a..a5f027a535 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
+import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionComparator
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
@@ -22,7 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
open class ActionListStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -50,7 +51,9 @@ open class ActionListStateProvider : PreviewParameterProvider {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
- actions = aTimelineItemActionList(),
+ actions = aTimelineItemActionList(
+ copyAction = TimelineItemAction.CopyCaption,
+ ),
)
),
anActionListState(
@@ -61,7 +64,9 @@ open class ActionListStateProvider : PreviewParameterProvider {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
- actions = aTimelineItemActionList(),
+ actions = aTimelineItemActionList(
+ copyAction = TimelineItemAction.CopyCaption,
+ ),
)
),
anActionListState(
@@ -72,7 +77,9 @@ open class ActionListStateProvider : PreviewParameterProvider {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
- actions = aTimelineItemActionList(),
+ actions = aTimelineItemActionList(
+ copyAction = null,
+ ),
)
),
anActionListState(
@@ -83,18 +90,22 @@ open class ActionListStateProvider : PreviewParameterProvider {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
- actions = aTimelineItemActionList(),
+ actions = aTimelineItemActionList(
+ copyAction = TimelineItemAction.CopyCaption,
+ ),
)
),
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
- content = aTimelineItemVoiceContent(),
+ content = aTimelineItemVoiceContent(caption = null),
timelineItemReactions = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
- actions = aTimelineItemActionList(),
+ actions = aTimelineItemActionList(
+ copyAction = null,
+ ),
)
),
anActionListState(
@@ -161,27 +172,31 @@ fun anActionListState(
eventSink = eventSink
)
-fun aTimelineItemActionList(): ImmutableList {
- return persistentListOf(
+fun aTimelineItemActionList(
+ copyAction: TimelineItemAction? = TimelineItemAction.CopyText
+): ImmutableList {
+ return setOfNotNull(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
- TimelineItemAction.Copy,
+ copyAction,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
TimelineItemAction.ReportContent,
TimelineItemAction.ViewSource,
)
+ .sortedWith(TimelineItemActionComparator())
+ .toPersistentList()
}
fun aTimelineItemPollActionList(): ImmutableList {
- return persistentListOf(
+ return setOf(
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
- TimelineItemAction.Copy,
+ TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
- TimelineItemAction.ViewSource,
- TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
)
+ .sortedWith(TimelineItemActionComparator())
+ .toPersistentList()
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
index cddf3ff8e4..f700dcc6b1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
@@ -22,12 +22,16 @@ sealed class TimelineItemAction(
) {
data object ViewInTimeline : TimelineItemAction(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on)
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
- data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy)
+ data object CopyText : TimelineItemAction(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy)
+ data object CopyCaption : TimelineItemAction(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy)
data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)
data object Redact : TimelineItemAction(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit)
+ data object EditCaption : TimelineItemAction(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit)
+ data object AddCaption : TimelineItemAction(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit)
+ data object RemoveCaption : TimelineItemAction(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_delete, destructive = true)
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt
new file mode 100644
index 0000000000..8eef2d7619
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.actionlist.model
+
+class TimelineItemActionComparator : Comparator {
+ // See order in https://www.figma.com/design/ux3tYoZV9WghC7hHT9Fhk0/Compound-iOS-Components?node-id=2946-2392
+ private val orderedList = listOf(
+ TimelineItemAction.EndPoll,
+ TimelineItemAction.ViewInTimeline,
+ TimelineItemAction.Reply,
+ TimelineItemAction.ReplyInThread,
+ TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
+ TimelineItemAction.Unpin,
+ TimelineItemAction.CopyLink,
+ TimelineItemAction.Edit,
+ TimelineItemAction.CopyText,
+ TimelineItemAction.AddCaption,
+ TimelineItemAction.EditCaption,
+ TimelineItemAction.CopyCaption,
+ TimelineItemAction.RemoveCaption,
+ TimelineItemAction.ViewSource,
+ TimelineItemAction.ReportContent,
+ TimelineItemAction.Redact,
+ )
+
+ override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int {
+ val index1 = orderedList.indexOf(o1)
+ val index2 = orderedList.indexOf(o2)
+ return index1.compareTo(index2)
+ }
+}
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 2417d8346e..e89d9a5052 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
@@ -20,12 +20,14 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
@ContributesNode(RoomScope::class)
class AttachmentsPreviewNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
presenterFactory: AttachmentsPreviewPresenter.Factory,
+ private val localMediaRenderer: LocalMediaRenderer,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val attachment: Attachment) : NodeInputs
@@ -46,6 +48,7 @@ class AttachmentsPreviewNode @AssistedInject constructor(
val state = presenter.present()
AttachmentsPreviewView(
state = state,
+ localMediaRenderer = localMediaRenderer,
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 bc3e121070..cda414934a 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
@@ -8,7 +8,9 @@
package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -19,10 +21,16 @@ 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.androidutils.file.safeDelete
+import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
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.mediaupload.api.MediaUploadInfo
+import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.coroutines.CancellationException
@@ -39,6 +47,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private val mediaSender: MediaSender,
private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
+ private val featureFlagService: FeatureFlagService,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -63,19 +72,64 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf(null) }
+ val userSentAttachment = remember { mutableStateOf(false) }
+ val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
+ val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false)
+
+ val mediaUploadInfoState = remember { mutableStateOf>(AsyncData.Uninitialized) }
+ LaunchedEffect(Unit) {
+ preProcessAttachment(
+ attachment,
+ mediaUploadInfoState,
+ )
+ }
+
+ LaunchedEffect(userSentAttachment.value, mediaUploadInfoState.value) {
+ if (userSentAttachment.value) {
+ // User confirmed sending the attachment
+ when (val mediaUploadInfo = mediaUploadInfoState.value) {
+ is AsyncData.Success -> {
+ // Pre-processing is done, send the attachment
+ val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
+ .takeIf { it.isNotEmpty() }
+ ongoingSendAttachmentJob.value = coroutineScope.launch {
+ sendPreProcessedMedia(
+ mediaUploadInfo = mediaUploadInfo.data,
+ caption = caption,
+ sendActionState = sendActionState,
+ )
+ }
+ }
+ is AsyncData.Failure -> {
+ // Pre-processing has failed, show the error
+ sendActionState.value = SendActionState.Failure(mediaUploadInfo.error)
+ }
+ AsyncData.Uninitialized,
+ is AsyncData.Loading -> {
+ // Pre-processing is still in progress, do nothing
+ }
+ }
+ }
+ }
+
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) {
- is AttachmentsPreviewEvents.SendAttachment -> {
- val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
- .takeIf { it.isNotEmpty() }
- ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(
- attachment = attachment,
- caption = caption,
- sendActionState = sendActionState,
- )
+ is AttachmentsPreviewEvents.SendAttachment -> coroutineScope.launch {
+ val useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
+ userSentAttachment.value = true
+ val instantSending = mediaUploadInfoState.value.isReady() && useSendQueue
+ sendActionState.value = if (instantSending) {
+ SendActionState.Sending.InstantSending
+ } else {
+ SendActionState.Sending.Processing
+ }
}
AttachmentsPreviewEvents.Cancel -> {
- coroutineScope.cancel(attachment)
+ coroutineScope.cancel(
+ attachment,
+ mediaUploadInfoState.value,
+ sendActionState,
+ )
}
AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let {
@@ -91,60 +145,101 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
attachment = attachment,
sendActionState = sendActionState.value,
textEditorState = textEditorState,
+ allowCaption = allowCaption,
+ showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
eventSink = ::handleEvents
)
}
- private fun CoroutineScope.sendAttachment(
+ private fun CoroutineScope.preProcessAttachment(
attachment: Attachment,
- caption: String?,
- sendActionState: MutableState,
+ mediaUploadInfoState: MutableState>,
) = launch {
when (attachment) {
is Attachment.Media -> {
- sendMedia(
+ preProcessMedia(
mediaAttachment = attachment,
- caption = caption,
- sendActionState = sendActionState,
+ mediaUploadInfoState = mediaUploadInfoState,
)
}
}
}
+ private suspend fun preProcessMedia(
+ mediaAttachment: Attachment.Media,
+ mediaUploadInfoState: MutableState>,
+ ) {
+ mediaUploadInfoState.value = AsyncData.Loading()
+ mediaSender.preProcessMedia(
+ uri = mediaAttachment.localMedia.uri,
+ mimeType = mediaAttachment.localMedia.info.mimeType,
+ ).fold(
+ onSuccess = { mediaUploadInfo ->
+ mediaUploadInfoState.value = AsyncData.Success(mediaUploadInfo)
+ },
+ onFailure = {
+ Timber.e(it, "Failed to pre-process media")
+ if (it is CancellationException) {
+ throw it
+ } else {
+ mediaUploadInfoState.value = AsyncData.Failure(it)
+ }
+ }
+ )
+ }
+
private fun CoroutineScope.cancel(
attachment: Attachment,
+ mediaUploadInfo: AsyncData,
+ sendActionState: MutableState,
) = launch {
// Delete the temporary file
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
+ mediaUploadInfo.dataOrNull()?.let { data ->
+ cleanUp(data)
+ }
}
}
+ // Reset the sendActionState to ensure that dialog is closed before the screen
+ sendActionState.value = SendActionState.Done
onDoneListener()
}
- private suspend fun sendMedia(
- mediaAttachment: Attachment.Media,
+ private fun cleanUp(
+ mediaUploadInfo: MediaUploadInfo,
+ ) {
+ mediaUploadInfo.allFiles().forEach { file ->
+ file.safeDelete()
+ }
+ }
+
+ private suspend fun sendPreProcessedMedia(
+ mediaUploadInfo: MediaUploadInfo,
caption: String?,
sendActionState: MutableState,
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
+ // Note will not happen if useSendQueue is true
if (context.isActive) {
sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
}
- sendActionState.value = SendActionState.Sending.Processing
- mediaSender.sendMedia(
- uri = mediaAttachment.localMedia.uri,
- mimeType = mediaAttachment.localMedia.info.mimeType,
+ mediaSender.sendPreProcessedMedia(
+ mediaUploadInfo = mediaUploadInfo,
caption = caption,
+ formattedCaption = null,
progressCallback = progressCallback
).getOrThrow()
}.fold(
onSuccess = {
+ cleanUp(mediaUploadInfo)
+ // Reset the sendActionState to ensure that dialog is closed before the screen
+ sendActionState.value = SendActionState.Done
onDoneListener()
},
onFailure = { error ->
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 5ffe9364ff..739efa8d24 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,21 +9,16 @@ 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 allowCaption: Boolean,
+ val showCaptionCompatibilityWarning: Boolean,
val eventSink: (AttachmentsPreviewEvents) -> Unit
-) {
- val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let {
- it.isMimeTypeImage() || it.isMimeTypeVideo()
- }.orFalse()
-}
+)
@Immutable
sealed interface SendActionState {
@@ -31,9 +26,11 @@ sealed interface SendActionState {
@Immutable
sealed interface Sending : SendActionState {
+ data object InstantSending : Sending
data object Processing : Sending
data class Uploading(val progress: Float) : Sending
}
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 78f3ffc81a..b718936a1c 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
@@ -10,12 +10,9 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
-import io.element.android.libraries.mediaviewer.api.local.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
@@ -23,11 +20,11 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider
get() = sequenceOf(
anAttachmentsPreviewState(),
- anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
- anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
- anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
+ anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
+ anAttachmentsPreviewState(allowCaption = false),
+ anAttachmentsPreviewState(showCaptionCompatibilityWarning = true),
)
}
@@ -35,11 +32,15 @@ fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageMediaInfo(),
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
+ allowCaption: Boolean = true,
+ showCaptionCompatibilityWarning: Boolean = true,
) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
),
sendActionState = sendActionState,
textEditorState = textEditorState,
+ allowCaption = allowCaption,
+ showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
eventSink = {}
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
index 1380add329..a91baaad8c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
@@ -8,18 +8,22 @@
package io.element.android.features.messages.impl.attachments.preview
import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
+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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -34,20 +38,20 @@ import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
-import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
-import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
+import io.element.android.libraries.designsystem.utils.CommonDrawables
+import io.element.android.libraries.mediaviewer.api.local.LocalMedia
+import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import io.element.android.libraries.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.display.TextDisplay
-import me.saket.telephoto.zoomable.ZoomSpec
-import me.saket.telephoto.zoomable.rememberZoomableState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentsPreviewView(
state: AttachmentsPreviewState,
+ localMediaRenderer: LocalMediaRenderer,
modifier: Modifier = Modifier,
) {
fun postSendAttachment() {
@@ -79,9 +83,11 @@ fun AttachmentsPreviewView(
title = {},
)
}
- ) {
+ ) { paddingValues ->
AttachmentPreviewContent(
+ modifier = Modifier.padding(paddingValues),
state = state,
+ localMediaRenderer = localMediaRenderer,
onSendClick = ::postSendAttachment,
)
}
@@ -99,12 +105,17 @@ private fun AttachmentSendStateView(
onRetryClick: () -> Unit
) {
when (sendActionState) {
- is SendActionState.Sending -> {
+ is SendActionState.Sending.Processing -> {
ProgressDialog(
- type = when (sendActionState) {
- is SendActionState.Sending.Uploading -> ProgressDialogType.Determinate(sendActionState.progress)
- SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate
- },
+ type = ProgressDialogType.Indeterminate,
+ text = stringResource(id = CommonStrings.common_sending),
+ showCancelButton = true,
+ onDismissRequest = onDismissClick,
+ )
+ }
+ is SendActionState.Sending.Uploading -> {
+ ProgressDialog(
+ type = ProgressDialogType.Determinate(sendActionState.progress),
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onDismissClick,
@@ -124,30 +135,23 @@ private fun AttachmentSendStateView(
@Composable
private fun AttachmentPreviewContent(
state: AttachmentsPreviewState,
+ localMediaRenderer: LocalMediaRenderer,
onSendClick: () -> Unit,
+ modifier: Modifier = Modifier,
) {
- Box(
- modifier = Modifier
+ Column(
+ modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
Box(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .weight(1f),
contentAlignment = Alignment.Center
) {
when (val attachment = state.attachment) {
is Attachment.Media -> {
- val localMediaViewState = rememberLocalMediaViewState(
- zoomableState = rememberZoomableState(
- zoomSpec = ZoomSpec(maxZoomFactor = 4f, preventOverOrUnderZoom = false)
- )
- )
- LocalMediaView(
- modifier = Modifier.fillMaxSize(),
- localMedia = attachment.localMedia,
- localMediaViewState = localMediaViewState,
- onClick = {}
- )
+ localMediaRenderer.Render(attachment.localMedia)
}
}
}
@@ -158,7 +162,6 @@ private fun AttachmentPreviewContent(
.fillMaxWidth()
.background(ElementTheme.colors.bgCanvasDefault)
.height(IntrinsicSize.Min)
- .align(Alignment.BottomCenter)
.imePadding(),
)
}
@@ -174,7 +177,10 @@ private fun AttachmentsPreviewBottomActions(
modifier = modifier,
state = state.textEditorState,
voiceMessageState = VoiceMessageState.Idle,
- composerMode = MessageComposerMode.Attachment(state.allowCaption),
+ composerMode = MessageComposerMode.Attachment(
+ allowCaption = state.allowCaption,
+ showCaptionCompatibilityWarning = state.showCaptionCompatibilityWarning,
+ ),
onRequestFocus = {},
onSendMessage = onSendClick,
showTextFormatting = false,
@@ -200,5 +206,15 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView(
state = state,
+ localMediaRenderer = object : LocalMediaRenderer {
+ @Composable
+ override fun Render(localMedia: LocalMedia) {
+ Image(
+ painter = painterResource(id = CommonDrawables.sample_background),
+ modifier = Modifier.fillMaxSize(),
+ contentDescription = null,
+ )
+ }
+ }
)
}
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 04750b6ad8..34c58bdb06 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
@@ -36,7 +36,6 @@ internal fun MessagesViewWithIdentityChangePreview(
onEventContentClick = { false },
onUserDataClick = {},
onLinkClick = {},
- onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt
index d987e97809..567c95bcc7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt
@@ -14,8 +14,6 @@ import io.element.android.features.messages.impl.crypto.identity.IdentityChangeS
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
-import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
-import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
@@ -48,9 +46,6 @@ interface MessagesModule {
@Binds
fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter
- @Binds
- fun bindMessageComposerPresenter(presenter: MessageComposerPresenter): Presenter
-
@Binds
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
index fca9948339..036401bb1a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
@@ -31,7 +31,6 @@ sealed interface MessageComposerEvents {
data object Poll : PickAttachmentSource
}
data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents
- data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
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 6101f48c1f..bf3ea2b112 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
@@ -14,7 +14,6 @@ import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -26,8 +25,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.draft.ComposerDraftService
@@ -38,11 +41,8 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
-import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
-import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -81,7 +81,6 @@ import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
@@ -89,16 +88,13 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
-import javax.inject.Inject
-import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
-@SingleIn(RoomScope::class)
-class MessageComposerPresenter @Inject constructor(
+class MessageComposerPresenter @AssistedInject constructor(
+ @Assisted private val navigator: MessagesNavigator,
private val appCoroutineScope: CoroutineScope,
private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider,
@@ -121,6 +117,11 @@ class MessageComposerPresenter @Inject constructor(
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val suggestionsProcessor: SuggestionsProcessor,
) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(navigator: MessagesNavigator): MessageComposerPresenter
+ }
+
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
private val suggestionSearchTrigger = MutableStateFlow(null)
@@ -151,9 +152,6 @@ class MessageComposerPresenter @Inject constructor(
}
val cameraPermissionState = cameraPermissionPresenter.present()
- val attachmentsState = remember {
- mutableStateOf(AttachmentsState.None)
- }
val canShareLocation = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -166,40 +164,26 @@ class MessageComposerPresenter @Inject constructor(
}
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
- handlePickedMedia(attachmentsState, uri, mimeType)
+ handlePickedMedia(uri, mimeType)
}
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
- handlePickedMedia(attachmentsState, uri)
+ handlePickedMedia(uri)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
- handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG)
+ handlePickedMedia(uri, MimeTypes.IMAGE_JPEG)
}
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
- handlePickedMedia(attachmentsState, uri, MimeTypes.VIDEO_MP4)
+ handlePickedMedia(uri, MimeTypes.VIDEO_MP4)
}
val isFullScreen = rememberSaveable {
mutableStateOf(false)
}
- val ongoingSendAttachmentJob = remember { mutableStateOf(null) }
-
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
val roomAliasSuggestions by roomAliasSuggestionsDataSource.getAllRoomAliasSuggestions().collectAsState(initial = emptyList())
- LaunchedEffect(attachmentsState.value) {
- when (val attachmentStateValue = attachmentsState.value) {
- is AttachmentsState.Sending.Processing -> {
- ongoingSendAttachmentJob.value = localCoroutineScope.sendAttachment(
- attachmentStateValue.attachments.first(),
- attachmentsState,
- )
- }
- else -> Unit
- }
- }
-
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted) {
when (pendingEvent) {
@@ -272,7 +256,7 @@ class MessageComposerPresenter @Inject constructor(
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
MessageComposerEvents.CloseSpecialMode -> {
- if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
+ if (messageComposerContext.composerMode.isEditing) {
localCoroutineScope.launch {
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = true)
}
@@ -295,7 +279,6 @@ class MessageComposerPresenter @Inject constructor(
formattedFileSize = null
),
),
- attachmentState = attachmentsState,
)
is MessageComposerEvents.SetMode -> {
localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
@@ -338,12 +321,6 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false
// Navigation to the create poll screen is done at the view layer
}
- is MessageComposerEvents.CancelSendAttachment -> {
- ongoingSendAttachmentJob.value?.let {
- it.cancel()
- ongoingSendAttachmentJob.value == null
- }
- }
is MessageComposerEvents.ToggleTextFormatting -> {
showAttachmentSourcePicker = false
localCoroutineScope.toggleTextFormatting(event.enabled, markdownTextEditorState, richTextEditorState)
@@ -420,7 +397,6 @@ class MessageComposerPresenter @Inject constructor(
showTextFormatting = showTextFormatting,
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
- attachmentsState = attachmentsState.value,
suggestions = suggestions.toPersistentList(),
resolveMentionDisplay = resolveMentionDisplay,
eventSink = { handleEvents(it) },
@@ -455,7 +431,15 @@ class MessageComposerPresenter @Inject constructor(
}
}
}
-
+ is MessageComposerMode.EditCaption -> {
+ timelineController.invokeOnCurrentTimeline {
+ editCaption(
+ capturedMode.eventOrTransactionId,
+ caption = message.markdown,
+ formattedCaption = message.html
+ )
+ }
+ }
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
@@ -475,14 +459,12 @@ class MessageComposerPresenter @Inject constructor(
private fun CoroutineScope.sendAttachment(
attachment: Attachment,
- attachmentState: MutableState,
) = when (attachment) {
is Attachment.Media -> {
launch {
sendMedia(
uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.info.mimeType,
- attachmentState = attachmentState,
)
}
}
@@ -490,14 +472,10 @@ class MessageComposerPresenter @Inject constructor(
@UnstableApi
private fun handlePickedMedia(
- attachmentsState: MutableState,
uri: Uri?,
mimeType: String? = null,
) {
- if (uri == null) {
- attachmentsState.value = AttachmentsState.None
- return
- }
+ uri ?: return
val localMedia = localMediaFactory.createFromUri(
uri = uri,
mimeType = mimeType,
@@ -505,44 +483,21 @@ class MessageComposerPresenter @Inject constructor(
formattedFileSize = null
)
val mediaAttachment = Attachment.Media(localMedia)
- val isPreviewable = when {
- MimeTypes.isImage(localMedia.info.mimeType) -> true
- MimeTypes.isVideo(localMedia.info.mimeType) -> true
- MimeTypes.isAudio(localMedia.info.mimeType) -> true
- else -> false
- }
- attachmentsState.value = if (isPreviewable) {
- AttachmentsState.Previewing(persistentListOf(mediaAttachment))
- } else {
- AttachmentsState.Sending.Processing(persistentListOf(mediaAttachment))
- }
+ navigator.onPreviewAttachment(persistentListOf(mediaAttachment))
}
private suspend fun sendMedia(
uri: Uri,
mimeType: String,
- attachmentState: MutableState,
) = runCatching {
- val context = coroutineContext
- val progressCallback = object : ProgressCallback {
- override fun onProgress(current: Long, total: Long) {
- if (context.isActive) {
- attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat())
- }
- }
- }
mediaSender.sendMedia(
uri = uri,
mimeType = mimeType,
- progressCallback = progressCallback
+ progressCallback = null,
).getOrThrow()
}
- .onSuccess {
- attachmentState.value = AttachmentsState.None
- }
.onFailure { cause ->
Timber.e(cause, "Failed to send attachment")
- attachmentState.value = AttachmentsState.None
if (cause is CancellationException) {
throw cause
} else {
@@ -612,6 +567,10 @@ class MessageComposerPresenter @Inject constructor(
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
}
is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId)
+ is MessageComposerMode.EditCaption -> {
+ // TODO Need a new type to save caption in the SDK
+ null
+ }
}
return if (draftType == null || message.markdown.isBlank()) {
null
@@ -686,7 +645,14 @@ class MessageComposerPresenter @Inject constructor(
val currentComposerMode = messageComposerContext.composerMode
when (newComposerMode) {
is MessageComposerMode.Edit -> {
- if (currentComposerMode !is MessageComposerMode.Edit) {
+ if (currentComposerMode.isEditing.not()) {
+ val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
+ updateDraft(draft, isVolatile = true).join()
+ }
+ setText(newComposerMode.content, markdownTextEditorState, richTextEditorState)
+ }
+ is MessageComposerMode.EditCaption -> {
+ if (currentComposerMode.isEditing.not()) {
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
updateDraft(draft, isVolatile = true).join()
}
@@ -694,7 +660,7 @@ class MessageComposerPresenter @Inject constructor(
}
else -> {
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
- if (currentComposerMode is MessageComposerMode.Edit) {
+ if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index 5ca4a9c52b..58e40dbb14 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -7,9 +7,7 @@
package io.element.android.features.messages.impl.messagecomposer
-import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
-import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
@@ -25,18 +23,7 @@ data class MessageComposerState(
val showTextFormatting: Boolean,
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
- val attachmentsState: AttachmentsState,
val suggestions: ImmutableList,
val resolveMentionDisplay: (String, String) -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit,
)
-
-@Immutable
-sealed interface AttachmentsState {
- data object None : AttachmentsState
- data class Previewing(val attachments: ImmutableList) : AttachmentsState
- sealed interface Sending : AttachmentsState {
- data class Processing(val attachments: ImmutableList) : Sending
- data class Uploading(val progress: Float) : Sending
- }
-}
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 a36102bc7d..7811322c3d 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
@@ -31,7 +31,6 @@ fun aMessageComposerState(
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
- attachmentsState: AttachmentsState = AttachmentsState.None,
suggestions: ImmutableList = persistentListOf(),
eventSink: (MessageComposerEvents) -> Unit = {},
) = MessageComposerState(
@@ -42,7 +41,6 @@ fun aMessageComposerState(
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation,
canCreatePoll = canCreatePoll,
- attachmentsState = attachmentsState,
suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
eventSink = eventSink,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
index 688b392fb6..148aa3d2ad 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
@@ -19,6 +19,7 @@ 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.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -35,6 +36,7 @@ class PinnedMessagesListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
presenterFactory: PinnedMessagesListPresenter.Factory,
+ actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
@@ -47,7 +49,10 @@ class PinnedMessagesListNode @AssistedInject constructor(
fun onForwardEventClick(eventId: EventId)
}
- private val presenter = presenterFactory.create(this)
+ private val presenter = presenterFactory.create(
+ navigator = this,
+ actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor())
+ )
private val callbacks = plugins()
private fun onEventClick(event: TimelineItem.Event) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
index 4673ae57b2..eb3412b30c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
@@ -23,7 +23,7 @@ import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.UserEventPermissions
-import io.element.android.features.messages.impl.actionlist.ActionListPresenter
+import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
@@ -64,13 +64,16 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private val timelineProvider: PinnedEventsTimelineProvider,
private val timelineProtectionPresenter: Presenter,
private val snackbarDispatcher: SnackbarDispatcher,
- actionListPresenterFactory: ActionListPresenter.Factory,
+ @Assisted private val actionListPresenter: Presenter,
private val appCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
) : Presenter {
@AssistedFactory
interface Factory {
- fun create(navigator: PinnedMessagesListNavigator): PinnedMessagesListPresenter
+ fun create(
+ navigator: PinnedMessagesListNavigator,
+ actionListPresenter: Presenter,
+ ): PinnedMessagesListPresenter
}
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
@@ -79,7 +82,6 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
computeReactions = false,
)
)
- private val actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor())
@Composable
override fun present(): PinnedMessagesListState {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
index 35a0a1e893..330a39fb0a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt
@@ -231,6 +231,7 @@ private fun PinnedMessagesListLoaded(
event = event,
timelineProtectionState = state.timelineProtectionState,
onContentClick = { onEventClick(event) },
+ onLongClick = { onMessageLongClick(event) },
onLinkClick = onLinkClick,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
@@ -247,6 +248,7 @@ private fun TimelineItemEventContentViewWrapper(
timelineProtectionState: TimelineProtectionState,
onContentClick: () -> Unit,
onLinkClick: (String) -> Unit,
+ onLongClick: (() -> Unit)?,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -265,7 +267,7 @@ private fun TimelineItemEventContentViewWrapper(
eventSink = { },
modifier = modifier,
onContentClick = onContentClick,
- onLongClick = null,
+ onLongClick = onLongClick,
onContentLayoutChange = onContentLayoutChange
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
index 5aab1a5bf1..2234aa09a9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
@@ -63,6 +63,7 @@ class DefaultHtmlConverterProvider @Inject constructor(
return TextDisplay.Custom(mentionSpan)
}
},
+ isEditor = false,
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
).apply {
configureWith(editorStyle)
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 c358dcdd0a..fa14877c6d 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
@@ -72,6 +72,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
+import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -146,6 +147,13 @@ fun TimelineItemEventRow(
val coroutineScope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
+ val onContentClick = if (event.mustBeProtected()) {
+ // In this case, let the content handle the click
+ {}
+ } else {
+ onEventClick
+ }
+
fun onUserDataClick() {
onUserDataClick(event.senderId)
}
@@ -178,7 +186,7 @@ fun TimelineItemEventRow(
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
- onContentClick = onEventClick,
+ onContentClick = onContentClick,
onLongClick = onLongClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
@@ -212,7 +220,7 @@ fun TimelineItemEventRow(
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
- onContentClick = onEventClick,
+ onContentClick = onContentClick,
onLongClick = onLongClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt
new file mode 100644
index 0000000000..2624a8b5e7
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.event
+
+import androidx.compose.foundation.background
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+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.messages.impl.timeline.components.layout.ContentAvoidingLayout
+import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
+import io.element.android.libraries.designsystem.theme.components.Text
+
+/**
+ * package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
+ */
+@Composable
+fun TimelineItemAttachmentView(
+ filename: String,
+ fileExtensionAndSize: String,
+ caption: String?,
+ onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
+ modifier: Modifier = Modifier,
+ icon: (@Composable () -> Unit) = {},
+) {
+ Column(
+ modifier = modifier,
+ ) {
+ TimelineItemAttachmentHeaderView(
+ filename = filename,
+ fileExtensionAndSize = fileExtensionAndSize,
+ hasCaption = caption != null,
+ onContentLayoutChange = onContentLayoutChange,
+ icon = icon,
+ )
+ if (caption != null) {
+ TimelineItemAttachmentCaptionView(
+ modifier = Modifier.padding(top = 4.dp),
+ caption = caption,
+ onContentLayoutChange = onContentLayoutChange,
+ )
+ }
+ }
+}
+
+@Composable
+private fun TimelineItemAttachmentHeaderView(
+ filename: String,
+ fileExtensionAndSize: String,
+ hasCaption: Boolean,
+ onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
+ modifier: Modifier = Modifier,
+ icon: (@Composable () -> Unit),
+) {
+ val iconSize = 32.dp
+ val spacing = 8.dp
+ Row(
+ modifier = modifier,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(iconSize)
+ .clip(CircleShape)
+ .background(ElementTheme.materialColors.background),
+ contentAlignment = Alignment.Center,
+ ) {
+ icon()
+ }
+ Spacer(Modifier.width(spacing))
+ Column {
+ Text(
+ text = filename,
+ color = ElementTheme.materialColors.primary,
+ maxLines = 2,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = fileExtensionAndSize,
+ color = ElementTheme.materialColors.secondary,
+ style = ElementTheme.typography.fontBodySmRegular,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ onTextLayout = if (hasCaption) {
+ {}
+ } else {
+ ContentAvoidingLayout.measureLastTextLine(
+ onContentLayoutChange = onContentLayoutChange,
+ extraWidth = iconSize + spacing
+ )
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun TimelineItemAttachmentCaptionView(
+ caption: String,
+ onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ modifier = modifier,
+ text = caption,
+ color = ElementTheme.materialColors.primary,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ onTextLayout = ContentAvoidingLayout.measureLastTextLine(
+ onContentLayoutChange = onContentLayoutChange,
+ )
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt
index 23069a1fac..32e97eed58 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt
@@ -7,32 +7,20 @@
package io.element.android.features.messages.impl.timeline.components.event
-import androidx.compose.foundation.background
-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.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-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.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
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.Text
@Composable
fun TimelineItemAudioView(
@@ -40,18 +28,13 @@ fun TimelineItemAudioView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
- val iconSize = 32.dp
- val spacing = 8.dp
- Row(
+ TimelineItemAttachmentView(
+ filename = content.filename,
+ fileExtensionAndSize = content.fileExtensionAndSize,
+ caption = content.caption,
+ onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
- ) {
- Box(
- modifier = Modifier
- .size(iconSize)
- .clip(CircleShape)
- .background(ElementTheme.materialColors.background),
- contentAlignment = Alignment.Center,
- ) {
+ icon = {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
@@ -60,28 +43,7 @@ fun TimelineItemAudioView(
.size(16.dp),
)
}
- Spacer(Modifier.width(spacing))
- Column {
- Text(
- text = content.bestDescription,
- color = ElementTheme.materialColors.primary,
- maxLines = 2,
- style = ElementTheme.typography.fontBodyLgRegular,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = content.fileExtensionAndSize,
- color = ElementTheme.materialColors.secondary,
- style = ElementTheme.typography.fontBodySmRegular,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- onTextLayout = ContentAvoidingLayout.measureLastTextLine(
- onContentLayoutChange = onContentLayoutChange,
- extraWidth = iconSize + spacing
- )
- )
- }
- }
+ )
}
@PreviewsDayNight
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
index e84f7ccbc2..b91b4ccc17 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
@@ -40,6 +40,15 @@ fun TimelineItemEncryptedView(
UtdCause.UnknownDevice -> {
CommonStrings.common_unable_to_decrypt_insecure_device to CompoundDrawables.ic_compound_block
}
+ UtdCause.HistoricalMessage -> {
+ CommonStrings.timeline_decryption_failure_historical_event_no_key_backup to CompoundDrawables.ic_compound_block
+ }
+ UtdCause.WithheldUnverifiedOrInsecureDevice -> {
+ CommonStrings.timeline_decryption_failure_withheld_unverified to CompoundDrawables.ic_compound_block
+ }
+ UtdCause.WithheldBySender -> {
+ CommonStrings.timeline_decryption_failure_unable_to_decrypt to CompoundDrawables.ic_compound_error
+ }
else -> {
CommonStrings.common_waiting_for_decryption_key to CompoundDrawables.ic_compound_time
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt
index dadfadc299..a3cdd7701e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt
@@ -7,24 +7,13 @@
package io.element.android.features.messages.impl.timeline.components.event
-import androidx.compose.foundation.background
-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.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
-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.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
@@ -32,7 +21,6 @@ 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.Icon
-import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemFileView(
@@ -40,18 +28,13 @@ fun TimelineItemFileView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
- val iconSize = 32.dp
- val spacing = 8.dp
- Row(
+ TimelineItemAttachmentView(
+ filename = content.filename,
+ fileExtensionAndSize = content.fileExtensionAndSize,
+ caption = content.caption,
+ onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
- ) {
- Box(
- modifier = Modifier
- .size(iconSize)
- .clip(CircleShape)
- .background(ElementTheme.materialColors.background),
- contentAlignment = Alignment.Center,
- ) {
+ icon = {
Icon(
resourceId = CompoundDrawables.ic_compound_attachment,
contentDescription = null,
@@ -61,28 +44,7 @@ fun TimelineItemFileView(
.rotate(-45f),
)
}
- Spacer(Modifier.width(spacing))
- Column {
- Text(
- text = content.bestDescription,
- color = ElementTheme.materialColors.primary,
- maxLines = 2,
- style = ElementTheme.typography.fontBodyLgRegular,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = content.fileExtensionAndSize,
- color = ElementTheme.materialColors.secondary,
- style = ElementTheme.typography.fontBodySmRegular,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- onTextLayout = ContentAvoidingLayout.measureLastTextLine(
- onContentLayoutChange = onContentLayoutChange,
- extraWidth = iconSize + spacing
- )
- )
- }
- }
+ )
}
@PreviewsDayNight
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 84b7026142..651f361d6c 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
@@ -49,6 +49,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
+import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -79,7 +80,7 @@ fun TimelineItemImageView(
}
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f),
- aspectRatio = content.aspectRatio,
+ aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
) {
ProtectedView(
hideContent = hideMediaContent,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt
index 58f2f25712..304527e806 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt
@@ -8,8 +8,10 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
@@ -44,14 +46,18 @@ fun TimelineItemInformativeView(
)
)
},
- verticalAlignment = Alignment.CenterVertically,
) {
- Icon(
- resourceId = iconResourceId,
- tint = MaterialTheme.colorScheme.secondary,
- contentDescription = iconDescription,
- modifier = Modifier.size(16.dp)
- )
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.height(20.dp)
+ ) {
+ Icon(
+ resourceId = iconResourceId,
+ tint = MaterialTheme.colorScheme.secondary,
+ contentDescription = iconDescription,
+ modifier = Modifier.size(16.dp)
+ )
+ }
Spacer(modifier = Modifier.width(4.dp))
Text(
fontStyle = FontStyle.Italic,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt
index 9037d0dffa..fd78699ac1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt
@@ -30,6 +30,7 @@ import coil.compose.AsyncImagePainter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
+import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -54,7 +55,7 @@ fun TimelineItemStickerView(
) {
TimelineItemAspectRatioBox(
modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f),
- aspectRatio = content.aspectRatio,
+ aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
minHeight = STICKER_SIZE_IN_DP,
maxHeight = STICKER_SIZE_IN_DP,
) {
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 afd45b171a..eadd84d130 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
@@ -54,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
+import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -90,7 +91,7 @@ fun TimelineItemVideoView(
}
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
- aspectRatio = content.aspectRatio,
+ aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
contentAlignment = Alignment.Center,
) {
ProtectedView(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt
index 7f6f8ee436..3100d90d45 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt
@@ -12,10 +12,10 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.libraries.matrix.api.core.EventId
open class ReactionSummaryStateProvider : PreviewParameterProvider {
- override val values = sequenceOf(anActionListState())
+ override val values = sequenceOf(aReactionSummaryState())
}
-fun anActionListState(): ReactionSummaryState {
+fun aReactionSummaryState(): ReactionSummaryState {
val reactions = aTimelineItemReactions(8, true).reactions
return ReactionSummaryState(
target = ReactionSummaryState.Summary(
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 ff5ca57c11..c2515560f5 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
@@ -87,6 +87,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -106,6 +107,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -143,8 +145,9 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
- videoSource = messageType.source,
+ mediaSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
@@ -162,6 +165,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -177,10 +181,13 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
waveform = messageType.details?.waveform?.toImmutableList() ?: persistentListOf(),
+ formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
+ fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
)
}
false -> {
@@ -188,6 +195,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@@ -203,8 +211,9 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
- fileSource = messageType.source,
+ mediaSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtension
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt
index b76dfdf07b..0d725d0e29 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt
@@ -36,6 +36,7 @@ class TimelineItemContentStickerFactory @Inject constructor(
filename = content.filename,
caption = content.body,
formattedCaption = null,
+ isEdited = false,
mediaSource = content.source,
thumbnailSource = content.info.thumbnailSource,
mimeType = content.info.mimetype ?: MimeTypes.OctetStream,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt
index 0ecf45ec55..a3b2061b92 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt
@@ -15,11 +15,12 @@ data class TimelineItemAudioContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
+ override val isEdited: Boolean,
val duration: Duration,
- val mediaSource: MediaSource,
- val mimeType: String,
- val formattedFileSize: String,
- val fileExtension: String,
+ override val mediaSource: MediaSource,
+ override val mimeType: String,
+ override val formattedFileSize: String,
+ override val fileExtension: String,
) : TimelineItemEventContentWithAttachment {
val fileExtensionAndSize =
formatFileExtensionAndSize(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt
index 7d4f6baf84..d3ca18e836 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt
@@ -17,14 +17,20 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider isEdited
- is TimelineItemPollContent -> isEdited
- else -> false
- }
+fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
+ is TimelineItemEventMutableContent -> isEdited
+ else -> false
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt
index b7007c8bbc..286411c511 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt
@@ -14,11 +14,12 @@ data class TimelineItemFileContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
- val fileSource: MediaSource,
+ override val isEdited: Boolean,
+ override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
- val formattedFileSize: String,
- val fileExtension: String,
- val mimeType: String,
+ override val formattedFileSize: String,
+ override val fileExtension: String,
+ override val mimeType: String,
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemFileContent"
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt
index d7852134a2..b8f28c741a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt
@@ -16,18 +16,22 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider,
val pollKind: PollKind,
val isEnded: Boolean,
- val isEdited: Boolean
-) : TimelineItemEventContent {
+ override val isEdited: Boolean,
+) : TimelineItemEventContent,
+ TimelineItemEventMutableContent {
override val type: String = "TimelineItemPollContent"
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt
index 06886307ae..ebf25d7e95 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt
@@ -13,11 +13,12 @@ data class TimelineItemStickerContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
- val mediaSource: MediaSource,
+ override val isEdited: Boolean,
+ override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
- val formattedFileSize: String,
- val fileExtension: String,
- val mimeType: String,
+ override val formattedFileSize: String,
+ override val fileExtension: String,
+ override val mimeType: String,
val blurhash: String?,
val width: Int?,
val height: Int?,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt
index 7b776a6fd1..76934df3ad 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt
@@ -29,6 +29,7 @@ fun aTimelineItemStickerContent(
filename = "a sticker.gif",
caption = "a body",
formattedCaption = null,
+ isEdited = false,
mediaSource = MediaSource(""),
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt
index 756c59f53e..5d61200d0e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt
@@ -14,7 +14,9 @@ import org.jsoup.nodes.Document
* Represents a text based content of a timeline item event (a message, a notice, an emote event...).
*/
@Immutable
-sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
+sealed interface TimelineItemTextBasedContent :
+ TimelineItemEventContent,
+ TimelineItemEventMutableContent {
/** The raw body of the event, in Markdown format. */
val body: String
@@ -30,9 +32,6 @@ sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
/** The plain text version of the event body. This is the Markdown version without actual Markdown formatting. */
val plainText: String
- /** Whether the event has been edited. */
- val isEdited: Boolean
-
/** The raw HTML body of the event. */
val htmlBody: String?
get() = htmlDocument?.body()?.html()
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 486a71b5d4..74406aaf37 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
@@ -14,8 +14,9 @@ data class TimelineItemVideoContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
+ override val isEdited: Boolean,
val duration: Duration,
- val videoSource: MediaSource,
+ override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val aspectRatio: Float?,
val blurHash: String?,
@@ -23,9 +24,9 @@ data class TimelineItemVideoContent(
val width: Int?,
val thumbnailWidth: Int?,
val thumbnailHeight: Int?,
- val mimeType: String,
- val formattedFileSize: String,
- val fileExtension: String,
+ override val mimeType: String,
+ override val formattedFileSize: String,
+ override val fileExtension: String,
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemImageContent"
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 b9390b4e52..477c00d808 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
@@ -30,11 +30,12 @@ fun aTimelineItemVideoContent(
filename = "Video.mp4",
caption = null,
formattedCaption = null,
+ isEdited = false,
thumbnailSource = null,
blurHash = blurhash,
aspectRatio = aspectRatio,
duration = 100.milliseconds,
- videoSource = MediaSource(""),
+ mediaSource = MediaSource(""),
width = 150,
height = 300,
thumbnailWidth = 150,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt
index ebeabef715..ad3908166a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt
@@ -17,9 +17,12 @@ data class TimelineItemVoiceContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
+ override val isEdited: Boolean,
val duration: Duration,
- val mediaSource: MediaSource,
- val mimeType: String,
+ override val mediaSource: MediaSource,
+ override val formattedFileSize: String,
+ override val fileExtension: String,
+ override val mimeType: String,
val waveform: ImmutableList,
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemAudioContent"
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt
index ddd731eceb..0c3fd246ca 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt
@@ -48,8 +48,11 @@ fun aTimelineItemVoiceContent(
filename = filename,
caption = caption,
formattedCaption = null,
+ isEdited = false,
duration = duration,
mediaSource = mediaSource,
mimeType = mimeType,
waveform = waveform.toPersistentList(),
+ formattedFileSize = "1.0 MB",
+ fileExtension = "ogg",
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioProvider.kt
new file mode 100644
index 0000000000..99cfff5625
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioProvider.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.messages.impl.timeline.protection
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class AspectRatioProvider : PreviewParameterProvider {
+ override val values: Sequence = sequenceOf(
+ null,
+ 0.05f,
+ 1f,
+ 20f,
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt
index 0387572388..b52084233b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt
@@ -13,7 +13,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -23,8 +22,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
+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.messages.impl.timeline.components.event.TimelineItemAspectRatioBox
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -79,11 +80,12 @@ fun ProtectedView(
@PreviewsDayNight
@Composable
-internal fun ProtectedViewPreview() = ElementPreview {
- Box(
- modifier = Modifier
- .size(160.dp)
- .blurHashBackground(A_BLUR_HASH)
+internal fun ProtectedViewPreview(
+ @PreviewParameter(AspectRatioProvider::class) aspectRatio: Float?,
+) = ElementPreview {
+ TimelineItemAspectRatioBox(
+ modifier = Modifier.blurHashBackground(A_BLUR_HASH, alpha = 0.9f),
+ aspectRatio = coerceRatioWhenHidingContent(aspectRatio, true),
) {
ProtectedView(
hideContent = true,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/RatioHelper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/RatioHelper.kt
new file mode 100644
index 0000000000..2386dff5f4
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/RatioHelper.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.messages.impl.timeline.protection
+
+fun coerceRatioWhenHidingContent(aspectRatio: Float?, hideContent: Boolean): Float? {
+ return if (hideContent) {
+ aspectRatio?.coerceIn(
+ minimumValue = 0.5f,
+ maximumValue = 3f
+ )
+ } else {
+ aspectRatio
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
index cf8d5668a3..cc6d134802 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
@@ -23,8 +23,6 @@ import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -45,7 +43,6 @@ import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
-@SingleIn(RoomScope::class)
class VoiceMessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope,
private val voiceRecorder: VoiceRecorder,
diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml
index 785a615cab..e235088a60 100644
--- a/features/messages/impl/src/main/res/values-de/translations.xml
+++ b/features/messages/impl/src/main/res/values-de/translations.xml
@@ -31,6 +31,7 @@
"Emoji hinzufügen"
"Dies ist der Anfang von %1$s."
"Dies ist der Anfang dieses Gesprächs."
+ "Anruftyp wird nicht unterstützt. Fragen Sie nach, ob der Anrufer die neue Element X-App verwenden kann."
"Weniger anzeigen"
"Nachricht wurde kopiert"
"Du bist nicht berechtigt, in diesem Raum zu schreiben"
diff --git a/features/messages/impl/src/main/res/values-el/translations.xml b/features/messages/impl/src/main/res/values-el/translations.xml
index 02a3a765ff..954ab07a64 100644
--- a/features/messages/impl/src/main/res/values-el/translations.xml
+++ b/features/messages/impl/src/main/res/values-el/translations.xml
@@ -31,6 +31,7 @@
"Προσθήκη emoji"
"Αυτή είναι η αρχή του %1$s."
"Αυτή είναι η αρχή τούτης της συνομιλίας."
+ "Μη υποστηριζόμενη κλήση. Ρώτα εάν ο καλών μπορεί να χρησιμοποιήσει τη νέα εφαρμογή Element X."
"Εμφάνιση λιγότερων"
"Το μήνυμα αντιγράφηκε"
"Δεν έχεις άδεια να δημοσιεύσεις σε αυτό το δωμάτιο"
diff --git a/features/messages/impl/src/main/res/values-fi/translations.xml b/features/messages/impl/src/main/res/values-fi/translations.xml
index c5353b6cf8..e689f9e406 100644
--- a/features/messages/impl/src/main/res/values-fi/translations.xml
+++ b/features/messages/impl/src/main/res/values-fi/translations.xml
@@ -31,6 +31,7 @@
"Lisää emoji"
"Tämä on huoneen %1$s alku."
"Tämä on tämän keskustelun alku."
+ "Puhelu, jota ei tueta. Kysy, voiko soittaja käyttää uutta Element X -sovellusta."
"Näytä vähemmän"
"Viesti kopioitu"
"Sinulla ei ole oikeutta kirjoittaa tässä huoneessa"
diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml
index ab47aed0ca..132d175eb8 100644
--- a/features/messages/impl/src/main/res/values-fr/translations.xml
+++ b/features/messages/impl/src/main/res/values-fr/translations.xml
@@ -31,6 +31,7 @@
"Ajouter un émoji"
"Ceci est le début de %1$s."
"Ceci est le début de cette conversation."
+ "Appel non pris en charge. Demandez à l’appelant s’il peut utiliser la nouvelle application Element X pour vous appeler."
"Afficher moins"
"Message copié"
"Vous n’êtes pas autorisé à publier dans ce salon"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt
index fa5e412f27..5dcc4e7496 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt
@@ -7,36 +7,37 @@
package io.element.android.features.messages.impl
+import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.collections.immutable.ImmutableList
-class FakeMessagesNavigator : MessagesNavigator {
- var onShowEventDebugInfoClickedCount = 0
- private set
-
- var onForwardEventClickedCount = 0
- private set
-
- var onReportContentClickedCount = 0
- private set
-
- var onEditPollClickedCount = 0
- private set
-
+class FakeMessagesNavigator(
+ private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() },
+ private val onForwardEventClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
+ private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
+ private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
+ private val onPreviewAttachmentLambda: (attachments: ImmutableList) -> Unit = { _ -> lambdaError() },
+) : MessagesNavigator {
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
- onShowEventDebugInfoClickedCount++
+ onShowEventDebugInfoClickLambda(eventId, debugInfo)
}
override fun onForwardEventClick(eventId: EventId) {
- onForwardEventClickedCount++
+ onForwardEventClickLambda(eventId)
}
override fun onReportContentClick(eventId: EventId, senderId: UserId) {
- onReportContentClickedCount++
+ onReportContentClickLambda(eventId, senderId)
}
override fun onEditPollClick(eventId: EventId) {
- onEditPollClickedCount++
+ onEditPollClickLambda(eventId)
+ }
+
+ override fun onPreviewAttachment(attachments: ImmutableList) {
+ onPreviewAttachmentLambda(attachments)
}
}
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 9ca5c0b98e..8cb7464a9a 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
@@ -14,7 +14,7 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
-import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
+import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
@@ -23,20 +23,19 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController
-import io.element.android.features.messages.impl.timeline.TimelinePresenter
-import io.element.android.features.messages.impl.timeline.createTimelinePresenter
+import io.element.android.features.messages.impl.timeline.TimelineEvents
+import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
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
@@ -46,6 +45,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+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.EventId
import io.element.android.libraries.matrix.api.core.UserId
@@ -55,20 +56,24 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THROWABLE
+import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
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.test.timeline.aTimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
@@ -82,6 +87,7 @@ import io.element.android.tests.testutils.lambda.assert
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.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -214,7 +220,10 @@ class MessagesPresenterTest {
@Test
fun `present - handle action forward`() = runTest {
- val navigator = FakeMessagesNavigator()
+ val onForwardEventClickLambda = lambdaRecorder { }
+ val navigator = FakeMessagesNavigator(
+ onForwardEventClickLambda = onForwardEventClickLambda,
+ )
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -222,7 +231,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
- assertThat(navigator.onForwardEventClickedCount).isEqualTo(1)
+ onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@@ -235,7 +244,7 @@ class MessagesPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
- initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Copy, event))
+ initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.CopyText, event))
skipItems(2)
assertThat(clipboardHelper.clipboardContents).isEqualTo((event.content as TimelineItemTextContent).body)
}
@@ -318,6 +327,7 @@ class MessagesPresenterTest {
filename = "image.jpg",
caption = null,
formattedCaption = null,
+ isEdited = false,
mediaSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = null,
mimeType = MimeTypes.Jpeg,
@@ -359,8 +369,9 @@ class MessagesPresenterTest {
filename = "video.mp4",
caption = null,
formattedCaption = null,
+ isEdited = false,
duration = 10.milliseconds,
- videoSource = MediaSource(AN_AVATAR_URL),
+ mediaSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
mimeType = MimeTypes.Mp4,
blurHash = null,
@@ -400,8 +411,9 @@ class MessagesPresenterTest {
content = TimelineItemFileContent(
filename = "file.pdf",
caption = null,
+ isEdited = false,
formattedCaption = null,
- fileSource = MediaSource(AN_AVATAR_URL),
+ mediaSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
formattedFileSize = "10 MB",
mimeType = MimeTypes.Pdf,
@@ -446,7 +458,10 @@ class MessagesPresenterTest {
@Test
fun `present - handle action edit poll`() = runTest {
- val navigator = FakeMessagesNavigator()
+ val onEditPollClickLambda = lambdaRecorder { }
+ val navigator = FakeMessagesNavigator(
+ onEditPollClickLambda = onEditPollClickLambda
+ )
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -454,22 +469,21 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent(content = aTimelineItemPollContent())))
awaitItem()
- assertThat(navigator.onEditPollClickedCount).isEqualTo(1)
+ onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@Test
fun `present - handle action end poll`() = runTest {
- val endPollAction = FakeEndPollAction()
- val presenter = createMessagesPresenter(endPollAction = endPollAction)
+ val timelineEventSink = EventsRecorder()
+ val presenter = createMessagesPresenter(timelineEventSink = timelineEventSink)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
- endPollAction.verifyExecutionCount(0)
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent(content = aTimelineItemPollContent())))
delay(1)
- endPollAction.verifyExecutionCount(1)
+ timelineEventSink.assertSingle(TimelineEvents.EndPoll(AN_EVENT_ID))
cancelAndIgnoreRemainingEvents()
}
}
@@ -510,7 +524,10 @@ class MessagesPresenterTest {
@Test
fun `present - handle action report content`() = runTest {
- val navigator = FakeMessagesNavigator()
+ val onReportContentClickLambda = lambdaRecorder { _: EventId, _: UserId -> }
+ val navigator = FakeMessagesNavigator(
+ onReportContentClickLambda = onReportContentClickLambda
+ )
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -518,7 +535,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
- assertThat(navigator.onReportContentClickedCount).isEqualTo(1)
+ onReportContentClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), value(A_USER_ID))
}
}
@@ -536,7 +553,10 @@ class MessagesPresenterTest {
@Test
fun `present - handle action show developer info`() = runTest {
- val navigator = FakeMessagesNavigator()
+ val onShowEventDebugInfoClickLambda = lambdaRecorder { _: EventId?, _: TimelineItemDebugInfo -> }
+ val navigator = FakeMessagesNavigator(
+ onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda
+ )
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -544,7 +564,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewSource, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
- assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1)
+ onShowEventDebugInfoClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), value(aTimelineItemDebugInfo()))
}
}
@@ -969,6 +989,165 @@ class MessagesPresenterTest {
}
}
+ @Test
+ fun `present - handle action edit caption`() = runTest {
+ val messageEvent = aMessageEvent(
+ content = aTimelineItemImageContent(
+ caption = A_CAPTION,
+ )
+ )
+ val composerRecorder = EventsRecorder()
+ val presenter = createMessagesPresenter(
+ messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent))
+ awaitItem()
+ composerRecorder.assertSingle(
+ MessageComposerEvents.SetMode(
+ composerMode = MessageComposerMode.EditCaption(
+ eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
+ content = A_CAPTION,
+ showCaptionCompatibilityWarning = true,
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - handle action edit caption without warning`() = runTest {
+ val messageEvent = aMessageEvent(
+ content = aTimelineItemImageContent(
+ caption = A_CAPTION,
+ )
+ )
+ val composerRecorder = EventsRecorder()
+ val presenter = createMessagesPresenter(
+ messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.MediaCaptionWarning.key to false)
+ )
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent))
+ awaitItem()
+ composerRecorder.assertSingle(
+ MessageComposerEvents.SetMode(
+ composerMode = MessageComposerMode.EditCaption(
+ eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
+ content = A_CAPTION,
+ showCaptionCompatibilityWarning = false,
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - handle action add caption`() = runTest {
+ val composerRecorder = EventsRecorder()
+ val presenter = createMessagesPresenter(
+ messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
+ )
+ val messageEvent = aMessageEvent(
+ content = aTimelineItemImageContent(
+ caption = null,
+ )
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent))
+ awaitItem()
+ composerRecorder.assertSingle(
+ MessageComposerEvents.SetMode(
+ composerMode = MessageComposerMode.EditCaption(
+ eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
+ content = "",
+ showCaptionCompatibilityWarning = true,
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - handle action add caption without warning`() = runTest {
+ val composerRecorder = EventsRecorder()
+ val presenter = createMessagesPresenter(
+ messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.MediaCaptionWarning.key to false)
+ )
+ )
+ val messageEvent = aMessageEvent(
+ content = aTimelineItemImageContent(
+ caption = null,
+ )
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent))
+ awaitItem()
+ composerRecorder.assertSingle(
+ MessageComposerEvents.SetMode(
+ composerMode = MessageComposerMode.EditCaption(
+ eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
+ content = "",
+ showCaptionCompatibilityWarning = false,
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - handle action remove caption`() = runTest {
+ val messageEvent = aMessageEvent(
+ content = aTimelineItemImageContent(
+ caption = A_CAPTION,
+ )
+ )
+ val editCaptionLambda = lambdaRecorder { _: EventOrTransactionId, _: String?, _: String? -> Result.success(Unit) }
+ val timeline = FakeTimeline().apply {
+ this.editCaptionLambda = editCaptionLambda
+ }
+ val room = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) },
+ canRedactOwnResult = { Result.success(true) },
+ canRedactOtherResult = { Result.success(true) },
+ canUserJoinCallResult = { Result.success(true) },
+ typingNoticeResult = { Result.success(Unit) },
+ canUserPinUnpinResult = { Result.success(true) },
+ )
+ val presenter = createMessagesPresenter(
+ matrixRoom = room,
+ )
+ presenter.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.RemoveCaption, messageEvent))
+ editCaptionLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toEventOrTransactionId()), value(null), value(null))
+ }
+ }
+
+ @Test
+ fun `present - handle action view in timeline, it should have no effect`() = runTest {
+ val messageEvent = aMessageEvent(
+ content = aTimelineItemTextContent()
+ )
+ val presenter = createMessagesPresenter()
+ presenter.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewInTimeline, messageEvent))
+ // No op!
+ }
+ }
+
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom(
@@ -982,9 +1161,10 @@ class MessagesPresenterTest {
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
},
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
+ featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
- endPollAction: EndPollAction = FakeEndPollAction(),
+ timelineEventSink: (TimelineEvents) -> Unit = {},
permalinkParser: PermalinkParser = FakePermalinkParser(),
messageComposerPresenter: Presenter = Presenter {
aMessageComposerState(
@@ -994,21 +1174,13 @@ class MessagesPresenterTest {
},
actionListEventSink: (ActionListEvents) -> Unit = {},
): MessagesPresenter {
- val timelinePresenterFactory = object : TimelinePresenter.Factory {
- override fun create(navigator: MessagesNavigator): TimelinePresenter {
- return createTimelinePresenter(
- endPollAction = endPollAction,
- )
- }
- }
- val featureFlagService = FakeFeatureFlagService()
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = { aVoiceMessageComposerState() },
- timelinePresenterFactory = timelinePresenterFactory,
+ timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
timelineProtectionPresenter = { aTimelineProtectionState() },
- actionListPresenterFactory = FakeActionListPresenter.Factory(actionListEventSink),
+ actionListPresenter = { anActionListState(eventSink = actionListEventSink) },
customReactionPresenter = { aCustomReactionState() },
reactionSummaryPresenter = { aReactionSummaryState() },
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
index c040ce9926..1d4e1a43b3 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
@@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
-import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aChangedIdentitySendFailure
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
@@ -64,7 +63,6 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
-import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
@@ -514,7 +512,6 @@ private fun AndroidComposeTestRule.setMessa
onEventClick: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
- onPreviewAttachments: (ImmutableList) -> Unit = EnsureNeverCalledWithParam(),
onSendLocationClick: () -> Unit = EnsureNeverCalled(),
onCreatePollClick: () -> Unit = EnsureNeverCalled(),
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
@@ -532,7 +529,6 @@ private fun AndroidComposeTestRule.setMessa
onEventContentClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
- onPreviewAttachments = onPreviewAttachments,
onSendLocationClick = onSendLocationClick,
onCreatePollClick = onCreatePollClick,
onJoinCallClick = onJoinCallClick,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
index 078fb9e676..49db6f6c95 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
@@ -26,15 +26,19 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -172,8 +176,53 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
- TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.CopyText,
+ TimelineItemAction.ViewSource,
+ TimelineItemAction.ReportContent,
+ )
+ )
+ )
+ initialState.eventSink.invoke(ActionListEvents.Clear)
+ assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+ }
+ }
+
+ @Test
+ fun `present - compute for others message in a thread`() = runTest {
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
+ presenter.test {
+ val initialState = awaitItem()
+ val messageEvent = aMessageEvent(
+ isMine = false,
+ isEditable = false,
+ isThreaded = true,
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ )
+ initialState.eventSink.invoke(
+ ActionListEvents.ComputeForMessage(
+ event = messageEvent,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = false,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
+ )
+ )
+ val successState = awaitItem()
+ assertThat(successState.target).isEqualTo(
+ ActionListState.Target.Success(
+ event = messageEvent,
+ displayEmojiReactions = true,
+ verifiedUserSendFailure = VerifiedUserSendFailure.None,
+ actions = persistentListOf(
+ TimelineItemAction.ReplyInThread,
+ TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
+ TimelineItemAction.CopyLink,
+ TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
@@ -219,8 +268,8 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Forward,
TimelineItemAction.Pin,
- TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
@@ -265,8 +314,8 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
- TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
@@ -312,8 +361,8 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
- TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
@@ -359,10 +408,55 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
- TimelineItemAction.Edit,
TimelineItemAction.Pin,
- TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.Edit,
+ TimelineItemAction.CopyText,
+ TimelineItemAction.ViewSource,
+ TimelineItemAction.Redact,
+ )
+ )
+ )
+ initialState.eventSink.invoke(ActionListEvents.Clear)
+ assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+ }
+ }
+
+ @Test
+ fun `present - compute for my message in a thread`() = runTest {
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
+ presenter.test {
+ val initialState = awaitItem()
+ val messageEvent = aMessageEvent(
+ isMine = true,
+ isThreaded = true,
+ content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
+ )
+ initialState.eventSink.invoke(
+ ActionListEvents.ComputeForMessage(
+ event = messageEvent,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ )
+ )
+ )
+ val successState = awaitItem()
+ assertThat(successState.target).isEqualTo(
+ ActionListState.Target.Success(
+ event = messageEvent,
+ displayEmojiReactions = true,
+ verifiedUserSendFailure = VerifiedUserSendFailure.None,
+ actions = persistentListOf(
+ TimelineItemAction.ReplyInThread,
+ TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
+ TimelineItemAction.CopyLink,
+ TimelineItemAction.Edit,
+ TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@@ -407,10 +501,10 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
- TimelineItemAction.Edit,
TimelineItemAction.Pin,
- TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.Edit,
+ TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
)
)
@@ -429,7 +523,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
- isEditable = false,
+ isEditable = true,
content = aTimelineItemImageContent(),
)
initialState.eventSink.invoke(
@@ -444,8 +538,6 @@ class ActionListPresenterTest {
),
)
)
- // val loadingState = awaitItem()
- // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@@ -457,6 +549,7 @@ class ActionListPresenterTest {
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
+ TimelineItemAction.AddCaption,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@@ -467,6 +560,155 @@ class ActionListPresenterTest {
}
}
+ @Test
+ fun `present - compute for a media item - caption disabled`() = runTest {
+ val presenter = createActionListPresenter(
+ isDeveloperModeEnabled = true,
+ isPinFeatureEnabled = true,
+ allowCaption = false,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val messageEvent = aMessageEvent(
+ isMine = true,
+ isEditable = true,
+ content = aTimelineItemImageContent(),
+ )
+ initialState.eventSink.invoke(
+ ActionListEvents.ComputeForMessage(
+ event = messageEvent,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ ),
+ )
+ )
+ val successState = awaitItem()
+ assertThat(successState.target).isEqualTo(
+ ActionListState.Target.Success(
+ event = messageEvent,
+ displayEmojiReactions = true,
+ verifiedUserSendFailure = VerifiedUserSendFailure.None,
+ actions = persistentListOf(
+ TimelineItemAction.Reply,
+ TimelineItemAction.Forward,
+ // Not here
+ // TimelineItemAction.AddCaption,
+ TimelineItemAction.Pin,
+ TimelineItemAction.CopyLink,
+ TimelineItemAction.ViewSource,
+ TimelineItemAction.Redact,
+ )
+ )
+ )
+ initialState.eventSink.invoke(ActionListEvents.Clear)
+ assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+ }
+ }
+
+ @Test
+ fun `present - compute for a media with caption item`() = runTest {
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val messageEvent = aMessageEvent(
+ isMine = true,
+ isEditable = true,
+ content = aTimelineItemImageContent(
+ caption = A_CAPTION,
+ ),
+ )
+ initialState.eventSink.invoke(
+ ActionListEvents.ComputeForMessage(
+ event = messageEvent,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ ),
+ )
+ )
+ val successState = awaitItem()
+ assertThat(successState.target).isEqualTo(
+ ActionListState.Target.Success(
+ event = messageEvent,
+ displayEmojiReactions = true,
+ verifiedUserSendFailure = VerifiedUserSendFailure.None,
+ actions = persistentListOf(
+ TimelineItemAction.Reply,
+ TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
+ TimelineItemAction.CopyLink,
+ TimelineItemAction.EditCaption,
+ TimelineItemAction.CopyCaption,
+ TimelineItemAction.RemoveCaption,
+ TimelineItemAction.ViewSource,
+ TimelineItemAction.Redact,
+ )
+ )
+ )
+ initialState.eventSink.invoke(ActionListEvents.Clear)
+ assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+ }
+ }
+
+ @Test
+ fun `present - compute for a media with caption item - other user event`() = runTest {
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val messageEvent = aMessageEvent(
+ isMine = false,
+ isEditable = false,
+ content = aTimelineItemImageContent(
+ caption = A_CAPTION,
+ ),
+ )
+ initialState.eventSink.invoke(
+ ActionListEvents.ComputeForMessage(
+ event = messageEvent,
+ userEventPermissions = aUserEventPermissions(
+ canRedactOwn = true,
+ canRedactOther = false,
+ canSendMessage = true,
+ canSendReaction = true,
+ canPinUnpin = true,
+ ),
+ )
+ )
+ val successState = awaitItem()
+ assertThat(successState.target).isEqualTo(
+ ActionListState.Target.Success(
+ event = messageEvent,
+ displayEmojiReactions = true,
+ verifiedUserSendFailure = VerifiedUserSendFailure.None,
+ actions = persistentListOf(
+ TimelineItemAction.Reply,
+ TimelineItemAction.Forward,
+ TimelineItemAction.Pin,
+ TimelineItemAction.CopyLink,
+ TimelineItemAction.CopyCaption,
+ TimelineItemAction.ViewSource,
+ TimelineItemAction.ReportContent,
+ )
+ )
+ )
+ initialState.eventSink.invoke(ActionListEvents.Clear)
+ assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
+ }
+ }
+
@Test
fun `present - compute for a state item in debug build`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
@@ -571,10 +813,10 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
- TimelineItemAction.Edit,
TimelineItemAction.Pin,
- TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.Edit,
+ TimelineItemAction.CopyText,
TimelineItemAction.Redact,
)
)
@@ -618,9 +860,9 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
- TimelineItemAction.Edit,
- TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.Edit,
+ TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@@ -672,10 +914,10 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
- TimelineItemAction.Edit,
TimelineItemAction.Unpin,
- TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
+ TimelineItemAction.Edit,
+ TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@@ -768,7 +1010,7 @@ class ActionListPresenterTest {
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Edit,
- TimelineItemAction.Copy,
+ TimelineItemAction.CopyText,
TimelineItemAction.Redact,
)
)
@@ -807,11 +1049,11 @@ class ActionListPresenterTest {
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
- TimelineItemAction.Reply,
- TimelineItemAction.Edit,
TimelineItemAction.EndPoll,
+ TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
+ TimelineItemAction.Edit,
TimelineItemAction.Redact,
)
)
@@ -850,8 +1092,8 @@ class ActionListPresenterTest {
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
- TimelineItemAction.Reply,
TimelineItemAction.EndPoll,
+ TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
@@ -912,7 +1154,9 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
- content = aTimelineItemVoiceContent(),
+ content = aTimelineItemVoiceContent(
+ caption = null,
+ ),
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
@@ -1011,6 +1255,7 @@ private fun createActionListPresenter(
isDeveloperModeEnabled: Boolean,
isPinFeatureEnabled: Boolean,
room: MatrixRoom = FakeMatrixRoom(),
+ allowCaption: Boolean = true,
): ActionListPresenter {
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
return DefaultActionListPresenter(
@@ -1018,6 +1263,11 @@ private fun createActionListPresenter(
appPreferencesStore = preferencesStore,
isPinnedMessagesFeatureEnabled = { isPinFeatureEnabled },
room = room,
- userSendFailureFactory = VerifiedUserSendFailureFactory(room)
+ userSendFailureFactory = VerifiedUserSendFailureFactory(room),
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(
+ FeatureFlags.MediaCaptionCreation.key to allowCaption,
+ ),
+ )
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/FakeActionListPresenter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/FakeActionListPresenter.kt
deleted file mode 100644
index 14f62a1daf..0000000000
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/FakeActionListPresenter.kt
+++ /dev/null
@@ -1,24 +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.actionlist
-
-import androidx.compose.runtime.Composable
-import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
-
-class FakeActionListPresenter(private val eventSink: (ActionListEvents) -> Unit = {}) : ActionListPresenter {
- class Factory(private val eventSink: (ActionListEvents) -> Unit = {}) : ActionListPresenter.Factory {
- override fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter {
- return FakeActionListPresenter(eventSink)
- }
- }
-
- @Composable
- override fun present(): ActionListState {
- return anActionListState(eventSink = eventSink)
- }
-}
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 ac753f6cdb..92e43aa03a 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
@@ -20,7 +20,10 @@ import io.element.android.features.messages.impl.attachments.preview.OnDoneListe
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.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.ProgressCallback
+import io.element.android.libraries.matrix.api.media.AudioInfo
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
@@ -39,9 +42,12 @@ import io.element.android.libraries.preferences.test.InMemorySessionPreferencesS
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.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.mockk
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -58,9 +64,43 @@ class AttachmentsPreviewPresenterTest {
private val mockMediaUrl: Uri = mockk("localMediaUri")
+ @Test
+ fun `present - initial state`() = runTest {
+ createAttachmentsPreviewPresenter().test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(initialState.allowCaption).isTrue()
+ assertThat(initialState.showCaptionCompatibilityWarning).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - initial state no caption warning`() = runTest {
+ createAttachmentsPreviewPresenter(
+ showCaptionCompatibilityWarning = false,
+ ).test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.showCaptionCompatibilityWarning).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - initial state - caption not allowed`() = runTest {
+ createAttachmentsPreviewPresenter(
+ allowCaption = false,
+ ).test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(initialState.allowCaption).isFalse()
+ }
+ }
+
@Test
fun `present - send media success scenario`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _ ->
+ val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@@ -82,16 +122,142 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
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))
- advanceUntilIdle()
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
}
+ @Test
+ fun `present - send media after pre-processing success scenario`() = runTest {
+ val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ Result.success(FakeMediaUploadHandler())
+ }
+ val room = FakeMatrixRoom(
+ sendFileResult = sendFileResult,
+ )
+ val onDoneListener = lambdaRecorder { }
+ val processLatch = CompletableDeferred()
+ val presenter = createAttachmentsPreviewPresenter(
+ room = room,
+ mediaPreProcessor = FakeMediaPreProcessor(
+ processLatch = processLatch,
+ ),
+ onDoneListener = { onDoneListener() },
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ // Pre-processing finishes
+ processLatch.complete(Unit)
+ advanceUntilIdle()
+ initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.InstantSending)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
+ sendFileResult.assertions().isCalledOnce()
+ onDoneListener.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - send media before pre-processing success scenario`() = runTest {
+ val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
+ Result.success(FakeMediaUploadHandler())
+ }
+ val room = FakeMatrixRoom(
+ sendFileResult = sendFileResult,
+ )
+ val onDoneListener = lambdaRecorder { }
+ val processLatch = CompletableDeferred()
+ val presenter = createAttachmentsPreviewPresenter(
+ room = room,
+ mediaPreProcessor = FakeMediaPreProcessor(
+ processLatch = processLatch,
+ ),
+ onDoneListener = { onDoneListener() },
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ // Pre-processing finishes
+ processLatch.complete(Unit)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
+ sendFileResult.assertions().isCalledOnce()
+ onDoneListener.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - send media with pre-processing failure after user sends media`() = runTest {
+ val room = FakeMatrixRoom()
+ val onDoneListener = lambdaRecorder { }
+ val processLatch = CompletableDeferred()
+ val presenter = createAttachmentsPreviewPresenter(
+ room = room,
+ mediaPreProcessor = FakeMediaPreProcessor().apply {
+ givenResult(Result.failure(Exception()))
+ },
+ onDoneListener = { onDoneListener() },
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ // Pre-processing finishes
+ processLatch.complete(Unit)
+ assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java)
+ }
+ }
+
+ @Test
+ fun `present - send media with pre-processing failure before user sends media`() = runTest {
+ val room = FakeMatrixRoom()
+ val onDoneListener = lambdaRecorder { }
+ val processLatch = CompletableDeferred()
+ val presenter = createAttachmentsPreviewPresenter(
+ room = room,
+ mediaPreProcessor = FakeMediaPreProcessor().apply {
+ givenResult(Result.failure(Exception()))
+ },
+ onDoneListener = { onDoneListener() },
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ // Pre-processing finishes
+ processLatch.complete(Unit)
+ advanceUntilIdle()
+ initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.InstantSending)
+ assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java)
+ }
+ }
+
@Test
fun `present - cancel scenario`() = runTest {
val onDoneListener = lambdaRecorder { }
@@ -106,6 +272,8 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.Cancel)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
deleteCallback.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
@@ -136,8 +304,10 @@ class AttachmentsPreviewPresenterTest {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
- advanceUntilIdle()
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendImageResult.assertions().isCalledOnce().with(
any(),
any(),
@@ -175,8 +345,10 @@ class AttachmentsPreviewPresenterTest {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
- advanceUntilIdle()
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendVideoResult.assertions().isCalledOnce().with(
any(),
any(),
@@ -189,10 +361,48 @@ class AttachmentsPreviewPresenterTest {
}
}
+ @Test
+ fun `present - send audio with caption success scenario`() = runTest {
+ val sendAudioResult =
+ lambdaRecorder> { _, _, _, _, _ ->
+ Result.success(FakeMediaUploadHandler())
+ }
+ val mediaPreProcessor = FakeMediaPreProcessor().apply {
+ givenAudioResult()
+ }
+ val room = FakeMatrixRoom(
+ sendAudioResult = sendAudioResult,
+ )
+ val onDoneListener = lambdaRecorder { }
+ val presenter = createAttachmentsPreviewPresenter(
+ room = room,
+ mediaPreProcessor = mediaPreProcessor,
+ onDoneListener = { onDoneListener() },
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
+ initialState.textEditorState.setMarkdown(A_CAPTION)
+ initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
+ sendAudioResult.assertions().isCalledOnce().with(
+ any(),
+ any(),
+ value(A_CAPTION),
+ any(),
+ any(),
+ )
+ onDoneListener.assertions().isCalledOnce()
+ }
+ }
+
@Test
fun `present - send media failure scenario`() = runTest {
val failure = MediaPreProcessor.Failure(null)
- val sendFileResult = lambdaRecorder> { _, _, _ ->
+ val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
Result.failure(failure)
}
val room = FakeMatrixRoom(
@@ -205,8 +415,9 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
- val loadingState = awaitItem()
- assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure))
sendFileResult.assertions().isCalledOnce()
@@ -225,6 +436,8 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
+ assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
initialState.eventSink(AttachmentsPreviewEvents.ClearSendState)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
@@ -239,7 +452,10 @@ class AttachmentsPreviewPresenterTest {
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
- onDoneListener: OnDoneListener = OnDoneListener {},
+ onDoneListener: OnDoneListener = OnDoneListener { lambdaError() },
+ mediaUploadOnSendQueueEnabled: Boolean = true,
+ allowCaption: Boolean = true,
+ showCaptionCompatibilityWarning: Boolean = true,
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
@@ -247,6 +463,13 @@ class AttachmentsPreviewPresenterTest {
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter,
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(
+ FeatureFlags.MediaUploadOnSendQueue.key to mediaUploadOnSendQueueEnabled,
+ FeatureFlags.MediaCaptionCreation.key to allowCaption,
+ FeatureFlags.MediaCaptionWarning.key to showCaptionCompatibilityWarning,
+ ),
+ )
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
index 405d356edf..51c4cb43ba 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
@@ -35,7 +35,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
-import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
+import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
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 2e68c9b199..a56da055aa 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
@@ -18,6 +18,9 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
+import io.element.android.features.messages.impl.FakeMessagesNavigator
+import io.element.android.features.messages.impl.MessagesNavigator
+import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
@@ -30,9 +33,7 @@ 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.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
@@ -49,6 +50,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -58,7 +60,6 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.core.aBuildMeta
-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.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@@ -90,8 +91,10 @@ import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert
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.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
+import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -134,7 +137,6 @@ class MessageComposerPresenterTest {
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue()
- assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
}
}
@@ -211,6 +213,91 @@ class MessageComposerPresenterTest {
}
}
+ @Test
+ fun `present - change mode to edit caption`() = runTest {
+ val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
+ ComposerDraft(A_MESSAGE, A_MESSAGE, ComposerDraftType.NewMessage)
+ }
+ val updateDraftLambda = lambdaRecorder { _: RoomId, _: ComposerDraft?, _: Boolean -> }
+ val draftService = FakeComposerDraftService().apply {
+ this.loadDraftLambda = loadDraftLambda
+ this.saveDraftLambda = updateDraftLambda
+ }
+ val presenter = createPresenter(
+ coroutineScope = this,
+ draftService = draftService,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ val state = presenter.present()
+ remember(state, state.textEditorState.messageHtml()) { state }
+ }.test {
+ var state = awaitFirstItem()
+ val mode = anEditCaptionMode(caption = A_CAPTION)
+ state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
+ state = awaitItem()
+ assertThat(state.mode).isEqualTo(mode)
+ assertThat(state.textEditorState.messageHtml()).isEqualTo(A_CAPTION)
+ state = backToNormalMode(state)
+ // The caption that was being edited is cleared and volatile draft is loaded
+ assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
+
+ assert(loadDraftLambda)
+ .isCalledExactly(2)
+ .withSequence(
+ // Automatic load of draft
+ listOf(value(A_ROOM_ID), value(false)),
+ // Load of volatile draft when closing edit mode
+ listOf(value(A_ROOM_ID), value(true))
+ )
+ assert(updateDraftLambda)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID), any(), value(true))
+ }
+ }
+
+ @Test
+ fun `present - change mode to edit caption and send the caption`() = runTest {
+ val editCaptionLambda = lambdaRecorder { _: EventOrTransactionId, _: String?, _: String? ->
+ Result.success(Unit)
+ }
+ val timeline = FakeTimeline().apply {
+ this.editCaptionLambda = editCaptionLambda
+ }
+ val fakeMatrixRoom = FakeMatrixRoom(
+ liveTimeline = timeline,
+ typingNoticeResult = { Result.success(Unit) }
+ )
+ val presenter = createPresenter(
+ coroutineScope = this,
+ room = fakeMatrixRoom,
+ isRichTextEditorEnabled = false,
+ )
+ val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("") })
+ presenter.test {
+ var state = awaitFirstItem()
+ val mode = anEditCaptionMode(caption = A_CAPTION)
+ state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
+ state = awaitItem()
+ assertThat(state.mode).isEqualTo(mode)
+ assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_CAPTION)
+ state.eventSink.invoke(MessageComposerEvents.SendMessage)
+ val messageSentState = awaitItem()
+ assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("")
+ waitForPredicate { analyticsService.capturedEvents.size == 1 }
+ assertThat(analyticsService.capturedEvents).containsExactly(
+ Composer(
+ inThread = false,
+ isEditing = true,
+ isReply = false,
+ messageType = Composer.MessageType.Text,
+ )
+ )
+ assert(editCaptionLambda)
+ .isCalledOnce()
+ .with(value(AN_EVENT_ID.toEventOrTransactionId()), value(A_CAPTION), value(null))
+ }
+ }
+
@Test
fun `present - change mode to reply after edit`() = runTest {
val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
@@ -601,7 +688,15 @@ class MessageComposerPresenterTest {
val room = FakeMatrixRoom(
typingNoticeResult = { Result.success(Unit) }
)
- val presenter = createPresenter(this, room = room)
+ val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList -> }
+ val navigator = FakeMessagesNavigator(
+ onPreviewAttachmentLambda = onPreviewAttachmentLambda
+ )
+ val presenter = createPresenter(
+ coroutineScope = this,
+ room = room,
+ navigator = navigator,
+ )
pickerProvider.givenMimeType(MimeTypes.Images)
mediaPreProcessor.givenResult(
Result.success(
@@ -625,9 +720,7 @@ class MessageComposerPresenterTest {
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
- val previewingState = awaitItem()
- assertThat(previewingState.showAttachmentSourcePicker).isFalse()
- assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
+ onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@@ -636,7 +729,15 @@ class MessageComposerPresenterTest {
val room = FakeMatrixRoom(
typingNoticeResult = { Result.success(Unit) }
)
- val presenter = createPresenter(this, room = room)
+ val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList -> }
+ val navigator = FakeMessagesNavigator(
+ onPreviewAttachmentLambda = onPreviewAttachmentLambda
+ )
+ val presenter = createPresenter(
+ coroutineScope = this,
+ room = room,
+ navigator = navigator,
+ )
pickerProvider.givenMimeType(MimeTypes.Videos)
mediaPreProcessor.givenResult(
Result.success(
@@ -661,9 +762,7 @@ class MessageComposerPresenterTest {
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
- val previewingState = awaitItem()
- assertThat(previewingState.showAttachmentSourcePicker).isFalse()
- assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
+ onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@@ -684,34 +783,25 @@ class MessageComposerPresenterTest {
}
@Test
- fun `present - Pick file from storage`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _ ->
- Result.success(FakeMediaUploadHandler())
- }
+ fun `present - Pick file from storage will open the preview`() = runTest {
val room = FakeMatrixRoom(
- progressCallbackValues = listOf(
- Pair(0, 10),
- Pair(5, 10),
- Pair(10, 10)
- ),
- sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) }
)
- val presenter = createPresenter(this, room = room)
+ val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList -> }
+ val navigator = FakeMessagesNavigator(
+ onPreviewAttachmentLambda = onPreviewAttachmentLambda
+ )
+ val presenter = createPresenter(
+ coroutineScope = this,
+ room = room,
+ navigator = navigator,
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
- val sendingState = awaitItem()
- assertThat(sendingState.showAttachmentSourcePicker).isFalse()
- assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java)
- assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0f))
- assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0.5f))
- assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f))
- val sentState = awaitItem()
- assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
- sendFileResult.assertions().isCalledOnce()
+ onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@@ -759,19 +849,22 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) }
)
val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
+ val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList -> }
+ val navigator = FakeMessagesNavigator(
+ onPreviewAttachmentLambda = onPreviewAttachmentLambda
+ )
val presenter = createPresenter(
- this,
+ coroutineScope = this,
room = room,
permissionPresenter = permissionPresenter,
+ navigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
- val finalState = awaitItem()
- assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
- cancelAndIgnoreRemainingEvents()
+ onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@@ -781,23 +874,23 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) }
)
val permissionPresenter = FakePermissionsPresenter()
+ val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList -> }
+ val navigator = FakeMessagesNavigator(
+ onPreviewAttachmentLambda = onPreviewAttachmentLambda
+ )
val presenter = createPresenter(
- this,
+ coroutineScope = this,
room = room,
permissionPresenter = permissionPresenter,
+ navigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
- val permissionState = awaitItem()
- assertThat(permissionState.showAttachmentSourcePicker).isFalse()
- assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
permissionPresenter.setPermissionGranted()
- skipItems(1)
- val finalState = awaitItem()
- assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
+ onPreviewAttachmentLambda.assertions().isCalledOnce()
cancelAndIgnoreRemainingEvents()
}
}
@@ -808,19 +901,22 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) }
)
val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
+ val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList -> }
+ val navigator = FakeMessagesNavigator(
+ onPreviewAttachmentLambda = onPreviewAttachmentLambda
+ )
val presenter = createPresenter(
- this,
+ coroutineScope = this,
room = room,
permissionPresenter = permissionPresenter,
+ navigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
- val finalState = awaitItem()
- assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
- cancelAndIgnoreRemainingEvents()
+ onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@@ -830,10 +926,15 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) }
)
val permissionPresenter = FakePermissionsPresenter()
+ val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList -> }
+ val navigator = FakeMessagesNavigator(
+ onPreviewAttachmentLambda = onPreviewAttachmentLambda
+ )
val presenter = createPresenter(
- this,
+ coroutineScope = this,
room = room,
permissionPresenter = permissionPresenter,
+ navigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -842,54 +943,9 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
val permissionState = awaitItem()
assertThat(permissionState.showAttachmentSourcePicker).isFalse()
- assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
permissionPresenter.setPermissionGranted()
skipItems(1)
- val finalState = awaitItem()
- assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
- cancelAndIgnoreRemainingEvents()
- }
- }
-
- @Test
- fun `present - Uploading media failure can be recovered from`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _ ->
- Result.failure(Exception())
- }
- val room = FakeMatrixRoom(
- sendFileResult = sendFileResult,
- typingNoticeResult = { Result.success(Unit) }
- )
- val presenter = createPresenter(this, room = room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitFirstItem()
- initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
- val sendingState = awaitItem()
- assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending::class.java)
- val finalState = awaitItem()
- assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
- snackbarDispatcher.snackbarMessage.test {
- // Assert error message received
- assertThat(awaitItem()).isNotNull()
- }
- }
- }
-
- @Test
- fun `present - CancelSendAttachment stops media upload`() = runTest {
- val presenter = createPresenter(this)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitFirstItem()
- initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
- val sendingState = awaitItem()
- assertThat(sendingState.showAttachmentSourcePicker).isFalse()
- assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java)
- sendingState.eventSink(MessageComposerEvents.CancelSendAttachment)
- assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.None)
+ onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@@ -1473,6 +1529,7 @@ class MessageComposerPresenterTest {
room: MatrixRoom = FakeMatrixRoom(
typingNoticeResult = { Result.success(Unit) }
),
+ navigator: MessagesNavigator = FakeMessagesNavigator(),
pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService,
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
@@ -1487,17 +1544,18 @@ class MessageComposerPresenterTest {
isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(),
) = MessageComposerPresenter(
- coroutineScope,
- room,
- pickerProvider,
- featureFlagService,
- sessionPreferencesStore,
- localMediaFactory,
- MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
- snackbarDispatcher,
- analyticsService,
- DefaultMessageComposerContext(),
- TestRichTextEditorStateFactory(),
+ navigator = navigator,
+ appCoroutineScope = coroutineScope,
+ room = room,
+ mediaPickerProvider = pickerProvider,
+ featureFlagService = featureFlagService,
+ sessionPreferencesStore = sessionPreferencesStore,
+ localMediaFactory = localMediaFactory,
+ mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
+ snackbarDispatcher = snackbarDispatcher,
+ analyticsService = analyticsService,
+ messageComposerContext = DefaultMessageComposerContext(),
+ richTextEditorStateFactory = TestRichTextEditorStateFactory(),
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser,
@@ -1525,6 +1583,16 @@ fun anEditMode(
message: String = A_MESSAGE,
) = MessageComposerMode.Edit(eventOrTransactionId, message)
+fun anEditCaptionMode(
+ eventOrTransactionId: EventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
+ caption: String = A_CAPTION,
+ showCaptionCompatibilityWarning: Boolean = false,
+) = MessageComposerMode.EditCaption(
+ eventOrTransactionId = eventOrTransactionId,
+ content = caption,
+ showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
+)
+
fun aReplyMode() = MessageComposerMode.Reply(
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),
hideImage = false,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
index 1036788cbb..ed92a34df3 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
@@ -9,7 +9,7 @@ package io.element.android.features.messages.impl.pinned.list
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
-import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
+import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
@@ -312,7 +312,7 @@ class PinnedMessagesListPresenterTest {
timelineProvider = timelineProvider,
timelineProtectionPresenter = { aTimelineProtectionState() },
snackbarDispatcher = SnackbarDispatcher(),
- actionListPresenterFactory = FakeActionListPresenter.Factory(),
+ actionListPresenter = { anActionListState() },
analyticsService = analyticsService,
appCoroutineScope = this,
)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessorTest.kt
new file mode 100644
index 0000000000..7043d3f848
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessorTest.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.pinned.list
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
+import org.junit.Test
+
+class PinnedMessagesListTimelineActionPostProcessorTest {
+ @Test
+ fun `ensure that ViewInTimeline is added`() {
+ val sut = PinnedMessagesListTimelineActionPostProcessor()
+ val result = sut.process(
+ listOf()
+ )
+ assertThat(result).isEqualTo(
+ listOf(TimelineItemAction.ViewInTimeline)
+ )
+ }
+
+ @Test
+ fun `ensure that some actions are kept and some other are filtered out`() {
+ val sut = PinnedMessagesListTimelineActionPostProcessor()
+ val result = sut.process(
+ listOf(
+ TimelineItemAction.Forward,
+ TimelineItemAction.CopyText,
+ TimelineItemAction.CopyCaption,
+ TimelineItemAction.CopyLink,
+ TimelineItemAction.Redact,
+ TimelineItemAction.Reply,
+ TimelineItemAction.ReplyInThread,
+ TimelineItemAction.Edit,
+ TimelineItemAction.EditCaption,
+ TimelineItemAction.AddCaption,
+ TimelineItemAction.RemoveCaption,
+ TimelineItemAction.ViewSource,
+ TimelineItemAction.ReportContent,
+ TimelineItemAction.EndPoll,
+ TimelineItemAction.Pin,
+ TimelineItemAction.Unpin,
+ )
+ )
+ assertThat(result).isEqualTo(
+ listOf(
+ TimelineItemAction.ViewInTimeline,
+ TimelineItemAction.Unpin,
+ TimelineItemAction.Forward,
+ TimelineItemAction.ViewSource,
+ )
+ )
+ }
+}
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 d153dd5743..a21dcb2834 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
@@ -431,7 +431,10 @@ import kotlin.time.Duration.Companion.seconds
@Test
fun `present - PollEditClicked event navigates`() = runTest {
- val navigator = FakeMessagesNavigator()
+ val onEditPollClickLambda = lambdaRecorder { _: EventId -> }
+ val navigator = FakeMessagesNavigator(
+ onEditPollClickLambda = onEditPollClickLambda
+ )
val presenter = createTimelinePresenter(
messagesNavigator = navigator,
)
@@ -439,7 +442,7 @@ import kotlin.time.Duration.Companion.seconds
presenter.present()
}.test {
awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID))
- assertThat(navigator.onEditPollClickedCount).isEqualTo(1)
+ onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@@ -657,35 +660,35 @@ import kotlin.time.Duration.Companion.seconds
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
return awaitItem()
}
-}
-internal fun TestScope.createTimelinePresenter(
- timeline: Timeline = FakeTimeline(),
- room: FakeMatrixRoom = FakeMatrixRoom(
- liveTimeline = timeline,
- canUserSendMessageResult = { _, _ -> Result.success(true) }
- ),
- redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
- messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
- endPollAction: EndPollAction = FakeEndPollAction(),
- sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
- sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
- timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
-): TimelinePresenter {
- return TimelinePresenter(
- timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
- room = room,
- dispatchers = testCoroutineDispatchers(),
- appScope = this,
- navigator = messagesNavigator,
- redactedVoiceMessageManager = redactedVoiceMessageManager,
- endPollAction = endPollAction,
- sendPollResponseAction = sendPollResponseAction,
- sessionPreferencesStore = sessionPreferencesStore,
- timelineItemIndexer = timelineItemIndexer,
- timelineController = TimelineController(room),
- resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
- typingNotificationPresenter = { aTypingNotificationState() },
- roomCallStatePresenter = { aStandByCallState() },
- )
+ private fun TestScope.createTimelinePresenter(
+ timeline: Timeline = FakeTimeline(),
+ room: FakeMatrixRoom = FakeMatrixRoom(
+ liveTimeline = timeline,
+ canUserSendMessageResult = { _, _ -> Result.success(true) }
+ ),
+ redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
+ messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
+ endPollAction: EndPollAction = FakeEndPollAction(),
+ sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
+ sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
+ timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
+ ): TimelinePresenter {
+ return TimelinePresenter(
+ timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
+ room = room,
+ dispatchers = testCoroutineDispatchers(),
+ appScope = this,
+ navigator = messagesNavigator,
+ redactedVoiceMessageManager = redactedVoiceMessageManager,
+ endPollAction = endPollAction,
+ sendPollResponseAction = sendPollResponseAction,
+ sessionPreferencesStore = sessionPreferencesStore,
+ timelineItemIndexer = timelineItemIndexer,
+ 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 a34e601e5d..fb853f4472 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
@@ -64,7 +64,7 @@ import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aStickerContent
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
-import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
+import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
@@ -237,8 +237,9 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
+ isEdited = false,
duration = Duration.ZERO,
- videoSource = MediaSource(url = "url", json = null),
+ mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
aspectRatio = null,
blurHash = null,
@@ -278,7 +279,8 @@ class TimelineItemContentMessageFactoryTest {
thumbnailSource = MediaSource("url_thumbnail"),
blurhash = A_BLUR_HASH,
),
- )
+ ),
+ isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
@@ -287,8 +289,9 @@ class TimelineItemContentMessageFactoryTest {
filename = "body.mp4",
caption = "body.mp4 caption",
formattedCaption = SpannedString("formatted"),
+ isEdited = true,
duration = 1.minutes,
- videoSource = MediaSource(url = "url", json = null),
+ mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
aspectRatio = 3f,
blurHash = A_BLUR_HASH,
@@ -315,6 +318,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
+ isEdited = false,
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.OctetStream,
@@ -339,7 +343,8 @@ class TimelineItemContentMessageFactoryTest {
size = 123L,
mimetype = MimeTypes.Mp3,
)
- )
+ ),
+ isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
@@ -348,6 +353,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "body.mp3",
caption = null,
formattedCaption = null,
+ isEdited = true,
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Mp3,
@@ -370,10 +376,13 @@ class TimelineItemContentMessageFactoryTest {
eventId = AN_EVENT_ID,
caption = null,
formattedCaption = null,
+ isEdited = false,
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.OctetStream,
- waveform = emptyList().toImmutableList()
+ waveform = emptyList().toImmutableList(),
+ fileExtension = "",
+ formattedFileSize = "0 Bytes",
)
assertThat(result).isEqualTo(expected)
}
@@ -397,7 +406,8 @@ class TimelineItemContentMessageFactoryTest {
duration = 1.minutes,
waveform = persistentListOf(1f, 2f),
),
- )
+ ),
+ isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
@@ -407,10 +417,13 @@ class TimelineItemContentMessageFactoryTest {
filename = "body.ogg",
caption = null,
formattedCaption = null,
+ isEdited = true,
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Ogg,
- waveform = persistentListOf(1f, 2f)
+ waveform = persistentListOf(1f, 2f),
+ fileExtension = "ogg",
+ formattedFileSize = "123 Bytes",
)
assertThat(result).isEqualTo(expected)
}
@@ -433,6 +446,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
+ isEdited = false,
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.OctetStream,
@@ -454,6 +468,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = "body",
formattedCaption = null,
+ isEdited = false,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
formattedFileSize = "0 Bytes",
@@ -483,6 +498,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
+ isEdited = false,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource(url = "thumbnail://url", json = null),
formattedFileSize = "8192 Bytes",
@@ -520,15 +536,17 @@ class TimelineItemContentMessageFactoryTest {
thumbnailSource = MediaSource("url_thumbnail"),
blurhash = A_BLUR_HASH,
)
- )
+ ),
+ isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemImageContent(
filename = "body.jpg",
- formattedCaption = SpannedString("formatted"),
caption = "body.jpg caption",
+ formattedCaption = SpannedString("formatted"),
+ isEdited = true,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
formattedFileSize = "888 Bytes",
@@ -556,7 +574,8 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
- fileSource = MediaSource(url = "url", json = null),
+ isEdited = false,
+ mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
formattedFileSize = "0 Bytes",
fileExtension = "",
@@ -586,7 +605,8 @@ class TimelineItemContentMessageFactoryTest {
),
thumbnailSource = MediaSource("url_thumbnail"),
)
- )
+ ),
+ isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
@@ -595,7 +615,8 @@ class TimelineItemContentMessageFactoryTest {
filename = "body.pdf",
caption = null,
formattedCaption = null,
- fileSource = MediaSource(url = "url", json = null),
+ isEdited = true,
+ mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
formattedFileSize = "123 Bytes",
fileExtension = "pdf",
diff --git a/features/preferences/impl/src/main/res/values-de/translations.xml b/features/preferences/impl/src/main/res/values-de/translations.xml
index 7804663579..7d83685eda 100644
--- a/features/preferences/impl/src/main/res/values-de/translations.xml
+++ b/features/preferences/impl/src/main/res/values-de/translations.xml
@@ -8,6 +8,8 @@
"Benutzerdefinierte Element-Aufruf-Basis-URL"
"Lege eine eigene Basis-URL für Element Call fest."
"Ungültige URL, bitte stelle sicher, dass du das Protokoll (http/https) und die richtige Adresse angibst."
+ "Laden Sie Fotos und Videos schneller hoch und reduzieren Sie die Datennutzung"
+ "Optimieren Sie die Medienqualität"
"Anbieter für Push-Benachrichtigungen"
"Deaktiviere den Rich-Text-Editor, um Markdown manuell einzugeben."
"Lesebestätigungen"
diff --git a/features/preferences/impl/src/main/res/values-el/translations.xml b/features/preferences/impl/src/main/res/values-el/translations.xml
index 9af59929fc..7b388644a1 100644
--- a/features/preferences/impl/src/main/res/values-el/translations.xml
+++ b/features/preferences/impl/src/main/res/values-el/translations.xml
@@ -8,6 +8,8 @@
"Προσαρμοσμένο URL βάσης κλήσεων Element"
"Όρισε μια προσαρμοσμένη διεύθυνση βάσης URL για κλήση Element."
"Μη έγκυρη διεύθυνση URL, βεβαιώσου ότι έχεις συμπεριλάβει το πρωτόκολλο (http/https) και τη σωστή διεύθυνση."
+ "Ανέβασε φωτογραφίες και βίντεο γρηγορότερα και μείωσε τη χρήση δεδομένων"
+ "Βελτιστοποίηση ποιότητας των μέσων"
"Πάροχος ειδοποιήσεων push"
"Απενεργοποίησε τον επεξεργαστή εμπλουτισμένου κειμένου για να πληκτρολογήσεις Markdown χειροκίνητα."
"Αποδεικτικά ανάγνωσης"
diff --git a/features/preferences/impl/src/main/res/values-nl/translations.xml b/features/preferences/impl/src/main/res/values-nl/translations.xml
index c13f26a5d1..0b6419a2f0 100644
--- a/features/preferences/impl/src/main/res/values-nl/translations.xml
+++ b/features/preferences/impl/src/main/res/values-nl/translations.xml
@@ -1,5 +1,6 @@
+ "Pas je instellingen aan om meldingen op het volledige scherm toe te staan wanneer de telefoon is vergrendeld. Zo mis je nooit een belangrijk gesprek."
"Verbeter je gesprekservaring"
"Kies hoe je meldingen wilt ontvangen"
"Ontwikkelaarsmodus"
diff --git a/features/preferences/impl/src/main/res/values-uz/translations.xml b/features/preferences/impl/src/main/res/values-uz/translations.xml
index 21104e4ab8..e626620468 100644
--- a/features/preferences/impl/src/main/res/values-uz/translations.xml
+++ b/features/preferences/impl/src/main/res/values-uz/translations.xml
@@ -4,6 +4,7 @@
"Dasturchi rejimi"
"Ishlab chiquvchilar uchun xususiyatlar va funksiyalarga kirishni yoqing."
"Maxsus element qo‘ng‘iroqlar bazasi URL manzili"
+ "Element qo\'ng\'irog\'iga maxsus asosiy url or\'natish"
"Boy matn muharriri o\'chiring Markdown bilan qo\'lda yozish uchun"
"Blokdan chiqarish"
"Ulardan kelgan barcha xabarlarni yana koʻrishingiz mumkin boʻladi."
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
index 4030e30272..12cdbdcadf 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt
@@ -32,20 +32,17 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
-import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
-import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
-import io.element.android.libraries.mediaviewer.api.local.MediaInfo
-import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
+import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.parcelize.Parcelize
@@ -59,6 +56,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
private val messagesEntryPoint: MessagesEntryPoint,
+ private val mediaViewerEntryPoint: MediaViewerEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(),
@@ -202,22 +200,18 @@ class RoomDetailsFlowNode @AssistedInject constructor(
createNode(buildContext, plugins)
}
is NavTarget.AvatarPreview -> {
- // We need to fake the MimeType here for the viewer to work.
- val mimeType = MimeTypes.Images
- val input = MediaViewerNode.Inputs(
- mediaInfo = MediaInfo(
- filename = navTarget.name,
- caption = null,
- mimeType = mimeType,
- formattedFileSize = "",
- fileExtension = ""
- ),
- mediaSource = MediaSource(url = navTarget.avatarUrl),
- thumbnailSource = null,
- canDownload = false,
- canShare = false,
- )
- createNode(buildContext, listOf(input))
+ val callback = object : MediaViewerEntryPoint.Callback {
+ override fun onDone() {
+ overlay.hide()
+ }
+ }
+ mediaViewerEntryPoint.nodeBuilder(this, buildContext)
+ .avatar(
+ navTarget.name,
+ navTarget.avatarUrl,
+ )
+ .callback(callback)
+ .build()
}
is NavTarget.PollHistory -> {
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
index a9f64c4d06..a2468c32d0 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
@@ -39,7 +39,6 @@ import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -51,6 +50,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
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.roomlist.RoomList
+import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationCleaner
@@ -231,10 +231,7 @@ class RoomListPresenter @Inject constructor(
}
}
val needsSlidingSyncMigration by produceState(false) {
- value = runCatching {
- // Note: this can fail when the session is destroyed from another client.
- client.isNativeSlidingSyncSupported() && !client.isUsingNativeSlidingSync()
- }.getOrNull().orFalse()
+ value = client.needsSlidingSyncMigration().getOrDefault(false)
}
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed, needsSlidingSyncMigration)
return when {
@@ -315,6 +312,19 @@ class RoomListPresenter @Inject constructor(
}
}
+ /**
+ * Checks if the user needs to migrate to a native sliding sync version.
+ */
+ private suspend fun MatrixClient.needsSlidingSyncMigration(): Result = runCatching {
+ val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
+ if (currentSlidingSyncVersion != SlidingSyncVersion.Native) {
+ val availableSlidingSyncVersions = availableSlidingSyncVersions().getOrThrow()
+ availableSlidingSyncVersions.contains(SlidingSyncVersion.Native)
+ } else {
+ false
+ }
+ }
+
private var currentUpdateVisibleRangeJob: Job? = null
private fun CoroutineScope.updateVisibleRange(range: IntRange) {
currentUpdateVisibleRangeJob?.cancel()
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 8671c381b8..3b8b70c40f 100644
--- a/features/roomlist/impl/src/main/res/values-de/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-de/translations.xml
@@ -7,8 +7,10 @@
"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."
+ "Bestätigen Sie die Validität Ihres Wiederherstellungsschlüssels, um weiterhin auf Ihren Schlüsselspeicher und den Nachrichtenverlauf zugreifen zu können."
+ "Geben Sie Ihren Wiederherstellungsschlüssel ein"
+ "Haben Sie Ihren Wiederherstellungsschlüssel vergessen?"
+ "Ihr Schlüsselspeicher ist nicht synchronisiert"
"Damit du keinen wichtigen Anruf verpasst, ändere bitte deine Einstellungen so, dass du bei gesperrtem Telefon Benachrichtigungen im Vollbildmodus erhältst."
"Verbessere dein Anruferlebnis"
"Möchtest du die Einladung zum Betreten von %1$s wirklich ablehnen?"
@@ -17,6 +19,7 @@
"Einladung ablehnen"
"Keine Einladungen"
"%1$s (%2$s) hat dich eingeladen"
+ "Beitrittsanfrage geschickt"
"Dies ist ein einmaliger Vorgang, danke fürs Warten."
"Dein Konto wird eingerichtet."
"Eine Unterthaltung oder Raum erstellen"
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 7156603892..c1ac4c5945 100644
--- a/features/roomlist/impl/src/main/res/values-el/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-el/translations.xml
@@ -8,6 +8,8 @@
"Ρύθμιση ανάκτησης"
"Ρύθμιση ανάκτησης"
"Επιβεβαίωσε το κλειδί ανάκτησης για να διατηρήσεις την πρόσβαση στο χώρο αποθήκευσης κλειδιών και στο ιστορικό μηνυμάτων."
+ "Εισήγαγε το κλειδί ανάκτησης"
+ "Ξέχασες το κλειδί ανάκτησης;"
"Ο χώρος αποθήκευσης κλειδιών σου δεν είναι συγχρονισμένος"
"Για να διασφαλίσεις ότι δεν θα χάσεις ποτέ μια σημαντική κλήση, άλλαξε τις ρυθμίσεις σου για να επιτρέψεις τις ειδοποιήσεις πλήρους οθόνης όταν το τηλέφωνό σου είναι κλειδωμένο."
"Βελτίωσε την εμπειρία κλήσεων"
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 8b3c48f88b..d8ea5bc6f3 100644
--- a/features/roomlist/impl/src/main/res/values-nl/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-nl/translations.xml
@@ -7,6 +7,7 @@
"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"
+ "Pas je instellingen aan om meldingen op het volledige scherm toe te staan wanneer de telefoon is vergrendeld. Zo mis je nooit een belangrijk gesprek."
"Verbeter je gesprekservaring"
"Weet je zeker dat je de uitnodiging om toe te treden tot %1$s wilt weigeren?"
"Uitnodiging weigeren"
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 5d9aeec21e..241f132252 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
@@ -28,6 +28,7 @@ import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.appyx.canPop
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@@ -111,10 +112,10 @@ class SecureBackupFlowNode @AssistedInject constructor(
NavTarget.EnterRecoveryKey -> {
val callback = object : SecureBackupEnterRecoveryKeyNode.Callback {
override fun onEnterRecoveryKeySuccess() {
- if (callbacks.isNotEmpty()) {
- callbacks.forEach { it.onDone() }
- } else {
+ if (backstack.canPop()) {
backstack.pop()
+ } else {
+ callbacks.forEach { it.onDone() }
}
}
}
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 7201aaba3a..29e43e2e2f 100644
--- a/features/securebackup/impl/src/main/res/values-de/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-de/translations.xml
@@ -2,11 +2,15 @@
"Backup deaktivieren"
"Backup aktivieren"
- "Das Backup stellt sicher, dass du deinen Nachrichtenverlauf nicht verlierst. %1$s."
- "Backup"
+ "Speichern Sie Ihre verschlüsselte Identität und Ihre codierten Nachrichtenschlüssel auf dem Server. Auf diese Weise können Sie Ihren Nachrichtenverlauf auf allen neuen Geräten einsehen. %1$s."
+ "Schlüsselspeicher"
+ "Der Schlüsselspeicher muss aktiviert sein, um Datenwiederherstellung zu ermöglichen."
+ "Schlüssel von diesem Gerät hochladen"
+ "Schlüsselspeicherung zulassen"
"Wiederherstellungsschlüssel ändern"
+ "Stellen Sie Ihre verschlüsselte Identität und Ihren Nachrichtenverlauf mit einem Wiederherstellungsschlüssel wieder her, falls Sie den Zugang zu allen Ihren Geräten verloren haben."
"Wiederherstellungsschlüssel eingeben"
- "Dein Chat-Backup ist derzeit nicht synchronisiert."
+ "Dein Schlüssel ist derzeit nicht synchronisiert."
"Wiederherstellung einrichten"
"Erhalte Zugriff auf deine verschlüsselten Nachrichten, wenn du alle deine Geräte verlierst oder von %1$s überall abgemeldet bist."
@@ -33,6 +37,8 @@
"Deine Kontodaten, Kontakte, Einstellungen und die Liste der Chats bleiben erhalten"
"Du verlierst alle deine bisherigen Nachrichten sofern sie nicht auf einem anderen Gerät vorliegen"
"Du musst alle deine bestehenden Geräte und Kontakte erneut verifizieren."
+ "Setzen Sie Ihre Identität nur dann zurück, wenn Sie keinen Zugriff auf ein anderes Ihrer angemeldeten Geräte und auch Ihren Wiederherstellungsschlüssel verloren haben."
+ "Sie können es nicht bestätigen? Dann müssen Sie Ihre Identität zurücksetzen."
"Ausschalten"
"Du verlierst deine verschlüsselten Nachrichten, wenn du auf allen Geräten abgemeldet bist."
"Bist du sicher, dass du das Backup deaktivieren willst?"
@@ -42,7 +48,7 @@
"Bist du sicher, dass du das Backup deaktivieren willst?"
"Hier kannst Du einen neuen Wiederherstellungsschlüssel erstellen. Nachdem Du einen neuen Wiederherstellungsschlüssel erstellt hast, funktioniert dein alter Schlüssel nicht mehr."
"Wiederherstellungsschlüssel erstellen"
- "Stelle sicher, dass du deinen Wiederherstellungsschlüssel an einem sicheren Ort aufbewahren kannst"
+ "Geben Sie dies an niemanden weiter!"
"Wiederherstellungsschlüssel geändert"
"Wiederherstellungsschlüssel ändern?"
@@ -51,23 +57,24 @@
" erstellen"
"Sorge dafür, dass niemand diesen Bildschirm sehen kann!"
- "Bitte versuche es noch einmal, um den Zugriff auf dein Chat-Backup zu bestätigen."
+ "Bitte versuchen Sie erneut, den Zugriff auf Ihren Schlüsselspeicher zu bestätigen."
"Falscher Wiederherstellungsschlüssel"
"Dies funktioniert auch mit einem Sicherheitsschlüssel oder Sicherheitsphrase."
"Eingeben…"
"Hast du deinen Wiederherstellungschlüssel vergessen?"
"Wiederherstellungsschlüssel bestätigt"
+ "Geben Sie Ihren Wiederherstellungsschlüssel ein"
"Wiederherstellungsschlüssel kopiert"
"Generieren…"
"Wiederherstellungsschlüssel speichern"
- "Notiere dir deinen Wiederherstellungsschlüssel an einem sicheren Ort oder speichere ihn in einem Passwort-Manager."
+ "Schreiben Sie Ihren Wiederherstellungsschlüssel in eine verschlüsselte Datei, oder in einem Passwort-Manager oder in einem Safe. "
"Tippe, um den Wiederherstellungsschlüssel zu kopieren"
"Speichere deinen Wiederherstellungsschlüssel"
"Nach diesem Schritt kannst du nicht mehr auf deinen neuen Wiederherstellungsschlüssel zugreifen."
"Hast du deinen Wiederherstellungsschlüssel gespeichert?"
"Dein Chat-Backup ist durch einen Wiederherstellungsschlüssel geschützt. Wenn du nach der Einrichtung einen neuen Wiederherstellungsschlüssel brauchst, kannst du ihn über die Option \"Wiederherstellungsschlüssel ändern\" neu erstellen."
"Wiederherstellungsschlüssel erstellen"
- "Stelle sicher, dass du deinen Wiederherstellungsschlüssel an einem sicheren Ort aufbewahren kannst"
+ "Geben Sie dies an niemanden weiter!"
"Einrichtung der Wiederherstellung erfolgreich"
"Wiederherstellung einrichten"
"Ja, zurücksetzen"
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 ef98e8ee37..e31621c5f5 100644
--- a/features/securebackup/impl/src/main/res/values-el/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-el/translations.xml
@@ -4,7 +4,11 @@
"Ενεργοποίηση αντιγράφων ασφαλείας"
"Αποθήκευσε την κρυπτογραφική σου ταυτότητα και τα κλειδιά μηνυμάτων με ασφάλεια στον διακομιστή. Αυτό θα σου επιτρέψει να δεις το ιστορικό μηνυμάτων σου σε οποιεσδήποτε νέες συσκευές. %1$s."
"Χώρος αποθήκευσης κλειδιού"
+ "Η αποθήκευση κλειδιών πρέπει να είναι ενεργοποιημένη για να ρυθμίσεις την ανάκτηση."
+ "Μεταφόρτωση κλειδιών από αυτήν τη συσκευή"
+ "Να επιτρέπεται η αποθήκευση κλειδιών"
"Αλλαγή κλειδιού ανάκτησης"
+ "Ανάκτησε την κρυπτογραφική σου ταυτότητα και το ιστορικό μηνυμάτων με ένα κλειδί ανάκτησης εάν έχεις χάσει όλες τις υπάρχουσες συσκευές σου."
"Εισαγωγή κλειδιού ανάκτησης"
"Ο αποθηκευτικός χώρος κλειδιών σου δεν είναι συγχρονισμένος αυτήν τη στιγμή."
"Ρύθμιση ανάκτησης"
@@ -42,6 +46,7 @@
"Εισαγωγή…"
"Έχασες το κλειδί ανάκτησης;"
"Επιβεβαιώθηκε το κλειδί ανάκτησης"
+ "Εισήγαγε το κλειδί ανάκτησης"
"Αντιγράφηκε το κλειδί ανάκτησης"
"Δημιουργία…"
"Αποθήκευση κλειδιού ανάκτησης"
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 f353a80d7c..c78c7ad85a 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
@@ -3,7 +3,7 @@
"Desativar o backup"
"Ativar o backup"
"O backup garante que você não perca seu histórico de mensagens. %1$s."
- "Backup"
+ "Armazenamento de chaves"
"Alterar chave de recuperação"
"Insira a chave de recuperação"
"Seu backup das conversas está atualmente fora de sincronia."
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 4bd702a339..988a301d60 100644
--- a/features/securebackup/impl/src/main/res/values-sv/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-sv/translations.xml
@@ -2,11 +2,11 @@
"Stäng av säkerhetskopiering"
"Slå på säkerhetskopiering"
- "Säkerhetskopior ser till att du inte blir av med din meddelandehistorik. %1$s."
+ "Lagra din kryptografiska identitet och dina meddelandenycklar säkert på servern. Detta gör att du kan se din meddelandehistorik på alla nya enheter. %1$s."
"Nyckellagring"
"Byt återställningsnyckel"
"Ange återställningsnyckel"
- "Din chattsäkerhetskopia är för närvarande osynkroniserad."
+ "Din nyckellagring är för närvarande osynkroniserad."
"Ställ in återställning"
"Få tillgång till dina krypterade meddelanden om du tappar bort alla dina enheter eller blir utloggad ur %1$s överallt."
"Öppna %1$s på en skrivbordsenhet"
@@ -31,7 +31,7 @@
"Är du säker på att du vill stänga av säkerhetskopiering?"
"Få en ny återställningsnyckel om du har tappat bort din befintliga. När du har bytt din återställningsnyckel fungerar din gamla inte längre."
"Generera en ny återställningsnyckel"
- "Se till att du kan lagra din återställningsnyckel någonstans säkert"
+ "Dela inte detta med någon!"
"Återställningsnyckel ändrad"
"Byt återställningsnyckel?"
"Skapa ny återställningsnyckel"
@@ -52,7 +52,7 @@
"Har du sparat din återställningsnyckel?"
"Din chattsäkerhetskopia skyddas av en återställningsnyckel. Om du behöver en ny återställningsnyckel efter installationen kan du återskapa genom att välja ”Byt återställningsnyckel”."
"Generera din återställningsnyckel"
- "Se till att du kan lagra din återställningsnyckel någonstans säkert"
+ "Dela inte detta med någon!"
"Konfiguration av återställning lyckades"
"Ställ in återställning"
"Ja, återställ nu"
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 10d42716a9..e59e2d74c8 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
@@ -116,7 +116,7 @@ class SharePresenterTest {
@Test
fun `present - send media ok`() = runTest {
- val sendFileResult = lambdaRecorder> { _, _, _ ->
+ val sendFileResult = lambdaRecorder> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val matrixRoom = FakeMatrixRoom(
diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt
index b544ad4750..ce0d4a07f0 100644
--- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt
+++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt
@@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@@ -24,18 +25,14 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.impl.root.UserProfileNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
-import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
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.architecture.inputs
-import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
-import io.element.android.libraries.mediaviewer.api.local.MediaInfo
-import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
+import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@@ -44,6 +41,7 @@ class UserProfileFlowNode @AssistedInject constructor(
@Assisted plugins: List,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val sessionIdHolder: CurrentSessionIdHolder,
+ private val mediaViewerEntryPoint: MediaViewerEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -80,22 +78,18 @@ class UserProfileFlowNode @AssistedInject constructor(
createNode(buildContext, listOf(callback, params))
}
is NavTarget.AvatarPreview -> {
- // We need to fake the MimeType here for the viewer to work.
- val mimeType = MimeTypes.Images
- val input = MediaViewerNode.Inputs(
- mediaInfo = MediaInfo(
+ val callback = object : MediaViewerEntryPoint.Callback {
+ override fun onDone() {
+ backstack.pop()
+ }
+ }
+ mediaViewerEntryPoint.nodeBuilder(this, buildContext)
+ .avatar(
filename = navTarget.name,
- caption = null,
- mimeType = mimeType,
- formattedFileSize = "",
- fileExtension = "",
- ),
- mediaSource = MediaSource(url = navTarget.avatarUrl),
- thumbnailSource = null,
- canDownload = false,
- canShare = false,
- )
- createNode(buildContext, listOf(input))
+ avatarUrl = navTarget.avatarUrl
+ )
+ .callback(callback)
+ .build()
}
}
}
diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/avatar/AvatarPreviewNode.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/avatar/AvatarPreviewNode.kt
deleted file mode 100644
index 159b7f56f6..0000000000
--- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/avatar/AvatarPreviewNode.kt
+++ /dev/null
@@ -1,24 +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.userprofile.shared.avatar
-
-import com.bumble.appyx.core.modality.BuildContext
-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
-import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
-import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerPresenter
-
-@ContributesNode(SessionScope::class)
-class AvatarPreviewNode @AssistedInject constructor(
- @Assisted buildContext: BuildContext,
- @Assisted plugins: List,
- presenterFactory: MediaViewerPresenter.Factory,
-) : MediaViewerNode(buildContext, plugins, presenterFactory)
diff --git a/features/userprofile/shared/src/main/res/values-de/translations.xml b/features/userprofile/shared/src/main/res/values-de/translations.xml
index ff20101939..f96af58bae 100644
--- a/features/userprofile/shared/src/main/res/values-de/translations.xml
+++ b/features/userprofile/shared/src/main/res/values-de/translations.xml
@@ -13,5 +13,7 @@
"Blockierung aufheben"
"Der Nutzer kann dir wieder Nachrichten senden & alle Nachrichten des Nutzers werden wieder angezeigt."
"Blockierung aufheben"
+ "Verwenden Sie die Web-App, um diesen Benutzer zu verifizieren."
+ "Überprüfen Sie %1$s"
"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"
diff --git a/features/verifysession/impl/src/main/res/values-de/translations.xml b/features/verifysession/impl/src/main/res/values-de/translations.xml
index 1ddda0126b..4534411033 100644
--- a/features/verifysession/impl/src/main/res/values-de/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-de/translations.xml
@@ -1,5 +1,6 @@
+ "Sie können es nicht bestätigen?"
"Erstelle einen neuen Wiederherstellungsschlüssel"
"Verifiziere dieses Gerät, um sicheres Messaging einzurichten."
"Bestätige, dass du es bist"
@@ -16,6 +17,7 @@
"Vergleiche die Zahlen"
"Deine neue Session ist nun verifiziert. Sie hat Zugriff auf deine verschlüsselten Nachrichten und wird von anderen Benutzern als vertrauenswürdig eingestuft."
"Wiederherstellungsschlüssel eingeben"
+ "Entweder ist bei der Anfrage ein Timeout aufgetreten, oder die Anfrage wurde abgelehnt, oder es gab eine Nichtübereinstimmung bei der Überprüfung."
"Beweise deine Identität, um auf deinen verschlüsselten Nachrichtenverlauf zuzugreifen."
"Öffne eine bestehende Session"
"Verifizierung wiederholen"
@@ -23,8 +25,20 @@
"Warten auf eine Übereinstimmung"
"Vergleiche eine spezielle Reihe von Emojis."
"Vergleiche die einzelnen Emojis und stelle sicher, dass sie in der gleichen Reihenfolge erscheinen."
+ "Angemeldet"
+ "Entweder ist bei der Anfrage ein Timeout aufgetreten, oder die Anfrage wurde abgelehnt, oder es gab eine Nichtübereinstimmung bei der Überprüfung."
+ "Überprüfung fehlgeschlagen"
+ "Fahren Sie nur fort, falls Sie für diese Überprüfung verantwortlich sind.."
+ "Verifizieren Sie das andere Gerät, um die Sicherheit Ihres Nachrichtenverlaufs zu gewährleisten."
+ "Jetzt können Sie gesichert Nachrichten auf Ihrem anderen Gerät lesen oder senden."
+ "Gerät verifiziert"
+ "Verifizierung angefordert"
"Sie stimmen nicht überein"
"Sie stimmen überein"
+ "Stellen Sie sicher, dass die App auf dem anderen Gerät geöffnet ist, bevor Sie die Überprüfung auf diesem Gerät aus starten."
+ "Öffnen Sie die App auf einem anderen verifizierten Gerät"
+ "Sie sollten ein Popup-Fenster auf dem anderen Gerät sehen. Starten Sie die Überprüfung von dort aus."
+ "Starten Sie die Überprüfung auf dem anderen Gerät"
"Akzeptiere die Anfrage, um den Verifizierungsprozess in deiner anderen Session zu starten, um fortzufahren."
"Warten auf die Annahme der Anfrage"
"Abmelden…"
diff --git a/features/verifysession/impl/src/main/res/values-el/translations.xml b/features/verifysession/impl/src/main/res/values-el/translations.xml
index 4da4315796..1cac842a7b 100644
--- a/features/verifysession/impl/src/main/res/values-el/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-el/translations.xml
@@ -27,11 +27,18 @@
"Σύγκρινε τα μοναδικά emoji και σιγουρέψου ότι εμφανίζονται με την ίδια σειρά."
"Έχεις συνδεθεί"
"Είτε το αίτημα έληξε είτε απορρίφθηκε είτε υπήρξε αναντιστοιχία επαλήθευσης."
+ "Αποτυχία επαλήθευσης"
"Συνέχισε μόνο εάν ξεκίνησες εσύ αυτήν την επαλήθευση."
"Επαλήθευσε την άλλη συσκευή για να διατηρήσεις το ιστορικό μηνυμάτων σου ασφαλές."
+ "Τώρα μπορείς να διαβάσεις ή να στείλεις μηνύματα με ασφάλεια στην άλλη συσκευή σου."
+ "Η συσκευή επαληθεύτηκε"
"Ζητήθηκε επαλήθευση"
"Δεν ταιριάζουν"
"Ταιριάζουν"
+ "Βεβαιώσου ότι έχεις ανοιχτή την εφαρμογή στην άλλη συσκευή πριν ξεκινήσεις την επαλήθευση από εδώ."
+ "Άνοιξε την εφαρμογή σε άλλη επαληθευμένη συσκευή"
+ "Πρόκειται να δεις ένα αναδυόμενο παράθυρο στην άλλη συσκευή. Ξεκίνα την επαλήθευση από εκεί τώρα."
+ "Έναρξη επαλήθευσης στην άλλη συσκευή"
"Αποδέξου το αίτημα για να ξεκινήσεις τη διαδικασία επαλήθευσης στην άλλη συνεδρία σου για να συνεχίσεις."
"Αναμονή για αποδοχή αιτήματος"
"Αποσύνδεση…"
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 2ee77b1f7d..46c24d253e 100644
--- a/features/verifysession/impl/src/main/res/values-pl/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-pl/translations.xml
@@ -17,6 +17,7 @@
"Porównaj liczby"
"Twoja nowa sesja jest teraz zweryfikowana. Ma ona dostęp do Twoich zaszyfrowanych wiadomości, a inni użytkownicy będą widzieć ją jako zaufaną."
"Wprowadź klucz przywracania"
+ "Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji."
"Udowodnij, że to ty, aby uzyskać dostęp do historii zaszyfrowanych wiadomości."
"Otwórz istniejącą sesję"
"Ponów weryfikację"
@@ -25,9 +26,11 @@
"Porównaj unikalny zestaw emoji."
"Porównaj unikalne emoji, upewniając się, że pojawiły się w tej samej kolejności."
"Zalogowano"
+ "Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji."
"Weryfikacja nie powiodła się"
"Kontynuuj tylko, jeśli to Ty zainicjowałeś tę weryfikację."
"Zweryfikuj drugie urządzenie, aby zabezpieczyć historię wiadomości."
+ "Już możesz bezpiecznie czytać lub wysyłać wiadomości na drugim urządzeniu."
"Urządzenie zweryfikowane"
"Zażądano weryfikacji"
"Nie pasują do siebie"
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 11c8d3d749..748a62bd88 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -21,7 +21,7 @@ constraintlayout = "2.2.0"
constraintlayout_compose = "1.1.0"
lifecycle = "2.8.7"
activity = "1.9.3"
-media3 = "1.4.1"
+media3 = "1.5.0"
camera = "1.4.0"
# Compose
@@ -46,14 +46,14 @@ coil = "2.7.0"
showkase = "1.0.3"
appyx = "1.5.1"
sqldelight = "2.0.2"
-wysiwyg = "2.37.13"
+wysiwyg = "2.37.14"
telephoto = "0.14.0"
# Dependency analysis
-dependencyAnalysis = "2.4.2"
+dependencyAnalysis = "2.5.0"
# DI
-dagger = "2.52"
+dagger = "2.53"
anvil = "0.4.0"
# Auto service
@@ -150,11 +150,11 @@ test_arch_core = "androidx.arch.core:core-testing:2.2.0"
test_junit = "junit:junit:4.13.2"
test_runner = "androidx.test:runner:1.6.2"
test_mockk = "io.mockk:mockk:1.13.13"
-test_konsist = "com.lemonappdev:konsist:0.16.1"
+test_konsist = "com.lemonappdev:konsist:0.17.1"
test_turbine = "app.cash.turbine:turbine:1.2.0"
test_truth = "com.google.truth:truth:1.4.4"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.18"
-test_robolectric = "org.robolectric:robolectric:4.14"
+test_robolectric = "org.robolectric:robolectric:4.14.1"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
test_composable_preview_scanner = "com.github.sergio-sastre.ComposablePreviewScanner:android:0.1.2"
@@ -163,7 +163,7 @@ coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" }
-compound = { module = "io.element.android:compound-android", version = "0.1.1" }
+compound = { module = "io.element.android:compound-android", version = "0.2.0" }
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.8"
@@ -173,7 +173,7 @@ 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.63"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.69"
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" }
@@ -195,13 +195,13 @@ zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
# Analytics
posthog = "com.posthog:posthog-android:3.9.2"
-sentry = "io.sentry:sentry-android:7.17.0"
+sentry = "io.sentry:sentry-android:7.18.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"
# Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.3.3"
-sigpwned_emoji4j = "com.sigpwned:emoji4j-core:15.1.2"
+sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0"
# Di
inject = "javax.inject:javax.inject:1"
@@ -233,14 +233,14 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
anvil = { id = "dev.zacsweers.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.7"
-ktlint = "org.jlleitschuh.gradle.ktlint:12.1.1"
+ktlint = "org.jlleitschuh.gradle.ktlint:12.1.2"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
-dependencycheck = "org.owasp.dependencycheck:11.1.0"
+dependencycheck = "org.owasp.dependencycheck:11.1.1"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
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" }
-sonarqube = "org.sonarqube:5.1.0.4882"
+sonarqube = "org.sonarqube:6.0.1.5171"
licensee = "app.cash.licensee:1.12.0"
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/libraries/dateformatter/api/build.gradle.kts b/libraries/dateformatter/api/build.gradle.kts
index 1aabe2a563..6ec28b9eb9 100644
--- a/libraries/dateformatter/api/build.gradle.kts
+++ b/libraries/dateformatter/api/build.gradle.kts
@@ -11,4 +11,9 @@ plugins {
android {
namespace = "io.element.android.libraries.dateformatter.api"
+
+ dependencies {
+ testImplementation(libs.test.junit)
+ testImplementation(libs.test.truth)
+ }
}
diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt
new file mode 100644
index 0000000000..7f8473b416
--- /dev/null
+++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.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.libraries.dateformatter.api
+
+import java.util.Locale
+
+/**
+ * Convert milliseconds to human readable duration.
+ * Hours in 1 digit or more.
+ * Minutes in 2 digits when hours are available.
+ * Seconds always on 2 digits.
+ * Example:
+ * - when the duration is longer than 1 hour:
+ * - "10:23:34"
+ * - "1:23:34"
+ * - "1:03:04"
+ * - when the duration is shorter:
+ * - "4:56"
+ * - "14:06"
+ * - Less than one minute:
+ * - "0:00"
+ * - "0:01"
+ * - "0:59"
+ */
+fun Long.toHumanReadableDuration(): String {
+ val inSeconds = this / 1_000
+ val hours = inSeconds / 3_600
+ val minutes = inSeconds % 3_600 / 60
+ val seconds = inSeconds % 60
+ return if (hours > 0) {
+ String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds)
+ } else {
+ String.format(Locale.US, "%d:%02d", minutes, seconds)
+ }
+}
diff --git a/libraries/dateformatter/api/src/test/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatterTest.kt b/libraries/dateformatter/api/src/test/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatterTest.kt
new file mode 100644
index 0000000000..1b8c155f9c
--- /dev/null
+++ b/libraries/dateformatter/api/src/test/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatterTest.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.dateformatter.api
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class DurationFormatterTest {
+ @Test
+ fun `format seconds only`() {
+ assertThat(buildDuration().toHumanReadableDuration()).isEqualTo("0:00")
+ assertThat(buildDuration(seconds = 1).toHumanReadableDuration()).isEqualTo("0:01")
+ assertThat(buildDuration(seconds = 59).toHumanReadableDuration()).isEqualTo("0:59")
+ }
+
+ @Test
+ fun `format minutes and seconds`() {
+ assertThat(buildDuration(minutes = 1).toHumanReadableDuration()).isEqualTo("1:00")
+ assertThat(buildDuration(minutes = 1, seconds = 30).toHumanReadableDuration()).isEqualTo("1:30")
+ assertThat(buildDuration(minutes = 59, seconds = 59).toHumanReadableDuration()).isEqualTo("59:59")
+ }
+
+ @Test
+ fun `format hours, minutes and seconds`() {
+ assertThat(buildDuration(hours = 1).toHumanReadableDuration()).isEqualTo("1:00:00")
+ assertThat(buildDuration(hours = 1, minutes = 1, seconds = 1).toHumanReadableDuration()).isEqualTo("1:01:01")
+ assertThat(buildDuration(hours = 24, minutes = 59, seconds = 59).toHumanReadableDuration()).isEqualTo("24:59:59")
+ assertThat(buildDuration(hours = 25, minutes = 0, seconds = 0).toHumanReadableDuration()).isEqualTo("25:00:00")
+ }
+
+ private fun buildDuration(
+ hours: Int = 0,
+ minutes: Int = 0,
+ seconds: Int = 0
+ ): Long {
+ return (hours * 60 * 60 + minutes * 60 + seconds) * 1000L
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt
index b5edbb2d65..5c0095b7b6 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt
@@ -5,19 +5,32 @@
* Please see LICENSE in the repository root for full details.
*/
+@file:OptIn(ExperimentalMaterial3Api::class)
+
package io.element.android.libraries.designsystem.theme.components
+import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SliderColors
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@@ -32,8 +45,20 @@ fun Slider(
steps: Int = 0,
onValueChangeFinish: (() -> Unit)? = null,
colors: SliderColors = SliderDefaults.colors(),
- interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ useCustomLayout: Boolean = false,
) {
+ val thumbColor = ElementTheme.colors.iconOnSolidPrimary
+ var isUserInteracting by remember { mutableStateOf(false) }
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect { interaction ->
+ isUserInteracting = when (interaction) {
+ is DragInteraction.Start,
+ is PressInteraction.Press -> true
+ else -> false
+ }
+ }
+ }
androidx.compose.material3.Slider(
value = value,
onValueChange = onValueChange,
@@ -43,6 +68,54 @@ fun Slider(
steps = steps,
onValueChangeFinished = onValueChangeFinish,
colors = colors,
+ thumb = {
+ if (useCustomLayout) {
+ SliderDefaults.Thumb(
+ modifier = Modifier.drawWithContent {
+ drawContent()
+ if (isUserInteracting.not()) {
+ drawCircle(thumbColor, radius = 8.dp.toPx())
+ }
+ },
+ interactionSource = interactionSource,
+ colors = colors.copy(
+ thumbColor = ElementTheme.colors.iconPrimary,
+ ),
+ enabled = enabled,
+ thumbSize = DpSize(
+ if (isUserInteracting) 44.dp else 22.dp,
+ 22.dp,
+ ),
+ )
+ } else {
+ SliderDefaults.Thumb(
+ interactionSource = interactionSource,
+ colors = colors,
+ enabled = enabled
+ )
+ }
+ },
+ track = { sliderState ->
+ if (useCustomLayout) {
+ SliderDefaults.Track(
+ modifier = Modifier.height(8.dp),
+ colors = colors.copy(
+ activeTrackColor = Color(0x66E0EDFF),
+ inactiveTrackColor = Color(0x66E0EDFF),
+ ),
+ enabled = enabled,
+ sliderState = sliderState,
+ thumbTrackGapSize = 0.dp,
+ drawStopIndicator = { },
+ )
+ } else {
+ SliderDefaults.Track(
+ colors = colors,
+ enabled = enabled,
+ sliderState = sliderState,
+ )
+ }
+ },
interactionSource = interactionSource,
)
}
@@ -55,5 +128,6 @@ internal fun SlidersPreview() = ElementThemedPreview {
Slider(onValueChange = { value = it }, value = value, enabled = true)
Slider(steps = 10, onValueChange = { value = it }, value = value, enabled = true)
Slider(onValueChange = { value = it }, value = value, enabled = false)
+ Slider(onValueChange = { value = it }, value = value, enabled = true, useCustomLayout = true)
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/HideKeyboardWhenDisposed.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/HideKeyboardWhenDisposed.kt
new file mode 100644
index 0000000000..0e18528605
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/HideKeyboardWhenDisposed.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.libraries.designsystem.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+
+@Composable
+fun HideKeyboardWhenDisposed() {
+ val keyboardController = LocalSoftwareKeyboardController.current
+ DisposableEffect(Unit) {
+ onDispose {
+ keyboardController?.hide()
+ }
+ }
+}
diff --git a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml
index 1b99fd2e5d..8c99ba44de 100644
--- a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml
@@ -28,7 +28,7 @@
"%1$s hat dich eingeladen"
"%1$s hat den Raum betreten"
"Du hast den Raum betreten"
- "%1$s hat angefragt beizutreten"
+ "%1$s beantragt den Beitritt"
"%1$s hat %2$s den Beitritt erlaubt"
"Du hast %1$s den Beitritt erlaubt."
"Du hast angefragt beizutreten"
diff --git a/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml b/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml
index d96f722111..6b31a6f47d 100644
--- a/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml
@@ -28,7 +28,7 @@
"%1$s bjöd in dig"
"%1$s gick med i rummet"
"Du gick med i rummet"
- "%1$s begärde att gå med"
+ "%1$s begär att gå med"
"%1$s tillät %2$s att gå med"
"Du lät %1$s att gå med"
"Du begärde att gå med"
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 490c12ebe0..7d21ed1138 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
@@ -140,4 +140,18 @@ enum class FeatureFlags(
defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
isFinished = false,
),
+ MediaCaptionCreation(
+ key = "feature.media_caption_creation",
+ title = "Allow creation of media captions",
+ description = null,
+ defaultValue = { true },
+ isFinished = false,
+ ),
+ MediaCaptionWarning(
+ key = "feature.media_caption_creation_warning",
+ title = "Show a compatibility warning on media captions creation",
+ description = null,
+ defaultValue = { true },
+ 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 38b0edac82..db7b5f8f11 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
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -145,14 +146,15 @@ interface MatrixClient : Closeable {
suspend fun getUrl(url: String): Result
suspend fun getRoomPreviewInfo(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result
- /** Returns `true` if the home server supports native sliding sync. */
- suspend fun isNativeSlidingSyncSupported(): Boolean
+ /**
+ * Returns the currently used sliding sync version.
+ */
+ suspend fun currentSlidingSyncVersion(): Result
- /** Returns `true` if the home server supports sliding sync using a proxy. */
- suspend fun isSlidingSyncProxySupported(): Boolean
-
- /** Returns `true` if the current session is using native sliding sync, `false` if it's using a proxy. */
- fun isUsingNativeSlidingSync(): Boolean
+ /**
+ * Returns the available sliding sync versions for the current user.
+ */
+ suspend fun availableSlidingSyncVersions(): Result>
fun canDeactivateAccount(): Boolean
suspend fun deactivateAccount(password: String, eraseData: Boolean): Result
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 8dd1c5ec5f..989c301e92 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
@@ -147,9 +147,21 @@ interface MatrixRoom : Closeable {
progressCallback: ProgressCallback?
): Result
- suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result
+ suspend fun sendAudio(
+ file: File,
+ audioInfo: AudioInfo,
+ caption: String?,
+ formattedCaption: String?,
+ progressCallback: ProgressCallback?,
+ ): Result
- suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result
+ suspend fun sendFile(
+ file: File,
+ fileInfo: FileInfo,
+ caption: String?,
+ formattedCaption: String?,
+ progressCallback: ProgressCallback?,
+ ): Result
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SlidingSyncVersion.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SlidingSyncVersion.kt
new file mode 100644
index 0000000000..119c5af194
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SlidingSyncVersion.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.libraries.matrix.api.sync
+
+sealed interface SlidingSyncVersion {
+ data object None : SlidingSyncVersion
+ data object Proxy : SlidingSyncVersion
+ data object Native : SlidingSyncVersion
+}
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 695fe906c5..00f7a9a17c 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
@@ -59,10 +59,17 @@ interface Timeline : AutoCloseable {
suspend fun editMessage(
eventOrTransactionId: EventOrTransactionId,
- body: String, htmlBody: String?,
+ body: String,
+ htmlBody: String?,
intentionalMentions: List,
): Result
+ suspend fun editCaption(
+ eventOrTransactionId: EventOrTransactionId,
+ caption: String?,
+ formattedCaption: String?,
+ ): Result
+
suspend fun replyMessage(
eventId: EventId,
body: String,
@@ -91,9 +98,21 @@ interface Timeline : AutoCloseable {
suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result
- suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result
+ suspend fun sendAudio(
+ file: File,
+ audioInfo: AudioInfo,
+ caption: String?,
+ formattedCaption: String?,
+ progressCallback: ProgressCallback?,
+ ): Result
- suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result
+ suspend fun sendFile(
+ file: File,
+ fileInfo: FileInfo,
+ caption: String?,
+ formattedCaption: String?,
+ progressCallback: ProgressCallback?,
+ ): Result
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/UtdCause.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/UtdCause.kt
index c89b4169c6..51427c6cba 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/UtdCause.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/UtdCause.kt
@@ -12,5 +12,22 @@ enum class UtdCause {
SentBeforeWeJoined,
VerificationViolation,
UnsignedDevice,
- UnknownDevice
+ UnknownDevice,
+
+ /**
+ * Expected utd because this is a device-historical message and
+ * key storage is not setup or not configured correctly.
+ */
+ HistoricalMessage,
+
+ /**
+ * The key was withheld on purpose because your device is insecure and/or the
+ * sender trust requirement settings are not met for your device.
+ */
+ WithheldUnverifiedOrInsecureDevice,
+
+ /**
+ * Key is withheld by sender.
+ */
+ WithheldBySender,
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt
index ec80d320c5..8516401350 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt
@@ -57,6 +57,7 @@ enum class Target(open val filter: String) {
MATRIX_SDK_HTTP_CLIENT("matrix_sdk::http_client"),
MATRIX_SDK_CLIENT("matrix_sdk::client"),
MATRIX_SDK_OIDC("matrix_sdk::oidc"),
+ MATRIX_SDK_SEND_QUEUE("matrix_sdk::send_queue"),
MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"),
MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"),
MATRIX_SDK_UI_TIMELINE("matrix_sdk_ui::timeline"),
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 3c3498da48..3bfb9e1efd 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.core.data.tryOrNull
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
@@ -41,6 +42,7 @@ import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
@@ -62,6 +64,7 @@ import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryS
import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory
import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
import io.element.android.libraries.matrix.impl.sync.RustSyncService
+import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.util.SessionPathsProvider
@@ -100,7 +103,6 @@ import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
-import org.matrix.rustcomponents.sdk.SlidingSyncVersion
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
@@ -116,44 +118,44 @@ import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
class RustMatrixClient(
- private val client: Client,
+ private val innerClient: Client,
private val baseDirectory: File,
private val sessionStore: SessionStore,
private val appCoroutineScope: CoroutineScope,
private val sessionDelegate: RustClientSessionDelegate,
- syncService: ClientSyncService,
+ innerSyncService: ClientSyncService,
dispatchers: CoroutineDispatchers,
baseCacheDirectory: File,
clock: SystemClock,
timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
featureFlagService: FeatureFlagService,
) : MatrixClient {
- override val sessionId: UserId = UserId(client.userId())
- override val deviceId: DeviceId = DeviceId(client.deviceId())
+ override val sessionId: UserId = UserId(innerClient.userId())
+ override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
-
- private val innerRoomListService = syncService.roomListService()
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
- private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope)
+ private val innerRoomListService = innerSyncService.roomListService()
+
+ private val rustSyncService = RustSyncService(innerSyncService, sessionCoroutineScope)
private val pushersService = RustPushersService(
- client = client,
+ client = innerClient,
dispatchers = dispatchers,
)
- private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(syncService)
- private val notificationClient = runBlocking { client.notificationClient(notificationProcessSetup) }
- private val notificationService = RustNotificationService(notificationClient, dispatchers, clock)
- private val notificationSettingsService = RustNotificationSettingsService(client, dispatchers)
+ private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(innerSyncService)
+ private val innerNotificationClient = runBlocking { innerClient.notificationClient(notificationProcessSetup) }
+ private val notificationService = RustNotificationService(innerNotificationClient, dispatchers, clock)
+ private val notificationSettingsService = RustNotificationSettingsService(innerClient, dispatchers)
.apply { start() }
private val encryptionService = RustEncryptionService(
- client = client,
+ client = innerClient,
syncService = rustSyncService,
sessionCoroutineScope = sessionCoroutineScope,
dispatchers = dispatchers,
)
private val roomDirectoryService = RustRoomDirectoryService(
- client = client,
+ client = innerClient,
sessionDispatcher = sessionDispatcher,
)
@@ -173,11 +175,12 @@ class RustMatrixClient(
)
private val verificationService = RustSessionVerificationService(
- client = client,
+ client = innerClient,
isSyncServiceReady = rustSyncService.syncState.map { it == SyncState.Running },
sessionCoroutineScope = sessionCoroutineScope,
)
+ private val roomMembershipObserver = RoomMembershipObserver()
private val roomFactory = RustRoomFactory(
roomListService = roomListService,
innerRoomListService = innerRoomListService,
@@ -191,31 +194,30 @@ class RustMatrixClient(
roomSyncSubscriber = roomSyncSubscriber,
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
featureFlagService = featureFlagService,
+ roomMembershipObserver = roomMembershipObserver,
)
override val mediaLoader: MatrixMediaLoader = RustMediaLoader(
baseCacheDirectory = baseCacheDirectory,
dispatchers = dispatchers,
- innerClient = client,
+ innerClient = innerClient,
)
- private val roomMembershipObserver = RoomMembershipObserver()
-
- private var clientDelegateTaskHandle: TaskHandle? = client.setDelegate(sessionDelegate)
+ private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
private val _userProfile: MutableStateFlow = MutableStateFlow(
MatrixUser(
userId = sessionId,
// TODO cache for displayName?
displayName = null,
- avatarUrl = client.cachedAvatarUrl(),
+ avatarUrl = innerClient.cachedAvatarUrl(),
)
)
override val userProfile: StateFlow = _userProfile
override val ignoredUsersFlow = mxCallbackFlow> {
- client.subscribeToIgnoredUsers(object : IgnoredUsersListener {
+ innerClient.subscribeToIgnoredUsers(object : IgnoredUsersListener {
override fun call(ignoredUserIds: List) {
channel.trySend(ignoredUserIds.map(::UserId).toPersistentList())
}
@@ -236,7 +238,7 @@ class RustMatrixClient(
override fun userIdServerName(): String {
return runCatching {
- client.userIdServerName()
+ innerClient.userIdServerName()
}
.onFailure {
Timber.w(it, "Failed to get userIdServerName")
@@ -247,7 +249,7 @@ class RustMatrixClient(
override suspend fun getUrl(url: String): Result = withContext(sessionDispatcher) {
runCatching {
- client.getUrl(url)
+ innerClient.getUrl(url)
}
}
@@ -277,23 +279,23 @@ class RustMatrixClient(
.filter { roomSummary -> roomSummary.info.currentUserMembership == currentUserMembership }
.first()
// Ensure that the room is ready
- .also { client.awaitRoomRemoteEcho(it.roomId.value) }
+ .also { innerClient.awaitRoomRemoteEcho(it.roomId.value) }
}
}
override suspend fun findDM(userId: UserId): RoomId? {
- return client.getDmRoom(userId.value)?.use { RoomId(it.id()) }
+ return innerClient.getDmRoom(userId.value)?.use { RoomId(it.id()) }
}
override suspend fun ignoreUser(userId: UserId): Result = withContext(sessionDispatcher) {
runCatching {
- client.ignoreUser(userId.value)
+ innerClient.ignoreUser(userId.value)
}
}
override suspend fun unignoreUser(userId: UserId): Result = withContext(sessionDispatcher) {
runCatching {
- client.unignoreUser(userId.value)
+ innerClient.unignoreUser(userId.value)
}
}
@@ -336,7 +338,7 @@ class RustMatrixClient(
},
canonicalAlias = createRoomParams.roomAliasName.getOrNull(),
)
- val roomId = RoomId(client.createRoom(rustParams))
+ val roomId = RoomId(innerClient.createRoom(rustParams))
// Wait to receive the room back from the sync but do not returns failure if it fails.
try {
awaitRoom(roomId.toRoomIdOrAlias(), 30.seconds, CurrentUserMembership.JOINED)
@@ -361,7 +363,7 @@ class RustMatrixClient(
override suspend fun getProfile(userId: UserId): Result = withContext(sessionDispatcher) {
runCatching {
- client.getProfile(userId.value).let(UserProfileMapper::map)
+ innerClient.getProfile(userId.value).let(UserProfileMapper::map)
}
}
@@ -371,28 +373,28 @@ class RustMatrixClient(
override suspend fun searchUsers(searchTerm: String, limit: Long): Result =
withContext(sessionDispatcher) {
runCatching {
- client.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map)
+ innerClient.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map)
}
}
override suspend fun setDisplayName(displayName: String): Result =
withContext(sessionDispatcher) {
- runCatching { client.setDisplayName(displayName) }
+ runCatching { innerClient.setDisplayName(displayName) }
}
override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result =
withContext(sessionDispatcher) {
- runCatching { client.uploadAvatar(mimeType, data) }
+ runCatching { innerClient.uploadAvatar(mimeType, data) }
}
override suspend fun removeAvatar(): Result =
withContext(sessionDispatcher) {
- runCatching { client.removeAvatar() }
+ runCatching { innerClient.removeAvatar() }
}
override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) {
runCatching {
- client.joinRoomById(roomId.value).destroy()
+ innerClient.joinRoomById(roomId.value).destroy()
try {
awaitRoom(roomId.toRoomIdOrAlias(), 10.seconds, CurrentUserMembership.JOINED)
} catch (e: Exception) {
@@ -404,7 +406,7 @@ class RustMatrixClient(
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result = withContext(sessionDispatcher) {
runCatching {
- client.joinRoomByIdOrAlias(
+ innerClient.joinRoomByIdOrAlias(
roomIdOrAlias = roomIdOrAlias.identifier,
serverNames = serverNames,
).destroy()
@@ -421,7 +423,7 @@ class RustMatrixClient(
sessionDispatcher
) {
runCatching {
- client.knock(roomIdOrAlias.identifier, message, serverNames).destroy()
+ innerClient.knock(roomIdOrAlias.identifier, message, serverNames).destroy()
try {
awaitRoom(roomIdOrAlias, 10.seconds, CurrentUserMembership.KNOCKED)
} catch (e: Exception) {
@@ -433,19 +435,19 @@ class RustMatrixClient(
override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result = withContext(sessionDispatcher) {
runCatching {
- client.trackRecentlyVisitedRoom(roomId.value)
+ innerClient.trackRecentlyVisitedRoom(roomId.value)
}
}
override suspend fun getRecentlyVisitedRooms(): Result> = withContext(sessionDispatcher) {
runCatching {
- client.getRecentlyVisitedRooms().map(::RoomId)
+ innerClient.getRecentlyVisitedRooms().map(::RoomId)
}
}
override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result> = withContext(sessionDispatcher) {
runCatching {
- val result = client.resolveRoomAlias(roomAlias.value)?.let {
+ val result = innerClient.resolveRoomAlias(roomAlias.value)?.let {
ResolvedRoomAlias(
roomId = RoomId(it.roomId),
servers = it.servers,
@@ -458,8 +460,8 @@ class RustMatrixClient(
override suspend fun getRoomPreviewInfo(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result = withContext(sessionDispatcher) {
runCatching {
when (roomIdOrAlias) {
- is RoomIdOrAlias.Alias -> client.getRoomPreviewFromRoomAlias(roomIdOrAlias.roomAlias.value)
- is RoomIdOrAlias.Id -> client.getRoomPreviewFromRoomId(roomIdOrAlias.roomId.value, serverNames)
+ is RoomIdOrAlias.Alias -> innerClient.getRoomPreviewFromRoomAlias(roomIdOrAlias.roomAlias.value)
+ is RoomIdOrAlias.Id -> innerClient.getRoomPreviewFromRoomId(roomIdOrAlias.roomId.value, serverNames)
}.use { roomPreview ->
RoomPreviewInfoMapper.map(roomPreview.info())
}
@@ -489,11 +491,6 @@ class RustMatrixClient(
clientDelegateTaskHandle?.cancelAndDestroy()
notificationSettingsService.destroy()
verificationService.destroy()
- innerRoomListService.destroy()
- notificationClient.destroy()
- notificationProcessSetup.destroy()
- encryptionService.destroy()
- client.destroy()
}
override suspend fun getCacheSize(): Long {
@@ -513,13 +510,13 @@ class RustMatrixClient(
withContext(sessionDispatcher) {
if (userInitiated) {
try {
- result = client.logout()
+ result = innerClient.logout()
} catch (failure: Throwable) {
if (ignoreSdkError) {
Timber.e(failure, "Fail to call logout on HS. Still delete local files.")
} else {
// If the logout failed we need to restore the delegate
- clientDelegateTaskHandle = client.setDelegate(sessionDelegate)
+ clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate)
Timber.e(failure, "Fail to call logout on HS.")
throw failure
}
@@ -537,7 +534,7 @@ class RustMatrixClient(
override fun canDeactivateAccount(): Boolean {
return runCatching {
- client.canDeactivateAccount()
+ innerClient.canDeactivateAccount()
}
.getOrNull()
.orFalse()
@@ -551,7 +548,7 @@ class RustMatrixClient(
runCatching {
// First call without AuthData, should fail
val firstAttempt = runCatching {
- client.deactivateAccount(
+ innerClient.deactivateAccount(
authData = null,
eraseData = eraseData,
)
@@ -560,7 +557,7 @@ class RustMatrixClient(
Timber.w(firstAttempt.exceptionOrNull(), "Expected failure, try again")
// This is expected, try again with the password
runCatching {
- client.deactivateAccount(
+ innerClient.deactivateAccount(
authData = AuthData.Password(
passwordDetails = AuthDataPasswordDetails(
identifier = sessionId.value,
@@ -572,7 +569,7 @@ class RustMatrixClient(
}.onFailure {
Timber.e(it, "Failed to deactivate account")
// If the deactivation failed we need to restore the delegate
- clientDelegateTaskHandle = client.setDelegate(sessionDelegate)
+ clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate)
throw it
}
}
@@ -587,13 +584,13 @@ class RustMatrixClient(
override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result = withContext(sessionDispatcher) {
val rustAction = action?.toRustAction()
runCatching {
- client.accountUrl(rustAction)
+ innerClient.accountUrl(rustAction)
}
}
override suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result = withContext(sessionDispatcher) {
runCatching {
- client.uploadMedia(mimeType, data, progressCallback?.toProgressWatcher())
+ innerClient.uploadMedia(mimeType, data, progressCallback?.toProgressWatcher())
}
}
@@ -616,29 +613,33 @@ class RustMatrixClient(
.distinctUntilChanged()
}
- override suspend fun setAllSendQueuesEnabled(enabled: Boolean) = withContext(sessionDispatcher) {
- Timber.i("setAllSendQueuesEnabled($enabled)")
- client.enableAllSendQueues(enabled)
+ override suspend fun setAllSendQueuesEnabled(enabled: Boolean) {
+ withContext(sessionDispatcher) {
+ Timber.i("setAllSendQueuesEnabled($enabled)")
+ tryOrNull {
+ innerClient.enableAllSendQueues(enabled)
+ }
+ }
}
override fun sendQueueDisabledFlow(): Flow = mxCallbackFlow {
- client.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener {
+ innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener {
override fun onError(roomId: String, error: ClientException) {
trySend(RoomId(roomId))
}
})
}.buffer(Channel.UNLIMITED)
- override suspend fun isNativeSlidingSyncSupported(): Boolean {
- return client.availableSlidingSyncVersions().contains(SlidingSyncVersion.Native)
+ override suspend fun availableSlidingSyncVersions(): Result> = withContext(sessionDispatcher) {
+ runCatching {
+ innerClient.availableSlidingSyncVersions().map { it.map() }
+ }
}
- override suspend fun isSlidingSyncProxySupported(): Boolean {
- return client.availableSlidingSyncVersions().any { it is SlidingSyncVersion.Proxy }
- }
-
- override fun isUsingNativeSlidingSync(): Boolean {
- return client.session().slidingSyncVersion == SlidingSyncVersion.Native
+ override suspend fun currentSlidingSyncVersion(): Result = withContext(sessionDispatcher) {
+ runCatching {
+ innerClient.session().slidingSyncVersion.map()
+ }
}
private suspend fun File.getCacheSize(
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 e22eda03f2..0ce1d2362b 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
@@ -77,12 +77,12 @@ class RustMatrixClientFactory @Inject constructor(
.finish()
return RustMatrixClient(
- client = client,
+ innerClient = client,
baseDirectory = baseDirectory,
sessionStore = sessionStore,
appCoroutineScope = appCoroutineScope,
sessionDelegate = sessionDelegate,
- syncService = syncService,
+ innerSyncService = syncService,
dispatchers = coroutineDispatchers,
baseCacheDirectory = cacheDirectory,
clock = clock,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTracker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTracker.kt
index cda2cc1893..56310a7b58 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTracker.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTracker.kt
@@ -27,6 +27,9 @@ class UtdTracker(
UtdCause.UNKNOWN_DEVICE -> {
Error.Name.ExpectedSentByInsecureDevice
}
+ UtdCause.HISTORICAL_MESSAGE -> Error.Name.HistoricalMessage
+ UtdCause.WITHHELD_FOR_UNVERIFIED_OR_INSECURE_DEVICE -> Error.Name.RoomKeysWithheldForUnverifiedDevice
+ UtdCause.WITHHELD_BY_SENDER -> Error.Name.OlmKeysNotSentError
}
val event = Error(
context = null,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
index 31dd6b90a1..f86644231f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
@@ -94,10 +94,6 @@ internal class RustEncryptionService(
}
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
- fun destroy() {
- service.destroy()
- }
-
override suspend fun enableBackups(): Result = withContext(dispatchers.io) {
runCatching {
service.enableBackups()
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 17ba1d2c4d..1d07a3e019 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
@@ -15,7 +15,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
-import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
import org.matrix.rustcomponents.sdk.use
import java.io.File
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
@@ -86,7 +85,7 @@ class RustMediaLoader(
return if (json != null) {
RustMediaSource.fromJson(json)
} else {
- mediaSourceFromUrl(url)
+ RustMediaSource.fromUrl(url)
}
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt
index 561a9ec7cb..6c6f2cac4c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt
@@ -42,7 +42,6 @@ class RustNotificationSettingsService(
fun destroy() {
notificationSettings.setDelegate(null)
- notificationSettings.destroy()
}
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result =
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 c12fc17553..c3057298fa 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
@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.MessageEventType
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.StateEventType
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.location.AssetType
@@ -60,7 +61,6 @@ import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -92,7 +92,6 @@ import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateCh
import org.matrix.rustcomponents.sdk.Room as InnerRoom
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
-@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixRoom(
override val sessionId: SessionId,
private val deviceId: DeviceId,
@@ -107,6 +106,7 @@ class RustMatrixRoom(
private val roomSyncSubscriber: RoomSyncSubscriber,
private val matrixRoomInfoMapper: MatrixRoomInfoMapper,
private val featureFlagService: FeatureFlagService,
+ private val roomMembershipObserver: RoomMembershipObserver,
) : MatrixRoom {
override val roomId = RoomId(innerRoom.id())
@@ -376,6 +376,8 @@ class RustMatrixRoom(
override suspend fun leave(): Result = withContext(roomDispatcher) {
runCatching {
innerRoom.leave()
+ }.onSuccess {
+ roomMembershipObserver.notifyUserLeftRoom(roomId)
}
}
@@ -467,12 +469,36 @@ class RustMatrixRoom(
return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback)
}
- override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result {
- return liveTimeline.sendAudio(file, audioInfo, progressCallback)
+ override suspend fun sendAudio(
+ file: File,
+ audioInfo: AudioInfo,
+ caption: String?,
+ formattedCaption: String?,
+ progressCallback: ProgressCallback?,
+ ): Result {
+ return liveTimeline.sendAudio(
+ file = file,
+ audioInfo = audioInfo,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ progressCallback = progressCallback,
+ )
}
- override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result {
- return liveTimeline.sendFile(file, fileInfo, progressCallback)
+ override suspend fun sendFile(
+ file: File,
+ fileInfo: FileInfo,
+ caption: String?,
+ formattedCaption: String?,
+ progressCallback: ProgressCallback?,
+ ): Result {
+ return liveTimeline.sendFile(
+ file,
+ fileInfo,
+ caption,
+ formattedCaption,
+ progressCallback,
+ )
}
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustPendingRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustPendingRoom.kt
index 635d362365..6e230b7ef7 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustPendingRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustPendingRoom.kt
@@ -10,15 +10,19 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.PendingRoom
+import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import org.matrix.rustcomponents.sdk.RoomPreview
class RustPendingRoom(
override val sessionId: SessionId,
override val roomId: RoomId,
private val inner: RoomPreview,
+ private val roomMembershipObserver: RoomMembershipObserver,
) : PendingRoom {
override suspend fun leave(): Result = runCatching {
inner.leave()
+ }.onSuccess {
+ roomMembershipObserver.notifyUserLeftRoom(roomId)
}
override fun close() {
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 487c4d1496..d39d45c067 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
@@ -17,13 +17,13 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
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.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -51,8 +51,8 @@ class RustRoomFactory(
private val roomSyncSubscriber: RoomSyncSubscriber,
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val featureFlagService: FeatureFlagService,
+ private val roomMembershipObserver: RoomMembershipObserver,
) {
- @OptIn(ExperimentalCoroutinesApi::class)
private val dispatcher = dispatchers.io.limitedParallelism(1)
private val mutex = Mutex()
private var isDestroyed: Boolean = false
@@ -120,6 +120,7 @@ class RustRoomFactory(
roomSyncSubscriber = roomSyncSubscriber,
matrixRoomInfoMapper = matrixRoomInfoMapper,
featureFlagService = featureFlagService,
+ roomMembershipObserver = roomMembershipObserver,
)
}
}
@@ -140,7 +141,7 @@ class RustRoomFactory(
}
val innerRoom = try {
roomListItem.previewRoom(via = emptyList())
- } catch (e: RoomListException) {
+ } catch (e: Exception) {
Timber.e(e, "Failed to get pending room for $roomId")
return@withContext null
}
@@ -148,6 +149,7 @@ class RustRoomFactory(
sessionId = sessionId,
roomId = roomId,
inner = innerRoom,
+ roomMembershipObserver = roomMembershipObserver,
)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt
index 3ff3c1b098..0da22426ba 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.room.preview
+import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
@@ -25,7 +26,7 @@ object RoomPreviewInfoMapper {
avatarUrl = info.avatarUrl,
numberOfJoinedMembers = info.numJoinedMembers.toLong(),
roomType = info.roomType.map(),
- isHistoryWorldReadable = info.isHistoryWorldReadable,
+ isHistoryWorldReadable = info.isHistoryWorldReadable.orFalse(),
isJoined = info.membership == Membership.JOINED,
isInvited = info.membership == Membership.INVITED,
isPublic = info.joinRule == JoinRule.Public,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
index 0a859ee146..a5da538b51 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt
@@ -10,12 +10,14 @@ package io.element.android.libraries.matrix.impl.sync
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.SyncServiceState
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
@@ -49,12 +51,11 @@ class RustSyncService(
Timber.d("Stop sync failed: $it")
}
- suspend fun destroy() {
+ suspend fun destroy() = withContext(NonCancellable) {
// If the service was still running, stop it
stopSync()
Timber.d("Destroying sync service")
isServiceReady.set(false)
- innerSyncService.destroy()
}
override val syncState: StateFlow =
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncVersion.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncVersion.kt
new file mode 100644
index 0000000000..05b3fe877a
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncVersion.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.libraries.matrix.impl.sync
+
+import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
+import org.matrix.rustcomponents.sdk.SlidingSyncVersion as RustSlidingSyncVersion
+
+internal fun RustSlidingSyncVersion.map(): SlidingSyncVersion {
+ return when (this) {
+ RustSlidingSyncVersion.None -> SlidingSyncVersion.None
+ is RustSlidingSyncVersion.Proxy -> SlidingSyncVersion.Proxy
+ RustSlidingSyncVersion.Native -> SlidingSyncVersion.Native
+ }
+}
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 2b6d8543c4..200f1289b4 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
@@ -295,22 +295,40 @@ class RustTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List,
- ): Result =
- withContext(dispatcher) {
- runCatching {
- val editedContent = EditedContent.RoomMessage(
- content = MessageEventContent.from(
- body = body,
- htmlBody = htmlBody,
- intentionalMentions = intentionalMentions
- ),
- )
- inner.edit(
- newContent = editedContent,
- eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
- )
- }
+ ): Result = withContext(dispatcher) {
+ runCatching {
+ val editedContent = EditedContent.RoomMessage(
+ content = MessageEventContent.from(
+ body = body,
+ htmlBody = htmlBody,
+ intentionalMentions = intentionalMentions
+ ),
+ )
+ inner.edit(
+ newContent = editedContent,
+ eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
+ )
}
+ }
+
+ override suspend fun editCaption(
+ eventOrTransactionId: EventOrTransactionId,
+ caption: String?,
+ formattedCaption: String?,
+ ): Result = withContext(dispatcher) {
+ runCatching {
+ val editedContent = EditedContent.MediaCaption(
+ caption = caption,
+ formattedCaption = formattedCaption?.let {
+ FormattedBody(body = it, format = MessageFormat.Html)
+ },
+ )
+ inner.edit(
+ newContent = editedContent,
+ eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
+ )
+ }
+ }
override suspend fun replyMessage(
eventId: EventId,
@@ -373,29 +391,44 @@ class RustTimeline(
}
}
- override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result {
+ override suspend fun sendAudio(
+ file: File,
+ audioInfo: AudioInfo,
+ caption: String?,
+ formattedCaption: String?,
+ progressCallback: ProgressCallback?,
+ ): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
inner.sendAudio(
url = file.path,
audioInfo = audioInfo.map(),
- // Maybe allow a caption in the future?
- caption = null,
- formattedCaption = null,
+ caption = caption,
+ formattedCaption = formattedCaption?.let {
+ FormattedBody(body = it, format = MessageFormat.Html)
+ },
useSendQueue = useSendQueue,
progressWatcher = progressCallback?.toProgressWatcher()
)
}
}
- override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result {
+ override suspend fun sendFile(
+ file: File,
+ fileInfo: FileInfo,
+ caption: String?,
+ formattedCaption: String?,
+ progressCallback: ProgressCallback?,
+ ): Result {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
inner.sendFile(
url = file.path,
fileInfo = fileInfo.map(),
- caption = null,
- formattedCaption = null,
+ caption = caption,
+ formattedCaption = formattedCaption?.let {
+ FormattedBody(body = it, format = MessageFormat.Html)
+ },
useSendQueue = useSendQueue,
progressWatcher = progressCallback?.toProgressWatcher(),
)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
index f85dc5acc1..6e079ffe3f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
@@ -145,6 +145,9 @@ private fun RustUtdCause.map(): UtdCause {
RustUtdCause.VERIFICATION_VIOLATION -> UtdCause.VerificationViolation
RustUtdCause.UNSIGNED_DEVICE -> UtdCause.UnsignedDevice
RustUtdCause.UNKNOWN_DEVICE -> UtdCause.UnknownDevice
+ RustUtdCause.HISTORICAL_MESSAGE -> UtdCause.HistoricalMessage
+ RustUtdCause.WITHHELD_FOR_UNVERIFIED_OR_INSECURE_DEVICE -> UtdCause.WithheldUnverifiedOrInsecureDevice
+ RustUtdCause.WITHHELD_BY_SENDER -> UtdCause.WithheldBySender
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt
index 84435119c0..74fba46500 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt
@@ -229,7 +229,6 @@ class RustSessionVerificationService(
recoveryStateListenerTaskHandle.cancelAndDestroy()
if (this::verificationController.isInitialized) {
verificationController.setDelegate(null)
- verificationController.destroy()
}
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
index 22fcad92bf..d9b21dc4c7 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
@@ -35,14 +35,14 @@ class RustMatrixClientTest {
private fun TestScope.createRustMatrixClient(
sessionStore: SessionStore = InMemorySessionStore(),
) = RustMatrixClient(
- client = FakeRustClient(),
+ innerClient = FakeRustClient(),
baseDirectory = File(""),
sessionStore = sessionStore,
appCoroutineScope = this,
sessionDelegate = aRustClientSessionDelegate(
sessionStore = sessionStore,
),
- syncService = FakeRustSyncService(),
+ innerSyncService = FakeRustSyncService(),
dispatchers = testCoroutineDispatchers(),
baseCacheDirectory = File(""),
clock = FakeSystemClock(),
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt
index e7e3ff219f..b43f4e0f70 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
+import org.matrix.rustcomponents.sdk.JoinRule
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.RoomHero
import org.matrix.rustcomponents.sdk.RoomInfo
@@ -47,6 +48,7 @@ fun aRustRoomInfo(
numUnreadMentions: ULong = 0uL,
pinnedEventIds: List = listOf(),
roomCreator: UserId? = null,
+ joinRule: JoinRule? = null,
) = RoomInfo(
id = id,
displayName = displayName,
@@ -78,4 +80,5 @@ fun aRustRoomInfo(
numUnreadMentions = numUnreadMentions,
pinnedEventIds = pinnedEventIds,
creator = roomCreator?.value,
+ joinRule = joinRule,
)
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt
index c2c23fe6d9..d00582270d 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt
@@ -32,5 +32,6 @@ internal fun aRustRoomPreviewInfo(
isHistoryWorldReadable = true,
membership = membership,
joinRule = joinRule,
+ heroes = null,
)
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index e912fd2f40..f4e1839dac 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -84,9 +85,8 @@ class FakeMatrixClient(
private val getUrlLambda: (String) -> Result = { lambdaError() },
private val canDeactivateAccountResult: () -> Boolean = { lambdaError() },
private val deactivateAccountResult: (String, Boolean) -> Result = { _, _ -> lambdaError() },
- var isNativeSlidingSyncSupportedLambda: suspend () -> Boolean = { true },
- var isSlidingSyncProxySupportedLambda: suspend () -> Boolean = { true },
- var isUsingNativeSlidingSyncLambda: () -> Boolean = { true },
+ private val currentSlidingSyncVersionLambda: () -> Result = { lambdaError() },
+ private val availableSlidingSyncVersionsLambda: () -> Result> = { lambdaError() }
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -340,15 +340,11 @@ class FakeMatrixClient(
return getUrlLambda(url)
}
- override suspend fun isNativeSlidingSyncSupported(): Boolean {
- return isNativeSlidingSyncSupportedLambda()
+ override suspend fun currentSlidingSyncVersion(): Result {
+ return currentSlidingSyncVersionLambda()
}
- override suspend fun isSlidingSyncProxySupported(): Boolean {
- return isSlidingSyncProxySupportedLambda()
- }
-
- override fun isUsingNativeSlidingSync(): Boolean {
- return isUsingNativeSlidingSyncLambda()
+ override suspend fun availableSlidingSyncVersions(): Result> {
+ return availableSlidingSyncVersionsLambda()
}
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 9574a2a22c..9974e36746 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -92,10 +92,10 @@ class FakeMatrixRoom(
{ _, _, _, _, _, _ -> lambdaError() },
private val sendVideoResult: (File, File?, VideoInfo, String?, String?, ProgressCallback?) -> Result =
{ _, _, _, _, _, _ -> lambdaError() },
- private val sendFileResult: (File, FileInfo, ProgressCallback?) -> Result =
- { _, _, _ -> lambdaError() },
- private val sendAudioResult: (File, AudioInfo, ProgressCallback?) -> Result =
- { _, _, _ -> lambdaError() },
+ private val sendFileResult: (File, FileInfo, String?, String?, ProgressCallback?) -> Result =
+ { _, _, _, _, _ -> lambdaError() },
+ private val sendAudioResult: (File, AudioInfo, String?, String?, ProgressCallback?) -> Result =
+ { _, _, _, _, _ -> lambdaError() },
private val sendVoiceMessageResult: (File, AudioInfo, List, ProgressCallback?) -> Result =
{ _, _, _, _ -> lambdaError() },
private val setNameResult: (String) -> Result = { lambdaError() },
@@ -354,12 +354,16 @@ class FakeMatrixRoom(
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendAudioResult(
file,
audioInfo,
+ caption,
+ formattedCaption,
progressCallback,
)
}
@@ -367,12 +371,16 @@ class FakeMatrixRoom(
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?
): Result = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendFileResult(
file,
fileInfo,
+ caption,
+ formattedCaption,
progressCallback,
)
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
index ae40a0a51e..6edfd22c50 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
@@ -92,6 +92,24 @@ class FakeTimeline(
intentionalMentions
)
+ var editCaptionLambda: (
+ eventOrTransactionId: EventOrTransactionId,
+ caption: String?,
+ formattedCaption: String?,
+ ) -> Result = { _, _, _ ->
+ lambdaError()
+ }
+
+ override suspend fun editCaption(
+ eventOrTransactionId: EventOrTransactionId,
+ caption: String?,
+ formattedCaption: String?,
+ ): Result = editCaptionLambda(
+ eventOrTransactionId,
+ caption,
+ formattedCaption,
+ )
+
var replyMessageLambda: (
eventId: EventId,
body: String,
@@ -173,36 +191,48 @@ class FakeTimeline(
var sendAudioLambda: (
file: File,
audioInfo: AudioInfo,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _ ->
+ ) -> Result = { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result = sendAudioLambda(
file,
audioInfo,
+ caption,
+ formattedCaption,
progressCallback
)
var sendFileLambda: (
file: File,
fileInfo: FileInfo,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?,
- ) -> Result = { _, _, _ ->
+ ) -> Result = { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
+ caption: String?,
+ formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result = sendFileLambda(
file,
fileInfo,
+ caption,
+ formattedCaption,
progressCallback
)
diff --git a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt
index ab48de0eaa..8622c8cc32 100644
--- a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt
+++ b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt
@@ -13,25 +13,23 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.content.FileProvider
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.mediapickers.api.ComposePickerLauncher
import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher
import io.element.android.libraries.mediapickers.api.PickerLauncher
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.api.PickerType
import java.io.File
-import java.util.UUID
import javax.inject.Inject
@ContributesBinding(AppScope::class)
-class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
- @Inject
- constructor() : this(false)
-
+class DefaultPickerProvider @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : PickerProvider {
/**
* Remembers and returns a [PickerLauncher] for a certain media/file [type].
*/
@@ -40,7 +38,7 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
type: PickerType,
onResult: (Output) -> Unit,
): PickerLauncher {
- return if (LocalInspectionMode.current || isInTest) {
+ return if (LocalInspectionMode.current) {
NoOpPickerLauncher { }
} else {
val contract = type.getContract()
@@ -56,7 +54,7 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
@Composable
override fun registerGalleryImagePicker(onResult: (Uri?) -> Unit): PickerLauncher {
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
- return if (LocalInspectionMode.current || isInTest) {
+ return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null) }
} else {
rememberPickerLauncher(type = PickerType.Image) { uri -> onResult(uri) }
@@ -72,10 +70,9 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
onResult: (uri: Uri?, mimeType: String?) -> Unit
): PickerLauncher {
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
- return if (LocalInspectionMode.current || isInTest) {
+ return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null, null) }
} else {
- val context = LocalContext.current
rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri ->
val mimeType = uri?.let { context.contentResolver.getType(it) }
onResult(uri, mimeType)
@@ -93,7 +90,7 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
onResult: (Uri?) -> Unit,
): PickerLauncher