Merge branch 'release/0.7.5' into main
This commit is contained in:
2
.github/workflows/maestro.yml
vendored
2
.github/workflows/maestro.yml
vendored
@@ -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 }}
|
||||
|
||||
50
CHANGES.md
50
CHANGES.md
@@ -1,3 +1,53 @@
|
||||
Changes in Element X v0.7.4 (2024-11-20)
|
||||
========================================
|
||||
|
||||
## What's Changed
|
||||
### 🙌 Improvements
|
||||
* Update the strings for unsupported calls by @bmarty in https://github.com/element-hq/element-x-android/pull/3857
|
||||
### 🐛 Bugfixes
|
||||
* Stop incoming call ringing if answered on another device. by @bmarty in https://github.com/element-hq/element-x-android/pull/3842
|
||||
* Use formatted captions for images and video by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3864
|
||||
* Fix unified push unregister by @bmarty in https://github.com/element-hq/element-x-android/pull/3877
|
||||
* Hide the keyboard when navigating from the chat room screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3878
|
||||
* Fix long click not working for media timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3879
|
||||
* Instantiate the verification controller ASAP by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3893
|
||||
* fix : display security banner for room list empty state by @ganfra in https://github.com/element-hq/element-x-android/pull/3892
|
||||
### 🗣 Translations
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3852
|
||||
* Sync Strings - add translations to Finnish by @ElementBot in https://github.com/element-hq/element-x-android/pull/3883
|
||||
### 🚧 In development 🚧
|
||||
* Create room : improve handling of room address by @ganfra in https://github.com/element-hq/element-x-android/pull/3868
|
||||
### Dependency upgrades
|
||||
* Update anvil to v0.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3792
|
||||
* Update kotlin to v2.0.21-1.0.27 by @renovate in https://github.com/element-hq/element-x-android/pull/3836
|
||||
* Update dependency org.maplibre.gl:android-sdk to v11.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3793
|
||||
* Update android.gradle.plugin to v8.7.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3785
|
||||
* Update lifecycle to v2.8.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3763
|
||||
* Update plugin dependencycheck to v11 by @renovate in https://github.com/element-hq/element-x-android/pull/3723
|
||||
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.61 by @renovate in https://github.com/element-hq/element-x-android/pull/3841
|
||||
* Update mobile-dev-inc/action-maestro-cloud action to v1.9.6 by @renovate in https://github.com/element-hq/element-x-android/pull/3846
|
||||
* Update dependency com.posthog:posthog-android to v3.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3856
|
||||
* Update core to v1.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3766
|
||||
* Update dependency com.android.tools:desugar_jdk_libs to v2.1.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3825
|
||||
* Update dependency io.nlopez.compose.rules:detekt to v0.4.18 by @renovate in https://github.com/element-hq/element-x-android/pull/3860
|
||||
* Update dependency com.posthog:posthog-android to v3.9.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3861
|
||||
* Update dependency io.sentry:sentry-android to v7.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3862
|
||||
* Update dependency androidx.compose:compose-bom to v2024.11.00 by @renovate in https://github.com/element-hq/element-x-android/pull/3869
|
||||
* Update telephoto to v0.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3870
|
||||
* Update SDK bindings version to `0.2.62` and fix `SendHandle` usages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3876
|
||||
* Update codecov/codecov-action action to v5 by @renovate in https://github.com/element-hq/element-x-android/pull/3874
|
||||
* Update dependency com.google.firebase:firebase-bom to v33.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3880
|
||||
* Update kotlin to v2.0.21-1.0.28 by @renovate in https://github.com/element-hq/element-x-android/pull/3881
|
||||
* Update dependency org.robolectric:robolectric to v4.14 by @renovate in https://github.com/element-hq/element-x-android/pull/3882
|
||||
* Update appyx to v1.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3889
|
||||
* Update dependency io.nlopez.compose.rules:detekt to v0.4.19 by @renovate in https://github.com/element-hq/element-x-android/pull/3900
|
||||
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.63 by @renovate in https://github.com/element-hq/element-x-android/pull/3898
|
||||
### Others
|
||||
* Design system : implement new TextField by @ganfra in https://github.com/element-hq/element-x-android/pull/3834
|
||||
* Remove :samples:minimal module by @bmarty in https://github.com/element-hq/element-x-android/pull/3871
|
||||
* Replace `textPlaceholder` color usages with `textSecondary` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3873
|
||||
* Room Preview API changes by @ganfra in https://github.com/element-hq/element-x-android/pull/3875
|
||||
|
||||
Changes in Element X v0.7.3 (2024-11-08)
|
||||
========================================
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
2
fastlane/metadata/android/en-US/changelogs/40007050.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40007050.txt
Normal 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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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: ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _"</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>
|
||||
|
||||
@@ -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">"Ορισμένοι χαρακτήρες δεν επιτρέπονται. Υποστηρίζονται μόνο γράμματα, ψηφία και τα ακόλουθα σύμβολα ! $ & \'() * +/; = ? @ [] - . _"</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _"</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<string name="screen_app_lock_biometric_authentication">"l’authentification 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ internal fun MessagesViewWithIdentityChangePreview(
|
||||
onEventContentClick = { false },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
onPreviewAttachments = {},
|
||||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ class DefaultHtmlConverterProvider @Inject constructor(
|
||||
return TextDisplay.Custom(mentionSpan)
|
||||
}
|
||||
},
|
||||
isEditor = false,
|
||||
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
|
||||
).apply {
|
||||
configureWith(editorStyle)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user