Merge branch 'develop' into renovate/lifecycle

This commit is contained in:
ganfra 2024-11-08 17:21:39 +01:00 committed by GitHub
commit 0ca2bd0585
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 746 additions and 322 deletions

View file

@ -27,10 +27,18 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider { class MatrixClientsHolder @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
) : MatrixClientProvider {
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>() private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
private val restoreMutex = Mutex() private val restoreMutex = Mutex()
init {
authenticationService.listenToNewMatrixClients { matrixClient ->
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
}
}
fun removeAll() { fun removeAll() {
sessionIdsToMatrixClient.clear() sessionIdsToMatrixClient.clear()
} }

View file

@ -81,4 +81,17 @@ class MatrixClientsHolderTest {
matrixClientsHolder.restoreWithSavedState(savedStateMap) matrixClientsHolder.restoreWithSavedState(savedStateMap)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
} }
@Test
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID))
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
assertThat(loginSucceeded.isSuccess).isTrue()
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNotNull()
}
} }

View file

@ -35,6 +35,7 @@ import androidx.core.content.IntentCompat
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallType.ExternalUrl
import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.pip.PictureInPictureEvents import io.element.android.features.call.impl.pip.PictureInPictureEvents
@ -44,11 +45,14 @@ import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("ElementCallActivity")
class ElementCallActivity : class ElementCallActivity :
AppCompatActivity(), AppCompatActivity(),
CallScreenNavigator, CallScreenNavigator,
@ -132,7 +136,7 @@ class ElementCallActivity :
DisposableEffect(Unit) { DisposableEffect(Unit) {
val listener = Runnable { val listener = Runnable {
if (requestPermissionCallback != null) { if (requestPermissionCallback != null) {
Timber.w("Ignoring onUserLeaveHint event because user is asked to grant permissions") Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
} else { } else {
pipEventSink(PictureInPictureEvents.EnterPictureInPicture) pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
} }
@ -146,7 +150,7 @@ class ElementCallActivity :
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo -> val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode)) pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.d("Exiting PiP mode: Hangup the call") Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
eventSink?.invoke(CallScreenEvents.Hangup) eventSink?.invoke(CallScreenEvents.Hangup)
} }
} }
@ -185,23 +189,23 @@ class ElementCallActivity :
private fun setCallType(intent: Intent?) { private fun setCallType(intent: Intent?) {
val callType = intent?.let { val callType = intent?.let {
IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java) IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
} }
val intentUrl = intent?.dataString?.let(::parseUrl) val currentCallType = webViewTarget.value
when { if (currentCallType == null && callType == null) {
// Re-opened the activity but we have no url to load or a cached one, finish the activity Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
intent?.dataString == null && callType == null && webViewTarget.value == null -> finish() finish()
callType != null -> { } else if (currentCallType == null) {
webViewTarget.value = callType Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
presenter = presenterFactory.create(callType, this) webViewTarget.value = callType
} presenter = presenterFactory.create(callType!!, this)
intentUrl != null -> { } else if (callType != currentCallType) {
val fallbackInputs = CallType.ExternalUrl(intentUrl) Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
webViewTarget.value = fallbackInputs setIntent(intent)
presenter = presenterFactory.create(fallbackInputs, this) recreate()
} } else {
// Coming back from notification, do nothing Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
else -> return
} }
} }

View file

@ -175,7 +175,12 @@ class ConfigureRoomPresenter @Inject constructor(
} }
private suspend fun uploadAvatar(avatarUri: Uri): String { private suspend fun uploadAvatar(avatarUri: Uri): String {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() val preprocessed = mediaPreProcessor.process(
uri = avatarUri,
mimeType = MimeTypes.Jpeg,
deleteOriginal = false,
compressIfPossible = false,
).getOrThrow()
val byteArray = preprocessed.file.readBytes() val byteArray = preprocessed.file.readBytes()
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow() return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
} }

View file

@ -45,6 +45,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.analytics.noop)
testImplementation(projects.libraries.permissions.impl) testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)

View file

@ -8,7 +8,10 @@
package io.element.android.features.ftue.impl package io.element.android.features.ftue.impl
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
@ -31,12 +34,14 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -80,14 +85,17 @@ class FtueFlowNode @AssistedInject constructor(
super.onBuilt() super.onBuilt()
lifecycle.subscribe(onCreate = { lifecycle.subscribe(onCreate = {
lifecycleScope.launch { moveToNextStep() } moveToNextStepIfNeeded()
}) })
analyticsService.didAskUserConsent() analyticsService.didAskUserConsent()
.distinctUntilChanged() .distinctUntilChanged()
.onEach { .onEach { moveToNextStepIfNeeded() }
lifecycleScope.launch { moveToNextStep() } .launchIn(lifecycleScope)
}
ftueState.isVerificationStatusKnown
.filter { it }
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
@ -99,7 +107,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.SessionVerification -> { NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback { val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() { override fun onDone() {
lifecycleScope.launch { moveToNextStep() } moveToNextStepIfNeeded()
} }
} }
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback)) createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
@ -107,7 +115,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.NotificationsOptIn -> { NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback { val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() { override fun onNotificationsOptInFinished() {
lifecycleScope.launch { moveToNextStep() } moveToNextStepIfNeeded()
} }
} }
createNode<NotificationsOptInNode>(buildContext, listOf(callback)) createNode<NotificationsOptInNode>(buildContext, listOf(callback))
@ -118,7 +126,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.LockScreenSetup -> { NavTarget.LockScreenSetup -> {
val callback = object : LockScreenEntryPoint.Callback { val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() { override fun onSetupDone() {
lifecycleScope.launch { moveToNextStep() } moveToNextStepIfNeeded()
} }
} }
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup) lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
@ -128,8 +136,11 @@ class FtueFlowNode @AssistedInject constructor(
} }
} }
private fun moveToNextStep() = lifecycleScope.launch { private fun moveToNextStepIfNeeded() = lifecycleScope.launch {
when (ftueState.getNextStep()) { when (ftueState.getNextStep()) {
FtueStep.WaitingForInitialState -> {
backstack.newRoot(NavTarget.Placeholder)
}
FtueStep.SessionVerification -> { FtueStep.SessionVerification -> {
backstack.newRoot(NavTarget.SessionVerification) backstack.newRoot(NavTarget.SessionVerification)
} }
@ -155,7 +166,14 @@ class FtueFlowNode @AssistedInject constructor(
class PlaceholderNode @AssistedInject constructor( class PlaceholderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) ) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
} }
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() { private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {

View file

@ -24,18 +24,14 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.timeout
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
@ -50,6 +46,14 @@ class DefaultFtueService @Inject constructor(
) : FtueService { ) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown) override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
/**
* This flow emits true when the FTUE flow is ready to be displayed.
* In this case, the FTUE flow is ready when the session verification status is known.
*/
val isVerificationStatusKnown = sessionVerificationService.sessionVerifiedStatus
.map { it != SessionVerifiedStatus.Unknown }
.distinctUntilChanged()
override suspend fun reset() { override suspend fun reset() {
analyticsService.reset() analyticsService.reset()
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
@ -70,7 +74,12 @@ class DefaultFtueService @Inject constructor(
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? = suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) { when (currentStep) {
null -> if (isSessionNotVerified()) { null -> if (!isSessionVerificationStateReady()) {
FtueStep.WaitingForInitialState
} else {
getNextStep(FtueStep.WaitingForInitialState)
}
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
FtueStep.SessionVerification FtueStep.SessionVerification
} else { } else {
getNextStep(FtueStep.SessionVerification) getNextStep(FtueStep.SessionVerification)
@ -90,34 +99,18 @@ class DefaultFtueService @Inject constructor(
} else { } else {
getNextStep(FtueStep.AnalyticsOptIn) getNextStep(FtueStep.AnalyticsOptIn)
} }
FtueStep.AnalyticsOptIn -> { FtueStep.AnalyticsOptIn -> null
updateState()
null
}
} }
private suspend fun isAnyStepIncomplete(): Boolean { private fun isSessionVerificationStateReady(): Boolean {
return listOf<suspend () -> Boolean>( return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
{ isSessionNotVerified() },
{ shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() },
{ shouldDisplayLockscreenSetup() },
).any { it() }
} }
@OptIn(FlowPreview::class)
private suspend fun isSessionNotVerified(): Boolean { private suspend fun isSessionNotVerified(): Boolean {
// Wait for the first known (or ready) verification status // Wait until the session verification status is known
val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus isVerificationStatusKnown.filter { it }.first()
.filter { it != SessionVerifiedStatus.Unknown }
// This is not ideal, but there are some very rare cases when reading the flow seems to get stuck return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
.timeout(5.seconds)
.catch {
Timber.e(it, "Failed to get session verification status, assume it's not verified")
emit(SessionVerifiedStatus.NotVerified)
}
.first()
return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !canSkipVerification()
} }
private suspend fun canSkipVerification(): Boolean { private suspend fun canSkipVerification(): Boolean {
@ -145,14 +138,17 @@ class DefaultFtueService @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal suspend fun updateState() { internal suspend fun updateState() {
val nextStep = getNextStep()
state.value = when { state.value = when {
isAnyStepIncomplete() -> FtueState.Incomplete // Final state, there aren't any more next steps
else -> FtueState.Complete nextStep == null -> FtueState.Complete
else -> FtueState.Incomplete
} }
} }
} }
sealed interface FtueStep { sealed interface FtueStep {
data object WaitingForInitialState : FtueStep
data object SessionVerification : FtueStep data object SessionVerification : FtueStep
data object NotificationsOptIn : FtueStep data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep data object AnalyticsOptIn : FtueStep

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.noop.NoopAnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -73,6 +74,27 @@ class DefaultFtueServiceTest {
assertThat(service.state.value).isEqualTo(FtueState.Complete) assertThat(service.state.value).isEqualTo(FtueState.Complete)
} }
@Test
fun `given all checks being true with no analytics, FtueState is Complete`() = runTest {
val analyticsService = NoopAnalyticsService()
val sessionVerificationService = FakeSessionVerificationService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService()
val service = createDefaultFtueService(
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
service.updateState()
assertThat(service.state.value).isEqualTo(FtueState.Complete)
}
@Test @Test
fun `traverse flow`() = runTest { fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply { val sessionVerificationService = FakeSessionVerificationService().apply {

View file

@ -12,5 +12,6 @@ import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed interface AttachmentsPreviewEvents { sealed interface AttachmentsPreviewEvents {
data object SendAttachment : AttachmentsPreviewEvents data object SendAttachment : AttachmentsPreviewEvents
data object Cancel : AttachmentsPreviewEvents
data object ClearSendState : AttachmentsPreviewEvents data object ClearSendState : AttachmentsPreviewEvents
} }

View file

@ -31,7 +31,14 @@ class AttachmentsPreviewNode @AssistedInject constructor(
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.attachment) private val onDoneListener = OnDoneListener {
navigateUp()
}
private val presenter = presenterFactory.create(
attachment = inputs.attachment,
onDoneListener = onDoneListener,
)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
@ -39,7 +46,6 @@ class AttachmentsPreviewNode @AssistedInject constructor(
val state = presenter.present() val state = presenter.present()
AttachmentsPreviewView( AttachmentsPreviewView(
state = state, state = state,
onDismiss = this::navigateUp,
modifier = modifier modifier = modifier
) )
} }

View file

@ -18,6 +18,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@ -34,12 +35,17 @@ import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor( class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment, @Assisted private val attachment: Attachment,
@Assisted private val onDoneListener: OnDoneListener,
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
) : Presenter<AttachmentsPreviewState> { ) : Presenter<AttachmentsPreviewState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(attachment: Attachment): AttachmentsPreviewPresenter fun create(
attachment: Attachment,
onDoneListener: OnDoneListener,
): AttachmentsPreviewPresenter
} }
@Composable @Composable
@ -68,6 +74,9 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
sendActionState = sendActionState, sendActionState = sendActionState,
) )
} }
AttachmentsPreviewEvents.Cancel -> {
coroutineScope.cancel(attachment)
}
AttachmentsPreviewEvents.ClearSendState -> { AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let { ongoingSendAttachmentJob.value?.let {
it.cancel() it.cancel()
@ -102,6 +111,18 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
} }
} }
private fun CoroutineScope.cancel(
attachment: Attachment,
) = launch {
// Delete the temporary file
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
}
}
onDoneListener()
}
private suspend fun sendMedia( private suspend fun sendMedia(
mediaAttachment: Attachment.Media, mediaAttachment: Attachment.Media,
caption: String?, caption: String?,
@ -124,7 +145,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
).getOrThrow() ).getOrThrow()
}.fold( }.fold(
onSuccess = { onSuccess = {
sendActionState.value = SendActionState.Done onDoneListener()
}, },
onFailure = { error -> onFailure = { error ->
Timber.e(error, "Failed to send attachment") Timber.e(error, "Failed to send attachment")

View file

@ -36,5 +36,4 @@ sealed interface SendActionState {
} }
data class Failure(val error: Throwable) : SendActionState data class Failure(val error: Throwable) : SendActionState
data object Done : SendActionState
} }

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.attachments.preview package io.element.android.features.messages.impl.attachments.preview
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
@ -17,9 +18,6 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -50,22 +48,22 @@ import me.saket.telephoto.zoomable.rememberZoomableState
@Composable @Composable
fun AttachmentsPreviewView( fun AttachmentsPreviewView(
state: AttachmentsPreviewState, state: AttachmentsPreviewState,
onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun postSendAttachment() { fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvents.SendAttachment) state.eventSink(AttachmentsPreviewEvents.SendAttachment)
} }
fun postCancel() {
state.eventSink(AttachmentsPreviewEvents.Cancel)
}
fun postClearSendState() { fun postClearSendState() {
state.eventSink(AttachmentsPreviewEvents.ClearSendState) state.eventSink(AttachmentsPreviewEvents.ClearSendState)
} }
if (state.sendActionState is SendActionState.Done) { BackHandler(enabled = state.sendActionState !is SendActionState.Sending) {
val latestOnDismiss by rememberUpdatedState(onDismiss) postCancel()
LaunchedEffect(state.sendActionState) {
latestOnDismiss()
}
} }
Scaffold( Scaffold(
@ -75,7 +73,7 @@ fun AttachmentsPreviewView(
navigationIcon = { navigationIcon = {
BackButton( BackButton(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
onClick = onDismiss, onClick = ::postCancel,
) )
}, },
title = {}, title = {},
@ -202,6 +200,5 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView( AttachmentsPreviewView(
state = state, state = state,
onDismiss = {},
) )
} }

View file

@ -0,0 +1,12 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview
fun interface OnDoneListener {
operator fun invoke()
}

View file

@ -51,7 +51,6 @@ import io.element.android.libraries.designsystem.components.blurhash.blurHashBac
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
@ -86,13 +85,7 @@ fun TimelineItemImageView(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier), .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData( model = content.thumbnailMediaRequestData,
source = content.preferredMediaSource,
kind = MediaRequestData.Kind.File(
fileName = content.filename,
mimeType = content.mimeType,
),
),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
alignment = Alignment.Center, alignment = Alignment.Center,
contentDescription = description, contentDescription = description,

View file

@ -57,6 +57,8 @@ import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -97,9 +99,9 @@ fun TimelineItemVideoView(
.then(if (isLoaded) Modifier.background(Color.White) else Modifier), .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData( model = MediaRequestData(
source = content.thumbnailSource, source = content.thumbnailSource,
kind = MediaRequestData.Kind.File( kind = MediaRequestData.Kind.Thumbnail(
fileName = content.filename, width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH,
mimeType = content.mimeType height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT,
) )
), ),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,

View file

@ -93,6 +93,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
blurhash = messageType.info?.blurhash, blurhash = messageType.info?.blurhash,
width = messageType.info?.width?.toInt(), width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(), height = messageType.info?.height?.toInt(),
thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename) fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
@ -146,6 +148,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(), width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(), height = messageType.info?.height?.toInt(),
thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
duration = messageType.info?.duration ?: Duration.ZERO, duration = messageType.info?.duration ?: Duration.ZERO,
blurHash = messageType.info?.blurhash, blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,

View file

@ -7,9 +7,12 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData
data class TimelineItemImageContent( data class TimelineItemImageContent(
override val filename: String, override val filename: String,
@ -23,15 +26,31 @@ data class TimelineItemImageContent(
val blurhash: String?, val blurhash: String?,
val width: Int?, val width: Int?,
val height: Int?, val height: Int?,
val thumbnailWidth: Int?,
val thumbnailHeight: Int?,
val aspectRatio: Float? val aspectRatio: Float?
) : TimelineItemEventContentWithAttachment { ) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemImageContent" override val type: String = "TimelineItemImageContent"
val showCaption = caption != null val showCaption = caption != null
val preferredMediaSource = if (mimeType == MimeTypes.Gif) { val thumbnailMediaRequestData: MediaRequestData by lazy {
mediaSource if (mimeType.isMimeTypeAnimatedImage()) {
} else { MediaRequestData(
thumbnailSource ?: mediaSource source = mediaSource,
kind = MediaRequestData.Kind.File(
fileName = filename,
mimeType = mimeType
)
)
} else {
MediaRequestData(
source = thumbnailSource ?: mediaSource,
kind = MediaRequestData.Kind.Thumbnail(
width = thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH,
height = thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT
),
)
}
} }
} }

View file

@ -37,6 +37,8 @@ fun aTimelineItemImageContent(
blurhash = blurhash, blurhash = blurhash,
width = null, width = null,
height = 300, height = 300,
thumbnailWidth = null,
thumbnailHeight = 150,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
formattedFileSize = "4MB", formattedFileSize = "4MB",
fileExtension = "jpg" fileExtension = "jpg"

View file

@ -22,6 +22,8 @@ data class TimelineItemVideoContent(
val blurHash: String?, val blurHash: String?,
val height: Int?, val height: Int?,
val width: Int?, val width: Int?,
val thumbnailWidth: Int?,
val thumbnailHeight: Int?,
val mimeType: String, val mimeType: String,
val formattedFileSize: String, val formattedFileSize: String,
val fileExtension: String, val fileExtension: String,

View file

@ -35,8 +35,10 @@ fun aTimelineItemVideoContent(
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
duration = 100.milliseconds, duration = 100.milliseconds,
videoSource = MediaSource(""), videoSource = MediaSource(""),
height = 300,
width = 150, width = 150,
height = 300,
thumbnailWidth = 150,
thumbnailHeight = 300,
mimeType = MimeTypes.Mp4, mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB", formattedFileSize = "14MB",
fileExtension = "mp4" fileExtension = "mp4"

View file

@ -324,6 +324,8 @@ class MessagesPresenterTest {
blurhash = null, blurhash = null,
width = 20, width = 20,
height = 20, height = 20,
thumbnailWidth = null,
thumbnailHeight = null,
aspectRatio = 1.0f, aspectRatio = 1.0f,
fileExtension = "jpg", fileExtension = "jpg",
formattedFileSize = "4MB" formattedFileSize = "4MB"
@ -364,6 +366,8 @@ class MessagesPresenterTest {
blurHash = null, blurHash = null,
width = 20, width = 20,
height = 20, height = 20,
thumbnailWidth = 20,
thumbnailHeight = 20,
aspectRatio = 1.0f, aspectRatio = 1.0f,
fileExtension = "mp4", fileExtension = "mp4",
formattedFileSize = "50MB" formattedFileSize = "50MB"

View file

@ -16,8 +16,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
import io.element.android.features.messages.impl.attachments.preview.SendActionState import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.fixtures.aMediaAttachment import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
@ -35,11 +37,13 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -67,7 +71,11 @@ class AttachmentsPreviewPresenterTest {
), ),
sendFileResult = sendFileResult, sendFileResult = sendFileResult,
) )
val presenter = createAttachmentsPreviewPresenter(room = room) val onDoneListener = lambdaRecorder<Unit> { }
val presenter = createAttachmentsPreviewPresenter(
room = room,
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -78,9 +86,28 @@ class AttachmentsPreviewPresenterTest {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
val successState = awaitItem() advanceUntilIdle()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - cancel scenario`() = runTest {
val onDoneListener = lambdaRecorder<Unit> { }
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createAttachmentsPreviewPresenter(
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.Cancel)
deleteCallback.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
} }
} }
@ -96,9 +123,11 @@ class AttachmentsPreviewPresenterTest {
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
sendImageResult = sendImageResult, sendImageResult = sendImageResult,
) )
val onDoneListener = lambdaRecorder<Unit> { }
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
room = room, room = room,
mediaPreProcessor = mediaPreProcessor, mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -108,8 +137,7 @@ class AttachmentsPreviewPresenterTest {
initialState.textEditorState.setMarkdown(A_CAPTION) initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
val successState = awaitItem() advanceUntilIdle()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
sendImageResult.assertions().isCalledOnce().with( sendImageResult.assertions().isCalledOnce().with(
any(), any(),
any(), any(),
@ -118,6 +146,7 @@ class AttachmentsPreviewPresenterTest {
any(), any(),
any(), any(),
) )
onDoneListener.assertions().isCalledOnce()
} }
} }
@ -133,9 +162,11 @@ class AttachmentsPreviewPresenterTest {
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
sendVideoResult = sendVideoResult, sendVideoResult = sendVideoResult,
) )
val onDoneListener = lambdaRecorder<Unit> { }
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
room = room, room = room,
mediaPreProcessor = mediaPreProcessor, mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -145,8 +176,7 @@ class AttachmentsPreviewPresenterTest {
initialState.textEditorState.setMarkdown(A_CAPTION) initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
val successState = awaitItem() advanceUntilIdle()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
sendVideoResult.assertions().isCalledOnce().with( sendVideoResult.assertions().isCalledOnce().with(
any(), any(),
any(), any(),
@ -155,6 +185,7 @@ class AttachmentsPreviewPresenterTest {
any(), any(),
any(), any(),
) )
onDoneListener.assertions().isCalledOnce()
} }
} }
@ -207,11 +238,15 @@ class AttachmentsPreviewPresenterTest {
room: MatrixRoom = FakeMatrixRoom(), room: MatrixRoom = FakeMatrixRoom(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
onDoneListener: OnDoneListener = OnDoneListener {},
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia),
onDoneListener = onDoneListener,
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter,
) )
} }
} }

View file

@ -246,6 +246,8 @@ class TimelineItemContentMessageFactoryTest {
width = null, width = null,
mimeType = MimeTypes.OctetStream, mimeType = MimeTypes.OctetStream,
formattedFileSize = "0 Bytes", formattedFileSize = "0 Bytes",
thumbnailWidth = null,
thumbnailHeight = null,
fileExtension = "", fileExtension = "",
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
@ -294,6 +296,8 @@ class TimelineItemContentMessageFactoryTest {
width = 300, width = 300,
mimeType = MimeTypes.Mp4, mimeType = MimeTypes.Mp4,
formattedFileSize = "555 Bytes", formattedFileSize = "555 Bytes",
thumbnailWidth = 5,
thumbnailHeight = 10,
fileExtension = "mp4", fileExtension = "mp4",
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
@ -458,6 +462,8 @@ class TimelineItemContentMessageFactoryTest {
blurhash = null, blurhash = null,
width = null, width = null,
height = null, height = null,
thumbnailWidth = null,
thumbnailHeight = null,
aspectRatio = null aspectRatio = null
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
@ -531,6 +537,8 @@ class TimelineItemContentMessageFactoryTest {
blurhash = A_BLUR_HASH, blurhash = A_BLUR_HASH,
width = 5, width = 5,
height = 10, height = 10,
thumbnailWidth = 5,
thumbnailHeight = 10,
aspectRatio = 0.5f, aspectRatio = 0.5f,
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)

View file

@ -22,6 +22,7 @@ import androidx.core.net.toUri
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -43,6 +44,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
private val temporaryUriDeleter: TemporaryUriDeleter,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<EditUserProfileState> { ) : Presenter<EditUserProfileState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@ -59,10 +61,20 @@ class EditUserProfilePresenter @AssistedInject constructor(
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) } var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) } var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) userAvatarUri = uri } onResult = { uri ->
if (uri != null) {
temporaryUriDeleter.delete(userAvatarUri)
userAvatarUri = uri
}
}
) )
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri -> if (uri != null) userAvatarUri = uri } onResult = { uri ->
if (uri != null) {
temporaryUriDeleter.delete(userAvatarUri)
userAvatarUri = uri
}
}
) )
val avatarActions by remember(userAvatarUri) { val avatarActions by remember(userAvatarUri) {
@ -96,7 +108,10 @@ class EditUserProfilePresenter @AssistedInject constructor(
pendingPermissionRequest = true pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
} }
AvatarAction.Remove -> userAvatarUri = null AvatarAction.Remove -> {
temporaryUriDeleter.delete(userAvatarUri)
userAvatarUri = null
}
} }
} }
@ -155,7 +170,12 @@ class EditUserProfilePresenter @AssistedInject constructor(
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> { private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
return runCatching { return runCatching {
if (avatarUri != null) { if (avatarUri != null) {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() val preprocessed = mediaPreProcessor.process(
uri = avatarUri,
mimeType = MimeTypes.Jpeg,
deleteOriginal = false,
compressIfPossible = false,
).getOrThrow()
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow() matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
} else { } else {
matrixClient.removeAvatar().getOrThrow() matrixClient.removeAvatar().getOrThrow()

View file

@ -12,6 +12,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -29,6 +30,9 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
@ -73,12 +77,14 @@ class EditUserProfilePresenterTest {
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
matrixUser: MatrixUser = aMatrixUser(), matrixUser: MatrixUser = aMatrixUser(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
): EditUserProfilePresenter { ): EditUserProfilePresenter {
return EditUserProfilePresenter( return EditUserProfilePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
matrixUser = matrixUser, matrixUser = matrixUser,
mediaPickerProvider = fakePickerProvider, mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor, mediaPreProcessor = fakeMediaPreProcessor,
temporaryUriDeleter = temporaryUriDeleter,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
) )
} }
@ -107,7 +113,12 @@ class EditUserProfilePresenterTest {
@Test @Test
fun `present - updates state in response to changes`() = runTest { fun `present - updates state in response to changes`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(matrixUser = user) val presenter = createEditUserProfilePresenter(
matrixUser = user,
temporaryUriDeleter = FakeTemporaryUriDeleter(
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -136,7 +147,12 @@ class EditUserProfilePresenterTest {
fun `present - obtains avatar uris from gallery`() = runTest { fun `present - obtains avatar uris from gallery`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = createEditUserProfilePresenter(matrixUser = user) val presenter = createEditUserProfilePresenter(
matrixUser = user,
temporaryUriDeleter = FakeTemporaryUriDeleter(
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -154,9 +170,13 @@ class EditUserProfilePresenterTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter() val fakePermissionsPresenter = FakePermissionsPresenter()
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createEditUserProfilePresenter( val presenter = createEditUserProfilePresenter(
matrixUser = user, matrixUser = user,
permissionsPresenter = fakePermissionsPresenter, permissionsPresenter = fakePermissionsPresenter,
temporaryUriDeleter = FakeTemporaryUriDeleter(
deleteLambda = deleteCallback,
),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -177,6 +197,10 @@ class EditUserProfilePresenterTest {
stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithNewAvatar2 = awaitItem() val stateWithNewAvatar2 = awaitItem()
assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(userAvatarUri) assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(userAvatarUri)
deleteCallback.assertions().isCalledExactly(2).withSequence(
listOf(value(userAvatarUri)),
listOf(value(anotherAvatarUri)),
)
} }
} }
@ -184,7 +208,13 @@ class EditUserProfilePresenterTest {
fun `present - updates save button state`() = runTest { fun `present - updates save button state`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(userAvatarUri) fakePickerProvider.givenResult(userAvatarUri)
val presenter = createEditUserProfilePresenter(matrixUser = user) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createEditUserProfilePresenter(
matrixUser = user,
temporaryUriDeleter = FakeTemporaryUriDeleter(
deleteLambda = deleteCallback
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -210,6 +240,10 @@ class EditUserProfilePresenterTest {
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isFalse() assertThat(saveButtonEnabled).isFalse()
} }
deleteCallback.assertions().isCalledExactly(2).withSequence(
listOf(value(userAvatarUri)),
listOf(value(null)),
)
} }
} }
@ -217,7 +251,13 @@ class EditUserProfilePresenterTest {
fun `present - updates save button state when initial values are null`() = runTest { fun `present - updates save button state when initial values are null`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null)
fakePickerProvider.givenResult(userAvatarUri) fakePickerProvider.givenResult(userAvatarUri)
val presenter = createEditUserProfilePresenter(matrixUser = user) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createEditUserProfilePresenter(
matrixUser = user,
temporaryUriDeleter = FakeTemporaryUriDeleter(
deleteLambda = deleteCallback
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -243,6 +283,10 @@ class EditUserProfilePresenterTest {
awaitItem().apply { awaitItem().apply {
assertThat(saveButtonEnabled).isFalse() assertThat(saveButtonEnabled).isFalse()
} }
deleteCallback.assertions().isCalledExactly(2).withSequence(
listOf(value(null)),
listOf(value(userAvatarUri)),
)
} }
} }
@ -252,7 +296,10 @@ class EditUserProfilePresenterTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter( val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
matrixUser = user matrixUser = user,
temporaryUriDeleter = FakeTemporaryUriDeleter(
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -318,7 +365,10 @@ class EditUserProfilePresenterTest {
givenPickerReturnsFile() givenPickerReturnsFile()
val presenter = createEditUserProfilePresenter( val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
matrixUser = user matrixUser = user,
temporaryUriDeleter = FakeTemporaryUriDeleter(
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -337,7 +387,10 @@ class EditUserProfilePresenterTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter( val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
matrixUser = user matrixUser = user,
temporaryUriDeleter = FakeTemporaryUriDeleter(
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
),
) )
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no"))) fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
@ -403,7 +456,13 @@ class EditUserProfilePresenterTest {
} }
private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) { private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) {
val presenter = createEditUserProfilePresenter(matrixUser = matrixUser, matrixClient = matrixClient) val presenter = createEditUserProfilePresenter(
matrixUser = matrixUser,
matrixClient = matrixClient,
temporaryUriDeleter = FakeTemporaryUriDeleter(
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.net.toUri import androidx.core.net.toUri
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -45,6 +46,7 @@ class RoomDetailsEditPresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
private val temporaryUriDeleter: TemporaryUriDeleter,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<RoomDetailsEditState> { ) : Presenter<RoomDetailsEditState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@ -59,6 +61,7 @@ class RoomDetailsEditPresenter @Inject constructor(
var roomAvatarUriEdited by rememberSaveable { mutableStateOf<Uri?>(null) } var roomAvatarUriEdited by rememberSaveable { mutableStateOf<Uri?>(null) }
LaunchedEffect(roomAvatarUri) { LaunchedEffect(roomAvatarUri) {
// Every time the roomAvatar change (from sync), we can set the new avatar. // Every time the roomAvatar change (from sync), we can set the new avatar.
temporaryUriDeleter.delete(roomAvatarUriEdited)
roomAvatarUriEdited = roomAvatarUri roomAvatarUriEdited = roomAvatarUri
} }
@ -98,10 +101,20 @@ class RoomDetailsEditPresenter @Inject constructor(
} }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri } onResult = { uri ->
if (uri != null) {
temporaryUriDeleter.delete(roomAvatarUriEdited)
roomAvatarUriEdited = uri
}
}
) )
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri } onResult = { uri ->
if (uri != null) {
temporaryUriDeleter.delete(roomAvatarUriEdited)
roomAvatarUriEdited = uri
}
}
) )
LaunchedEffect(cameraPermissionState.permissionGranted) { LaunchedEffect(cameraPermissionState.permissionGranted) {
@ -143,7 +156,10 @@ class RoomDetailsEditPresenter @Inject constructor(
pendingPermissionRequest = true pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
} }
AvatarAction.Remove -> roomAvatarUriEdited = null AvatarAction.Remove -> {
temporaryUriDeleter.delete(roomAvatarUriEdited)
roomAvatarUriEdited = null
}
} }
} }
@ -202,7 +218,12 @@ class RoomDetailsEditPresenter @Inject constructor(
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> { private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
return runCatching { return runCatching {
if (avatarUri != null) { if (avatarUri != null) {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() val preprocessed = mediaPreProcessor.process(
uri = avatarUri,
mimeType = MimeTypes.Jpeg,
deleteOriginal = false,
compressIfPossible = false,
).getOrThrow()
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow() room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
} else { } else {
room.removeAvatar().getOrThrow() room.removeAvatar().getOrThrow()

View file

@ -8,14 +8,12 @@
package io.element.android.features.roomdetails.edit package io.element.android.features.roomdetails.edit
import android.net.Uri import android.net.Uri
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -31,9 +29,11 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
@ -46,6 +46,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
@Suppress("LargeClass")
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomDetailsEditPresenterTest { class RoomDetailsEditPresenterTest {
@get:Rule @get:Rule
@ -77,12 +78,14 @@ class RoomDetailsEditPresenterTest {
private fun createRoomDetailsEditPresenter( private fun createRoomDetailsEditPresenter(
room: MatrixRoom, room: MatrixRoom,
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
): RoomDetailsEditPresenter { ): RoomDetailsEditPresenter {
return RoomDetailsEditPresenter( return RoomDetailsEditPresenter(
room = room, room = room,
mediaPickerProvider = fakePickerProvider, mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor, mediaPreProcessor = fakeMediaPreProcessor,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
temporaryUriDeleter = temporaryUriDeleter,
) )
} }
@ -95,10 +98,12 @@ class RoomDetailsEditPresenterTest {
emitRoomInfo = true, emitRoomInfo = true,
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.roomId).isEqualTo(room.roomId) assertThat(initialState.roomId).isEqualTo(room.roomId)
assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME) assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME)
@ -127,10 +132,12 @@ class RoomDetailsEditPresenterTest {
} }
}, },
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
// Initially false // Initially false
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.canChangeName).isFalse() assertThat(initialState.canChangeName).isFalse()
@ -141,6 +148,7 @@ class RoomDetailsEditPresenterTest {
assertThat(settledState.canChangeName).isTrue() assertThat(settledState.canChangeName).isTrue()
assertThat(settledState.canChangeAvatar).isFalse() assertThat(settledState.canChangeAvatar).isFalse()
assertThat(settledState.canChangeTopic).isFalse() assertThat(settledState.canChangeTopic).isFalse()
deleteCallback.assertions().isCalledOnce().with(value(null))
} }
} }
@ -157,10 +165,12 @@ class RoomDetailsEditPresenterTest {
} }
} }
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
// Initially false // Initially false
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.canChangeName).isFalse() assertThat(initialState.canChangeName).isFalse()
@ -187,10 +197,12 @@ class RoomDetailsEditPresenterTest {
} }
} }
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
// Initially false // Initially false
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.canChangeName).isFalse() assertThat(initialState.canChangeName).isFalse()
@ -213,10 +225,12 @@ class RoomDetailsEditPresenterTest {
emitRoomInfo = true, emitRoomInfo = true,
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.roomTopic).isEqualTo("My topic") assertThat(initialState.roomTopic).isEqualTo("My topic")
assertThat(initialState.roomRawName).isEqualTo("Name") assertThat(initialState.roomRawName).isEqualTo("Name")
@ -258,10 +272,12 @@ class RoomDetailsEditPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
@ -282,13 +298,13 @@ class RoomDetailsEditPresenterTest {
) )
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter() val fakePermissionsPresenter = FakePermissionsPresenter()
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
room = room, room = room,
permissionsPresenter = fakePermissionsPresenter, permissionsPresenter = fakePermissionsPresenter,
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
@ -305,6 +321,12 @@ class RoomDetailsEditPresenterTest {
stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithNewAvatar2 = awaitItem() val stateWithNewAvatar2 = awaitItem()
assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(roomAvatarUri)
deleteCallback.assertions().isCalledExactly(4).withSequence(
listOf(value(null)),
listOf(value(null)),
listOf(value(roomAvatarUri)),
listOf(value(anotherAvatarUri)),
)
} }
} }
@ -318,10 +340,12 @@ class RoomDetailsEditPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
fakePickerProvider.givenResult(roomAvatarUri) fakePickerProvider.givenResult(roomAvatarUri)
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse() assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled // Once a change is made, the save button is enabled
@ -367,10 +391,12 @@ class RoomDetailsEditPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
fakePickerProvider.givenResult(roomAvatarUri) fakePickerProvider.givenResult(roomAvatarUri)
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse() assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled // Once a change is made, the save button is enabled
@ -421,10 +447,12 @@ class RoomDetailsEditPresenterTest {
removeAvatarResult = removeAvatarResult, removeAvatarResult = removeAvatarResult,
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name"))
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
@ -445,10 +473,12 @@ class RoomDetailsEditPresenterTest {
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name ")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic ")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
@ -465,14 +495,17 @@ class RoomDetailsEditPresenterTest {
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
deleteCallback.assertions().isCalledOnce().with(value(null))
} }
} }
@ -484,14 +517,17 @@ class RoomDetailsEditPresenterTest {
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
deleteCallback.assertions().isCalledOnce().with(value(null))
} }
} }
@ -506,15 +542,21 @@ class RoomDetailsEditPresenterTest {
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
givenPickerReturnsFile() givenPickerReturnsFile()
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(4) skipItems(4)
updateAvatarResult.assertions().isCalledOnce().with(value(MimeTypes.Jpeg), value(fakeFileContents)) updateAvatarResult.assertions().isCalledOnce().with(value(MimeTypes.Jpeg), value(fakeFileContents))
deleteCallback.assertions().isCalledExactly(2).withSequence(
listOf(value(null)),
listOf(value(null)),
)
} }
} }
@ -528,10 +570,12 @@ class RoomDetailsEditPresenterTest {
) )
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no"))) fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
@ -576,7 +620,7 @@ class RoomDetailsEditPresenterTest {
removeAvatarResult = { Result.failure(Throwable("!")) }, removeAvatarResult = { Result.failure(Throwable("!")) },
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 3)
} }
@Test @Test
@ -590,7 +634,7 @@ class RoomDetailsEditPresenterTest {
updateAvatarResult = { _, _ -> Result.failure(Throwable("!")) }, updateAvatarResult = { _, _ -> Result.failure(Throwable("!")) },
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 3)
} }
@Test @Test
@ -603,10 +647,12 @@ class RoomDetailsEditPresenterTest {
setTopicResult = { Result.failure(Throwable("!")) }, setTopicResult = { Result.failure(Throwable("!")) },
canSendStateResult = { _, _ -> Result.success(true) } canSendStateResult = { _, _ -> Result.success(true) }
) )
val presenter = createRoomDetailsEditPresenter(room) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
moleculeFlow(RecompositionMode.Immediate) { val presenter = createRoomDetailsEditPresenter(
presenter.present() room = room,
}.test { temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
@ -617,17 +663,24 @@ class RoomDetailsEditPresenterTest {
} }
} }
private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) { private suspend fun saveAndAssertFailure(
val presenter = createRoomDetailsEditPresenter(room) room: MatrixRoom,
moleculeFlow(RecompositionMode.Immediate) { event: RoomDetailsEditEvents,
presenter.present() deleteCallbackNumberOfInvocation: Int = 2,
}.test { ) {
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
room = room,
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(event) initialState.eventSink(event)
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(1) skipItems(1)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
deleteCallback.assertions().isCalledExactly(deleteCallbackNumberOfInvocation)
} }
} }

View file

@ -10,9 +10,7 @@ package io.element.android.features.verifysession.impl.incoming
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -35,6 +33,7 @@ import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.InvisibleButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -166,8 +165,7 @@ private fun IncomingVerificationBottomMenu(
enabled = false, enabled = false,
showProgress = true, showProgress = true,
) )
// Placeholder so the 1st button keeps its vertical position InvisibleButton()
Spacer(modifier = Modifier.height(40.dp))
} }
} else { } else {
VerificationBottomMenu { VerificationBottomMenu {
@ -194,8 +192,7 @@ private fun IncomingVerificationBottomMenu(
enabled = false, enabled = false,
showProgress = true, showProgress = true,
) )
// Placeholder so the 1st button keeps its vertical position InvisibleButton()
Spacer(modifier = Modifier.height(40.dp))
} }
} else { } else {
VerificationBottomMenu { VerificationBottomMenu {

View file

@ -12,10 +12,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -44,6 +42,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.InvisibleButton
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
@ -282,8 +281,7 @@ private fun VerifySelfSessionBottomMenu(
text = stringResource(CommonStrings.action_start_verification), text = stringResource(CommonStrings.action_start_verification),
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
) )
// Placeholder so the 1st button keeps its vertical position InvisibleButton()
Spacer(modifier = Modifier.height(40.dp))
} }
} }
is Step.Canceled -> { is Step.Canceled -> {
@ -293,8 +291,7 @@ private fun VerifySelfSessionBottomMenu(
text = stringResource(CommonStrings.action_done), text = stringResource(CommonStrings.action_done),
onClick = onCancelClick, onClick = onCancelClick,
) )
// Placeholder so the 1st button keeps its vertical position InvisibleButton()
Spacer(modifier = Modifier.height(40.dp))
} }
} }
is Step.Ready -> { is Step.Ready -> {
@ -320,8 +317,7 @@ private fun VerifySelfSessionBottomMenu(
showProgress = true, showProgress = true,
enabled = false, enabled = false,
) )
// Placeholder so the 1st button keeps its vertical position InvisibleButton()
Spacer(modifier = Modifier.height(40.dp))
} }
} }
is Step.Verifying -> { is Step.Verifying -> {
@ -335,17 +331,22 @@ private fun VerifySelfSessionBottomMenu(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = positiveButtonTitle, text = positiveButtonTitle,
showProgress = isVerifying, showProgress = isVerifying,
enabled = !isVerifying,
onClick = { onClick = {
if (!isVerifying) { if (!isVerifying) {
eventSink(VerifySelfSessionViewEvents.ConfirmVerification) eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
} }
}, },
) )
TextButton( if (isVerifying) {
modifier = Modifier.fillMaxWidth(), InvisibleButton()
text = stringResource(R.string.screen_session_verification_they_dont_match), } else {
onClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) }, TextButton(
) modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_dont_match),
onClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
)
}
} }
} }
is Step.Completed -> { is Step.Completed -> {
@ -355,8 +356,7 @@ private fun VerifySelfSessionBottomMenu(
text = stringResource(CommonStrings.action_continue), text = stringResource(CommonStrings.action_continue),
onClick = onContinueClick, onClick = onContinueClick,
) )
// Placeholder so the 1st button keeps its vertical position InvisibleButton()
Spacer(modifier = Modifier.height(48.dp))
} }
} }
is Step.Skipped -> return is Step.Skipped -> return

View file

@ -0,0 +1,39 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.androidutils.file
import android.content.Context
import android.net.Uri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
interface TemporaryUriDeleter {
/**
* Delete the Uri only if it is a temporary one.
*/
fun delete(uri: Uri?)
}
@ContributesBinding(AppScope::class)
class DefaultTemporaryUriDeleter @Inject constructor(
@ApplicationContext private val context: Context,
) : TemporaryUriDeleter {
private val baseCacheUri = "content://${context.packageName}.fileprovider/cache"
override fun delete(uri: Uri?) {
uri ?: return
if (uri.toString().startsWith(baseCacheUri)) {
context.contentResolver.delete(uri, null, null)
} else {
Timber.d("Do not delete the uri")
}
}
}

View file

@ -38,6 +38,7 @@ object MimeTypes {
fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this
fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse() fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse()
fun String?.isMimeTypeAnimatedImage() = this == Gif || this == WebP
fun String?.isMimeTypeVideo() = this?.startsWith("video/").orFalse() fun String?.isMimeTypeVideo() = this?.startsWith("video/").orFalse()
fun String?.isMimeTypeAudio() = this?.startsWith("audio/").orFalse() fun String?.isMimeTypeAudio() = this?.startsWith("audio/").orFalse()
fun String?.isMimeTypeApplication() = this?.startsWith("application/").orFalse() fun String?.isMimeTypeApplication() = this?.startsWith("application/").orFalse()

View file

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -118,6 +119,14 @@ fun TextButton(
leadingIcon = leadingIcon leadingIcon = leadingIcon
) )
@Composable
fun InvisibleButton(
modifier: Modifier = Modifier,
size: ButtonSize = ButtonSize.Large,
) {
Spacer(modifier = modifier.height(size.toMinHeight()))
}
@Composable @Composable
private fun ButtonInternal( private fun ButtonInternal(
text: String, text: String,
@ -131,14 +140,7 @@ private fun ButtonInternal(
showProgress: Boolean = false, showProgress: Boolean = false,
leadingIcon: IconSource? = null, leadingIcon: IconSource? = null,
) { ) {
val minHeight = when (size) { val minHeight = size.toMinHeight()
ButtonSize.Small -> 32.dp
ButtonSize.Medium,
ButtonSize.MediumLowPadding -> 40.dp
ButtonSize.Large,
ButtonSize.LargeLowPadding -> 48.dp
}
val hasStartDrawable = showProgress || leadingIcon != null val hasStartDrawable = showProgress || leadingIcon != null
val contentPadding = when (size) { val contentPadding = when (size) {
@ -253,6 +255,14 @@ private fun ButtonInternal(
} }
} }
private fun ButtonSize.toMinHeight() = when (this) {
ButtonSize.Small -> 32.dp
ButtonSize.Medium,
ButtonSize.MediumLowPadding -> 40.dp
ButtonSize.Large,
ButtonSize.LargeLowPadding -> 48.dp
}
@Immutable @Immutable
sealed interface IconSource { sealed interface IconSource {
val contentDescription: String? val contentDescription: String?

View file

@ -56,4 +56,7 @@ interface MatrixAuthenticationService {
suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> suspend fun loginWithOidc(callbackUrl: String): Result<SessionId>
suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result<SessionId> suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result<SessionId>
/** Listen to new Matrix clients being created on authentication. */
fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit)
} }

View file

@ -25,6 +25,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.Session import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.SlidingSyncVersion import org.matrix.rustcomponents.sdk.SlidingSyncVersion
@ -51,8 +52,9 @@ class RustMatrixClientFactory @Inject constructor(
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val clientBuilderProvider: ClientBuilderProvider, private val clientBuilderProvider: ClientBuilderProvider,
) { ) {
private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) { suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
val client = getBaseClientBuilder( val client = getBaseClientBuilder(
sessionPaths = sessionData.getSessionPaths(), sessionPaths = sessionData.getSessionPaths(),
passphrase = sessionData.passphrase, passphrase = sessionData.passphrase,
@ -60,18 +62,21 @@ class RustMatrixClientFactory @Inject constructor(
) )
.homeserverUrl(sessionData.homeserverUrl) .homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId) .username(sessionData.userId)
.setSessionDelegate(sessionDelegate)
.use { it.build() } .use { it.build() }
client.restoreSession(sessionData.toSession()) client.restoreSession(sessionData.toSession())
create(client)
}
suspend fun create(client: Client): RustMatrixClient {
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
val syncService = client.syncService() val syncService = client.syncService()
.withUtdHook(UtdTracker(analyticsService)) .withUtdHook(UtdTracker(analyticsService))
.finish() .finish()
val (anonymizedAccessToken, anonymizedRefreshToken) = sessionData.anonymizedTokens() return RustMatrixClient(
RustMatrixClient(
client = client, client = client,
baseDirectory = baseDirectory, baseDirectory = baseDirectory,
sessionStore = sessionStore, sessionStore = sessionStore,
@ -98,6 +103,7 @@ class RustMatrixClientFactory @Inject constructor(
dataPath = sessionPaths.fileDirectory.absolutePath, dataPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath, cachePath = sessionPaths.cacheDirectory.absolutePath,
) )
.setSessionDelegate(sessionDelegate)
.passphrase(passphrase) .passphrase(passphrase)
.userAgent(userAgentProvider.provide()) .userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides()) .addRootCertificates(userCertificatesProvider.provides())

View file

@ -51,7 +51,6 @@ import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress import org.matrix.rustcomponents.sdk.QrLoginProgress
import org.matrix.rustcomponents.sdk.QrLoginProgressListener import org.matrix.rustcomponents.sdk.QrLoginProgressListener
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
import uniffi.matrix_sdk.OidcAuthorizationData import uniffi.matrix_sdk.OidcAuthorizationData
import javax.inject.Inject import javax.inject.Inject
@ -77,6 +76,11 @@ class RustMatrixAuthenticationService @Inject constructor(
private var currentClient: Client? = null private var currentClient: Client? = null
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null) private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var newMatrixClientObserver: ((MatrixClient) -> Unit)? = null
override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) {
newMatrixClientObserver = lambda
}
private fun rotateSessionPath(): SessionPaths { private fun rotateSessionPath(): SessionPaths {
sessionPaths?.deleteRecursively() sessionPaths?.deleteRecursively()
return sessionPathsFactory.create() return sessionPathsFactory.create()
@ -155,7 +159,7 @@ class RustMatrixAuthenticationService @Inject constructor(
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths, sessionPaths = currentSessionPaths,
) )
clear() newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)
SessionId(sessionData.userId) SessionId(sessionData.userId)
}.mapFailure { failure -> }.mapFailure { failure ->
@ -226,9 +230,9 @@ class RustMatrixAuthenticationService @Inject constructor(
passphrase = pendingPassphrase, passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths, sessionPaths = currentSessionPaths,
) )
clear()
pendingOidcAuthorizationData?.close() pendingOidcAuthorizationData?.close()
pendingOidcAuthorizationData = null pendingOidcAuthorizationData = null
newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)
SessionId(sessionData.userId) SessionId(sessionData.userId)
}.mapFailure { failure -> }.mapFailure { failure ->
@ -256,15 +260,14 @@ class RustMatrixAuthenticationService @Inject constructor(
oidcConfiguration = oidcConfiguration, oidcConfiguration = oidcConfiguration,
progressListener = progressListener, progressListener = progressListener,
) )
val sessionData = client.use { rustClient -> val sessionData = client.session()
rustClient.session() .toSessionData(
.toSessionData( isTokenValid = true,
isTokenValid = true, loginType = LoginType.QR,
loginType = LoginType.QR, passphrase = pendingPassphrase,
passphrase = pendingPassphrase, sessionPaths = emptySessionPaths,
sessionPaths = emptySessionPaths, )
) newMatrixClientObserver?.invoke(rustMatrixClientFactory.create(client))
}
sessionStore.storeData(sessionData) sessionStore.storeData(sessionData)
SessionId(sessionData.userId) SessionId(sessionData.userId)
}.mapFailure { }.mapFailure {

View file

@ -37,7 +37,7 @@ class RustMediaLoader(
withContext(mediaDispatcher) { withContext(mediaDispatcher) {
runCatching { runCatching {
source.toRustMediaSource().use { source -> source.toRustMediaSource().use { source ->
innerClient.getMediaContent(source).toUByteArray().toByteArray() innerClient.getMediaContent(source)
} }
} }
} }
@ -55,7 +55,7 @@ class RustMediaLoader(
mediaSource = mediaSource, mediaSource = mediaSource,
width = width.toULong(), width = width.toULong(),
height = height.toULong() height = height.toULong()
).toUByteArray().toByteArray() )
} }
} }
} }

View file

@ -18,15 +18,13 @@ import io.element.android.libraries.matrix.api.verification.VerificationFlowStat
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -61,11 +59,13 @@ class RustSessionVerificationService(
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown) private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow() override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
private val recoveryState = MutableStateFlow(RecoveryState.UNKNOWN)
// Listen for changes in verification status and update accordingly // Listen for changes in verification status and update accordingly
private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener { private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
override fun onUpdate(status: VerificationState) { override fun onUpdate(status: VerificationState) {
Timber.d("New verification state: $status") Timber.d("New verification state: $status")
sessionCoroutineScope.launch { updateVerificationStatus() } _sessionVerifiedStatus.value = status.map()
} }
}) })
@ -74,7 +74,7 @@ class RustSessionVerificationService(
override fun onUpdate(status: RecoveryState) { override fun onUpdate(status: RecoveryState) {
Timber.d("New recovery state: $status") Timber.d("New recovery state: $status")
// We could check the `RecoveryState`, but it's easier to just use the verification state directly // We could check the `RecoveryState`, but it's easier to just use the verification state directly
sessionCoroutineScope.launch { updateVerificationStatus() } recoveryState.value = status
} }
}) })
@ -88,22 +88,7 @@ class RustSessionVerificationService(
verificationStatus == SessionVerifiedStatus.NotVerified verificationStatus == SessionVerifiedStatus.NotVerified
} }
init { private var isOwnVerification = true
// Update initial state in case sliding sync isn't ready
sessionCoroutineScope.launch { updateVerificationStatus() }
isReady.onEach { isReady ->
if (isReady) {
Timber.d("Starting verification service")
// Immediate status update
updateVerificationStatus()
} else {
Timber.d("Stopping verification service")
updateVerificationStatus()
}
}
.launchIn(sessionCoroutineScope)
}
override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) { override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) {
listener?.onIncomingSessionRequest(details.map()) listener?.onIncomingSessionRequest(details.map())
@ -135,6 +120,8 @@ class RustSessionVerificationService(
} }
override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail { override suspend fun acknowledgeVerificationRequest(details: SessionVerificationRequestDetails) = tryOrFail {
isOwnVerification = false
initVerificationControllerIfNeeded()
verificationController.acknowledgeVerificationRequest( verificationController.acknowledgeVerificationRequest(
senderId = details.senderId.value, senderId = details.senderId.value,
flowId = details.flowId.value, flowId = details.flowId.value,
@ -179,14 +166,22 @@ class RustSessionVerificationService(
// Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it returns false if run immediately // Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it returns false if run immediately
// It also sometimes unexpectedly fails to report the session as verified, so we have to handle that possibility and fail if needed // It also sometimes unexpectedly fails to report the session as verified, so we have to handle that possibility and fail if needed
runCatching { runCatching {
withTimeout(30.seconds) { withTimeout(20.seconds) {
while (encryptionService.verificationState() != VerificationState.VERIFIED) { // Wait until the SDK reports the state as verified
delay(100) sessionVerifiedStatus.first { it == SessionVerifiedStatus.Verified }
}
} }
} }
.onSuccess { .onSuccess {
// Order here is important, first set the flow state as finished, then update the verification status if (isOwnVerification) {
// Try waiting for the final recovery state for better UX, but don't block the verification state on it
tryOrNull {
withTimeout(10.seconds) {
// Wait until the recovery state is either fully loaded or we check it's explicitly disabled
recoveryState.first { it == RecoveryState.ENABLED || it == RecoveryState.DISABLED }
}
}
}
_verificationFlowState.value = VerificationFlowState.DidFinish _verificationFlowState.value = VerificationFlowState.DidFinish
updateVerificationStatus() updateVerificationStatus()
} }
@ -209,6 +204,7 @@ class RustSessionVerificationService(
// end-region // end-region
override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) { override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) {
isOwnVerification = true
if (isReady.value && cancelAnyPendingVerificationAttempt) { if (isReady.value && cancelAnyPendingVerificationAttempt) {
// Cancel any pending verification attempt // Cancel any pending verification attempt
tryOrNull { verificationController.cancelVerification() } tryOrNull { verificationController.cancelVerification() }
@ -237,37 +233,20 @@ class RustSessionVerificationService(
} }
} }
private suspend fun updateVerificationStatus() { private fun updateVerificationStatus() {
if (verificationFlowState.value == VerificationFlowState.DidFinish) { runCatching {
// Calling `encryptionService.verificationState()` performs a network call and it will deadlock if there is no network _sessionVerifiedStatus.value = encryptionService.verificationState().map()
// So we need to check that *only* if we know there is network connection, which is the case when the verification flow just finished Timber.d("New verification status: ${_sessionVerifiedStatus.value}")
Timber.d("Updating verification status: flow just finished")
runCatching {
encryptionService.waitForE2eeInitializationTasks()
}.onSuccess {
_sessionVerifiedStatus.value = when (encryptionService.verificationState()) {
VerificationState.UNKNOWN -> SessionVerifiedStatus.Unknown
VerificationState.VERIFIED -> SessionVerifiedStatus.Verified
VerificationState.UNVERIFIED -> SessionVerifiedStatus.NotVerified
}
Timber.d("New verification status: ${_sessionVerifiedStatus.value}")
}
} else {
// Otherwise, just check the current verification status from the session verification controller instead
Timber.d("Updating verification status: flow is pending or was finished some time ago")
runCatching {
initVerificationControllerIfNeeded()
_sessionVerifiedStatus.value = if (encryptionService.verificationState() == VerificationState.VERIFIED) {
SessionVerifiedStatus.Verified
} else {
SessionVerifiedStatus.NotVerified
}
Timber.d("New verification status: ${_sessionVerifiedStatus.value}")
}
} }
} }
} }
private fun VerificationState.map() = when (this) {
VerificationState.UNKNOWN -> SessionVerifiedStatus.Unknown
VerificationState.VERIFIED -> SessionVerifiedStatus.Verified
VerificationState.UNVERIFIED -> SessionVerifiedStatus.NotVerified
}
private fun RustSessionVerificationData.map(): SessionVerificationData { private fun RustSessionVerificationData.map(): SessionVerificationData {
return use { sessionVerificationData -> return use { sessionVerificationData ->
when (sessionVerificationData) { when (sessionVerificationData) {

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -41,6 +42,7 @@ class FakeMatrixAuthenticationService(
private var loginError: Throwable? = null private var loginError: Throwable? = null
private var changeServerError: Throwable? = null private var changeServerError: Throwable? = null
private var matrixClient: MatrixClient? = null private var matrixClient: MatrixClient? = null
private var onAuthenticationListener: ((MatrixClient) -> Unit)? = null
var getLatestSessionIdLambda: (() -> SessionId?) = { null } var getLatestSessionIdLambda: (() -> SessionId?) = { null }
@ -55,6 +57,7 @@ class FakeMatrixAuthenticationService(
return it.invoke(sessionId) return it.invoke(sessionId)
} }
return if (matrixClient != null) { return if (matrixClient != null) {
onAuthenticationListener?.invoke(matrixClient!!)
Result.success(matrixClient!!) Result.success(matrixClient!!)
} else { } else {
Result.failure(IllegalStateException()) Result.failure(IllegalStateException())
@ -74,7 +77,10 @@ class FakeMatrixAuthenticationService(
} }
override suspend fun login(username: String, password: String): Result<SessionId> = simulateLongTask { override suspend fun login(username: String, password: String): Result<SessionId> = simulateLongTask {
loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) loginError?.let { Result.failure(it) } ?: run {
onAuthenticationListener?.invoke(matrixClient ?: FakeMatrixClient())
Result.success(A_USER_ID)
}
} }
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> = simulateLongTask { override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> = simulateLongTask {
@ -90,13 +96,21 @@ class FakeMatrixAuthenticationService(
} }
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> = simulateLongTask { override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> = simulateLongTask {
loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) loginError?.let { Result.failure(it) } ?: run {
onAuthenticationListener?.invoke(matrixClient ?: FakeMatrixClient())
Result.success(A_USER_ID)
}
} }
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result<SessionId> = simulateLongTask { override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result<SessionId> = simulateLongTask {
onAuthenticationListener?.invoke(matrixClient ?: FakeMatrixClient())
loginWithQrCodeResult(qrCodeData, progress) loginWithQrCodeResult(qrCodeData, progress)
} }
override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) {
onAuthenticationListener = lambda
}
fun givenOidcError(throwable: Throwable?) { fun givenOidcError(throwable: Throwable?) {
oidcError = throwable oidcError = throwable
} }

View file

@ -37,3 +37,9 @@ data class MediaRequestData(
} }
} }
} }
/** Max width a thumbnail can have according to [the spec](https://spec.matrix.org/v1.10/client-server-api/#thumbnails). */
const val MAX_THUMBNAIL_WIDTH = 800L
/** Max height a thumbnail can have according to [the spec](https://spec.matrix.org/v1.10/client-server-api/#thumbnails). */
const val MAX_THUMBNAIL_HEIGHT = 600L

View file

@ -18,8 +18,8 @@ interface MediaPreProcessor {
suspend fun process( suspend fun process(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
deleteOriginal: Boolean = false, deleteOriginal: Boolean,
compressIfPossible: Boolean compressIfPossible: Boolean,
): Result<MediaUploadInfo> ): Result<MediaUploadInfo>
data class Failure(override val cause: Throwable?) : Exception(cause) data class Failure(override val cause: Throwable?) : Exception(cause)

View file

@ -39,7 +39,7 @@ class MediaSender @Inject constructor(
.process( .process(
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,
deleteOriginal = true, deleteOriginal = false,
compressIfPossible = compressIfPossible, compressIfPossible = compressIfPossible,
) )
.flatMapCatching { info -> .flatMapCatching { info ->

View file

@ -13,6 +13,7 @@ import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.androidutils.file.createTmpFile import io.element.android.libraries.androidutils.file.createTmpFile
import io.element.android.libraries.androidutils.file.getFileName import io.element.android.libraries.androidutils.file.getFileName
import io.element.android.libraries.androidutils.file.safeRenameTo import io.element.android.libraries.androidutils.file.safeRenameTo
@ -36,6 +37,7 @@ import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
@ -49,6 +51,7 @@ class AndroidMediaPreProcessor @Inject constructor(
private val imageCompressor: ImageCompressor, private val imageCompressor: ImageCompressor,
private val videoCompressor: VideoCompressor, private val videoCompressor: VideoCompressor,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val temporaryUriDeleter: TemporaryUriDeleter,
) : MediaPreProcessor { ) : MediaPreProcessor {
companion object { companion object {
/** /**
@ -82,8 +85,11 @@ class AndroidMediaPreProcessor @Inject constructor(
} }
if (deleteOriginal) { if (deleteOriginal) {
tryOrNull { tryOrNull {
Timber.w("Deleting original uri $uri")
contentResolver.delete(uri, null, null) contentResolver.delete(uri, null, null)
} }
} else {
temporaryUriDeleter.delete(uri)
} }
result.postProcess(uri) result.postProcess(uri)
} }

View file

@ -8,10 +8,12 @@
package io.element.android.libraries.mediaupload.impl package io.element.android.libraries.mediaupload.impl
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Build import android.os.Build
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
@ -21,6 +23,8 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -42,7 +46,12 @@ class AndroidMediaPreProcessorTest {
deleteOriginal: Boolean = false, deleteOriginal: Boolean = false,
): MediaUploadInfo { ): MediaUploadInfo {
val context = InstrumentationRegistry.getInstrumentation().context val context = InstrumentationRegistry.getInstrumentation().context
val sut = createAndroidMediaPreProcessor(context, sdkIntVersion) val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val sut = createAndroidMediaPreProcessor(
context = context,
sdkIntVersion = sdkIntVersion,
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
val file = getFileFromAssets(context, asset.filename) val file = getFileFromAssets(context, asset.filename)
val result = sut.process( val result = sut.process(
uri = file.toUri(), uri = file.toUri(),
@ -52,6 +61,7 @@ class AndroidMediaPreProcessorTest {
) )
val data = result.getOrThrow() val data = result.getOrThrow()
assertThat(data.file.path).endsWith(asset.filename) assertThat(data.file.path).endsWith(asset.filename)
deleteCallback.assertions().isCalledExactly(if (deleteOriginal) 0 else 1)
return data return data
} }
@ -356,13 +366,15 @@ class AndroidMediaPreProcessorTest {
private fun TestScope.createAndroidMediaPreProcessor( private fun TestScope.createAndroidMediaPreProcessor(
context: Context, context: Context,
sdkIntVersion: Int = Build.VERSION_CODES.P sdkIntVersion: Int = Build.VERSION_CODES.P,
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
) = AndroidMediaPreProcessor( ) = AndroidMediaPreProcessor(
context = context, context = context,
thumbnailFactory = ThumbnailFactory(context, FakeBuildVersionSdkIntProvider(sdkIntVersion)), thumbnailFactory = ThumbnailFactory(context, FakeBuildVersionSdkIntProvider(sdkIntVersion)),
imageCompressor = ImageCompressor(context, testCoroutineDispatchers()), imageCompressor = ImageCompressor(context, testCoroutineDispatchers()),
videoCompressor = VideoCompressor(context), videoCompressor = VideoCompressor(context),
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
temporaryUriDeleter = temporaryUriDeleter,
) )
@Throws(IOException::class) @Throws(IOException::class)

View file

@ -32,9 +32,10 @@ class KonsistComposableTest {
.withoutReceiverType() .withoutReceiverType()
.withoutName( .withoutName(
// Add some exceptions... // Add some exceptions...
"InvisibleButton",
"OutlinedButton", "OutlinedButton",
"TextButton",
"SimpleAlertDialogContent", "SimpleAlertDialogContent",
"TextButton",
) )
.assertTrue( .assertTrue(
additionalMessage = additionalMessage =

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.tests.testutils.fake
import android.net.Uri
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.tests.testutils.lambda.lambdaError
class FakeTemporaryUriDeleter(
val deleteLambda: (uri: Uri?) -> Unit = { lambdaError() }
) : TemporaryUriDeleter {
override fun delete(uri: Uri?) {
deleteLambda(uri)
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dbf6f78ad928bcc9878e546345a98d334ae92c9b81d4d8404892a16d19b446c3 oid sha256:bea78fb1bb813bedce30e5b13257892bcc23a7d6eb1a404d24e764337568d6cd
size 41534 size 41596

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dfc69dc6d93a62e23df2f817ad5a167b1e94d7fb0d408d6ec0051d666b6cf175 oid sha256:5d8163be5e84aa851df9666822a4d9bc7a40d3a99d6fa8498243c256b729d58f
size 44869 size 44724

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b76212b5942484621b7a58044a958203d838807d50687e8f4e2f9c8bdb6ad37c oid sha256:db8b048d11f766a8e3db28bac99f3e8dea34e6f37fba1eacd9e6f98f6127462b
size 40232 size 40383

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1033af0fc84e2819509fc798c17e8f0b07d74a08da99d0e059b7ff19db2ce56a oid sha256:c1098ef994ad9552aca2f9ad9efe7b1239133544e561e73eced6fa96d08eaa3b
size 43674 size 43785

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:18dadaebe7a32aacde31afa0352a343913955b099ca4a07851e3ffe75e88b4d6 oid sha256:104d1d386398aac789420babf33755e03d163f40ae596cd8d24f35da0363fac6
size 31012 size 30952

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:71b9f32b26b391ff3bf231ca9f364a157f535c1e6ff52ee3e3ead3630bb1b239 oid sha256:145c85570217621cc576f305ed72e27205381c438e434205e07b7363cfd67f04
size 30007 size 30069

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c5435926f4a54e99902b78881c59d9d816110e401bcb92baf3b2fca844338f31 oid sha256:5d8163be5e84aa851df9666822a4d9bc7a40d3a99d6fa8498243c256b729d58f
size 48182 size 44724

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:000374157cb5fbf6670b4af041fc385538df78bf4aecaf83ea24d39dfec84f23 oid sha256:781d869dff205f99d5a9bc9986abbe489bef24551e6fb695280541741895a508
size 24278 size 24238

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4dd274f8c2ade6213a13a47400ec3a571844eafcc82b292b1c813bd9aa098236 oid sha256:85b92d23ab2f690c3c15ded7f14c513371cd36482fbeda1478f62c82650f1829
size 30241 size 30274

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:994b39ba25e011cce97504a1f3d4ed0c420b7bce87daafae77ba481cc629cdae oid sha256:b848d50320f1494c9d0edbc21bddcc773de0cf9b1c36ce034235fd3fbbd77cf1
size 29051 size 29210

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a6c90e5be60738ded8312822f95279aa76d3ec8d86265164f4e3e31dbcce61c5 oid sha256:c1098ef994ad9552aca2f9ad9efe7b1239133544e561e73eced6fa96d08eaa3b
size 47355 size 43785

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3a3f08002e805fe5f7a4c96aa4b73c2fcd6e8b79e6a9e82bcc7bd3df50d8c22d oid sha256:5931dfac0337cf856282d9c62983aa185fe208f2926868097238d5b95e222fe6
size 24015 size 23953