Merge branch 'release/0.7.5' into main

This commit is contained in:
ganfra
2024-12-06 11:55:06 +01:00
761 changed files with 7125 additions and 3328 deletions

View File

@@ -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 }}

View File

@@ -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)
========================================

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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<String> = emptyList(),
trigger: JoinedRoom.Trigger? = null,
eventId: EventId? = null,
clearBackstack: Boolean,
) {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.RoomList
}
attachChild<RoomFlowNode> {
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<Plugin>,
) : Node(buildContext, plugins = plugins)
}
@Parcelize
private class AttachRoomOperation(
val roomTarget: LoggedInFlowNode.NavTarget.Room,
val clearBackstack: Boolean,
) : BackStackOperation<LoggedInFlowNode.NavTarget> {
override fun isApplicable(elements: NavElements<LoggedInFlowNode.NavTarget, BackStack.State>) = true
override fun invoke(elements: BackStackElements<LoggedInFlowNode.NavTarget>): BackStackElements<LoggedInFlowNode.NavTarget> {
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<LoggedInFlowNode.NavTarget>(roomTarget).invoke(elements)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<Boolean> = 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<AsyncData<Unit>>) {
Timber.tag(pusherTag.value).d("Ensure pusher is registered")
val currentPushProvider = pushService.getCurrentPushProvider()

View File

@@ -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<RoomFlowNode.NavTarget>(
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 {

View File

@@ -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) {

View File

@@ -52,6 +52,10 @@ allprojects {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.19")
}
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().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

View File

@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View File

@@ -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<CallScreenState>
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")
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
)

View File

@@ -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,
),

View File

@@ -3,11 +3,22 @@
<string name="screen_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_add_people_title">"Personen einladen"</string>
<string name="screen_create_room_error_creating_room">"Beim Erstellen des Chats ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_private_option_description">"Die Nachrichten in diesem Chat sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
<string name="screen_create_room_public_option_description">"Die Nachrichten sind nicht verschlüsselt und können von jedem gelesen werden. Die Verschlüsselung kann zu einem späteren Zeitpunkt aktiviert werden."</string>
<string name="screen_create_room_public_option_title">"Öffentlicher Raum (für alle)"</string>
<string name="screen_create_room_private_option_description">"Nur eingeladene Personen haben Zutritt zu diesem Chatroom. Alle Nachrichten sind durchgehend verschlüsselt."</string>
<string name="screen_create_room_private_option_title">"Privater Chatroom"</string>
<string name="screen_create_room_public_option_description">"Jeder kann diesen Chatroom finden.
Sie können dies aber jederzeit in den Chatroomeinstellungen ändern."</string>
<string name="screen_create_room_public_option_title">"Öffentlicher Chatroom"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Jeder kann diesem Chatroom beitreten"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Jemand"</string>
<string name="screen_create_room_room_access_section_header">"Chatroom Zugang"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Jeder kann darum bitten, dem Chatroom beizutreten, aber ein Administrator oder ein Moderator muss die Anfrage akzeptieren."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Beitritt beantragen"</string>
<string name="screen_create_room_room_address_invalid_symbols_error_description">"Einige Zeichen sind nicht erlaubt. Es werden nur Buchstaben, Ziffern und die folgenden Symbole unterstützt: ! $ &amp; ( ) * + / ; = ? @ [ ] - . _"</string>
<string name="screen_create_room_room_address_not_available_error_description">"Diese Chatroomadresse existiert bereits. Bitte versuchen Sie, das Adressenfeld des Chatrooms zu bearbeiten oder den Namen des Chatrooms zu ändern"</string>
<string name="screen_create_room_room_address_section_footer">"Damit dieser Chatroom im öffentlichen Chatroomverzeichnis sichtbar ist, benötigen Sie eine Chatroomadresse."</string>
<string name="screen_create_room_room_address_section_title">"Chatroom Adresse"</string>
<string name="screen_create_room_room_name_label">"Raumname"</string>
<string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chatrooms"</string>
<string name="screen_create_room_title">"Raum erstellen"</string>
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>

View File

@@ -13,7 +13,12 @@
<string name="screen_create_room_room_access_section_header">"Πρόσβαση Δωματίου"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στο δωμάτιο, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχθεί το αίτημα"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Αίτημα συμμετοχής"</string>
<string name="screen_create_room_room_address_invalid_symbols_error_description">"Ορισμένοι χαρακτήρες δεν επιτρέπονται. Υποστηρίζονται μόνο γράμματα, ψηφία και τα ακόλουθα σύμβολα ! $ &amp; \'() * +/; = ? @ [] - . _"</string>
<string name="screen_create_room_room_address_not_available_error_description">"Αυτή η διεύθυνση δωματίου υπάρχει ήδη, δοκίμασε να επεξεργαστείς το πεδίο διεύθυνσης δωματίου ή να αλλάξεις το όνομα δωματίου"</string>
<string name="screen_create_room_room_address_section_footer">"Για να είναι ορατό αυτό το δωμάτιο στον κατάλογο των δημόσιων δωματίων, θα χρειαστείς μια διεύθυνση δωματίου."</string>
<string name="screen_create_room_room_address_section_title">"Διεύθυνση δωματίου"</string>
<string name="screen_create_room_room_name_label">"Όνομα δωματίου"</string>
<string name="screen_create_room_room_visibility_section_title">"Ορατότητα δωματίου"</string>
<string name="screen_create_room_title">"Δημιούργησε ένα δωμάτιο"</string>
<string name="screen_create_room_topic_label">"Θέμα (προαιρετικό)"</string>
<string name="screen_start_chat_error_starting_chat">"Παρουσιάστηκε σφάλμα κατά την προσπάθεια έναρξης μιας συνομιλίας"</string>

View File

@@ -3,9 +3,10 @@
<string name="screen_create_room_action_create_room">"Nova sala"</string>
<string name="screen_create_room_add_people_title">"Convidar pessoas"</string>
<string name="screen_create_room_error_creating_room">"Ocorreu um erro ao criar a sala"</string>
<string name="screen_create_room_private_option_description">"As mensagens nesta sala serão criptografadas. A criptografia não pode ser desativada posteriormente."</string>
<string name="screen_create_room_private_option_description">"Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são criptografadas de ponta a ponta."</string>
<string name="screen_create_room_private_option_title">"Sala privativa (somente por convite)"</string>
<string name="screen_create_room_public_option_description">"As mensagens não serão criptografadas e qualquer pessoa pode lê-las. Você pode ativar a criptografia posteriormente."</string>
<string name="screen_create_room_public_option_description">"Qualquer um pode encontrar esta sala.
Você pode mudar isso a qualquer momento nas configurações da sala."</string>
<string name="screen_create_room_public_option_title">"Sala pública (qualquer pessoa)"</string>
<string name="screen_create_room_room_name_label">"Nome da sala"</string>
<string name="screen_create_room_title">"Criar uma sala"</string>

View File

@@ -14,7 +14,7 @@ You can change this anytime in room settings."</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Anyone can ask to join the room but an administrator or a moderator will have to accept the request"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Ask to join"</string>
<string name="screen_create_room_room_address_invalid_symbols_error_description">"Some characters are not allowed. Only letters, digits and the following symbols are supported ! $ &amp; ( ) * + / ; = ? @ [ ] - . _"</string>
<string name="screen_create_room_room_address_not_available_error_description">"This room address already exists, please try editing the room address field or change the room name"</string>
<string name="screen_create_room_room_address_not_available_error_description">"This room address already exists. Please try editing the room address field or change the room name"</string>
<string name="screen_create_room_room_address_section_footer">"In order for this room to be visible in the public room directory, you will need a room address."</string>
<string name="screen_create_room_room_address_section_title">"Room address"</string>
<string name="screen_create_room_room_name_label">"Room name"</string>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Bitte bestätigen Sie, dass Sie Ihr Benutzerkonto deaktivieren möchten. Diese Aktion kann nicht rückgängig gemacht werden."</string>
<string name="screen_deactivate_account_delete_all_messages">"Lösche alle meine Nachrichten"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Warnung: Benutzern werden möglicherweise unvollständige Konversationen angezeigt."</string>
<string name="screen_deactivate_account_description">"Wenn Sie Ihr Konto deaktivieren%1$s, wird es:"</string>
<string name="screen_deactivate_account_description_bold_part">"irreversibel"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s Ihr Konto (Sie können sich nicht erneut anmelden und Ihre ID kann nicht wiederverwendet werden)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Dauerhaft deaktivieren"</string>
<string name="screen_deactivate_account_list_item_2">"Sie werden aus allen Chatrooms entfernt."</string>
<string name="screen_deactivate_account_list_item_3">"Löschen Sie Ihre Kontoinformationen von unserem Identitätsserver."</string>
<string name="screen_deactivate_account_list_item_4">"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."</string>
<string name="screen_deactivate_account_title">"Benutzerkonto deaktivieren"</string>
</resources>

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
)
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -40,17 +40,6 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
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<JoinRoomState> {
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",

View File

@@ -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
)
}
}

View File

@@ -1,7 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_cancel_knock_action">"Anfrage abbrechen"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Ja, abbrechen"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Möchten Sie Ihre Beitrittsanfrage für diesen Chatroom wirklich stornieren?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Beitrittsanfrage stornieren"</string>
<string name="screen_join_room_join_action">"Raum beitreten"</string>
<string name="screen_join_room_knock_action">"Anklopfen"</string>
<string name="screen_join_room_knock_message_description">"Nachricht (optional)"</string>
<string name="screen_join_room_knock_sent_description">"Falls Ihre Anfrage, dem Raum beizutreten, akzeptiert wird, werden Sie eine Einladung erhalten."</string>
<string name="screen_join_room_knock_sent_title">"Beitrittsanfrage geschickt"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s unterstützt noch keine Spaces. Du kannst auf Spaces im Web zugreifen."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces werden noch nicht unterstützt"</string>
<string name="screen_join_room_subtitle_knock">"Klopfe an um einen Administrator zu benachrichtigen. Nach der Freigabe kannst du dich an der Unterhaltung beteiligen."</string>

View File

@@ -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<LeaveRoomState> {
@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<LeaveRoomState.Confirmation>,
progress: MutableState<LeaveRoomState.Progress>,
error: MutableState<LeaveRoomState.Error>,
@@ -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
}

View File

@@ -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<Unit>> { 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),
)

View File

@@ -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>(LockScreenLockState.Unlocked)
override val lockState: StateFlow<LockScreenLockState> = _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()

View File

@@ -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.Callback>
) : BiometricUnlock {
private val callbacks: List<BiometricAuthenticator.Callback>
) : 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<BiometricUnlock.AuthenticationResult>()
override suspend fun authenticate(): BiometricAuthenticator.AuthenticationResult {
val cryptoObject = cryptoObject ?: return BiometricAuthenticator.AuthenticationResult.Failure()
val deferredAuthenticationResult = CompletableDeferred<BiometricAuthenticator.AuthenticationResult>()
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<BiometricUnlock.Callback>,
private val deferredAuthenticationResult: CompletableDeferred<BiometricUnlock.AuthenticationResult>,
private val callbacks: List<BiometricAuthenticator.Callback>,
private val deferredAuthenticationResult: CompletableDeferred<BiometricAuthenticator.AuthenticationResult>,
) : 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())
}
}

View File

@@ -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
}

View File

@@ -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<BiometricUnlock.Callback>()
) : BiometricAuthenticatorManager {
private val callbacks = CopyOnWriteArrayList<BiometricAuthenticator.Callback>()
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)
}

View File

@@ -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
}

View File

@@ -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<LockScreenSettingsState> {
@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
)
}

View File

@@ -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<Plugin>,
private val pinCodeManager: PinCodeManager,
val biometricAuthenticatorManager: BiometricAuthenticatorManager,
) : BaseFlowNode<LockScreenSetupFlowNode.NavTarget>(
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()
}
}
}

View File

@@ -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<SetupBiometricState> {
@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)

View File

@@ -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)
}
}

View File

@@ -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<String?>>(AsyncAction.Uninitialized)
}
var biometricUnlockResult by remember {
mutableStateOf<BiometricUnlock.AuthenticationResult?>(null)
mutableStateOf<BiometricAuthenticator.AuthenticationResult?>(null)
}
val isUnlocked = remember {
mutableStateOf(false)
}
val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
val biometricUnlock = biometricAuthenticatorManager.rememberUnlockBiometricAuthenticator()
LaunchedEffect(Unit) {
suspend {
val pinCodeSize = pinCodeManager.getPinCodeSize()

View File

@@ -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<String?>,
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

View File

@@ -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<PinUnlockState> {
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<String?> = AsyncAction.Uninitialized,
) = PinUnlockState(

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biometrische Authentifizierung"</string>
<string name="screen_app_lock_biometric_unlock">"biometrisches Entsperren"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Mit Biometrie entsperren"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Biometrische Daten bestätigen"</string>
<string name="screen_app_lock_forgot_pin">"PIN vergessen?"</string>
<string name="screen_app_lock_settings_change_pin">"PIN-Code ändern"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Biometrisches Entsperren zulassen"</string>

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"βιομετρική ταυτοποίηση"</string>
<string name="screen_app_lock_biometric_unlock">"βιομετρικό ξεκλείδωμα"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Ξεκλείδωμα με βιομετρικά"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Επιβεβαίωσε τον βιομετρικό έλεγχο ταυτότητας"</string>
<string name="screen_app_lock_forgot_pin">"Ξέχασες το PIN;"</string>
<string name="screen_app_lock_settings_change_pin">"Αλλαγή κωδικού PIN"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Να επιτρέπεται το βιομετρικό ξεκλείδωμα"</string>

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biomeetrilist autentimist"</string>
<string name="screen_app_lock_biometric_unlock">"biomeetrilist lukustuse eemaldamist"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Eemalda lukustus biomeetrilise tuvastuse abil"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Kinnita biomeetriline tuvastus"</string>
<string name="screen_app_lock_forgot_pin">"Kas unustasid PIN-koodi?"</string>
<string name="screen_app_lock_settings_change_pin">"Muuda PIN-koodi"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Kasuta lukustuse eemaldamiseks biomeetrilist tuvastust"</string>

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"lauthentification biométrique"</string>
<string name="screen_app_lock_biometric_unlock">"déverrouillage biométrique"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Déverrouiller avec la biométrie"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Confirmer la biométrie"</string>
<string name="screen_app_lock_forgot_pin">"Code PIN oublié?"</string>
<string name="screen_app_lock_settings_change_pin">"Modifier le code PIN"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Autoriser le déverrouillage biométrique"</string>

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"биометрическая идентификация"</string>
<string name="screen_app_lock_biometric_unlock">"биометрическая разблокировка"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Разблокировать с помощью биометрии"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Подтвердить биометрические данные"</string>
<string name="screen_app_lock_forgot_pin">"Забыли PIN-код?"</string>
<string name="screen_app_lock_settings_change_pin">"Изменить PIN-код"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Разрешить биометрическую разблокировку"</string>

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biometrické overenie"</string>
<string name="screen_app_lock_biometric_unlock">"biometrické odomknutie"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Odomknúť pomocou biometrie"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Potvrdiť biometrické údaje"</string>
<string name="screen_app_lock_forgot_pin">"Zabudli ste PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Zmeniť PIN kód"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Povoliť biometrické odomknutie"</string>

View File

@@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biometric authentication"</string>
<string name="screen_app_lock_biometric_unlock">"biometric unlock"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Unlock with biometric"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Confirm biometric"</string>
<string name="screen_app_lock_forgot_pin">"Forgot PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Change PIN code"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Allow biometric unlock"</string>

View File

@@ -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()
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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),
)
}
}

View File

@@ -60,6 +60,7 @@ Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Ger
<string name="screen_qr_code_login_initial_state_item_3">"Wähle %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Neues Gerät verknüpfen\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Scanne den QR-Code mit diesem Gerät"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Nur verfügbar für den Fall dass Ihr Kontoanbieter dies unterstützt."</string>
<string name="screen_qr_code_login_initial_state_title">"Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Erneut versuchen"</string>

View File

@@ -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<MessagesNode>(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<MediaViewerNode>(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)

View File

@@ -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<Attachment>)
}

View File

@@ -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<Plugin>,
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<Callback>()
data class Inputs(val focusedEventId: EventId?) : NodeInputs
@@ -114,10 +126,6 @@ class MessagesNode @AssistedInject constructor(
.orFalse()
}
private fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
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<Attachment>) {
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,

View File

@@ -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<MessageComposerState>,
@Assisted private val composerPresenter: Presenter<MessageComposerState>,
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
timelinePresenterFactory: TimelinePresenter.Factory,
@Assisted private val timelinePresenter: Presenter<TimelineState>,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
private val actionListPresenterFactory: ActionListPresenter.Factory,
@Assisted private val actionListPresenter: Presenter<ActionListState>,
private val customReactionPresenter: Presenter<CustomReactionState>,
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
@@ -110,12 +109,14 @@ class MessagesPresenter @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
private val analyticsService: AnalyticsService,
) : Presenter<MessagesState> {
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<MessageComposerState>,
timelinePresenter: Presenter<TimelineState>,
actionListPresenter: Presenter<ActionListState>,
): 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))
}
}
}

View File

@@ -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<MessagesState> {
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(),
),

View File

@@ -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<Attachment>) -> 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<Attachment>) -> 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 = {},

View File

@@ -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<TimelineItemAction> {
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<TimelineItemAction>.postFilter(content: TimelineItemEventContent): List<TimelineItemAction> {
private fun Iterable<TimelineItemAction>.postFilter(content: TimelineItemEventContent): Iterable<TimelineItemAction> {
return filter { action ->
when (content) {
is TimelineItemCallNotifyContent,

View File

@@ -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<ActionListState> {
override val values: Sequence<ActionListState>
@@ -50,7 +51,9 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
actions = aTimelineItemActionList(
copyAction = TimelineItemAction.CopyCaption,
),
)
),
anActionListState(
@@ -61,7 +64,9 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
actions = aTimelineItemActionList(
copyAction = TimelineItemAction.CopyCaption,
),
)
),
anActionListState(
@@ -72,7 +77,9 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
actions = aTimelineItemActionList(
copyAction = null,
),
)
),
anActionListState(
@@ -83,18 +90,22 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
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<TimelineItemAction> {
return persistentListOf(
fun aTimelineItemActionList(
copyAction: TimelineItemAction? = TimelineItemAction.CopyText
): ImmutableList<TimelineItemAction> {
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<TimelineItemAction> {
return persistentListOf(
return setOf(
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Copy,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
)
.sortedWith(TimelineItemActionComparator())
.toPersistentList()
}

View File

@@ -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)

View File

@@ -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<TimelineItemAction> {
// 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)
}
}

View File

@@ -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<Plugin>,
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
)
}

View File

@@ -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<AttachmentsPreviewState> {
@AssistedFactory
interface Factory {
@@ -63,19 +72,64 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(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<MediaUploadInfo>>(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<SendActionState>,
mediaUploadInfoState: MutableState<AsyncData<MediaUploadInfo>>,
) = 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<AsyncData<MediaUploadInfo>>,
) {
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<MediaUploadInfo>,
sendActionState: MutableState<SendActionState>,
) = 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<SendActionState>,
) = 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 ->

View File

@@ -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
}

View File

@@ -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<Attachment
override val values: Sequence<AttachmentsPreviewState>
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 = {}
)

View File

@@ -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,
)
}
}
)
}

View File

@@ -36,7 +36,6 @@ internal fun MessagesViewWithIdentityChangePreview(
onEventContentClick = { false },
onUserDataClick = {},
onLinkClick = {},
onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},

View File

@@ -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<TimelineProtectionState>
@Binds
fun bindMessageComposerPresenter(presenter: MessageComposerPresenter): Presenter<MessageComposerState>
@Binds
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>

View File

@@ -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

View File

@@ -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<MessageComposerState> {
@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<Suggestion?>(null)
@@ -151,9 +152,6 @@ class MessageComposerPresenter @Inject constructor(
}
val cameraPermissionState = cameraPermissionPresenter.present()
val attachmentsState = remember {
mutableStateOf<AttachmentsState>(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<Job?>(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<AttachmentsState>,
) = 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<AttachmentsState>,
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<AttachmentsState>,
) = 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)
}
}

View File

@@ -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<ResolvedSuggestion>,
val resolveMentionDisplay: (String, String) -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit,
)
@Immutable
sealed interface AttachmentsState {
data object None : AttachmentsState
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
sealed interface Sending : AttachmentsState {
data class Processing(val attachments: ImmutableList<Attachment>) : Sending
data class Uploading(val progress: Float) : Sending
}
}

View File

@@ -31,7 +31,6 @@ fun aMessageComposerState(
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None,
suggestions: ImmutableList<ResolvedSuggestion> = 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,

View File

@@ -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<Plugin>,
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<Callback>()
private fun onEventClick(event: TimelineItem.Event) {

View File

@@ -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<TimelineProtectionState>,
private val snackbarDispatcher: SnackbarDispatcher,
actionListPresenterFactory: ActionListPresenter.Factory,
@Assisted private val actionListPresenter: Presenter<ActionListState>,
private val appCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
) : Presenter<PinnedMessagesListState> {
@AssistedFactory
interface Factory {
fun create(navigator: PinnedMessagesListNavigator): PinnedMessagesListPresenter
fun create(
navigator: PinnedMessagesListNavigator,
actionListPresenter: Presenter<ActionListState>,
): 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 {

View File

@@ -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
)
}

View File

@@ -63,6 +63,7 @@ class DefaultHtmlConverterProvider @Inject constructor(
return TextDisplay.Custom(mentionSpan)
}
},
isEditor = false,
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
).apply {
configureWith(editorStyle)

View File

@@ -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,

View File

@@ -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,
)
)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
) {

View File

@@ -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(

View File

@@ -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<ReactionSummaryState> {
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(

View File

@@ -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

View File

@@ -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,

View File

@@ -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(

View File

@@ -17,14 +17,20 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineI
get() = sequenceOf(
aTimelineItemAudioContent("A sound.mp3"),
aTimelineItemAudioContent("A bigger name sound.mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit.mp3"),
aTimelineItemAudioContent(caption = "A caption"),
aTimelineItemAudioContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
)
}
fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAudioContent(
fun aTimelineItemAudioContent(
fileName: String = "A sound.mp3",
caption: String? = null,
) = TimelineItemAudioContent(
filename = fileName,
caption = null,
caption = caption,
formattedCaption = null,
isEdited = false,
mimeType = MimeTypes.Mp3,
formattedFileSize = "100kB",
fileExtension = "mp3",

View File

@@ -33,6 +33,24 @@ open class TimelineItemEncryptedContentProvider : PreviewParameterProvider<Timel
utdCause = UtdCause.UnsignedDevice,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.HistoricalMessage,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.WithheldUnverifiedOrInsecureDevice,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.WithheldBySender,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",

View File

@@ -8,17 +8,29 @@
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.media.MediaSource
@Immutable
sealed interface TimelineItemEventContent {
val type: String
}
interface TimelineItemEventMutableContent {
/** Whether the event has been edited. */
val isEdited: Boolean
}
@Immutable
sealed interface TimelineItemEventContentWithAttachment : TimelineItemEventContent {
sealed interface TimelineItemEventContentWithAttachment :
TimelineItemEventContent,
TimelineItemEventMutableContent {
val filename: String
val caption: String?
val formattedCaption: CharSequence?
val mediaSource: MediaSource
val mimeType: String
val formattedFileSize: String
val fileExtension: String
val bestDescription: String
get() = caption ?: filename
@@ -74,9 +86,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
/**
* Whether the event content has been edited.
*/
fun TimelineItemEventContent.isEdited(): Boolean =
when (this) {
is TimelineItemTextBasedContent -> isEdited
is TimelineItemPollContent -> isEdited
else -> false
}
fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
is TimelineItemEventMutableContent -> isEdited
else -> false
}

View File

@@ -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"

View File

@@ -16,18 +16,22 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
get() = sequenceOf(
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger name file.pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit.pdf"),
aTimelineItemFileContent(caption = "A caption"),
aTimelineItemFileContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
)
}
fun aTimelineItemFileContent(
fileName: String = "A file.pdf",
caption: String? = null,
) = TimelineItemFileContent(
filename = fileName,
caption = null,
caption = caption,
formattedCaption = null,
isEdited = false,
thumbnailSource = null,
fileSource = MediaSource(url = ""),
mediaSource = MediaSource(url = ""),
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",
fileExtension = "pdf"

View File

@@ -17,11 +17,12 @@ data class TimelineItemImageContent(
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?,

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